diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 22253231..f2ac1a55 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": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "category": "Design" } ] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1125dfb..aa24f6f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,14 @@ 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 + .agents/plugins/marketplace.json + .claude-plugin/marketplace.json)" + - name: Type check run: pnpm typecheck diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 5de2ea44..bef75708 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,8 @@ jobs: - name: Publish working-directory: packages/mcp-server - run: npm publish --access public + run: npm publish --access public --tag "${{ inputs.tag }}" + + - name: Verify published version + working-directory: packages/mcp-server + run: npm view "@tempad-dev/mcp@$(node -p "require('./package.json').version")" version diff --git a/.gitignore b/.gitignore index 82a32a28..9b392a30 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,10 @@ stats-*.json .wxt web-ext.config.ts dist +.dev/ coverage .artifacts/ +.vitest-attachments/ packages/*/coverage packages/extension/tests/**/__screenshots__/ 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 823f76e6..aacd06d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,121 +2,102 @@ ## Purpose -Provide a single entry point for coding agents. This file links to package-level guides and highlights repo-wide constraints and workflows. - -## Repo map (high level) - -- `packages/extension/` — Figma plugin + MCP tools implementation -- `packages/mcp-server/` — MCP server runtime -- `packages/shared/` — shared types and contracts -- `packages/plugins/` — plugin-side code and transforms -- `agent-plugins/` — shared agent plugin bundles and platform manifests - -## Start here - -- `packages/extension/AGENTS.md` -- `packages/mcp-server/AGENTS.md` -- `packages/shared/AGENTS.md` -- `packages/plugins/AGENTS.md` - -## Global conventions - -- Package manager: `pnpm` -- Prefer repo-level scripts unless a package explicitly documents otherwise. -- When creating commits, use Conventional Commits (for example: `feat: ...`, `fix: ...`, `docs: ...`, `chore: ...`). - -## Common commands - -- Typecheck: `pnpm typecheck` -- Lint (and format): `pnpm lint:fix` -- Test (watch): `pnpm test` -- Test (run): `pnpm test:run` -- Test (coverage): `pnpm test:coverage` -- 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` - -## Doc index - -- `TESTING.md` -- `docs/testing/architecture.md` -- `docs/extension/mcp-get-code-requirements.md` -- `docs/extension/mcp-get-code-design.md` -- `docs/extension/mcp-browser-gateway-design.md` -- `docs/marketing-screenshots.md` - -## Guardrails - -- Keep changes minimal and consistent with existing style. -- Avoid adding new global dependencies unless explicitly requested or approved. -- Keep pull request descriptions concise. Do not include a validation section unless explicitly requested. - -## Contributing & verification - -### Tech stack (repo-wide) - -- Package manager: `pnpm` (workspace scripts are commonly run as `pnpm -r ...`). -- Language: TypeScript. -- Extension: Vue 3 + WXT (Web Extension Toolkit). -- MCP server: Node.js 18+ + `@modelcontextprotocol/sdk` + WebSocket transport. -- Shared contracts: `zod` schemas. -- Build tool (non-extension packages): `tsdown`. - -### Key scripts - -Run these at repo root unless noted. - -- Dev extension: `pnpm dev` -- Dev site: `pnpm dev:site` -- Build everything: `pnpm build` -- Build site: `pnpm build:site` -- Build extension: `pnpm build:ext` -- Build plugins: `pnpm build:plugins` -- Build MCP: `pnpm build:mcp` -- Typecheck all packages: `pnpm typecheck` -- Lint all packages: `pnpm lint` / auto-fix: `pnpm lint:fix` -- Test all packages: `pnpm test:run` -- Coverage report: `pnpm test:coverage` -- Format: `pnpm format` -- Zip extension artifact: `pnpm zip` - -### Verification checklist (agent-driven changes) - -Pick the checks that match your change. - -1. Always - -- `pnpm typecheck` -- `pnpm lint` (or `pnpm lint:fix`) -- `pnpm test:run` - -2. Extension UI / codegen - -- `pnpm dev` -- In Figma, open TemPad Dev panel and validate the impacted section (e.g. “Inspect → Code”). - -3. Extension build / packaging - -- `pnpm build:ext` -- `pnpm zip` - -4. Rewrite subsystem - -- `pnpm --filter @tempad-dev/extension build:rewrite` -- Optional: `pnpm --filter @tempad-dev/extension tsx scripts/check-rewrite.ts` - - Requires `FIGMA_EMAIL`, `FIGMA_PASSWORD`, `FIGMA_FILE_KEY`. - -5. MCP schemas / tool behavior - -- If you change tool schemas/contracts: update `packages/shared` first, then `packages/mcp-server`, then `packages/extension`. -- Re-check payload limits and omission rules; see `docs/extension/mcp-get-code-requirements.md` and `docs/extension/mcp-get-code-design.md`. - -## Testing notes - -- 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 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`. -- Do not introduce jsdom-based tests in this repository. +Use this file as the repo-wide router and source of global invariants. Read only +the package guide and conditional runbook required by the task; do not preload +every linked document. + +## Repository routing + +| Work area | Read next | Responsibility | +| ----------------------------- | ------------------------------- | -------------------------------------------------------------------------------- | +| `packages/extension/` | `packages/extension/AGENTS.md` | Figma extension, UI, codegen, browser runtime, and MCP tool implementation | +| `packages/mcp-server/` | `packages/mcp-server/AGENTS.md` | MCP server, Hub, transport, and tool exposure | +| `packages/shared/` | `packages/shared/AGENTS.md` | Shared schemas, types, and contracts | +| `packages/plugins/` | `packages/plugins/AGENTS.md` | Plugin transforms and sandboxed plugin-side code | +| `skill/` and `agent-plugins/` | This guide | Shared agent skills, manifests, compatibility wrappers, and marketplace metadata | + +For a cross-package change, read the guides for every affected package. For a +repo-wide documentation, configuration, or release task, this root guide is the +default authority unless a routed document says otherwise. + +## Repo-wide invariants + +- Package manager: `pnpm`. Prefer repo-level scripts unless a package guide + explicitly requires a filtered command. +- Keep changes minimal and consistent with existing style. Do not add global + dependencies without explicit approval. +- When a tool schema or shared contract changes, update `packages/shared` first, + then `packages/mcp-server`, then `packages/extension`. +- Generated artifacts are not source. Follow the owning workflow and never edit + ignored or generated output to simulate a source change. +- Do not create or amend commits unless explicitly requested. When creating a + commit, use Conventional Commits. +- Keep pull request descriptions concise. Do not add a validation section unless + explicitly requested. + +## Agent plugin invariants + +- `agent-plugins/tempad-dev/` is the tracked release source shared by Codex and + Claude. The plugin is distributed through the Git marketplace, not npm. +- Edit `skill/` for `figma-design-to-code`; the development generator copies it + into the tracked release plugin. Edit the tracked + `agent-plugins/tempad-dev/skills/figma-canvas-authoring/` source directly. +- The portable `plugin.json` and `mcp.json` own shared manifest and MCP fields. + `pnpm agent-plugin:dev` synchronizes client compatibility wrappers, the copied + design-to-code skill, derived icons, and shared marketplace metadata; do not + hand-edit those derived fields or copies. +- `.dev/plugins/tempad-dev-dev/` is the ignored local build. Generate it with + `pnpm agent-plugin:dev`; never edit it directly. +- Run `pnpm agent-plugin:dev` after a change to any generator input, inspect all + tracked synchronized outputs, and include the intended release-source changes. + Ordinary `pnpm build` must not modify agent-plugin artifacts. +- Keep Codex and Claude development support equivalent. Both manifests must + launch the same working-tree MCP runtime. +- Release MCP configuration must use `@tempad-dev/mcp@latest`, never an alpha + tag, fixed version, or local path. +- Before preparing, running, reviewing, or asking the user to test an end-to-end + Figma authoring task, read `docs/testing/agent-authoring-evolution.md`. It is + the sole detailed runbook for runtime refresh, plugin replacement, clean-task + identity, evidence review, fix placement, and candidate promotion. + +## Core commands + +Run these from the repo root: + +| Task | Command | +| --------------------------------- | ----------------------------- | +| Development | `pnpm dev` | +| Build all packages | `pnpm build` | +| Typecheck | `pnpm typecheck` | +| Lint / auto-fix | `pnpm lint` / `pnpm lint:fix` | +| Test once | `pnpm test:run` | +| Coverage | `pnpm test:coverage` | +| Format | `pnpm format` | +| Generate development agent plugin | `pnpm agent-plugin:dev` | + +Use package-owned commands from the applicable package guide when a narrower +check is sufficient. + +## Conditional documentation + +| Task | Read first | +| ------------------------------------------------------- | --------------------------------------------------------------- | +| Test selection, required checks, or troubleshooting | `TESTING.md` | +| Test runtime or coverage architecture | `docs/testing/architecture.md` | +| End-to-end authoring evolution or live agent evaluation | `docs/testing/agent-authoring-evolution.md` | +| Extension implementation or MCP behavior | `packages/extension/AGENTS.md`, then its routed design document | +| Public agent-plugin installation or usage documentation | `agent-plugins/tempad-dev/README.md` | +| Marketing screenshot work | `docs/marketing-screenshots.md` | + +## Verification + +Follow `TESTING.md` and every affected package guide. The default repository +checks are: + +1. `pnpm typecheck` +2. `pnpm lint` +3. `pnpm test:run` + +Add build, browser, packaging, rewrite, coverage, or live Figma checks only when +the routed guidance and change risk require them. Browser runtime tests must use +Playwright; do not introduce jsdom-based tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1328fd..4b9aa926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,7 @@ - Extension: [packages/extension/CHANGELOG.md](packages/extension/CHANGELOG.md) - MCP server: [packages/mcp-server/CHANGELOG.md](packages/mcp-server/CHANGELOG.md) +- Agent Plugin: [agent-plugins/tempad-dev/CHANGELOG.md](agent-plugins/tempad-dev/CHANGELOG.md) - Plugins SDK: [packages/plugins/CHANGELOG.md](packages/plugins/CHANGELOG.md) + +For the coordinated publication process, see [Releasing](docs/releasing.md). diff --git a/README.md b/README.md index a9aeb748..13f5fa48 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ - Shows a screenshot of the extension panel. + TemPad Dev

-

Open handoff tooling for Figma

+

Connecting Figma with developers and their coding agents

Install on Chrome Web Store @@ -20,17 +20,159 @@ check-script-rewrite

-

+TemPad Dev is an open-source connection between Figma, developers, and their coding agents. Inspect designs and customize code output in the browser, or let your agent read designs, create and edit native Figma content, and implement UI in your project. + +## Contents + +- [Quick start](#quick-start) +- [Agent integration](#agent-integration): [canvas design](#create-and-edit-figma-designs), [implementation](#implement-designs-in-code), [setup](#setup-guide), [connection status](#mcp-connection-status) +- [Inspect designs](#inspect-designs): [CSS and variables](#inspect-css-code), [deep select](#deep-select-mode), [measure](#measure-to-selection-mode), [scroll into view](#scroll-selection-into-view) +- [Output plugins](#output-plugins): installation, development, and sharing + +## Quick start + +1. Install TemPad Dev from the [Chrome Web Store](https://chromewebstore.google.com/detail/tempad-dev/lgoeakbaikpkihoiphamaeopmliaimpc) and open a Figma Design file. +2. Select an element to inspect its code, variables, and layout in the TemPad Dev panel. Manual inspection needs no agent setup. +3. To use a coding agent, enable **Preferences → Agent integration → MCP access**, select **Set up agents**, and follow the instructions for your client. + +The agent connection requires Node.js 22.x, 24.x, or 26+. Canvas editing also requires edit access to the Figma Design file. Setup and upgrade details for the extension, MCP server, and both skills follow below. + +## Agent integration + +Work with Figma through the coding agent or IDE you already use. TemPad Dev provides design context and canvas operations; your agent uses them alongside your instructions and project context. + +### Create and edit Figma designs + +Create screens, adjust layout and typography, or revise existing designs. The result consists of native, editable layers. Reuse accessible components, variables, and styles when the task calls for them. + +For example, after connecting your agent: + +> Create a settings screen in Figma using the available components. + +Or select an existing design and ask: + +> Adjust the spacing and typography in this screen, keeping its components and content. + +The `figma-canvas-authoring` skill guides your agent through relevant resource inspection, editing, and checking the rendered result. Writes require an editable Figma Design file; view-only files and Dev Mode remain read-only. + +### Implement designs in code + +Select the design in Figma, then ask your agent in the target code project: + +> Implement the current Figma selection using this project’s components and styling conventions. + +TemPad Dev provides layout, styles, variable references, component information, and assets. The `figma-design-to-code` skill guides the agent through adapting that evidence to the repository and validating the implementation. Generated design code is a starting point; the agent produces the project implementation. + +Both workflows use the same MCP connection to Figma. Compatible clients can install the [Agent Plugin](./agent-plugins/tempad-dev/README.md), which bundles the MCP configuration and both skills. Other clients can set up MCP and skills separately. + +### Setup guide + + + + + TemPad Dev agent setup dialog. + + +1. Install Node.js 22.x, 24.x, or 26+ 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. The setup flow installs the portable Agent Plugin first for Codex, Cursor, Claude Code, and VS Code. For Gemini, OpenCode, TRAE, and other clients without compatible plugin installation, it uses the client's MCP flow plus the two standalone skills. Every command or config is shown in full for review and copying. + +For clients with separate MCP and skill installation, the setup shows each command. Gemini is one example: + + + + + Gemini setup showing the MCP installation command. + + +Scroll down in the dialog for both skill installation commands: + + + + + The complete Gemini commands for the design-to-code and canvas authoring skills. + + +To install the portable package into all compatible agents detected on your machine: + +```bash +npx plugins add ecomfe/tempad-dev +``` + +Pass `--target codex`, `--target cursor`, `--target claude-code`, or `--target vscode` to limit the +installation to one of the built-in setup targets. Native Codex and Claude marketplace commands, +plus direct MCP and skill installation, remain documented as compatibility fallbacks in the +[Agent Plugin guide](./agent-plugins/tempad-dev/README.md). + +All plugin and direct `npx`-based setup paths use `@tempad-dev/mcp@latest`. + +For the canvas-authoring release, use extension **0.21.0**, MCP server **0.8.0**, and Agent +Plugin **0.2.0** together. See the [upgrade guide](./agent-plugins/tempad-dev/README.md#upgrading) +when updating an existing installation. + +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. + +### MCP connection status + +When the MCP server is enabled, a badge appears in the TemPad Dev panel title bar showing the current connection status: + +- **Unavailable**: The local MCP server is not configured or not running. + - - - Shows a screenshot of the extension panel. + + + MCP status badge showing Unavailable. -

+ +- **Inactive**: TemPad Dev is connected to a local MCP server, but this tab is not currently active because multiple Figma tabs are open. Click the badge to activate MCP for this tab (this deactivates MCP in other tabs). + + + + + MCP status badge showing Inactive. + + +- **Active**: The MCP server is running, and this tab is active and ready to respond to MCP tool calls. + + + + + MCP status badge showing Active. + + +### Configuration + +For optional environment variables, see [`packages/mcp-server/README.md`](./packages/mcp-server/README.md). + +### MCP tools + +These tools are called by the agent. For everyday use, describe the task in your own words. + +- `get_code`: High-fidelity JSX/Vue + TailwindCSS code output by default, plus attached assets and the codegen preset/config used. +- `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. + With `scope: "fonts"`, it queries available font families and exact native styles without + scanning file resources. +- `apply_canvas`: Creates, updates, removes, or activates exact pages and managed roots. Canvas HTML + is optional for page-only operations and native-only updates to existing stable keys inside an + exact managed root; a root can be written directly to an exact off-current page without switching + editor context. The extension resolves, validates, diffs, applies, and + structurally verifies each requested 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 an exact node, exact managed + page, or the current selection. +- `upload_asset`: Stores a generated PNG/JPEG/GIF in the local Hub and returns an `assetHash` + for canvas authoring. +- Binary assets are returned as metadata + HTTP download URLs (`asset.url`) in tool responses. Asset MCP resources are not exposed. --- -## Key features + + +## Inspect designs ### Inspect CSS code @@ -40,7 +182,7 @@ Shows the CSS and JavaScript code for a selected element. -Select any element, and you can obtain the CSS code through the plugin's Code panel. In addition to standard CSS code, TemPad Dev also provides styles in the form of JavaScript objects, making it convenient for use in JSX and similar scenarios. +Select an element to read its CSS in the extension’s Code panel. In addition to standard CSS code, TemPad Dev also provides styles in the form of JavaScript objects, making it convenient for use in JSX and similar scenarios. @@ -89,7 +231,9 @@ When you hover over a node name section in TemPad Dev's inspect panel, a corresp --- -### Plugins + + +## Output plugins @@ -104,7 +248,7 @@ A TemPad Dev plugin is a simple JavaScript file that exports a plugin object as > [!NOTE] > Plugin code is stored in the browser's local storage. Plugins are not versioned or auto-updated, so you must manually update them from the UI. -#### Creating plugins +### Creating plugins Use the fully typed `definePlugin` function from the `@tempad-dev/plugins` package to simplify plugin creation. @@ -154,7 +298,7 @@ Additionally, you can specify a custom `title` and `lang` for the code block or For full type definitions and helper functions, see [`packages/plugins/src/index.ts`](./packages/plugins/src/index.ts). -#### Deploying a plugin +### Deploying a plugin Ensure your plugin is accessible via a URL that supports cross-origin requests, such as a GitHub repository (or Gist). For instance, you can use a raw URL: @@ -177,7 +321,7 @@ side channels, deliberate memory pressure, and unsafe generated output are outsi Review plugin sources accordingly. See [the threat model](./docs/security/local-mcp-threat-model.md) for the exact guarantees and non-goals. -#### Sharing a plugin +### Sharing a plugin You can also register the plugin into our [plugin registry file](https://github.com/ecomfe/tempad-dev/blob/main/packages/extension/plugins/available-plugins.json) so that your plugin can be installed by name directly. @@ -201,67 +345,6 @@ Current available plugins: -## Agent integration - -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 - -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. - -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_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. - -### Setup guide - - - - - 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`. -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. - -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. - -### MCP connection status - -When the MCP server is enabled, a badge appears in the TemPad Dev panel title bar showing the current connection status: - -- **Unavailable**: The local MCP server is not configured or not running. - - - - - MCP status badge showing Unavailable. - - -- **Inactive**: TemPad Dev is connected to a local MCP server, but this tab is not currently active because multiple Figma tabs are open. Click the badge to activate MCP for this tab (this deactivates MCP in other tabs). - - - - - MCP status badge showing Inactive. - - -- **Active**: The MCP server is running, and this tab is active and ready to respond to MCP tool calls. - - - - - MCP status badge showing Active. - - -### Configuration - -For optional environment variables, see [`packages/mcp-server/README.md`](./packages/mcp-server/README.md). -

Inspect TemPad component code

diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 6a6e63cd..7a84227a 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -2,11 +2,11 @@ - 展示扩展面板的截图。 + TemPad Dev

-

Figma 上的开放交付工具

+

连接 Figma、开发者和 coding agent 的开放工具

在 Chrome Web Store 安装 @@ -19,17 +19,153 @@ check-script-rewrite

-

+TemPad Dev 是连接 Figma、开发者和 coding agent 的开源工具。你可以直接在浏览器里检查设计、定制代码输出,也可以让 agent 读取设计、创建和修改原生 Figma 内容,并结合项目实现 UI。 + +## 目录 + +- [快速开始](#快速开始) +- [Agent 集成](#agent-集成):[画布设计](#创建和修改-figma-设计)、[代码实现](#根据设计实现代码)、[配置指南](#配置指南)、[连接状态](#mcp-连接状态) +- [检查设计](#检查设计):[CSS 与变量](#查看-css-代码)、[深度选择](#深度选择模式)、[测量](#测量到选中项模式)、[定位](#将选中项滚动到视图中) +- [输出插件](#输出插件):安装、开发与分享 + +## 快速开始 + +1. 从 [Chrome Web Store](https://chromewebstore.google.com/detail/tempad-dev/lgoeakbaikpkihoiphamaeopmliaimpc) 安装 TemPad Dev,打开 Figma Design 文件。 +2. 选中设计中的元素,在 TemPad Dev 面板查看代码、变量和布局信息。手动检查无需配置 agent。 +3. 如需使用 coding agent,启用 **Preferences → Agent integration → MCP access**,点击 **Set up agents**,按所选客户端的说明安装。 + +Agent 连接需要 Node.js 22.x、24.x 或 26+;画布编辑还需要当前 Figma Design 文件的编辑权限。扩展、MCP server 和两个 skill 的配置与升级说明见下文。 + +## Agent 集成 + +通过你已经在使用的 coding agent 或 IDE 处理 Figma 设计。TemPad Dev 提供设计信息和画布操作,agent 结合你的要求与项目上下文完成工作。 + +### 创建和修改 Figma 设计 + +在 Figma 中创建界面、调整布局和文字,或修改已有设计。结果由原生、可编辑的图层构成;任务需要时,可以复用可访问的组件、变量和样式。 + +例如,在连接好 agent 后提出: + +> 在 Figma 中使用可访问的组件创建一个设置页面。 + +也可以选中已有设计后提出: + +> 调整这个页面的间距和文字层级,保留现有组件和内容。 + +`figma-canvas-authoring` skill 指导 agent 检查相关资源、执行修改并检查实际渲染结果。写入需要可编辑的 Figma Design 文件;只读文件和 Dev Mode 中的访问仍然是只读的。 + +### 根据设计实现代码 + +在 Figma 中选中要实现的设计,在目标代码项目中提出: + +> 根据当前 Figma 选区实现 UI,使用这个项目已有的组件和样式约定。 + +TemPad Dev 提供布局、样式、变量引用、组件信息和素材。`figma-design-to-code` skill 指导 agent 结合仓库实现界面,完成验证。生成的设计代码是实现起点,最终代码由 agent 适配项目。 + +这两个工作流通过同一个 MCP 连接访问 Figma。兼容客户端可以安装包含 MCP 配置和两个 skill 的 [Agent Plugin](./agent-plugins/tempad-dev/README.zh-Hans.md);其它客户端可以分别配置 MCP 和 skill。 + +### 配置指南 + + + + + TemPad Dev agent setup 对话框。 + + +1. 安装 Node.js 22.x、24.x 或 26+ 并确保 `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. 对 Codex、Cursor、Claude Code 和 VS Code,配置流程会优先安装可移植的 Agent Plugin。对 Gemini、OpenCode、TRAE 及其它尚无兼容 plugin 安装能力的客户端,则使用对应客户端的 MCP 流程并单独安装两个 skill。所有命令和 config 都会完整显示,便于检查和复制。 + +以下以 Gemini 为例,展示分别配置 MCP 和两个 skill 的安装路径: + + + + + Gemini 的 MCP 安装说明。 + + +向下滚动可查看两个 skill 的完整安装命令: + + + + + Gemini 的两个 skill 安装命令。 + + +要把可移植插件安装到本机检测到的所有兼容 agent,可运行: + +```bash +npx plugins add ecomfe/tempad-dev +``` + +使用 `--target codex`、`--target cursor`、`--target claude-code` 或 `--target vscode` 可以只 +安装到内置配置入口中的某一个目标。Codex 与 Claude 的原生 marketplace 命令,以及直接 +安装 MCP 和 skill 的方式,仍作为兼容回退保留在 +[Agent Plugin 指南](./agent-plugins/tempad-dev/README.zh-Hans.md)中。 + +所有 plugin 和直接使用 `npx` 的配置路径都使用 `@tempad-dev/mcp@latest`。 + +本次画布创作版本应配套使用扩展 **0.21.0**、MCP server **0.8.0** 和 Agent Plugin +**0.2.0**。更新既有安装时,请参阅 [升级指南](./agent-plugins/tempad-dev/README.zh-Hans.md#升级)。 + +使用期间请保持 TemPad Dev 打开并启用 MCP。如果连接了多个 Figma 文件,请点击目标文件面板中的 MCP 徽标;该文件会成为 agent 当前访问的上下文。 + +### MCP 连接状态 + +启用 MCP 服务器后,TemPad Dev 面板标题栏中会显示一个徽标,表示当前的连接状态: + +- **Unavailable**:本地 MCP 服务器未配置或未运行。 + - - - 展示扩展面板代码视图的截图。 + + + MCP 状态徽标,显示为 Unavailable。 -

+ +- **Inactive**:TemPad Dev 已连接到本地 MCP 服务器,但由于打开了多个 Figma 标签页,此标签页当前未激活。点击徽标即可为当前标签页激活 MCP(同时会停用其他标签页的 MCP)。 + + + + + MCP 状态徽标,显示为 Inactive。 + + +- **Active**:MCP 服务器正在运行,并且当前标签页已激活,可随时响应 MCP 工具调用。 + + + + + MCP 状态徽标,显示为 Active。 + + +### 配置项 + +`@tempad-dev/mcp` 的环境变量配置请参见 [`packages/mcp-server/README.zh-Hans.md`](./packages/mcp-server/README.zh-Hans.md)。 + +### MCP 工具 + +以下工具供 agent 调用;日常使用可以直接描述任务。 + +- `get_code`:默认输出高保真的 JSX/Vue + TailwindCSS 代码,同时包含相关资源以及使用的 codegen 预设和配置。 +- `get_design_system`:创建不可变、确定性的紧凑目录,按资源类型平衡分页返回可访问页面的 + 组件定义,以及本地或被定义直接引用的变量、集合/模式、样式和 shader 定义;既不扫描 + 画布中的使用情况,也不加载所有页面。游标可继续读取遗漏定义;使用同一目录精确查询 + 某个引用时,返回该资源的有界定义。使用 `scope: "fonts"` 可查询当前可用字体家族和精确 + 原生样式,不扫描文件资源。 +- `apply_canvas`:对精确页面或托管根节点执行创建、更新、删除或激活。仅操作页面时可省略 + Canvas HTML;对精确托管根内既有稳定 key 的纯 native 更新也可省略。也可以直接把根节点写入 + 非当前的精确目标页面,而不切换编辑器上下文。扩展会在本地解析、验证、计算与实时画布的 + 差异、应用修改并校验结构。画布创作要求当前 Figma Design 文件具有编辑权限。 +- `get_screenshot`:返回一张有大小限制的渲染 PNG,用于按需视觉验证。 +- `get_structure`:精确节点、精确托管页面或当前选中节点的结构信息(id、类型、几何数据)。 +- `upload_asset`:将生成的 PNG/JPEG/GIF 存入本地 Hub,并返回供画布创作使用的 `assetHash`。 +- 二进制资源会通过工具响应中的元数据 + HTTP 下载地址(`asset.url`)提供;MCP 不再暴露 asset 资源模板。 --- -## 主要功能 + + +## 检查设计 ### 查看 CSS 代码 @@ -39,7 +175,7 @@ 展示所选元素的 CSS 和 JavaScript 代码。 -选择任意元素后,你可以在插件的 Code 面板中获取对应的 CSS 代码。除了标准的 CSS 代码之外,TemPad Dev 还会以 JavaScript 对象的形式提供样式,方便在 JSX 等场景中直接使用。 +选择元素后,你可以在扩展的 Code 面板中获取对应的 CSS 代码。除了标准的 CSS 代码之外,TemPad Dev 还会以 JavaScript 对象的形式提供样式,方便在 JSX 等场景中直接使用。 @@ -88,7 +224,9 @@ --- -### 插件 + + +## 输出插件 @@ -103,7 +241,7 @@ > [!NOTE] > 插件代码存储在浏览器的本地存储中,不支持版本管理或自动更新,需要你在 UI 中手动更新。 -#### 创建插件 +### 创建插件 使用 `@tempad-dev/plugins` 包中提供的、带完整类型定义的 `definePlugin` 函数,可以简化插件的创建过程。 @@ -153,7 +291,7 @@ export default definePlugin({ 完整的类型定义和辅助函数请参见 [`packages/plugins/src/index.ts`](./packages/plugins/src/index.ts)。 -#### 部署插件 +### 部署插件 请确保你的插件可以通过支持跨域请求的 URL 访问,例如托管在 GitHub 仓库或 Gist 中。比如可以使用 raw 地址: @@ -173,7 +311,7 @@ sandboxed extension page 内启动一个全新的 Worker,并在完成或五秒 蓄意内存压力以及不安全的生成内容不属于该边界。仍建议审查插件来源。准确保证与非目标见 [威胁模型](./docs/security/local-mcp-threat-model.md)。 -#### 分享插件 +### 分享插件 你也可以将插件注册到我们的 [插件注册表文件](https://github.com/ecomfe/tempad-dev/blob/main/packages/extension/plugins/available-plugins.json) 中,这样就可以通过插件名直接安装。 @@ -197,67 +335,6 @@ sandboxed extension page 内启动一个全新的 Worker,并在完成或五秒 -## Agent 集成 - -TemPad Dev 内置了面向编码 agent 和 IDE 的 Agent 集成。该集成包含: - -- 一个 [MCP](https://modelcontextprotocol.io/) 服务器,使 agent 可以直接从你在 Figma 中选中的节点拉取代码和上下文 -- 一个 agent skill,用于指导 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_structure`:当前选中节点的结构信息(id、类型、几何数据)。 -- 二进制资源会通过工具响应中的元数据 + HTTP 下载地址(`asset.url`)提供;MCP 不再暴露 asset 资源模板。 - -### 配置指南 - - - - - 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`。 -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;其它路径会分别展示两个必要步骤。 - -使用期间请保持 TemPad Dev 打开并启用 MCP。如果连接了多个 Figma 文件,请点击目标文件面板中的 MCP 徽标;该文件会成为 agent 当前访问的上下文。 - -### MCP 连接状态 - -启用 MCP 服务器后,TemPad Dev 面板标题栏中会显示一个徽标,表示当前的连接状态: - -- **Unavailable**:本地 MCP 服务器未配置或未运行。 - - - - - MCP 状态徽标,显示为 Unavailable。 - - -- **Inactive**:TemPad Dev 已连接到本地 MCP 服务器,但由于打开了多个 Figma 标签页,此标签页当前未激活。点击徽标即可为当前标签页激活 MCP(同时会停用其他标签页的 MCP)。 - - - - - MCP 状态徽标,显示为 Inactive。 - - -- **Active**:MCP 服务器正在运行,并且当前标签页已激活,可随时响应 MCP 工具调用。 - - - - - MCP 状态徽标,显示为 Active。 - - -### 配置项 - -`@tempad-dev/mcp` 的环境变量配置请参见 [`packages/mcp-server/README.zh-Hans.md`](./packages/mcp-server/README.zh-Hans.md)。 -

查看 TemPad 组件代码

diff --git a/TESTING.md b/TESTING.md index 66acf6b4..988e5916 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,6 +39,13 @@ Root: - `pnpm --filter @tempad-dev/extension test:setup` (install extension browser runtime) - `pnpm --filter @tempad-dev/extension test:node` (extension node tests only) - `pnpm --filter @tempad-dev/extension test:browser` (extension browser tests only) +- `pnpm agent-eval:authoring [...]` (inspect comparable rollout evidence) +- `pnpm agent-eval:preflight [--checkout ] [--app-path ]` (reject a stale + checkout runtime, inactive extension, or development plugin that does not match + the configured Codex desktop host before page creation) +- `pnpm agent-eval:skills ` (fingerprint the presented skill catalog) +- `pnpm agent-eval:log ` (retain the small amount + of provenance needed to trust a live authoring run) Per package: @@ -57,6 +64,9 @@ Per package: - `pnpm --filter @tempad-dev/mcp test:coverage` - `pnpm --filter @tempad-dev/shared test:run` - `pnpm --filter @tempad-dev/shared test:coverage` +- `pnpm --filter @tempad-dev/site test:run` (node and Chromium reader regressions) +- `pnpm --filter @tempad-dev/site test:browser` +- `pnpm --filter @tempad-dev/site test:setup` (install Chromium for site browser tests) ## Required checks by change type @@ -81,6 +91,23 @@ When changing DOM/browser runtime behavior in extension: - `pnpm --filter @tempad-dev/extension test:browser` - Use Playwright browser tests only; do not add jsdom-based tests. +When changing the end-to-end authoring evaluation process or its runtime identity gate: + +- Follow `docs/testing/agent-authoring-evolution.md`. +- Use the stable minimal open-run wrapper from the evolution runbook, varying only the product + platform and product situation by default; add a broad visual direction only when relevant + to the question. The agent chooses dimensions and screen/flow extent. Freeze the prompt, intent, + model, and reasoning effort immediately before dispatch; do not add a predicted result or evaluator-authored solution detail. +- Judge the authored result as a whole in plain language. Treat screenshots, native structure, + timing, and tool traces as clues: inspect only what can confirm or explain the judgment. Do not + introduce fixed quality axes, scores, finding counts, or promotion gates. +- Add deterministic tests for run-log integrity, runtime fingerprinting, bridge handshake, and + write-before rejection at the owning package layers. Deterministic checks do not need live-run + log entries. +- A fresh live Figma task is required only when the selected question needs agent + discoverability, visual, structural, transfer, or drift evidence; it is not a default check for + deterministic process infrastructure. + ## Coverage rules (operational) - Workspace coverage is configured in root `vitest.config.ts`. @@ -136,3 +163,4 @@ When changing DOM/browser runtime behavior in extension: - Testing architecture: `docs/testing/architecture.md` - Extension get_code requirements: `docs/extension/mcp-get-code-requirements.md` - Extension get_code design: `docs/extension/mcp-get-code-design.md` +- Agent authoring evolution: `docs/testing/agent-authoring-evolution.md` diff --git a/agent-plugins/tempad-dev/.claude-plugin/plugin.json b/agent-plugins/tempad-dev/.claude-plugin/plugin.json index bde03b87..80030bed 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.", + "version": "0.2.0", + "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "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..833ede6f 100644 --- a/agent-plugins/tempad-dev/.codex-plugin/plugin.json +++ b/agent-plugins/tempad-dev/.codex-plugin/plugin.json @@ -1,29 +1,47 @@ { "name": "tempad-dev", - "version": "0.1.1", - "description": "Use the TemPad Dev agent integration to turn selected Figma nodes into repo-ready UI code.", + "version": "0.2.0", + "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "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": "Inspect, edit, and implement Figma designs with your agent.", + "longDescription": "TemPad Dev connects your coding agent to Figma. Read designs, components, variables, and assets; create and edit native Figma layers; and implement existing designs using your project’s conventions. Includes the MCP connection and skills for canvas editing and design-to-code. Requires the TemPad Dev browser extension; canvas editing also requires edit access to the Figma Design file.", "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." + "Implement the selected Figma design using this project’s components and styles.", + "Create an editable settings screen in Figma using the available components.", + "Update the spacing and typography in this Figma design." ], - "brandColor": "#0098FF" + "brandColor": "#0098FF", + "composerIcon": "./assets/icon-padded.svg", + "logo": "./assets/icon-padded.svg" }, "mcpServers": "./.mcp.json" } diff --git a/agent-plugins/tempad-dev/CHANGELOG.md b/agent-plugins/tempad-dev/CHANGELOG.md new file mode 100644 index 00000000..d3d405f7 --- /dev/null +++ b/agent-plugins/tempad-dev/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 0.2.0 + +- Added `figma-canvas-authoring` for creating and editing native Figma designs, alongside the + existing `figma-design-to-code` skill. +- Added progressive references for native authoring, fonts, images, icons, resource bindings, + and scoped editing. Direct, Reuse, and Author workflows keep resource decisions tied to the task. +- Grounded new compositions in inspectable evidence and required inspection of the rendered result + plus relevant native facts, with focused repair of observed defects. +- Made the portable Agent Plugins 1.0 bundle the shared source for installation, with synchronized + Codex and Claude compatibility manifests and refreshed icons. +- Paired the plugin with extension 0.21.0 and MCP 0.8.0 through `@tempad-dev/mcp@latest`. + The MCP server requires Node.js 22.x, 24.x, or 26+. diff --git a/agent-plugins/tempad-dev/README.md b/agent-plugins/tempad-dev/README.md index 3042a180..9f2281ed 100644 --- a/agent-plugins/tempad-dev/README.md +++ b/agent-plugins/tempad-dev/README.md @@ -1,29 +1,90 @@ # TemPad Dev Agent Plugin -This plugin packages the TemPad Dev agent integration for Codex and Claude Code. It bundles: +[简体中文](./README.zh-Hans.md) -- the `figma-design-to-code` agent skill -- the TemPad Dev MCP server configuration for selected-node design evidence +Read, edit, and implement Figma designs through your coding agent or IDE. This plugin includes: -Install it for Codex: +- `figma-canvas-authoring`: create and revise native Figma designs, reusing accessible components, variables, and styles as needed. +- `figma-design-to-code`: use Figma design context to implement UI with your project’s components and conventions. +- The TemPad Dev MCP server configuration: connect to the Figma file open in your browser. + +Requires the TemPad Dev browser extension. Canvas editing also requires edit access to the Figma Design file. For manual inspection and output plugins, see the full [user guide](../../README.md). + +The root `plugin.json`, `skills/`, and `mcp.json` follow [Agent Plugins 1.0](https://agent-plugins.org/); client-specific manifests are compatibility wrappers. + +## Install the portable plugin + +Install into every compatible agent detected on your machine: + +```bash +npx plugins add ecomfe/tempad-dev +``` + +To install into one agent only, pass a target such as: + +```bash +npx plugins add ecomfe/tempad-dev --target codex +npx plugins add ecomfe/tempad-dev --target cursor +npx plugins add ecomfe/tempad-dev --target claude-code +npx plugins add ecomfe/tempad-dev --target vscode +``` + +The installer reads the portable package first and adapts it only when the selected client needs a +client-specific layout. + +## Client-specific fallbacks + +Use these native marketplace flows only when the portable installer is unavailable or client +policy requires the native path. + +### Codex ```bash codex plugin marketplace add ecomfe/tempad-dev --ref main codex plugin add tempad-dev@tempad-dev ``` -You can also install **TemPad Dev** from the Codex app plugin directory after adding the marketplace. +You can also install **TemPad Dev** from the Codex app plugin directory after adding the +marketplace. -Install it for Claude Code CLI and Desktop: +### Claude Code and Claude Desktop ```bash claude plugin marketplace add ecomfe/tempad-dev 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. +The plugin appears in Claude Desktop after the marketplace is added. + +For clients without Agent Plugin support, follow the direct MCP and standalone skill setup in the +[complete setup guide](../../README.md#agent-integration). + +## Usage + +Before using the integration, open TemPad Dev in Figma, then open **Preferences → Agent +integration** and enable **MCP access**. Canvas authoring is available while the active Figma +Design file is editable. + +## Upgrading + +The canvas-authoring release pairs Agent Plugin **0.2.0**, TemPad Dev extension **0.21.0**, and +MCP server **0.8.0**. Node.js **22.x, 24.x, or 26+** is required for the MCP server. + +1. Update the browser extension and reload the Figma tab. +2. Update the installed plugin through the client or installer used originally. With standalone + setup, update both `figma-design-to-code` and `figma-canvas-authoring`. +3. Keep the release MCP configuration on `@tempad-dev/mcp@latest`; replace any previous + `@alpha` or fixed alpha version. Reconnect the MCP client and start a new task so it loads the + updated tools and skills. If a stale Hub is reported, close tasks using the old MCP server + before reconnecting. +4. Open TemPad Dev, enable **MCP access**, and click the MCP badge in the intended Figma tab when + a session choice is needed. The badge selects the file receiving tool calls. -Before using the integration, open TemPad Dev in Figma, then open **Preferences -> Agent integration** and enable **MCP access**. +## Packaging source of truth -For app, CLI, direct MCP, and manual fallbacks, see the [complete setup guide](../../README.md#agent-integration). +- Edit `plugin.json`, `skills/`, and `mcp.json` for portable content. +- `.codex-plugin/plugin.json`, `.claude-plugin/plugin.json`, and `.mcp.json` are compatibility + wrappers. Their shared metadata and MCP entries are synchronized from the portable files by + `pnpm agent-plugin:dev`. +- Codex-only interface metadata remains in `.codex-plugin/plugin.json` and is preserved during + synchronization. diff --git a/agent-plugins/tempad-dev/README.zh-Hans.md b/agent-plugins/tempad-dev/README.zh-Hans.md new file mode 100644 index 00000000..39806f30 --- /dev/null +++ b/agent-plugins/tempad-dev/README.zh-Hans.md @@ -0,0 +1,84 @@ +# TemPad Dev Agent Plugin + +[English](./README.md) + +在你的 coding agent 或 IDE 中读取、编辑和实现 Figma 设计。这个插件包含: + +- `figma-canvas-authoring`:创建和修改原生 Figma 设计,按任务需要复用可访问的组件、变量和样式。 +- `figma-design-to-code`:读取 Figma 设计信息,结合项目已有组件和约定实现 UI。 +- TemPad Dev MCP server 配置:连接浏览器中打开的 Figma 文件。 + +需要安装 TemPad Dev 浏览器扩展。画布编辑还需要 Figma Design 文件的编辑权限。手动检查设计和输出插件的完整说明见 [使用指南](../../README.zh-Hans.md)。 + +根目录的 `plugin.json`、`skills/` 和 `mcp.json` 遵循 [Agent Plugins 1.0](https://agent-plugins.org/);客户端专用清单是兼容包装。 + +## 安装可移植插件 + +安装到本机检测到的所有兼容 agent: + +```bash +npx plugins add ecomfe/tempad-dev +``` + +如果只安装到一个 agent,请指定 target,例如: + +```bash +npx plugins add ecomfe/tempad-dev --target codex +npx plugins add ecomfe/tempad-dev --target cursor +npx plugins add ecomfe/tempad-dev --target claude-code +npx plugins add ecomfe/tempad-dev --target vscode +``` + +安装器会优先读取可移植 package,仅在目标客户端需要时转换为客户端专用目录结构。 + +## 客户端专用回退 + +仅当可移植安装器不可用,或客户端策略要求使用原生流程时,才使用以下 marketplace +安装方式。 + +### Codex + +```bash +codex plugin marketplace add ecomfe/tempad-dev --ref main +codex plugin add tempad-dev@tempad-dev +``` + +添加 marketplace 后,也可以从 Codex 应用的插件目录安装 **TemPad Dev**。 + +### Claude Code 和 Claude Desktop + +```bash +claude plugin marketplace add ecomfe/tempad-dev +claude plugin install tempad-dev@tempad-dev +``` + +添加 marketplace 后,该插件也会出现在 Claude Desktop 中。 + +不支持 Agent Plugin 的客户端,请按照 +[完整配置指南](../../README.zh-Hans.md#agent-集成)直接配置 MCP 并安装独立 skill。 + +## 使用 + +使用前,请在 Figma 中打开 TemPad Dev,然后进入 **Preferences → Agent integration** +并启用 **MCP access**。启用后,只要当前 Figma Design 文件可编辑,即可进行画布创作。 + +## 升级 + +本次画布创作版本应配套使用 Agent Plugin **0.2.0**、TemPad Dev 扩展 **0.21.0** 和 MCP +server **0.8.0**。MCP server 要求 Node.js **22.x、24.x 或 26+**。 + +1. 更新浏览器扩展,并重新加载 Figma 标签页。 +2. 通过原先使用的客户端或安装器更新 plugin。独立配置时,请同时更新 + `figma-design-to-code` 和 `figma-canvas-authoring`。 +3. 正式版 MCP 配置使用 `@tempad-dev/mcp@latest`;请替换旧的 `@alpha` 或固定 alpha 版本。 + 重新连接 MCP client 并新建任务,以加载更新后的工具和 skill。若提示 Hub 过期,请先关闭 + 使用旧 MCP server 的任务,再重新连接。 +4. 打开 TemPad Dev 并启用 **MCP access**;需要选择会话时,点击目标 Figma 标签页内的 MCP + badge。实际接收工具调用的文件由该 badge 选择。 + +## 封装内容源 + +- 可移植内容请修改 `plugin.json`、`skills/` 和 `mcp.json`。 +- `.codex-plugin/plugin.json`、`.claude-plugin/plugin.json` 和 `.mcp.json` 是兼容封装; + `pnpm agent-plugin:dev` 会从可移植文件同步公共 metadata 与 MCP 配置。 +- Codex 专用的 interface metadata 仍保存在 `.codex-plugin/plugin.json` 中,并会在同步时保留。 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..2e8c6946 --- /dev/null +++ b/agent-plugins/tempad-dev/assets/icon-padded.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/agent-plugins/tempad-dev/assets/icon.png b/agent-plugins/tempad-dev/assets/icon.png new file mode 100644 index 00000000..5d1f6bf2 Binary files /dev/null and b/agent-plugins/tempad-dev/assets/icon.png differ diff --git a/agent-plugins/tempad-dev/mcp.json b/agent-plugins/tempad-dev/mcp.json new file mode 100644 index 00000000..4326590e --- /dev/null +++ b/agent-plugins/tempad-dev/mcp.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "tempad-dev": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@tempad-dev/mcp@latest"] + } + } +} diff --git a/agent-plugins/tempad-dev/plugin.json b/agent-plugins/tempad-dev/plugin.json new file mode 100644 index 00000000..78fa0ccc --- /dev/null +++ b/agent-plugins/tempad-dev/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "tempad-dev", + "version": "0.2.0", + "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", + "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", + "canvas-authoring", + "design-system", + "frontend" + ] +} 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..f4a54ea5 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/SKILL.md @@ -0,0 +1,150 @@ +--- +name: figma-canvas-authoring +description: >- + Create or update native, editable Figma designs with TemPad Dev MCP: screens, + flows, components, and requested local design-system resources, including on + an empty canvas. Use for Design in Figma work, not Figma-to-code, critique + without edits, or raw Plugin API automation. +--- + +# Design in Figma + +Deliver the smallest complete native Figma result that serves the user's +situation. Keep the working experience in view as you research, compose, and +repair. A successful tool call establishes a document change; the rendered +result and its editable structure establish whether that change served the task. + +## Establish the task + +Use the user's exact target and constraints. For an existing design, inspect +`get_code` and its pixels before changing the composition; use `get_structure` +for hierarchy, geometry, stable keys, or selected native facts. Write to a known +page directly. Create or activate a page only when the task calls for it. +Infer low-consequence gaps; ask when a missing decision would materially change +the result. Keep unrelated account, filesystem, task, and page metadata out of +product identity and content. + +Require an editable Figma Design file and the intended tab's active MCP +connection. Use the host's TemPad MCP tools for all canvas reads and writes. +If unavailable, report the integration problem and stop. Do not launch the CLI, +recreate its transport, use browser automation to set up the canvas, or emit raw +Plugin API operations. Research and asset acquisition use the host's appropriate +tools; website research uses the in-app browser when available unless the user +selected another browser. + +## Ground and compose + +For net-new or materially redesigned interfaces without an established system, +read [style-grounding.md](references/style-grounding.md) and inspect relevant +real product screens or a permitted implementation before the first Canvas +write. The evidence must expose the interface relationships informing the new +work. Search snippets, URLs, failed retrievals, and generated concepts do not +establish a precedent. Subject imagery establishes its depicted content, not +its surrounding application's design. Try another permitted source when +retrieval fails; if none is inspectable, disclose the gap and stop. Supplied +source pixels or implementation can satisfy this boundary; mechanical edits do +not require unrelated research. + +Resolve what the person needs to recognize or change, which content and states +carry that work, and how the interface makes their consequences perceptible. +Choose the screen or flow, visual language, density, and scrolling model from +that situation. Use [visual-composition.md](references/visual-composition.md) +when forming or reconsidering a composition. Familiar structures and distinctive +ones both need a reason in the task. Research informs an independent solution; +it does not authorize copying a composition or placing reference pixels on the +canvas unless the user requested that treatment. + +When selecting or changing fonts, or when script coverage is uncertain, read +[typefaces.md](references/typefaces.md) to resolve candidates and native identities. + +Choose representations by their role in the work. Once an image, icon, diagram, +or visualization matters to the direction, read +[visual-assets.md](references/visual-assets.md) and its selected branch. Do not +silently replace the chosen content or medium to simplify sourcing or markup. +For content-bearing graphics, preserve meaningful marks and editable +relationships with native shapes, vectors, text, and groups; styled FRAME +lookalikes do not acquire drawing semantics. Read +[document-geometry.md](references/document-geometry.md) for that construction. +Ordinary UI panels, controls, backgrounds, and separators remain Canvas HTML. + +Choose resources from the task, not repetition alone: + +- **Direct:** default for a first net-new composition. Use primitives, literals, + and assets. Do not discover or create a design system just because shapes or + values repeat. +- **Reuse:** use [design-system-reuse.md](references/design-system-reuse.md) when + the user, selected source, or project evidence establishes the applicable + system. Catalog names, domain similarity, or mere file presence do not prove + relevance. +- **Author:** use [design-system-authoring.md](references/design-system-authoring.md) + when reusable resources are requested or established as part of the + deliverable. Prove the composition and one real consumer before propagation. + +For selected variables and typography styles, read +[resource-mapping.md](references/resource-mapping.md): define or discover their +identities once, then use variable utilities and text-style classes throughout +the markup. + +## Build, inspect, and repair + +For markup create or structural update, read +[canvas-html.md](references/canvas-html.md) and check its preflight before the +call. Canvas HTML is a strict native-state dialect; browser CSS assumptions do +not apply. Page-only and native-only operations omit markup. Load native +mechanics only for the capabilities selected below. + +Build a materially complete representative screen, then open its PNG before +expanding the flow or extracting resources. Judge whether the whole supports +the intended work. When it does not, focus on the particular relationship or +execution defect that explains the mismatch and repair it. A skeleton, resource +board, or generated concept does not establish the real composition. + +For updates, read [editing.md](references/editing.md). Preserve the requested +source, unrelated fields, and stable identities while updating every dependent +representation of the changed state. For larger results, split at meaningful +screen or section boundaries and carry shared roles coherently across them. + +Inspect every `apply_canvas` result, including warnings. Repair each observed +unintended defect or disclose why it remains. A local validation failure calls +for a local payload correction; it does not justify discarding a working root +or simplifying away the intended content. Open pixels again after the final +material write, covering every materially distinct screen. Verify native facts +with `get_structure` when identity, placement, editability, or representation +matters. Opened pixels prove visual access, not good judgment; a structural pass +proves only the conditions checked. + +Finish when the requested experience is coherent and observed defects are +repaired, accepted with reason, or disclosed. Report the delivered result and +material limitations. A verified Direct result is complete without an +unsolicited component pass. + +## Native mechanics — load when selected + +Read the selected reference completely; do not preload the capability catalog. +Examples demonstrate syntax, not a design template. + +| Capability | Reference | +| --------------------------------------------------------------------- | ----------------------------------------------------------- | +| Exact updates, removal, or editor context | [editing.md](references/editing.md) | +| Pages, sections, groups, Booleans, masks, transforms, shapes, vectors | [document-geometry.md](references/document-geometry.md) | +| Paints, media, effects, shaders, grids, guides | [paints-effects.md](references/paints-effects.md) | +| Exact fonts, rich text, range styles, lists, hyperlinks | [rich-text.md](references/rich-text.md) | +| Components, variants, properties, Slots | [component-authoring.md](references/component-authoring.md) | +| Variables, collections, modes, bindings | [variables.md](references/variables.md) | +| CSS variable utilities and named text-style classes | [resource-mapping.md](references/resource-mapping.md) | +| Paint, Text, Effect, Grid styles | [local-styles.md](references/local-styles.md) | +| Authorized independent research, assets, inventory, or QA delegation | [delegation.md](references/delegation.md) | + +## Mutation boundaries + +Use returned IDs and stable keys as identity, never names. Create describes a +new complete root or exact new page. Update targets an exact node or page; +omissions preserve live state. `activate` always requires `page.id` or +`page.pageKey`, even when only changing selection. + +Never mutate outside scope, remove manual or unkeyed content, or remove a +component with surviving instances. An instance's definition-derived sublayers +are not authoring targets. Do not mutate remote resources, publish, detach or +reset instances, execute arbitrary JavaScript, or imitate an unresolved +resource. Use `null` only for supported links or managed resources the requested +change actually removes. 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..25e5075e --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: 'Design in Figma' + 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..2e8c6946 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + 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..aa4cb38f --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/canvas-html.md @@ -0,0 +1,269 @@ +# Canvas HTML and Tailwind subset + +Canvas HTML describes desired state, not browser rendering. Use its elements for +interface structure and genuine simple UI geometry, not as a drawing medium. +Do not assemble `div` or `span` primitives to imitate a photograph, +illustration, icon, logo, texture, or other content-bearing visual; acquire the +appropriate routed raster or vector asset instead. Classes do not cover every +Figma result: use routed native bindings for gradients, media, non-shadow +effects, masks, transforms, exact fonts, and rich text. + +One `apply_canvas` markup tree may contain at most 160 elements and 12 levels. +This is a safety ceiling, not a target. Before calling, count the tree, include +only assets referenced by that call, and split larger work at meaningful screen +or section boundaries. + +Prefer supported Tailwind utilities; use arbitrary pixels only off the default +scale. Numeric spacing follows Tailwind v4's `4px` unit. Selected Figma resources +can use CSS variable utilities and `type-*` text-style classes through +[resource-mapping.md](resource-mapping.md). Arbitrary project theme extensions, +variants, plugins, viewport-dependent utilities, and CSS cascade are unsupported. + +## Contents + +- [Preflight each markup tree](#preflight-each-markup-tree) +- [Elements and identity](#elements-and-identity) +- [Layout](#layout) +- [Appearance and text](#appearance-and-text) + +## Preflight each markup tree + +Immediately before each create or structural update, scan the complete supplied +tree once: + +- require a fixed width and height on the markup root; +- give every `div` with children `flex` or `grid`, or make every child absolute + with one edge per axis and fixed parent and child dimensions; +- keep flex, grid, gap, padding, border, corner, and box-shadow classes off + `span`; +- trace every `w-full`, `h-full`, and `grow` against its direct parent's axis and + the element's required dimensions; +- give a fixed-height grid explicit row tracks when its children should fill or + divide that height; omitted rows remain content-sized; +- count at most 160 elements and 12 levels, and include only assets referenced by + this call. + +Correct the complete set before calling instead of serializing until validation +reveals issues one at a time. + +## 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; instance + sublayers are not authoring targets. +- When only native state changes, omit markup, target the exact managed root, + and key `native` by existing stable keys in that scope. This preserves + topology; masks and node removal still require structural markup. +- 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 only text and `
` or `
` line breaks. Use + `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is + literal unless it forms a semicolon-terminated entity; supported entities + decode. Canvas typography does not inherit from a parent `div`: put font and + other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, + borders, corners, and box shadows on a parent `div`. +- A component tag is childless, includes its returned `data-ref`, and accepts + returned props plus the shared class, identity, variable, and style + attributes. + +Variable attributes use kebab-case native field names: fill, stroke, characters, +visible, dimensions/bounds, gaps, four paddings/corners/stroke sides, radius, +stroke weight, opacity, and whole-node font/line-height/letter-spacing/paragraph +fields. Style attributes are `data-style-fill`, `data-style-stroke`, +`data-style-text`, `data-style-effect`, and `data-style-grid`. Node-type and +fallback rules 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 + +Text using `w-fit` also needs `h-fit`; prefer `size-fit`. Fixed-width `h-fit` +remains valid for wrapping text. + +Create and update markup roots require fixed width and height; fill, hug, and +grow are invalid even when the live target has a sized parent. + +Use `w-full` only on a `flex-col` cross axis, `h-full` only on a `flex-row` +cross axis, and `grow` on the main axis; `grow-0` clears growth. `grow` does not +replace required dimensions—for a row track use `grow w-fit h-[3px]`. Give +growing text in constrained rows a positive `min-w-*` to prevent collapse. +Prefer a hug main axis for content stacks whose extent is not behaviorally +fixed. Otherwise budget the fixed axis as padding + gaps + fixed/minimum child +extents. A non-overflowing result is still wrong when resolved content consumes +the intended inset; compare rendered child edges with the layout's padding. +Grid children may fill cells. Direct dimension variables require fixed +fallbacks. Fixed sizes must be at least `0.01px`; native lines use `h-[0px]`. + +## Layout + +Use Auto Layout for ordinary product UI. `flex` follows CSS's horizontal default; +use `flex-row` when that direction should be explicit and `flex-col` for a +vertical stack: + +- `flex`, `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` + +New Auto Layout frames include inside strokes by default (`box-border`); +`box-content` excludes them. Center/outside strokes never affect layout, and +each nested frame owns its setting. Fixed create sizes must cover opposing +padding plus included inside strokes. Figma determines `FILL` geometry and +border-box distribution. Derive exact descendant or instance sizes from the +rendered inner box, not nominal parent size; prefer valid cross-axis fill and +exceed the box only for intentional bleed or overlap. + +`managed-content-overflow` means managed Text or INSTANCE exceeds its direct +managed Frame or Component, or a native INSTANCE contains descendant content +beyond its own fixed root. Inspect edges, clipping, rendering, and instance +bounds; resize or realign accidental overflow and retain only intentional bleed, +crop, or overlap. Property-driven content outside an INSTANCE root is a broken +component contract rather than intentional consumer overflow. + +`justify-between` uses nonnegative native Auto gap and keeps one child at the +start. Use negative `figma.autoLayout.itemSpacing` only for intentional overlap. +Omitting box-sizing on update preserves the live setting. + +`hidden` and BOOLEAN visibility remove in-flow children, changing gaps, +positions, and hug bounds. To preserve geometry, keep a fixed slot and toggle +its inner child. `absolute left-[Npx] top-[Npx]` maps to Ignore Auto Layout for +true overlays; it needs fixed offsets, cannot fill/grow, and leaves surrounding +flow unchanged. Its text and Auto Layout descendants may still hug. + +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 manual grid children both row and column starts or neither. Auto-flow uses +source order without explicit starts. A height-hugging grid cannot use flexible +or automatic rows; fix either its height or row tracks. Omitting `grid-rows-*` +creates native automatic content-sized rows; increasing only the container +height does not enlarge them. + +For a coherent board larger than one call, first create one fixed parent: + +```json +{ + "mode": "create", + "markup": "
" +} +``` + +Then append one bounded screen per update. Keep the root key and classes stable, +target its returned ID, and omit previously added children so they remain in +place: + +```json +{ + "mode": "update", + "targetNodeId": "FrameID:app-board", + "markup": "
" +} +``` + +For freeform composition, omit layout classes and give each child `absolute` +with exactly one horizontal edge (`left-*` or `right-*`) and one vertical edge +(`top-*` or `bottom-*`), including negative or exact values, or use a native +relative transform. Edge placement needs fixed parent and child sizing modes; +right/bottom offsets are resolved from live bounds after each markup apply. They +are placements, not reactive CSS anchors: use Auto Layout for alignment that +must follow later mode changes without another markup apply. A plain +non-flex/grid `div` is freeform even with one child; opt into layout for every +in-flow child. Absolute children cannot grow or fill; use `static` to return one +to Auto Layout on update. + +## Appearance and text + +Frame appearance: + +- `bg-transparent|white|black`, or an exact CSS hex value +- Linear backgrounds use `bg-linear-to-t|tr|r|br|b|bl|l|tl` with exact + `from-white|black|[#hex]`, optional `via-white|black|[#hex]`, and required + `to-white|black|[#hex]` stops. Stops are fixed at 0, optional 0.5, and 1; + `bg-gradient-to-*` is accepted as a legacy alias. Do not combine a gradient + with a solid background, direct fill paints, or a fill style/variable. +- `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` +- A clipped rounded frame does not paint its inside stroke above children. A + filled child that reaches a curved edge can therefore square off or hide the + boundary even with `overflow-hidden`; inset it, give the touching child + corners a corresponding inner radius, or add a dedicated foreground + boundary, then inspect the rendered pixels. +- Exact pixel shadow lists through `shadow-[...]` or `inset-shadow-[...]`. + Each layer needs an explicit hex, `rgb()`, or `rgba()` color and two to four + pixel lengths; use underscores for spaces, for example + `shadow-[0_8px_24px_rgba(0,0,0,0.16)]`. +- `shadow-none` and `inset-shadow-none` clear their class-owned effect stack. + Theme-dependent named scales such as `shadow-md` are unsupported: use an + explicit native style or typed effect/variable binding for a reusable token, + or resolve the governing theme before applying and provide the exact value. + +Figma accepts shadow spread only on rectangles and ellipses, or on frames, +components, and instances with a visible fill and clipping enabled. + +A new border needs weight and paint, literal or bound. Updates may change either +independently; omission preserves the other. + +New frames are transparent when background is omitted, including frames added +during update. On an existing frame, omission preserves its live background; +use `bg-transparent` to clear it. Set an explicit background when fill is +intended. + +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|serif|mono` resolve to an editor-available family in that category, + preferring Inter, Noto Serif, and Noto Sans Mono +- `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` +- `text-shadow-[...]` for an exact pixel text-shadow list with a color and two + or three pixel lengths; `text-shadow-none` clears it + +A `span` is one TEXT node, so `bg-*` and `text-*` share its fill channel. Put +background on a parent `div` and color on its child `span`. + +Shadow classes compile to the native effect stack; never combine them with +`figma.effects` or an Effect style on that node. + +Unknown elements, attributes, classes, CSS, responsive/state prefixes, custom +themes, margins, percentages, and plugins fail closed. 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..99573887 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/component-authoring.md @@ -0,0 +1,294 @@ +# Author reusable components + +Use this reference after selecting a reusable local component. It explains +representation, not library strategy. New local components need no +`get_design_system`; use catalogs only for discovery or normalized library +props, and exact returned IDs for newly authored components. + +## Shared-responsibility decision + +New local components are opt-in for net-new authoring. Use Author when the user +requested reusable components before delivery, accepted a component pass after +seeing the completed design, or applicable project evidence makes a local +component deliverable part of the task. Existing components may still be Reuse +without authoring new ones. Repeated appearance, repeated data, screen count, +possible future reuse, or tool availability do not opt the user into Author. + +Make the decision from real usages after the representative composition is +visually sound: + +1. Name the shared job and compare the intended consumers. +2. Identify stable anatomy and meaningful content, media, state, availability, + label, swap, or slot differences. +3. Choose Author only when a truthful supported contract provides more + coordination value than it costs to create, migrate, and verify. Otherwise + keep the responsibility Direct; a brief reason is enough. +4. Bound Author at the smallest subtree that owns the complete shared job. Do + not infer that a parent must become reusable because a nested label, icon, + status, or button is reusable. + +Do not inventory or rank every recurring family, and do not turn repetition +into a quota. Record only selected Author responsibilities and their concrete +consumers. Before propagation, create the smallest real definition, instantiate +it once, and verify the exact reference. Then replace the selected consumers +with native instances; never leave literal lookalikes for a responsibility that +was deliberately selected as Author. Use the exact returned `rootNodeId` or +`nodeIdsByKey` entry for every usage. + +A keyed primitive cannot become an INSTANCE in place. Update its bounded +ancestor, add the instance under a new key, and remove the old key in the same +call. + +Stop component authoring if the ID is missing, the instance fails, or the +definition is empty, default-sized, or loses +properties. Do not substitute primitives or claim completion. Continue only +independent Direct work, report the degraded component result, and remove a +temporary definition only when unused and safe. Re-read a corrupt definition +and its intended usage; never rebuild it in place or remove one with instances. +Recreate only when unused. If a diagnostic would systematize primitives that +this definition replaces, reconcile the component first; independent token work +does not need to wait. + +Before handoff, reconcile only selected Author responsibilities with actual +consumers. Each selected consumer must be a native INSTANCE. Inspect the most +demanding instance through its descendants; root type and size do not prove +wrapping, slots, media, or state content fit. +Revise the contract or boundary when real content breaks it. + +Markup-only updates preserve keyed components, sets, instances, and shapes. +Restate native bindings only when changing native state; new native nodes still +need declarations or component references. + +Copy a complete recipe and change its design facts. Do not infer TemPad's +component shape from raw Plugin API calls. + +## Contents + +- [Define the contract from real usages](#define-the-contract-from-real-usages) +- [Keep source definitions discoverable](#keep-source-definitions-discoverable) +- [Component and properties](#component-and-properties) +- [Consume an authored component directly](#consume-an-authored-component-directly) +- [Variant set](#variant-set) +- [Slots and instances](#slots-and-instances) + +## Define the contract from real usages + +Compare every intended usage. Separate stable anatomy from varying content, +state, or nested substitution; map differences to the smallest supported Text, +Boolean, Instance Swap, variant, Slot, or nested-composition mechanism. Treat a +field as invariant only when real usages agree. + +Size the contract from real extremes: test the longest wrapping text, widest +label, largest nested swap, and materially different slots. Compare descendant +bounds with the INSTANCE root; screenshots can still paint invalid overflow. +If content exceeds the root, enlarge the definition, add a truthful size +variant, or move the varying region outside a smaller stable boundary. +If consumer-specific media cannot be expressed by the available instance +contract, keep that media direct and componentize the stable surrounding +responsibility; never freeze one image into every instance to retain a larger +component boundary. + +When stable anatomy should evolve together, expressible state differences +support a shared contract. Keep it local only when divergence or contract cost +outweighs coordinated change. + +If the contract cannot express a meaningful difference, revise it or keep the +responsibility local. Never force usages to share placeholder content or an +accidental default merely because outer geometry repeats. + +Model each mutually exclusive categorical concern as one variant axis; do not +replace it with Booleans that allow impossible combinations. Reserve Booleans +for independently optional content or behavior. + +Expose one choice through both a variant and independent property only when real +usages vary them independently. Keep each source variant's visible state +truthful; instance overrides do not repair accidental source defaults. + +## Keep source definitions discoverable + +Keep main components and sets visible at natural bounds in a clearly named +source area separate from screens. Never hide, clip, make transparent, or +invisibly nest them. For several families, use a top-level SECTION with +`contentsHidden: false`, discoverable definition children, and content-sized +bounds. + +Keep each real definition once, without redundant specimens. Before handoff, +use `get_structure` to verify every definition is visible and every intended +consumer is an INSTANCE. Inspect distinct source variants at readable scale; +names, content, and styling must encode the same state. + +Keep the source area operational and visually subordinate: use the smallest +content-sized container that exposes the definitions, outside the consumer +board or screen sequence. Do not turn it into a branded artboard, mood board, +visual-thesis panel, token showcase, or documentation page unless the user asks +for that deliverable. Product screenshots and presentation framing should stay +focused on the requested experience. + +## 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 keys such as `label` connect definitions and sublayer references within +one result; they are not generated Figma property names. Supported property +types are `BOOLEAN`, `TEXT`, and `INSTANCE_SWAP`, linked through `visible`, +`characters`, and `mainComponent` respectively. + +BOOLEAN properties control visibility, not styling. Hidden in-flow children +leave Auto Layout. Use this only for intentionally optional content. To preserve +geometry, toggle an inner layer inside a fixed slot, use `absolute` for a true +overlay, or use geometry-equivalent variants for whole-state changes. + +Treat `layout-affecting-visibility-property` as a contract warning. Fix it when +geometry must stay stable. Accept intentional reflow only after comparing true +and false instances for bounds, sibling positions, baselines, and clipping; one +default-state screenshot is insufficient. + +## Consume an authored component directly + +Use the exact ID returned by `apply_canvas`. For TemPad-authored components, +`componentProperties` accepts their stable definition keys. This follow-up +needs no catalog: + +```json +{ + "mode": "create", + "markup": "
", + "native": { + "screen/action": { + "component": { "id": "ComponentID:created-button" }, + "componentProperties": { "label": "Save", "show-label": true } + } + } +} +``` + +Replace the illustrative ID with the returned ID. Never invent IDs or use this +shortcut for unidentified library components. + +## Variant set + +This call creates two components in one variant set. Every direct child of a new +set must be an authored component; names encode axes as `Property=Value`. + +```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" } + } + } + } +} +``` + +Consume the returned set ID and select siblings through variant properties. If +the call returns the set as `rootNodeId`, this creates Default and Hover: + +```json +{ + "mode": "create", + "markup": "
", + "native": { + "screen/default": { + "component": { "id": "ComponentSetID:created-button-set" } + }, + "screen/hover": { + "component": { "id": "ComponentSetID:created-button-set" }, + "componentProperties": { "State": "Hover" } + } + } +} +``` + +Replace the ID with returned `rootNodeId`. The set ID creates its default; +`componentProperties` selects another encoded variant. An exact child ID from +`nodeIdsByKey` may instantiate that variant directly. + +Use `descriptionMarkdown` and `documentationLink` only for real guidance, inside +`figma.component` beside `type` and `properties`: + +```json +{ + "figma": { + "name": "Button", + "component": { + "type": "COMPONENT", + "descriptionMarkdown": "Primary action" + } + } +} +``` + +Define shared properties on the component set rather than on one variant. + +## Slots and instances + +Use `figma.slot` only for an intentional flexible nested-content API. New slots +must be inside local authored components and include `property.name`; markup +children become defaults. Optional settings control stretching, empty display, +child limits, and preferred values. + +An `INSTANCE_SWAP` default uses exact live component/set ID `{ "id": "..." }` +or importable library key `{ "key": "..." }`. Preferred values require +`{ "type": "COMPONENT" | "COMPONENT_SET", "key": "..." }` and accept neither +live IDs nor catalog refs. Resolve catalog identity before authoring and never +invent it. Put advanced state under `figma.instance`; omission preserves normal +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/delegation.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/delegation.md new file mode 100644 index 00000000..181b7cc9 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/delegation.md @@ -0,0 +1,82 @@ +# Delegate bounded evidence work + +Delegate evidence gathering or isolated production, never focal judgment. The +main agent synthesizes results and remains the only Canvas writer. + +## Pass the delegation gate + +Delegate only work that is: + +1. **Separable:** has a stable objective independent of evolving design choices. +2. **Compressible:** needs only a compact task-local brief. +3. **Isolated:** is read-only or produces an isolated artifact without mutating + Figma, design-system state, or another agent's files. +4. **Verifiable:** returns citations, importable asset references, exact facts, + or a bounded defect list the main agent can inspect. +5. **Worth coordinating:** gains enough from parallelism, specialist capability, + or independent review to justify handoff and synthesis. + +Keep work local if any condition fails. Do not delegate for ritual, convenience, +or another unsupported aesthetic opinion. + +## Write a complete handoff + +Give each worker one objective and its relevance, only required task evidence +and constraints, permitted tools and sources, explicit exclusions including no +Canvas writes, and an exact output contract and stop condition. The main agent +must read required Canvas references and set safety boundaries; never delegate +interpretation of this skill. Prefer fresh or minimum-context workers, pass +source evidence rather than conclusions, and avoid overlapping assignments. + +## Suitable tracks + +### Research scout + +After framing the design problem, delegate a bounded evidence question. Return: + +```txt +open decision; exact source; applicable finding; relevance; authority boundary +``` + +The scout does not choose direction. Combine questions only when their search +space is shared; use multiple scouts only for independent spaces. + +### Asset scout + +After fixing asset requirements and import contract, return one importable +`imageUrl` or `assetHash` per asset plus MIME type, dimensions, provenance, and +factual description. Return no bytes, rejected candidates, or transcript. The +main agent owns selection and integration. + +### Independent QA scout + +After a representative composition exists, provide a fresh worker its +screenshot and frozen brief without creator rationale or suspected defects. Ask +for at most eight observations: + +```txt +severity; screen/node or region; observed defect; visible evidence; violated constraint +``` + +The scout neither edits nor declares completion; the main agent checks findings +against the live canvas. + +### Inventory scout + +Use read-only inventory when independent volume warrants it, such as several +screens or icon candidates. Require exact findings and references, not a design +proposal. + +## Orchestrate conservatively + +- Default to one worker; use at most two concurrent non-overlapping workers. +- Keep a faster local critical path with the main agent. +- Only the main agent resolves intent and conflicts, chooses direction, calls + `apply_canvas`, and accepts the result. +- Resolve conflicts from evidence, not voting; discard unverifiable or + out-of-scope claims and stop when evidence is sufficient. + +Never delegate interdependent page or component construction, component +authoring plus instance placement, concurrent updates to one root, final +composition, or final acceptance. These require one ordered mutation stream and +continuous awareness of the whole. 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..45825de5 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-authoring.md @@ -0,0 +1,87 @@ +# Implement a selected local design system + +Use this reference only when the user or resolved plan requires new local +components, variables, or styles. It translates that plan into native resources +and verifies delivery; it does not choose component strategy, visual language, +resource inventory, or token taxonomy. + +## Establish the implementation contract + +Before writing, identify each selected resource, responsibility, concrete +consumer, meaningful variation, and exclusion. Resolve any open material +boundary first. For components, use the gate in +[component-authoring.md](component-authoring.md); screen count, one-screen scope, +and visual similarity alone neither establish nor exclude a component. + +Keep a private reconciliation map: + +```txt +selected resource -> native representation -> intended consumers +``` + +A resource is complete only when its native definition or binding exists and +every intended consumer uses it. Equivalent primitives or literals are not +coverage. + +## Translate the plan + +Use this loop: + +1. Stabilize one representative composition. +2. Author only selected resources with known consumers. +3. Exercise each contract in that composition. +4. Propagate native instances and bindings to all intended consumers. +5. Reconcile the final artifact with the map. + +Preserve the decided semantics: + +- A variable carries a semantic value consumers must bind and evolve together; + name it by role, not literal. +- A local style carries a reusable paint, text, effect, or grid definition. Do + not duplicate one decision across resource types unless required. +- A component carries a reusable responsibility. Define stable anatomy and + expose only variations required by real usages. + +Use [resource-mapping.md](resource-mapping.md) to map selected variable and text +style identities once per apply, then consume them through familiar variable +utilities and `type-*` classes. A new resource and its first consumer can share +one call. Query available fonts independently through `get_design_system` with +`scope: "fonts"`; selecting a family does not require discovering a file system. + +Consume a component through a childless instance placeholder without layout or +appearance classes. Do not make a repeated shell or wrapping top-level subtree +a component unless every consumer can use that placeholder through supported +properties. Slots do not permit markup children on instance placeholders; keep +incompatible wrappers as ordinary structure around a compatible inner boundary. + +Map each real component difference to the smallest supported mechanism: Text, +Boolean, Instance Swap, variant, Slot, or nested composition. Use one variant +axis per mutually exclusive categorical concern and Booleans only for +independently optional concerns. Do not encode arbitrary content as variants, +generate unused combinations, or freeze varying content as invariant. + +If supported native mechanisms cannot express a real usage, do not weaken or +redesign it silently. Choose another valid boundary or report the limitation. + +Read [variables.md](variables.md), [local-styles.md](local-styles.md), or +[component-authoring.md](component-authoring.md) only for selected resource +types. + +## Verify the native handoff + +Verify through representative consumers, not definitions alone: inspect native +bindings, Auto Layout, text resizing, property behavior, and every material +state. Raw literals and primitive lookalikes do not demonstrate system usage. + +For components, verify visible inspectable definitions and native INSTANCE +consumers using [component-authoring.md](component-authoring.md). For variables +and styles, inspect live bindings rather than apply input or equal values. + +Resolve warnings through real consumers, or remove a resource only when the +resolved plan no longer includes it. Tool friction, payload size, or an easy +resource type does not alter the plan. Do not create swatches, specimens, +definition panels, or redundant examples solely for verification; add +documentation only when requested. + +Finish when selected resources support all requested usages and the live Figma +structure reconciles with the map. Do not expand 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..11cf574f --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/design-system-reuse.md @@ -0,0 +1,59 @@ +# Reuse an existing design system + +Use this reference only when reuse is allowed and relevant. If the user rejects +a design system, use Direct. + +## Discover definitions + +Call `get_design_system` without arguments. Its immutable deterministic catalog +contains: + +- a `catalogId` scoping all short refs; +- component tags, props, source pages, and native sizes; +- variables, collections, modes, styles, and shaders as refs such as `v1`, + `k1`, `m1_2`, `s1`, and `h1`; +- `cssName` on variables and `className` on text styles for direct use in markup; +- `omitted` and `nextCursor` when more definitions remain. + +The catalog neither scans usage nor loads pages or ranks resources. Select from +returned names, pages, summaries, props, types, scopes, and defaults. Continue a +cursor or inspect an exact ref only until evidence is sufficient. + +Prefer, in order: catalog component, supported component prop, matching native +style, semantic variable, then primitive or literal for a real gap. + +When variants, anatomy, layout, or semantic meaning affect the result, inspect +the exact `ref` with the same `catalogId`. Use its `previewNodeId` with +`get_screenshot` only when appearance affects selection. Read an existing +composition with `get_code` or `get_screenshot`; catalogs do not reveal usage +conventions. Never invent refs, IDs, keys, props, or variant values. + +## Apply catalog resources + +Component tags are childless, include returned `data-ref`, and use exact props. +Omit size classes to preserve native size. Use returned CSS variable names and +text-style classes through [resource-mapping.md](resource-mapping.md). For other +native fields, bind `data-var-="vN"` or `data-style-="sN"`; put +collection modes or strict native links under `native[data-key]`. + +Replace every illustrative ref in this contract with one from the active +catalog: + +```json +{ + "mode": "create", + "catalogId": "ds_example", + "markup": "
Team settings
", + "theme": { "textStyles": { "type-body": { "ref": "s1" } } }, + "native": { + "settings": { + "variableModes": { "k1": "m1_1" } + } + } +} +``` + +If a mandatory component is absent, ask the user to open its definition page; +otherwise use the normal primitive fallback. An empty canvas does not block +catalog reuse. When reuse is unavailable, create a small coherent primitive +draft—never a token or component library solely for 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..b54a98d4 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/document-geometry.md @@ -0,0 +1,123 @@ +# Document and native geometry + +Use `native[key].figma` only for state HTML and classes cannot express honestly; +it remains declarative desired state. + +## Contents + +- [Pages and containers](#pages-and-containers) +- [Shapes and vectors](#shapes-and-vectors) +- [Transforms, masks, and native state](#transforms-masks-and-native-state) + +## Pages and containers + +Top-level `page` can set a name, exact zero-based document index, solid RGBA +background, ordered guides, and explicit variable modes. Page-only create uses +a new `pageKey` plus name and activates the created page. Page-only update uses +an exact `id` or `pageKey` and omits markup. A create root may target an existing +or new page directly; writing an existing off-current page does not activate it. +Markup updates stay on the target node's page. + +Use top-level `mode: "activate"` with exact page identity when editor context or +selection matters; `selection: []` clears selection. Use top-level `mode: +"remove"` with an owned `pageKey` to delete a page. Page deletion rejects the +last page, manual or unowned content, and surviving external dependencies. + +Use: + +- `figma.section: { contentsHidden? }` for canvas organization; +- `figma.group: true` for an intrinsic group; +- `figma.booleanOperation: "UNION" | "SUBTRACT" | "INTERSECT" | "EXCLUDE"` + for non-destructive geometry. + +Sections can be canvas roots or direct children of sections; a frame cannot +contain a section. Sections require fixed pixel dimensions and freeform +children. Groups and Booleans use `w-fit h-fit` with freeform children. A new +group needs one child and a Boolean needs two. When updating an intrinsic +container's children, +describe every live direct child because order is semantic. + +Sections have no frame clipping, so omit `overflow-hidden` and +`overflow-visible`. When `targetNodeId` is an existing section, retain +`figma.section` on the root or the frame-typed markup root is rejected. + +## 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 uppercase `M L Q C Z` paths for already-decided custom vector +geometry. Selected icon roles use sourced SVG through [icons.md](icons.md), not +remembered paths. Use a vector network only for branching segments, per-vertex +state, or region-specific fills or styles. Never provide both. New vectors need +geometry; omission preserves it on update and an empty path or network clears +it. + +Each path item is an object. `windingRule` is `"NONE"`, `"NONZERO"`, or +`"EVENODD"`; use `"NONE"` for an open stroked path. Path data uses +whitespace-separated uppercase commands and numbers. + +Figma normalizes path geometry to tight bounds before applying markup size. The +childless `div` defines final bounds, not a preserved viewport. For alignment, +offset it by the path's minimum x/y and size it to the x/y spans; otherwise a +partial-range path stretches to the box. Verify rendered anchors because +`get_structure` returns node bounds, not path coordinates. + +This Direct recipe creates an editable branch curve: + +```json +{ + "mode": "create", + "markup": "
", + "native": { + "branch": { + "figma": { + "name": "Branch", + "shape": { + "type": "VECTOR", + "paths": [ + { + "windingRule": "NONE", + "data": "M 14 300 C 30 252 52 188 104 20" + } + ] + }, + "fills": [], + "strokes": [{ "type": "SOLID", "color": { "r": 0.447, "g": 0.314, "b": 0.231 } }], + "stroke": { "weight": 2, "cap": "ROUND", "join": "ROUND" } + } + } + } +} +``` + +## Transforms, masks, and native state + +- `figma.name` sets the display name; `data-key` remains identity. +- `locked` and `aspectRatioLocked` set interaction state. +- `relativeTransform` is a complete native 2×3 unit-axis transform; width and + height carry scale. Do not combine it with `rotate-*`. On create roots, TemPad + preserves rotation and skew but replaces translation with automatic placement. +- `stroke` carries weights, alignment, caps, joins, miter, and `dashPattern`. +- `corners` carries radii and smoothing. +- `mask` is `"ALPHA"`, `"VECTOR"`, `"LUMINANCE"`, or `null`. + +Place a mask before masked siblings inside one dedicated frame and describe all +direct siblings on update. A non-null mask needs a following sibling. Omission +preserves mask state; `null` disables it. + +After changing a mask, layout grid, or frame guide, call `get_structure` with +`options.native: true` on the smallest relevant root. Verify `native.mask` and +sibling order, or returned `native.layoutGrids` and `native.guides`; desired +bindings alone are insufficient. + +Use `{ "ref": "…" }` for catalog resources nested in native state and +`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/editing.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/editing.md new file mode 100644 index 00000000..f8653bd5 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/editing.md @@ -0,0 +1,44 @@ +# Edit an existing result + +Use this reference for an update, removal, or change of editor context. Read the +exact target and recover managed keys from structured tool results when prior +call context is unavailable. Names are labels, not identity. + +## Describe the desired change + +Trace the requested change through its visible dependents: a changed selection +may affect the working surface, label, enabled action, and summary. Preserve +unrelated content and relationships. Preserving the source does not mean +retaining stale representations of its previous state. + +Use the smallest owning target that can express the complete change: + +- For native state on existing keys, target the exact managed root and send only + `native`; omit markup to preserve topology. +- For structural changes, read `canvas-html.md`, keep `data-key` stable, and + include the affected structure. Omitted existing fields and keyed elements + retain their live state; omission is not deletion. +- `removeKeys` removes owned descendants. Top-level `mode: "remove"` removes an + exact managed root or page. Do not remove manual/unkeyed content, unmanaged + resources, or surviving external consumers. +- `mode: "activate"` requires `page.id` or `page.pageKey`, even for a + selection-only change. It changes editor context, not document state. An + exact off-current-page write does not require activation. + +Respect instance boundaries. Change an instance root or its authorized +component definition, never a definition-derived sublayer. Select only the +native references needed for the intended change. + +## Recover locally + +Read the entire mutation result. For a rejected payload, correct all reported +issues together without changing the design to fit the error. A verification +failure is rolled back by TemPad; do not assume a partial successful edit. +For an unknown transport outcome, read the exact target before retrying a create +or removal so an uncertain response does not become a duplicate mutation. + +Repair warnings where they occur. Replace a whole root only when an observed +structural defect requires it and the complete intended content can be +preserved. Reopen the affected composition after its last material write and +inspect its dependents; read back protected native facts when preservation +matters. Do not expand a local correction into an unrelated restyle. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/icons.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/icons.md new file mode 100644 index 00000000..b3457638 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/icons.md @@ -0,0 +1,65 @@ +# Deliver icons + +Use this reference only after the composition selects an icon role. It does not +require icons, set an icon count, or choose a family or visual style. + +Prefer permitted current-file, catalog, project, or user sources; otherwise use +a trustworthy brief-compatible source and record material license constraints. +When inspected evidence establishes a family or geometry, use that permitted +source or a compatible one. A general library is a fallback only when its +stroke or fill, optical weight, corners, negative space, and platform semantics +remain coherent. Do not diversify sources by quota. + +Import exact SVG geometry. Never redraw a known icon from memory or replace an +icon role with Unicode, emoji, TEXT, or assembled primitives. A character, +shape, or cluster that communicates an affordance, object, or semantic category +is an icon role even when beside a worded label. Before markup, scan literal +text for pictographic Unicode, emoji, and symbols and route each qualifying mark +to a permitted vector source. Simple geometry remains valid only when it is +itself the intended status or data mark, divider, decoration, or brand shape. + +Search results and snippets identify external candidates only; they establish +neither geometry nor license. Open the governing license once and fetch or open +every exact SVG used before markup. If either remains uninspected, omit an +optional icon or report a required gap instead of inventing one. + +For Direct delivery, give the icon a childless `div` whose classes supply the +decided wrapper bounds. Declare the inspected SVG document in +`assets[assetKey]` with `type: "SVG"`, then set +`native[nodeKey].figma.svg.assetKey` to that alias. An optional `color` resolves +`currentColor`; omit it for explicit-color SVGs. Figma may import a Frame with +Vector children; treat that subtree as one opaque asset and never flatten or +reconcile it. + +This complete Direct recipe demonstrates the required shape, not a design +default; its identifiers and values stand in for the already-decided role and +inspected source: + +```json +{ + "mode": "create", + "markup": "
", + "assets": { + "search": { + "type": "SVG", + "svg": "" + } + }, + "native": { + "search-icon": { + "figma": { "svg": { "assetKey": "search", "color": "#334155" } } + } + } +} +``` + +Omit `color` when it is not part of the selected source. A markup-only call +cannot deliver the SVG geometry. Once an icon source has been selected and +inspected, do not replace it with text or primitives merely to avoid the +`assets` and `native` mapping. + +For larger exact SVG, declare a Hub asset using a full lowercase SHA-256: + +```json +{ "type": "SVG", "assetHash": "" } +``` diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/images.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/images.md new file mode 100644 index 00000000..b019a960 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/images.md @@ -0,0 +1,120 @@ +# Deliver images and illustrations + +Use this reference only after the composition selects an image or illustration +role and the common boundaries in [visual-assets.md](visual-assets.md) establish +its subject and medium. + +Treat existing assets, rights-established remote sources, generation, and +purpose-built vectors as acquisition routes. Choose the nearest route that +satisfies content, fidelity, rights, quality, and import requirements; tools +have no global priority. Before importing a remote asset, establish its +applicable usage rights and a recoverable source. A search result, accessible +URL, CDN host, or lack of a watermark does not establish permission. Confirm +Canvas delivery before layout depends on the asset. + +When depiction is part of a record, keep it. Text, category icons, generic +placeholders, and numbered markers may index the record but cannot replace its +visual content. Source or generate an established raster role, preserve exact +vector art when vector is the real medium, or disclose the gap. + +Keep only enough trace to recover material choices, the remote source and its +applicable terms, or content distinctions. Combine role, evidence, medium, +source, rights, and import treatment in one short rationale when needed; do not +create a per-asset ceremony. Record exact creator, license, or attribution only +when the applicable terms, policy, or handoff requires it; assets sharing one +route and terms may share a trace. + +When medium is unspecified, use nearest visual evidence or ask if the choice is +material; otherwise state a low-consequence assumption. + +Use generation when the decided role needs a bespoke or fictional subject, +identity, composition, or treatment. In a prototype, a coherent generated set +may be the nearest truthful source for distinct fictional records; do not +require stock search merely because each subject is ordinary. For a real named +subject or supplied identity, use the supplied or rights-established source and +do not generate a substitute. Before generation, map each planned asset to the +subject and consumer it serves; skip ceremony that does not protect fidelity, +rights, or import. + +Compose generation and Hub import in one programmatic execution so image bytes +never enter prose or expire between calls: pass the generator's `data:` URL +directly to TemPad's `upload_asset`, read its returned `assetHash`, then declare +that hash as an IMAGE asset in `apply_canvas`. Do not regenerate an unchanged +prompt only to recover an importable URL. If generation or `upload_asset` is +unavailable, choose a rights-established public image source only when it +preserves the intended medium; otherwise disclose the required gap. Never +generate first and silently switch medium because import failed. + +Use `imageUrl` for a rights-established public IMAGE paint or same-file +`imageHash` for an existing image. For generated or other local Hub content, +declare the returned full lowercase SHA-256, then use its alias in a basic fill: + +```json +{ + "assets": { "image": { "type": "IMAGE", "assetHash": "" } }, + "native": { + "image-node": { + "figma": { + "fills": [{ "type": "IMAGE", "assetKey": "image", "scaleMode": "FILL" }] + } + } + } +} +``` + +Inline bytes and local paths are unsupported. Remote URLs must resolve directly +to accessible images, not pages or thumbnails. + +When a supplied canvas image is itself a permitted source artifact and an exact +visible subregion must carry into the result, reuse its same-file `imageHash` +instead of redrawing that content. For an axis-aligned source rectangle +`(x, y, width, height)` within an image of size `(imageWidth, imageHeight)`, and +a destination with the same aspect ratio, declare: + +```js +{ + type: "IMAGE", + imageHash: "", + scaleMode: "CROP", + imageTransform: [ + [width / imageWidth, 0, x / imageWidth], + [0, height / imageHeight, y / imageHeight] + ] +} +``` + +Supply the evaluated finite numbers, not expression strings. If the destination +aspect ratio differs, first choose an aspect-correct source rectangle rather +than stretching the subject. Open the rendered crop and verify its native IMAGE +fill; a valid transform does not prove that the intended subject was isolated. + +When the medium must remain a real image, verify with `get_structure` and +`options.native: true`; `native.imageFills` must contain the expected non-null +Figma hash. Input URLs, successful mutation, and visually similar screenshots +are not native read-back. + +The main agent owns placement, crop, and final verification. In a comparison, +make visual differences represent the subjects rather than their source files: +normalize incidental canvas padding, crop, background, viewpoint, and apparent +scale when they would bias the decision; preserve and explain differences that +are real or cannot be normalized faithfully. + +Before markup, map every content-bearing image consumer to the subject it +claims to depict. Reuse one asset and crop only when consumers represent that +same subject; distinct records require distinct assets or crops that visibly +isolate the correct subject. A composite scene may serve the composition it +depicts, but cannot stand in for several named records. Stop and source or +generate missing media instead of serializing a false mapping. + +When a gallery, carousel, or thumbnail set promises several views of one +subject, every retained view must add distinct, truthful information. Repeating +one unchanged source and crop does not satisfy that role; unrelated subjects +break identity. Use distinct sourced views, evidence-supported crops, or +generation/editing only for a named same-subject coverage need that sourcing +cannot satisfy. Otherwise reduce the views or disclose the gap. + +For repeated depictions of the same subject, keep asset identity and crop +stable unless evidence requires variation. If required media remains +unavailable, report it; omit optional media or use a neutral slot only when the +requested outcome is unchanged. A neutral slot is an explicit fallback, not +representative content or proof of reusable variation. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/local-styles.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/local-styles.md new file mode 100644 index 00000000..74aec685 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/local-styles.md @@ -0,0 +1,61 @@ +# Author local styles + +Use this reference only when the user or resolved system plan requires a local +style. Do not extract styles from an ordinary screen. New local resources need +no catalog; send `catalogId` only when a nested `{ "ref": "…" }` deliberately +reuses an existing resource. + +Copy this recipe and change its design facts. Style authoring keys persist +file-wide and are neither names nor IDs. Namespace keys by product and role. In +shared drafts, also prefix generic visible names that could collide; retain +established project naming when already clear. + +For whole-node typography, prefer a `theme.textStyles` alias and a `type-*` +class using [resource-mapping.md](resource-mapping.md). The recipe below shows +the explicit native binding form, also used for paint, effect, and grid styles. + +```json +{ + "mode": "create", + "markup": "
Account
", + "styles": { + "product/style/surface": { + "type": "PAINT", + "name": "Product/Color/Surface", + "paints": [{ "type": "SOLID", "color": { "r": 1, "g": 1, "b": 1 } }] + }, + "product/style/heading": { + "type": "TEXT", + "name": "Product/Typography/Heading", + "fontName": { "family": "Inter", "style": "Semi Bold" }, + "fontSize": 20, + "lineHeight": { "unit": "PIXELS", "value": 28 } + } + }, + "native": { + "card": { + "styles": { + "fill": { "styleKey": "product/style/surface" } + } + }, + "card/title": { + "styles": { + "text": { "styleKey": "product/style/heading" } + } + } + } +} +``` + +Types are `PAINT`, `TEXT`, `EFFECT`, and `GRID`, using `paints`, text fields, +`effects`, or `layoutGrids` respectively. For exact Paint, Effect, and Grid +shapes beyond this recipe, read [paints-effects.md](paints-effects.md). + +Omission preserves managed state. Top-level `null` removes a managed style only +when absence is required and all live consumers are cleared or removed in the +same result. Never mutate remote resources, invent library keys, or create a +broad style library for one screen. + +`unbound-created-style` means a same-call style lacks a `styleKey` consumer. +Bind it to a representative property performing its named role or remove it. A +swatch or unrelated binding is not coverage. 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..c32b82f2 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/paints-effects.md @@ -0,0 +1,111 @@ +# Paints, effects, grids, guides, and media + +Use this reference whenever the result uses a nontrivial shadow, blur, glass, +texture, noise, image paint, layered gradient material, or layout aid, including +effects expressed as Canvas HTML classes. Resolve an image or illustration's +role, subject, and medium through [visual-assets.md](visual-assets.md), then its +source and delivery through [images.md](images.md). Prefer a matching catalog +style; otherwise use direct native arrays. + +## Catalog links + +```html +
+``` + +A style owns its channel. Do not combine a non-null fill or stroke style with a +whole-node variable on the same paint. Styled strokes still need literal, +typed, or variable-bound geometry. `null` unlinks; omission preserves. + +## Resolve shadow references + +Named scales such as `shadow-md` are theme references, not portable geometry: + +- Reuse: bind the matching catalog Effect style. +- Author: create and bind a local Effect style only when the system plan requires + it. +- Direct: use an exact `shadow-[...]` class or typed `figma.effects` value. + +Never assume Tailwind defaults or create a token only to resolve a named class. +`shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. + +Treat an outer shadow's rendered halo as part of the composition. Inspect the +final PNG beyond the root edges; visible granular or noisy fringe, or a halo +that dominates the captured bounds, is a defect even when the frame itself is +intact. Preserve intended depth by tightening blur, spread, or opacity or using +smaller layered shadows, then recheck. Do not flatten established material +treatment merely to hide the defect. + +## Native paint and effect stacks + +`figma.fills` and `figma.strokes` support ordered solid, linear/radial/angular/ +diamond gradient, image/video, Pattern, and fill-shader paints. +`figma.effects` supports ordered shadows, normal/progressive blur, noise, +texture, glass, and effect shaders. + +A `SOLID` paint uses RGB `color` and optional paint-level `opacity`; only +gradient stops use RGBA colors. Keep stroke geometry, including `dashPattern`, +in `figma.stroke`, not the stroke paint. + +Use the exact gradient enum and normalized RGBA stop shape; do not translate +from CSS or Plugin API names: + +```json +{ + "figma": { + "fills": [ + { + "type": "GRADIENT_LINEAR", + "gradientTransform": [ + [1, 0, 0], + [0, 1, 0] + ], + "gradientStops": [ + { "position": 0, "color": { "r": 1, "g": 0.43, "b": 0.29, "a": 1 } }, + { "position": 1, "color": { "r": 0.16, "g": 0.09, "b": 0.24, "a": 1 } } + ] + } + ] + } +} +``` + +Other gradient enums are `GRADIENT_RADIAL`, `GRADIENT_ANGULAR`, and +`GRADIENT_DIAMOND`. + +Omission preserves a stack; `[]` clears it. Direct stacks cannot share their +channel with a literal class, whole-node variable, or style. Use variable refs +such as `{ "ref": "v1" }` and shader refs such as `{ "ref": "h1" }`; use only +returned shader property IDs and declared value shapes. + +For images, provide exactly one same-file `imageHash`, public HTTP(S) `imageUrl`, +or call-scoped `assetKey` for a full-SHA-256 Hub IMAGE asset. PNG, JPEG, and GIF +are limited to 4096×4096. For video, provide exactly one same-file `videoHash` or +public `videoUrl` for MP4, MOV, or WebM up to 100 MB. URLs must need no +credentials. Reuse `figmaImageHash`, `figmaImageHashes`, or `figmaVideoHashes` +from `get_code` only in the same file; they identify native media, not preview +bytes. + +A Pattern uses exactly one existing `sourceNodeId` or same-result +`sourceCanvasKey`. + +## Layout aids + +Prefer a matching Grid style. Otherwise `figma.layoutGrids` declares ordered +row, column, or square grids on frames, components, sets, and instances. Use +`"AUTO"` for automatic row or column count. Do not bind `sectionSize` with +`STRETCH` or `offset` with `CENTER`. + +`figma.guides` is the complete ordered X/Y guide list: omission preserves and +`[]` clears. Page guides live under `page.guides`. + +For wrapping linear Auto Layout, `figma.autoLayout` may set signed +`itemSpacing`, positive or synchronized-null `counterAxisSpacing`, and +`itemReverseZIndex`. Never declare one physical gap in both classes and native +state. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/resource-mapping.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/resource-mapping.md new file mode 100644 index 00000000..4d3f3db8 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/resource-mapping.md @@ -0,0 +1,138 @@ +# Use resources in Canvas classes + +Keep layout and visual composition in markup. Put a reusable value's native +identity in the catalog or a call-scoped `theme`, then reference it by class. +Variables remain bound Figma variables, including mode changes. A `type-*` +class binds an entire native TextStyle. Ordinary utilities such as `gap-4` and +`text-base` remain literals and do not create or discover resources. + +## Existing system + +When the applicable system permits reuse, discover it with `get_design_system`. +Use returned variable `cssName` and TEXT-style `className` with its `catalogId`: +`bg-(--surface)`, `gap-(--spacing-content)`, `type-body`. These are examples of +names, not assumed resources. Read a style's exact ref when its font, metrics, +or bindings affect the choice. + +The catalog uses valid WEB code syntax when available, otherwise derives a +name. It disambiguates collisions and keeps the resulting alias tied to one +exact identity for that catalog's lifetime. Use the returned name unchanged; +never derive identity from equal values, similar names, or another catalog. + +For task-specific names, add `theme.variables: { "--surface": { "ref": "v1" } }` +or `theme.textStyles: { "type-body": { "ref": "s1" } }`. Use returned refs. An +alias cannot replace another catalog alias with a different resource. A stable +authoring key and catalog ref for the same native identity may share an alias. + +## New system + +When the deliverable includes a design system, define the selected variables +and styles through `variableCollections` and `styles`. Map their stable keys in +`theme` and consume them in the same call. A primitive draft without a system +still uses ordinary classes; repetition alone does not require resource creation. + +This complete recipe illustrates the relationship. Change the design facts, +namespace resource keys for the product, and confirm the font family/style in +the environment before authoring it. + +```json +{ + "mode": "create", + "markup": "
Account settings
", + "theme": { + "variables": { + "--surface": { "variableKey": "product/color/surface" }, + "--content-gap": { "variableKey": "product/space/content" } + }, + "textStyles": { "type-body": { "styleKey": "product/type/body" } } + }, + "variableCollections": { + "product/theme": { + "name": "Product/Theme", + "modes": { "light": { "name": "Light" }, "dark": { "name": "Dark" } }, + "variables": { + "product/color/surface": { + "name": "Surface", + "type": "COLOR", + "codeSyntax": { "WEB": "var(--surface)" }, + "values": { + "light": { "r": 1, "g": 1, "b": 1 }, + "dark": { "r": 0.08, "g": 0.09, "b": 0.11 } + } + }, + "product/space/content": { + "name": "Space/Content", + "type": "FLOAT", + "values": { "light": 16, "dark": 16 } + } + } + } + }, + "styles": { + "product/type/body": { + "type": "TEXT", + "name": "Product/Typography/Body", + "fontName": { "family": "Inter", "style": "Regular" }, + "fontSize": 16, + "lineHeight": { "unit": "PIXELS", "value": 24 } + } + } +} +``` + +On later calls, retain the small `theme` mapping and omit resource definitions +unless changing them. The stable keys resolve the same native resources. The +mapping is local to the call, so different screens can use different aliases +without changing the file's naming. Authoring keys and native identities +persist; aliases do not create a second resource registry. + +Use [variables.md](variables.md) for modes, aliases, scopes, and resource updates; +use [local-styles.md](local-styles.md) for style definitions. A TextStyle may +bind selected typography primitives through its `variables` fields when those +values must change together. Do not create font-family, size, or weight tokens +solely to express a single named text role: the TextStyle can hold those facts. + +## Supported variable utilities + +Both `gap-(--space)` and `gap-[var(--space)]` work. Explicit type hints resolve +ambiguous Tailwind prefixes, for example `text-(length:--body-size)` versus +`text-(color:--foreground)`. + +| Utility | Native value | +| ---------------------------------------------------------------------- | ------------------------------------------------------- | +| `bg-(--surface)`, `text-(--foreground)`, `border-(--border)` | COLOR fill or stroke; border still needs a width | +| `w/h/size/min-w/max-w/min-h/max-h-(--value)` | FLOAT dimensions, in pixels | +| `gap/gap-x/gap-y-(--value)` | FLOAT layout gaps, in pixels; axes follow flex or grid | +| `p/px/py/pt/pr/pb/pl-(--value)` | FLOAT padding, in pixels | +| `rounded/rounded-tl/rounded-tr/rounded-br/rounded-bl-(--value)` | FLOAT corner radius, in pixels | +| `border-(length:--width)` | FLOAT stroke width, in pixels | +| `text-(length:--size)`, `leading-(--leading)`, `tracking-(--tracking)` | FLOAT font size, line height, letter spacing, in pixels | +| `font-(family-name:--family)` | STRING font family | +| `font-(--weight)` | FLOAT font weight, 1–1000 | +| `opacity-(--opacity)` | FLOAT opacity, 0–1 | + +The tool reads an initial native value itself and retains the variable binding; +do not add a second literal fallback class. Native node, layout, and scope rules +still apply. This is a bounded mapping to Figma fields, not a CSS engine: no +`calc()`, var fallbacks, arbitrary expressions, or cascade. FLOAT metrics use +the native units above, not unitless CSS line-height multipliers. + +## Typography ownership + +`type-body` consumes the whole TextStyle. Keep color, sizing, alignment, and +wrapping classes on the text node as needed; omit font, weight, size, leading, +tracking, case, and decoration overrides owned by that style. Choose another +style or explicitly unlink the style for a deliberate local treatment. Composite +typography has no single Figma variable type, so `type-*` is an explicit custom +utility convention rather than a Tailwind default or a fabricated CSS variable. + +Inline `data-var-*`, `data-style-*`, and `native` bindings remain available for +fields outside this subset, exact native fonts/styles, and explicit unlinking. +Use one mechanism per property. Unknown names, conflicting declarations, +incompatible types, and cyclic variable aliases require correction; the tool +does not guess a replacement. + +Updates preserve omitted native state. Removing a resource class or replacing +it with a literal does not unlink the existing binding: explicitly clear the +variable/style with its `data-var-*="none"`, `data-style-*="none"`, or supported +`native` null binding when that is the intended change. 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..3e26cc97 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/rich-text.md @@ -0,0 +1,52 @@ +# Rich text and hyperlinks + +Use this reference for native font application or Figma-only text behavior; +use [typefaces.md](typefaces.md) when font selection or availability needs resolving. +Use `span` for editable text. Put whole-node typography in classes, a catalog +Text style, or semantic variable bindings when possible. + +`native[key].figma.text` supports: + +- exact whole-node `fontName`, `autoRename`, vertical alignment, and leading + trim; +- paragraph indent/spacing, list spacing, hanging punctuation/list; +- whole-node hyperlink; +- ordered rich-text `ranges`. + +Do not combine `autoRename: true` with fixed `figma.name`. + +When no Text style or typography variable expresses the chosen family and +style, use the exact available Figma font: + +```json +{ + "fontName": { "family": "IBM Plex Sans", "style": "Medium" } +} +``` + +Do not combine it with `font-*` classes, linked Text styles, or font family/style +variables. Never guess family or style availability. + +Range `start` and `end` are UTF-16 offsets into final characters. Ranges must be +ordered, non-overlapping, and set at least one property; split overlapping +intentions into disjoint intervals. A range may set font name/size, case, +letter spacing, line height, complete underline state, native fills, Text/Paint +style, list options, indentation, paragraph spacing, hyperlink, and supported +text-range variables. + +Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a +range variable. `null` unlinks supported styles or hyperlinks; omission +preserves. + +Hyperlinks support URLs and node targets. For a same-result target: + +```json +{ + "type": "NODE", + "value": { "canvasKey": "settings/help" } +} +``` + +The target may appear later in markup; never remove a live hyperlink target. If +a catalog component exposes text through a prop, set that prop instead of +editing internal 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..2a83efca --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/style-grounding.md @@ -0,0 +1,80 @@ +# Ground design judgment + +Use this reference for a new direction, material redesign, or consequential +uncertainty not settled by the user or an established source. Exact reproduction +and mechanical edits use their supplied evidence directly. + +## Inspect what can change the decision + +Start with the nearest credible evidence: the supplied design or implementation, +real product states, primary platform requirements, or adjacent visual work. +Choose separate evidence for behavior and visual expression when needed. A +functional walkthrough can establish behavior without settling visual language; +a visually relevant product state or adjacent visual work can show how density, +controls, surfaces, icon/text economy, and states cohere without establishing +behavior it does not expose. One artifact may inform both only when the relevant +behavior and pixels are actually inspected. + +Open the relevant state at useful scale. A homepage or brand campaign may not +show the working interface. Search cards, prose, remembered products, generated +images, and failed retrievals are not inspected visual precedents. A content +photograph establishes what it depicts, not the surrounding application's +interaction or composition. Follow the main skill's first-write evidence +boundary when retrieval fails. + +An image-search result that exposes only a screenshot description or URL remains +a search card. Open the actual product-state pixels at useful scale before +treating them as visual grounding; otherwise use the result only as behavioral +description. + +Research is grounded when it changes, confirms, or reopens a material decision +in the new result. Retain enough source identity and context to support that +claim; do not invent a source-by-source decision report. If a source contributed +nothing consequential, do not cite it as a precedent. Generic expertise helps +interpret the evidence; its familiar defaults are not evidence about this +product. + +There is no source quota. Stop when further investigation is unlikely to change +a material choice. Do not research routine decisions for ceremony. Keep source +screens outside the authored result unless the user asked to place or reproduce +them, and preserve required source content and behavior when adapting a design. + +## Form a provisional direction + +Integrate the brief, evidence, and professional judgment into a relationship +among content, state, and action, with a visual language that makes it fitting +and perceptible. A new design needs its own solution; independence is not a +reason to discard an applicable interaction or representation because it is +harder to source or serialize. + +Before the first write, be able to state privately what the inspected pixels +changed or confirmed about the recurring visual language. A mood label or +task-themed palette is not that direction; if the same control and surface +grammar could survive a noun swap, inspect more relevant pixels or reconsider +the synthesis. + +Resolve recurring visual roles enough to try them in a real composition. The +foundation is provisional and may change after seeing pixels. It is not a +separate foundation board or permission to create components, variables, or +styles outside the task's resource scope. + +Reconsider choices whose only justification is habit or semantic association. +Ask what in the brief or inspected reality makes the proposed treatment fit. +Familiar solutions can be appropriate; choosing the opposite of a criticized +motif is no stronger evidence. Functional specificity alone does not settle +expression, and stylistic difference alone does not make the product useful. + +## Externalize unresolved visual choices + +If materially different visual hypotheses remain and a capable image-generation +tool is available, a bounded visual exploration can help you see their +consequences. Open those pixels and use them to reconsider the composition; +omit this step when the direction is already clear. + +Generated concepts are speculative sketches, not real-product evidence or +flattened Figma deliverables. Do not trust their text/data or trace them +literally. A generated subject chosen as actual product content is a separate +asset decision under `visual-assets.md`. + +Return to the representative native composition and inspect it. Let a mismatch +reopen the decision it actually challenges; otherwise complete the design. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/typefaces.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/typefaces.md new file mode 100644 index 00000000..185284bc --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/typefaces.md @@ -0,0 +1,48 @@ +# Choose and apply fonts + +Use this reference when selecting or changing fonts, delivering a required +family/style, or resolving uncertainty about the text's scripts. Availability +can change a provisional choice; query before committing when that matters. + +Start from applicable Text styles, typography variables, project fonts, or +supplied references. Preserve established font identities for ordinary edits. +For a new direction, form candidates from the language, text roles, density, +and visual intent. A portable `font-sans|serif|mono` category does not establish +an exact family or suitable coverage for the actual text. + +## Query what can change the choice + +`get_design_system` with `scope: "fonts"` reads the environment without scanning +file resources. It is valid for direct composition, reuse, and an independent +system, including a blank page. + +- With candidate family names, use `families: ["Noto Sans SC"]` to inspect + exact native style names and missing families in one call; batch candidates. +- Use `query: "Noto"` when the family name itself needs discovery. This searches + names, not language coverage or visual suitability. +- Continue `nextCursor` with the same filters only when more results could + affect the choice. Reuse current evidence rather than querying per text node. + +Use returned or source-established native names. For an unavailable provisional +candidate, reconsider the choice. For a required font, preserve the requirement +and disclose the delivery gap rather than silently substituting another family. + +## Apply the selected typography + +Reuse the applicable TextStyle or font variables. If a new design system is in +scope, define the selected text roles as TextStyles and consume their `type-*` +classes through [resource-mapping.md](resource-mapping.md). Font selection alone +does not require creating styles or tokens. + +For direct composition, `font-[family-name:Noto_Sans_SC] font-semibold` fixes +the family and chooses its closest available weight. Underscores encode spaces; +`\_` preserves an underscore. Use `native[key].figma.text.fontName` with exact +`{ family, style }` when the native style identity matters; see +[rich-text.md](rich-text.md). Weight matching is approximate. For variable-driven +typography, consider the family/weight/style combinations in the delivered modes. + +Inspect representative real content in the composition, including relevant +scripts, numbers, punctuation, and wrapping. Availability and successful loading +do not prove glyph coverage; a correct-looking screenshot alone does not prove +native font identity or current editability. Reopen the font choice when the +observed text challenges it, without requiring a separate specimen board. 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..692a8699 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/variables.md @@ -0,0 +1,154 @@ +# Author local variables + +Use this reference after the representative pixel check when repeated colors +may carry shared semantic roles, or when the user or resolved system plan +requires local variables. Do not extract tokens from an ordinary screen. New +resources need no catalog; send `catalogId` only for deliberate nested +`{ "ref": "…" }` reuse. + +## Contents + +- [Decide from real roles](#decide-from-real-roles) +- [Author variables](#author-variables) +- [Bind and verify](#bind-and-verify) +- [Update and remove](#update-and-remove) + +## Decide from real roles + +After any selected representative component reconciliation and before +propagation, call full `get_code` with unresolved tokens only when repeated +colors plausibly represent semantic roles whose coordinated maintenance matters. +Treat `literalClusters` as candidate locations, not a to-do list. Select a role +only when concrete consumers should evolve together; split mixed roles even +when their literal values match. Leave incidental, local, and ambiguous +repetition literal. If the diagnostic is unavailable, do not infer a system +from repetition. + +For each selected role, map concrete consumer and field to a semantic variable +key, bind every representative consumer, then re-run once to confirm the role is +exposed through `tokens` and no longer unresolved. A non-empty +`literalClusters` result is acceptable. + +Carry only selected mappings into propagation. A later apply that includes a +consumer of a selected role must bind that field in the same call; an inherited +instance binding does not cover sibling literals. Before finalization, scan each +materially distinct dependent root that uses a selected role once, fix missing +bindings for those roles, and recheck only changed roots. Do not create variables +to empty diagnostics, expand the map from literal equality, or repeat scans after +the selected roles are verified. + +## Author variables + +Copy this recipe and change its design facts. Collection and variable authoring +keys persist file-wide and are neither names nor IDs. Choose one +collision-resistant prefix for the independent system; recover existing exact +keys when intentionally updating it. Mode keys are collection-scoped. + +```json +{ + "mode": "create", + "markup": "
Account
", + "variableCollections": { + "product/theme": { + "name": "Theme", + "modes": { + "light": { "name": "Light" }, + "dark": { "name": "Dark" } + }, + "variables": { + "product/color/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 } + } + }, + "product/space/md": { + "name": "Spacing/Medium", + "type": "FLOAT", + "scopes": ["GAP"], + "values": { + "light": 16, + "dark": 16 + } + } + } + } + }, + "native": { + "card": { + "variables": { + "fill": { "variableKey": "product/color/surface" }, + "gap": { "variableKey": "product/space/md" } + }, + "variableModes": { + "product/theme": "dark" + } + } + } +} +``` + +A new collection needs `name` and at least one named mode. Each variable needs +`name`, `type`, and a value for every mode. Types are `BOOLEAN`, `COLOR`, +`FLOAT`, and `STRING`. Values may alias another variable: + +```json +{ "variable": { "variableKey": "…" } } +``` + +Valid scopes: + +- general: `ALL_SCOPES`, `TEXT_CONTENT`, `CORNER_RADIUS`, `WIDTH_HEIGHT`, `GAP`, + `OPACITY`; +- color: `ALL_FILLS`, `FRAME_FILL`, `SHAPE_FILL`, `TEXT_FILL`, `STROKE_COLOR`, + `EFFECT_COLOR`; +- numeric effect/stroke: `STROKE_FLOAT`, `EFFECT_FLOAT`; +- typography: `FONT_FAMILY`, `FONT_STYLE`, `FONT_WEIGHT`, `FONT_SIZE`, + `LINE_HEIGHT`, `LETTER_SPACING`, `PARAGRAPH_SPACING`, `PARAGRAPH_INDENT`. + +Use `STROKE_COLOR`, not `ALL_STROKES`. Combine neither `ALL_SCOPES` with other +scopes nor `ALL_FILLS` with `FRAME_FILL`, `SHAPE_FILL`, or `TEXT_FILL`; +`ALL_FILLS` may coexist with a non-fill scope such as `STROKE_COLOR`. + +## Bind and verify + +Bind through `native[key].variables` using the exact supported field, such as +`fill`, `stroke`, `gap`, `paddingTop`, `width`, `visible`, `fontSize`, or +`characters`. Retain a matching literal class when Figma needs an initial paint +or numeric fallback. + +Bind each variable to representative fields performing its semantic role. +Prefer `GAP` for shared gaps/padding, `WIDTH_HEIGHT` for semantic control/icon +sizes, and `CORNER_RADIUS` for shared radii. Do not tokenize viewport dimensions, +one-off crops, content-derived geometry, or optical corrections merely because +numbers repeat. + +A representative binding proves usability, not complete coverage. Bind every +consumer intended to evolve with the role; keep equal peer literals only when +incidental or independently owned. + +`apply_canvas` reports `unbound-created-variable` when a new variable lacks a +same-result consumer. Bind it to a real consumer or remove it. A staged warning +may be temporary, but final delivery must show a native binding; equal literals +do not count. + +`variable-fallback-mismatch` means a bound literal matches none of the +same-call variable's direct or aliased mode values. Align the fallback with a +real mode or bind the variable that owns the value, or the binding will silently +change the declared markup. + +## Update and remove + +After changing a variable value, update and verify every intended consumer that +cannot carry a native binding, such as `figma.svg.color`; omission leaves its +old literal in place. + +Omission preserves managed state. Top-level `null` removes a managed variable, +mode, or collection only when absence is required and all consumers are cleared +or removed in the same result. Never mutate remote resources, invent parent +collections or library keys, or build a broad token system for one screen. +Extended collections must inherit a real local or catalog collection and obey +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..eaa3d4c8 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-assets.md @@ -0,0 +1,57 @@ +# Choose and preserve visual assets + +Use this reference after the design selects an icon, image, +illustration, diagram, or vector asset. It governs role, medium, source +integrity, editability, and delivery—not whether the design should contain that +asset or what the finished visual style should be. + +Research pixels and generated screen concepts remain evidence for judgment, +not canvas content. Import, reproduce, annotate, or compare them only when the +user explicitly requests that treatment. When evidence establishes that the +new product needs an asset role, acquire or author a truthful asset for the new +result instead of redrawing or embedding the reference. + +## Preserve the decided role + +Start from the composition, not an available tool or assumed asset slot. Once a +material role is selected, fulfill it faithfully; sourcing difficulty is not a +reason to replace an image, icon, visualization, or exact medium with easier +text or plausible geometry. + +Depiction is a role, not a medium. Choose raster, sourced vector, +agent-authored vector, diagram, or another medium only when the brief, inspected +evidence, or a low-consequence assumption supports it. Convenience never +changes the medium. + +Treat content-bearing visualization—such as a chart, map, waveform, notation, +document or media preview, or domain instrument—as a first-class +representation. Identify the user decision and the visual structures that make +it possible. Preserve enough context and density to act; a stylized trace or +labeled decoration is not the representation. When only topology or sequence +is intended, name and design it as a diagram. + +When recognition depends on a subject's real appearance—such as a person, +product, food, place, room, photograph, cover, or shared-media preview—preserve +that distinction with a real sourced or generated image unless the brief or +inspected evidence independently establishes an illustrated language. + +Preserve editability semantics. Build changing diagram labels, shapes, and +relationships as native structure; use an opaque SVG only when exact vector art +is the asset. An SVG wrapper with Vector descendants does not make a diagram +model editable. If editable primitives cannot carry a required representation, +use an evidence-supported native, vector, or raster base with changing overlays +editable, or disclose the gap. + +For material assets retain enough evidence for identity and content fidelity, +provenance and applicable rights, source quality, and Canvas-compatible form. +Never silently change subject, style, or medium. Crops, masks, overlays, and +retouching must preserve the depicted subject; do not hide distinctive branding +or features to make one subject represent another. + +## Load only the selected branch + +- For an icon role, read [icons.md](icons.md). +- For an image or illustration, read [images.md](images.md). + +For diagrams and other custom vector art, use the source and editability +boundaries above, then load only the required geometry or paint mechanics. diff --git a/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-composition.md b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-composition.md new file mode 100644 index 00000000..27993d33 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-canvas-authoring/references/visual-composition.md @@ -0,0 +1,64 @@ +# Compose a product interface + +Use this reference when forming or reconsidering a composition. Keep the +person's working experience in view; use the questions below only where they +help resolve a decision. They are not independent quality axes. + +## Find the working relationship + +What is the person attending to, and what can they do with it in the depicted +state? Does the chosen screen or flow expose the user's central work, or merely +promise it through a button whose destination is absent? What changes across +states, and what must remain perceptible while that happens? Arrange content, +controls, and context so their relationship is understandable in the rendered +whole. + +A familiar shell may be the right answer. Reconsider it when it hides the +working object, requires unnecessary reading or navigation, or survives only +because task-specific nouns make it look relevant. Novelty and decoration do +not repair that mismatch. + +As content grows, what extends: the document or an owned scrolling region? +Choose document flow, a fixed workspace, or a hybrid from product behavior; +check that making room has not silently redefined the device or window viewport. +Density and control scale follow platform, frequency, precision, and environment +of use. In frequent expert work, inspect the real product's interaction economy +before carrying over the spacing and repeated explanations of an occasional +consumer journey. Preserve legibility and suitable targets in either case. + +## Make meaning perceptible + +What should someone notice now, and what should stay available without +competing? Resolve type, position, scale, color, contrast, media, depth, and space +together. Repeated roles need recognizable treatment; differences need to carry +meaning in this task. An expressive role does not by itself justify the first +familiar palette, shape, or effect. + +Choose text, icons, images, and graphics by recognition, comparison, +manipulation, and expression. Familiar iconographic affordances can reduce the +reading and space required by repeated controls; words can be more precise. +Inspect that tradeoff at actual size, including when every action has become +text. Source selected icons through `visual-assets.md`; sourcing effort is not +a design reason to drop their role. + +A working graphic must carry the distinctions needed for the decision. Check +whether its marks, scale, context, and state make the relevant comparison or +manipulation possible. Changing a label does not change what the marks encode. This is a question +of represented meaning, not a quota for detail or a preferred graphic style. Use `visual-assets.md` for truthful +source and native representation. + +## Learn from the rendered result + +Open the representative composition at useful scale. Mentally follow the +central action through its visible consequences. Does the selected state agree +with the working surface, available action, and result? If the experience breaks, +inspect the particulars that explain where and why. + +Judge spacing from visible relationships: nested insets, seams, baselines, +grouping, and repeated rhythm. Nominal padding or a non-overflowing bounding box +does not prove that the intended space survived native layout. Repair the +owning relationship instead of decorating over it. + +Carry resolved shared roles into dependent screens while allowing their layouts +to differ with the work. Stop when the requested whole is coherent and the +observed defects are resolved; do not keep polishing to fill a checklist. diff --git a/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md b/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md index a65e8e30..a5bb5f20 100644 --- a/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md @@ -1,392 +1,177 @@ --- name: figma-design-to-code description: >- - Implement or update project-consistent UI code from a Figma selection or - nodeId using TemPad Dev MCP. Use when the user wants visible Figma UI - recreated, ported, or integrated into the target project's framework, - styling system, tokens, and existing components when available. Do not use - for design critique, product invention, generic code review, or for guessing - hidden states, responsiveness, or behavior not shown in design or project - evidence. -metadata: - version: '4.3' + Implement or update project-consistent UI code from a visible Figma selection + or nodeId using TemPad Dev MCP. Use when the user wants Figma UI recreated, + ported, or integrated into the target project's framework, styling system, + tokens, assets, and existing components. Do not use for design critique, + product invention, generic code review, or guessing states, responsiveness, + or behavior not evidenced by Figma, the project, or the user. --- -# TemPad Dev: Figma Design to Code +# Implement Figma design in code -Use this skill to turn TemPad Dev design evidence into project-consistent UI -code. +Turn visible Figma evidence into the smallest project-native implementation +that preserves the intended result. Keep that result focal: project files, +TemPad output, rules, and tool calls are evidence for the implementation, not +deliverables to reproduce mechanically. -TemPad Dev MCP must be available and able to provide trustworthy design -evidence for the current selection or provided `nodeId`. If not, stop and tell -the user to enable or reconnect TemPad Dev MCP. +Require TemPad Dev MCP to provide trustworthy design evidence for the current +selection or an exact `nodeId` inside the user's established scope. Never +reconstruct the design from memory, screenshots alone, or `get_structure` +metadata. -Within this skill, TemPad Dev MCP is the authoritative source of design -evidence. Treat: +## Evidence and authority -- project files and project instructions as implementation truth when available -- TemPad Dev output as design truth -- the user as the source of truth for missing product or implementation - decisions +Use each source only for what it can establish: -Do not infer project conventions before reading local evidence. +- **The user** sets scope, requirements, prohibitions, and missing product or + implementation decisions. +- **The project** sets framework, file placement, component boundaries, + styling, tokens, assets, dependencies, and verification conventions. +- **TemPad Dev** sets visible structure and rendered design facts. -For concerns orthogonal to Figma-to-code translation, follow project -instruction files such as `AGENTS.md` and other project instructions instead of -defining new policy in this skill. If such a concern is unspecified there and -would materially change the implementation, ask the user or stop. +Follow project instruction files for concerns outside Figma-to-code +translation. Do not add policy for routing, analytics, i18n, CMS, or other +orthogonal systems. -## Evidence model +TemPad can establish visible hierarchy, layout, spacing, typography, color, +effects, token references, exported assets, and codegen unit context. It cannot +establish unevidenced states, responsive behavior, business logic, navigation, +validation, analytics, or project conventions. Treat `get_structure` as +hierarchy and geometry evidence only, never as missing style truth. -Use three evidence channels for different jobs: +## Workflow -- **Project evidence**: `AGENTS.md` or equivalent project instruction files, - design-system docs, token/theme docs, component docs, existing primitives, - nearby implementations, framework/styling config, asset rules, and project - scripts -- **Design evidence**: `tempad-dev:get_code` first for markup, styles, tokens, - assets, warnings, and codegen facts; `tempad-dev:get_structure` only for - hierarchy, geometry, overlap, and retry targeting -- **User input**: missing behavioral intent, responsive intent, target file, - acceptable tradeoffs, asset or dependency decisions, or other product or - implementation decisions that cannot be recovered from project or design - evidence +### 1. Establish the implementation envelope -## What TemPad Dev can and cannot prove +Read only local evidence that can change this implementation, in this order: -TemPad Dev can prove: +1. applicable `AGENTS.md` or equivalent instructions; +2. relevant design-system, token, component, and asset guidance; +3. the nearest comparable implementation and reusable primitives; +4. framework, styling, and check configuration needed for this task. -- the visible structure of the current selection or a provided `nodeId` -- explicit layout, spacing, typography, color, radius, borders, shadows, - gradients, masks, filters, compositing, and other rendered visual details -- token references and values when present -- exported assets and whether an SVG may safely adopt one contextual color - channel via `themeable` -- codegen facts such as actual output language, `cssUnit`, `scale`, and - `rootFontSize` +Determine the target file or component boundary, framework, styling method, +token and asset paths, reuse candidates, dependency constraints, and narrowest +relevant checks. Inspect Tailwind version and theme scales only when the +project actually uses Tailwind-compatible tooling. -TemPad Dev cannot prove: +Do not inventory the repository broadly after the needed envelope is clear. If +a missing project decision would materially change the result, ask before +implementation. -- hidden, hover, active, loading, error, empty, disabled, or responsive states - unless separately evidenced -- non-visual product requirements such as behavior, business logic, validation, - navigation, or analytics -- project conventions, file placement, component boundaries, primitive-reuse - policy, token-mapping policy, or asset workflow beyond what the project - already establishes -- missing style truth from `get_structure`; it is only a structure aid +### 2. Read the design at the requested scope -## Default operating rules +Call TemPad Dev's `get_code` before implementing: -Do not output `data-hint-*` attributes. +- use `resolveTokens: false` by default; +- omit `nodeId` for the current single selection; pass one only when the user + supplied it or TemPad returned the exact ID for a targeted read inside the + user's established scope; +- set `preferredLang` from the established project target; +- keep TemPad's default vector behavior unless the user explicitly requests + asset-preserving vector fidelity and the active MCP version supports it. -Never invent visual details or behavior not evidenced, including color, -typography, spacing, radius, borders, shadows, gradients, opacity, overlays, -blur, hidden states, responsive behavior, interactions, or asset semantics. +Use `resolveTokens: true` only when the user explicitly does not want design +token references. Treat returned `lang` as authoritative because plugin +configuration may override `preferredLang`. -Treat advanced or uncommon style output from TemPad Dev as intentional unless -project constraints force an adaptation. +Retain the returned `code`, `lang`, `warnings`, `assets`, `tokens`, and +`codegen` facts that bear on the implementation. Use +`codegen.config.{cssUnit,rootFontSize,scale}` for exact unit conversion. -Only ask the user when the answer would materially change the implementation and -cannot be established from project or design evidence. Typical blockers: +Prefer one top-level read that preserves the requested composition. If the +tool is unavailable, points at the wrong file, or returns incomplete evidence, +read [recovery.md](references/recovery.md) before doing anything else. -- more than one plausible target file or component boundary -- more than one plausible existing primitive or abstraction to reuse -- missing behavior, state, or responsive intent -- asset, dependency, or token workflow requiring a product decision +### 3. Separate facts, adaptations, and gaps -If a gap is minor and non-blocking, proceed with a clearly stated inference. +Before editing, distinguish: -Prefer the **smallest safe change**. Do not perform unrelated refactors or add -new abstractions unless project patterns clearly call for them. +- **design facts** to preserve; +- **project-native adaptations** supported by existing components, tokens, + utilities, or asset conventions; +- **unevidenced product decisions** that must remain unimplemented or be asked. -Do not enter open-ended visual tuning loops without new evidence. If remaining -differences cannot be proved from project or design evidence, warn clearly and -stop or hand off for user validation. +Map by rendered value and semantics, not by a convenient name. A familiar +component or token is a candidate, not proof of equivalence. If more than one +material implementation path remains equally plausible, ask the user. Infer +only low-consequence details and report any inference that affects the result. -## Workflow +### 4. Implement the smallest coherent change + +- Keep the established framework, styling system, file placement, imports, and + abstraction level. Do not introduce a parallel system. +- Reuse an existing primitive only when its semantics and rendered behavior fit + without guessing. Do not force reuse that erases design facts. +- Preserve exact rendered values unless project evidence proves an equivalent + token, utility, or component. For `rem` output, convert with TemPad's actual + `cssUnit`, `rootFontSize`, and `scale`. +- Preserve intentional uncommon output, including pseudo-elements, filters, + masks, blend and backdrop effects, gradients, and non-default compositing, + unless a documented project constraint requires an adaptation. +- Implement only evidenced states and responsiveness. Do not invent hover, + loading, error, empty, disabled, or responsive behavior. +- Use native semantic elements and preserve keyboard access and accessible + names when an established primitive does not already provide them. +- Add no runtime or build dependency without user approval unless the user has + explicitly waived that constraint. +- Keep `data-hint-*` attributes out of shipped code. + +When TemPad returns relevant entries, load only the matching protocol: + +- assets: read [Assets](references/assets-and-tokens.md#assets) and follow the + project's asset delivery path; +- token references: read [Tokens](references/assets-and-tokens.md#tokens) and + follow the project's token workflow. + +Read both when both are present and skip both when neither is present. + +Do not enter a visual tuning loop. Change the implementation again only when +new project, design, tool, or verification evidence identifies a concrete +defect. + +### 5. Verify in the project's real workflow + +Run the narrowest relevant checks defined by project instructions and scripts. +Repair implementation failures and rerun the affected checks. Use an existing +preview, screenshot, or comparison workflow when available; do not invent a +universal verification matrix. + +If no runnable check exists, report the implementation as unverified. Do not +claim visual completion without a real project comparison path; ask the user +to confirm the rendered result against Figma. + +## Hard stops + +Stop instead of shipping when: -### 1. Read local evidence first +- TemPad is unavailable, unauthorized, inactive on the intended file, or + cannot provide a trustworthy visible parent composition; +- the target is unreadable or not visible; +- project, design, and user evidence still conflict after targeted recovery; +- a missing decision would materially change behavior, structure, dependency, + asset delivery, or token mapping; +- required assets cannot be retrieved or stored under project policy. -Read local evidence before implementing. Prioritize, in order: +If blocked, give at most three concrete actions that would unblock the task. -1. `AGENTS.md` or equivalent project instruction files -2. relevant design-system, token, and component docs -3. existing primitives/components and nearby implementations -4. config files and scripts that constrain output +## Handoff -Establish at least: +Report: -- framework/runtime and file conventions -- styling rules, including whether utilities are used and how classes are - ordered or formatted -- token/theme system and mode handling -- asset and icon pipeline -- reusable primitives/components, file placement, and import path conventions -- the narrowest established project checks for this change, if any +- what changed and where; +- only the relevant adaptation, inference, warning, asset/token handling, or + residual visual risk; +- checks run, their result, and what remains unverified. + +Keep absent concerns absent from the handoff. Do not produce a compliance +checklist for branches the task never used. -Only if the project actually uses Tailwind or Tailwind-compatible tooling, -detect Tailwind version and config before changing class syntax or ordering. +## Decision example -For Tailwind projects, also inspect the local theme scales relevant to exact- -value mapping, especially spacing, sizing, radius, and typography. - -If a material implementation constraint is still missing after local evidence, -ask the user instead of inferring it. - -### 2. Fetch the top-level design snapshot - -Call `tempad-dev:get_code` first. - -Use these defaults: - -- `resolveTokens: false` -- pass `nodeId` only when the user provided one; otherwise use the current - selection -- set `preferredLang` to match the project target, such as `jsx` or `vue` - -Use TemPad's default vector behavior unless the user explicitly asks for -asset-preserving vector fidelity and the current MCP version clearly supports -it. - -Use `resolveTokens: true` only when the user explicitly does not want -design-token usage. - -Treat returned `lang` as authoritative because TemPad Dev plugin or config may -override `preferredLang`. - -Record these as design facts: - -- `code` -- `lang` -- `warnings` -- `assets`, if present -- `tokens`, if present -- `codegen` - -Use `codegen.config.{cssUnit,rootFontSize,scale}` as the authoritative unit -context for exact-value mapping. - -Prefer fetching the full requested top-level selection first so parent -composition and containment are not lost. - -### 3. Resolve incomplete or conflicting evidence before implementing - -If `get_code` warns or fails, narrow uncertainty instead of guessing. - -- **`depth-cap`**: keep the returned top-level result as the source of parent - layout and composition, then use returned `data-hint-id` values to choose - narrower `get_code` follow-ups for the subtrees you still need. -- **budget overflow or shell response**: keep the returned parent shell as the - composition source of truth, then fetch omitted child subtrees separately and - fill them into that known shell. Prefer the smallest parent container that - still preserves the shared layout for the child subtrees you must assemble. - Do not treat plain string truncation as usable evidence. -- **layout, hierarchy, or overlap uncertainty**: call - `tempad-dev:get_structure`, but use it only to resolve hierarchy or geometry, - or to choose a narrower parent-shell retry target. Do not treat it as - missing style truth. -- **remaining contradiction**: if project evidence, design evidence, and - structure evidence still conflict after narrowing, stop. -- **untrustworthy parent recovery**: if you still cannot obtain a trustworthy - parent shell or parent composition via `get_code`, stop full implementation - and ask the user to narrow scope or choose the highest-priority subtree. - -Retry policy: - -- retry once only for transient transport or connectivity failures -- do not blind-retry deterministic issues such as invalid selection, hidden - node, wrong file, `depth-cap`, budget overflow, or unreadable target; change - scope or inputs first - -If TemPad MCP appears unavailable, inactive, or pointed at the wrong file, stop -and tell the user to: - -- enable MCP access in TemPad Dev Preferences > Agent integration -- keep the correct TemPad Dev / Figma tab active -- use the MCP badge in the TemPad Dev panel to activate the correct file if - multiple Figma tabs are open - -If asking the user to narrow scope because of budget overflow, report the -current consumption, limit, and overage from the error text. - -### 4. Implement code in the established project style - -Translate TemPad Dev output into the implementation's established patterns. - -- Reuse existing primitives and abstractions when they fit **without guessing**. -- Keep the established framework and styling system. Do not introduce a second - one. -- Follow established file placement and import conventions. -- If the implementation is utility-first, keep utilities and match existing - conventions. Otherwise translate generated utilities into the established - styling approach while preserving values. -- Preserve exact values. Do not coarsen arbitrary values such as `py-[4px]`, - `text-[12px]`, or `font-[600]` into named utilities unless local project - evidence proves the same rendered value; for `rem` output, use - `codegen.config.{cssUnit,rootFontSize,scale}` to convert exactly. Apply this - to spacing, sizing, - inset, gap, radius, `font-size`, `line-height`, `letter-spacing`, and - `font-weight`. -- Implement the base state only unless variants, interactions, or responsive - behavior are evidenced. -- Preserve emitted pseudo-elements. If TemPad output includes `before:`, - `after:`, `content-*`, or equivalent CSS, keep them or use an established - equivalent with the same rendered result. -- Preserve other high-fidelity details from `get_code`, including pseudo- - classes, filters, masks, blend or backdrop effects, and other non-default - visual properties, unless implementation constraints require adaptation. -- New runtime or build dependencies require user confirmation unless explicitly - waived. -- Extract new abstractions only when repetition plus established patterns - justify it. -- If multiple plausible primitives, layout abstractions, or delivery strategies - fit and evidence does not decide, ask the user instead of guessing. - -#### Assets - -Follow the established asset policy first. - -- Download bytes only from TemPad-provided `asset.url`. Never substitute public - internet assets. -- Treat assets as files to save or reference, not as text evidence to parse. -- If policy forbids storing assets, you may reference TemPad URLs, but you must - warn that the output depends on the local TemPad asset server. -- If a vector is emitted as `` in `code`, treat that - placeholder markup as the current design truth for structure, sizing, and - instance color evidence. `data-src` points at the uploaded SVG asset. Only - refactor delivery when the implementation already has another established SVG - policy. -- If TemPad falls back to inline SVG because asset upload failed, treat that - inline markup as the design truth for that vector instead of re-synthesizing - the shape from the asset metadata. -- Do not introduce a new SVG pipeline if one is already established. -- Preserve vector semantics: - - `themeable: true` means one context-driven color channel, typically via - `currentColor` - - drive that color from the established wrapper or component styling rather - than inventing a new icon API - - vectors without `themeable: true` keep their internal palette -- Use `asset.themeable` only after accounting for the project's existing SVG - delivery policy. -- Do not invent multi-color SVG props or custom CSS variables unless the - implementation already has an established icon API that requires them. - -#### Tokens - -Preserve design-token usage by default. - -Token evidence may be either direct values or mode-specific values keyed by -`Collection:Mode`. Preserve references between variables when present. - -- Prefer existing tokens only when equivalence is justified by value, - references, and semantics, not by name alone. -- If the implementation can safely carry design-token references for this - change, preserve TemPad token references until they are mapped through the - normal token workflow. -- Add new tokens only when there is already an established process for doing so - and this change is expected to use it. -- If token landing, mode selection, or mapping remains ambiguous or unsupported, - use explicit values and warn. -- Hints may be used only for reasoning about mode selection; never output hint - attributes. - -#### Semantics and accessibility - -When not already using an appropriate primitive or component: - -- use native elements where appropriate, such as `button`, `a`, `input`, and - `label` -- preserve keyboard interaction and focusability -- add accessible names when needed, such as `aria-label` or `alt` - -Assume the existing CSS reset or normalize strategy. Do not add new reset -libraries or global CSS unless there is already a defined pattern for it. - -### 5. Project checks and handoff - -Project checks are project-defined, not skill-defined. - -- Follow project instruction files such as `AGENTS.md`, local docs, and - existing project scripts for any lint, format, typecheck, build, test, - preview, screenshot, or design-comparison steps relevant to this change. -- Run the narrowest relevant checks that the project already defines and the - current host or client can actually execute. -- If those checks fail, repair obvious implementation issues when feasible and - re-run the relevant checks. -- Do not invent a default verification matrix just because this is a - Figma-to-code task. -- If no established or runnable check path exists for this change, say the - output is **unverified**. -- If shell recovery or subtree stitching was involved and no existing project - check can confirm the resulting layout, explicitly call out the remaining - visual risk. -- Do not claim visual or design-complete verification unless the project - already has a normal preview, screenshot, or design-comparison workflow. - Otherwise ask the user to visually validate the result against Figma. - -## Stop conditions - -Stop instead of shipping code when: - -- TemPad Dev MCP is unavailable, unauthorized, disconnected, inactive on the - correct file, or otherwise cannot provide trustworthy design evidence -- the target cannot be read or is not visible -- project, design, and user evidence still conflict after narrowing -- a missing user decision would materially change the implementation and cannot - be safely inferred -- required implementation constraints are missing and cannot be safely inferred - from project or design evidence -- a trustworthy parent composition cannot be recovered after `depth-cap`, shell - response, or budget overflow -- required assets cannot be retrieved or stored under the established policy -- new dependencies would be required and user confirmation has not been obtained - -## Output contract - -When shipping code, end with: - -- what was implemented and where -- evidence caveats, warnings, any stated inference, and whether shell recovery - or subtree stitching was used -- asset handling, including whether assets were stored locally or still depend - on TemPad URLs -- token handling, including mapped tokens, preserved references, or explicit - fallback values -- dependency notes, including whether any were added and whether approval was - obtained -- project-check status, including commands run if any, what passed or failed, - and what remains unverified -- any residual visual risk and the visual confirmation the user should still - perform - -If blocked, provide at most 3 concrete next items needed from the user. - -## Examples - -### Example: over-budget parent with recoverable shell - -- `get_code` returns a parent shell and a shell warning -- keep that shell as the composition source of truth -- fetch missing child subtrees with `get_code` -- insert them into the known parent structure -- do not rebuild sibling layout from guesswork -- if no trustworthy parent shell can be recovered, stop and ask for a narrower - scope instead of reconstructing parent layout from guesses -- report any remaining visual risk if project checks cannot confirm the layout - -### Example: SVG marked `themeable: true` - -- first check the established icon or SVG delivery policy -- if the implementation already uses contextual icon color, adapt the SVG to - one color channel, usually `currentColor` -- do not invent multi-color props or a custom icon API -- if more than one delivery strategy is plausible and evidence does not decide, - ask the user - -### Example: token mapping is ambiguous - -- preserve TemPad token references if the implementation can safely carry them -- map to existing tokens only when value plus semantic equivalence is justified -- if mode selection or landing zone is still unclear, use explicit values and - warn instead of inventing a mapping +If TemPad emits `padding: 15px` and the project has a `space-4` token worth +`16px`, preserve `15px` unless project evidence explicitly makes the token the +intended mapping. Project consistency selects the representation; it does not +authorize changing the visible design. 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..2e8c6946 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/agent-plugins/tempad-dev/skills/figma-design-to-code/references/assets-and-tokens.md b/agent-plugins/tempad-dev/skills/figma-design-to-code/references/assets-and-tokens.md new file mode 100644 index 00000000..01dec4a6 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/references/assets-and-tokens.md @@ -0,0 +1,47 @@ +# Translate assets and tokens + +Read this reference only when `get_code` returns `assets` or `tokens`. + +## Assets + +Follow the project's established asset and icon policy before TemPad delivery +details. + +- Download bytes only from a TemPad-provided `asset.url`. Never substitute a + public internet asset. +- Treat assets as files to store or reference, not text evidence to parse. +- If project policy forbids storing them, reference TemPad URLs only when the + user accepts the local-server dependency, and report it. +- Treat emitted `` markup as design truth for structure, + size, and instance color. Refactor delivery only through an existing project + SVG path. +- If upload falls back to inline SVG, preserve that markup rather than + resynthesizing the vector. +- `themeable: true` permits one contextual color channel, usually + `currentColor`; drive it through the established wrapper or icon convention. + Preserve internal palettes when `themeable` is absent. +- Do not invent a new SVG pipeline, multi-color props, or custom variables. + +If a required asset cannot be retrieved or represented under project policy, +stop rather than draw or substitute it from memory. + +## Tokens + +Preserve token usage when the target project can carry or map it safely. +Token facts may be direct values or mode-specific values keyed by +`Collection:Mode`; preserve aliases between variables when present. + +- Map to an existing project token only when value, reference behavior, + semantics, and relevant mode agree. A similar name is insufficient. +- Preserve TemPad token references through the project's normal token workflow + when that workflow can accept them. +- Add a token only when the project already defines how and this task calls for + it. +- If landing location, mode, or mapping remains ambiguous, use the exact + rendered value and report the fallback. +- Use hint metadata only while reasoning about a mode; never ship hint + attributes. + +When tokens and explicit rendered values disagree, do not silently choose. +Narrow the design evidence or ask the user which source expresses the intended +state. diff --git a/agent-plugins/tempad-dev/skills/figma-design-to-code/references/recovery.md b/agent-plugins/tempad-dev/skills/figma-design-to-code/references/recovery.md new file mode 100644 index 00000000..88cf1e89 --- /dev/null +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/references/recovery.md @@ -0,0 +1,54 @@ +# Recover trustworthy design evidence + +Read this reference only when TemPad is unavailable, a `get_code` call warns +or fails, or the requested selection cannot fit in one trustworthy response. + +## Connection and target failures + +For a transient transport failure, retry once. Do not blind-retry invalid +selection, hidden node, wrong file, deterministic budget, or depth errors. + +If TemPad is unavailable or active on the wrong file, stop and ask the user to: + +1. enable MCP access in TemPad Dev **Preferences > Agent integration**; +2. keep the intended TemPad Dev and Figma tab active; +3. use the MCP badge in the panel to activate the intended file when multiple + Figma tabs are open. + +Do not edit code while design evidence is untrustworthy. + +## Incomplete `get_code` results + +Preserve the largest trustworthy parent composition and narrow only the +missing evidence: + +- **`depth-cap`**: keep the returned top-level composition, then use returned + `data-hint-id` values for targeted child `get_code` calls. +- **budget overflow or shell response**: keep the returned parent shell, then + fetch omitted children separately. Use the smallest parent that still proves + their shared layout. Plain string truncation is not evidence. +- **hierarchy, geometry, or overlap uncertainty**: call TemPad Dev's + `get_structure` only to resolve that uncertainty or select a narrower retry + target. + +Never rebuild a missing parent from child metadata. If no trustworthy parent +shell can be recovered, stop the full implementation and ask the user to +narrow the selection or choose the highest-priority subtree. + +If a budget error requires user action, report its consumption, limit, and +overage from the tool response. + +## Resolve contradictions + +Prefer the evidence source with authority over the disputed fact: project +evidence for implementation conventions, `get_code` for visible design, and +the user for product intent. Narrow the read once when the conflict may be a +scope problem. If the sources still disagree, stop rather than choose silently. + +## Worked example + +When a large frame returns a usable header-and-grid shell but omits three cards, +keep the shell as the parent layout, fetch only those card subtrees, and insert +them into the known grid. If the response contains cards but no trustworthy +grid shell, do not infer columns or spacing from `get_structure`; request a +narrower parent selection. diff --git a/docs/engineering/optimization-audit.md b/docs/engineering/optimization-audit.md index 714c7193..5bfdc66a 100644 --- a/docs/engineering/optimization-audit.md +++ b/docs/engineering/optimization-audit.md @@ -78,9 +78,11 @@ 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; the Hub retains download-only support for legacy 8-character + identifiers until cached assets expire. 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..e9342363 100644 --- a/docs/extension/mcp-browser-gateway-design.md +++ b/docs/extension/mcp-browser-gateway-design.md @@ -21,17 +21,24 @@ 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. 6. A 20-second ping keeps the Manifest V3 service worker alive. A disconnected content port or WebSocket reconnects while its session remains enabled. +The bridge protocol version covers the shared tool contract as well as transport messages. Bump it +whenever a Hub and extension built from different revisions must not exchange tool calls. + The hub chooses the active browser connection. Inside that connection, the broker chooses the -active Figma session. A sole session is selected automatically; switching sessions is explicit. -Broker activation is sent to the hub only from that explicit user action. Pending tool results are +active Figma session. A sole session is selected automatically. More than one session requires an +explicit choice: registering another Figma tab clears the previous choice, and a newly connected +Hub clears an ambiguous choice inherited from its predecessor. Foregrounding a tab does not route +MCP calls; clicking its badge does. Broker activation is sent to the hub only from that explicit +user action. Pending tool results are bound to the extension connection that received the request, so a second connection cannot satisfy or reject another connection's request by guessing its id. While an extension connection is active, the hub accepts replacement activation only from the same extension Origin. Normal reconnects and @@ -40,11 +47,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..2a3e7961 --- /dev/null +++ b/docs/extension/mcp-canvas-assets-design.md @@ -0,0 +1,536 @@ +# MCP Canvas SVG and image assets + +Status: implemented, including programmatic generated-image upload +Date: 2026-07-31 + +## Decision + +Keep `apply_canvas` as the only canvas-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-search, image-search, or SVG-operation tools to TemPad. Expose one narrow +`upload_asset` bridge for PNG, JPEG, or GIF `data:` URLs returned by an image-generation tool. The +agent must compose generation and upload inside one programmatic tool call so bytes do not enter +model-authored prose or later Canvas arguments. The dedicated upload argument carries the image +once; subsequent Canvas calls see only a content hash. Do not put raster bytes or large SVG +documents in `apply_canvas`. + +Image generation may run in an isolated subagent when the host supports it and the canvas-authoring +delegation gate passes. The main agent fixes the art brief, remains the only Canvas writer, and owns +placement and final judgment. This is an optional agent-orchestration optimization, not part of the +TemPad protocol. + +Asset-medium selection remains an evidence decision. Creative latitude, Canvas editability, and +delivery convenience do not establish a geometric or vector language. When several assets represent +distinct content, the chosen existing, licensed, generated, or vector route must preserve the +distinctions the composition depends on instead of substituting one reusable placeholder motif. + +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. + +The expected layer shape is a managed Frame containing one native imported SVG subtree. TemPad does +not flatten its Vector descendants because that can change strokes, holes, masks, multicolor art, +and exact source replacement. + +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. TemPad therefore accepts one bounded image +data URL through a dedicated Hub-only call instead of widening `apply_canvas`, reading local files, +or adding an arbitrary URL fetcher. + +## Public desired-result contract + +Add one optional top-level field to the compact public schema: + +```ts +type ApplyCanvasInput = { + // existing fields + assets?: Record +} +``` + +As with `styles`, `variableCollections`, and advanced `native` state, the public schema exposes the +outer record while keeping each asset definition opaque. 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. + +The asset manifest is a delivery contract, not a medium selector. Before declaring a material +asset, the agent records which user requirement, inspected evidence, or explicit brief decision +establishes its subject and medium. A content-image role uses a sourced, generated, supplied, or +current-file asset; availability of inline SVG does not justify replacing it with primitives or +newly invented vector artwork. Agent-authored vectors require an independently established +illustration, diagram, pattern, or decorative-geometry role. + +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 page-only or remove +operations 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 + +When custom focal imagery is appropriate and a generation capability is +available, generation runs before layout instead of substituting hand-built +Canvas geometry. + +Support three factual routes: + +1. A rights-established public HTTPS PNG/JPEG/GIF URL: use `imageUrl`. +2. The generator returns a PNG/JPEG/GIF `data:` URL: compose its result directly into + `upload_asset`, then use the returned `assetHash`. +3. Neither path exists: omit optional imagery or disclose the required gap; never synthesize an + image-role illustration from Figma primitives. + +`upload_asset` is Hub-only, content-addressed, idempotent, and bounded by the existing per-asset and +aggregate quotas. It accepts no local path, remote URL, headers, credentials, SVG, or arbitrary MIME +type. It validates base64 canonically, recomputes SHA-256, and stores through the existing loopback +asset server. Its response contains only `assetHash`, MIME type, and size. + +When the host supports subagents and generation is separable, importable, verifiable, and worth its +coordination cost, 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. The main agent programmatically uploads + its selected `data:` URL through `upload_asset`; a rights-established public result URL remains a + valid direct route. +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, direct URL imports, +crop/placement decisions, or final acceptance. 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`, prose, or a copied/manual tool argument; +- 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 + +New 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. The Hub retains download-only +support for legacy 8-character identifiers until cached assets expire; new model-facing asset IDs +always use the full digest. + +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 `
` creates a line break; `whitespace-pre-wrap` preserves every decoded character, including repeated spaces and literal source line breaks | +| Text-content variable | STRING variable bound to `characters` | **Supported** on the whole text node | +| Font family and style | `fontName` | **Supported**: common Inter shorthand remains available through classes; any available exact family/style can be loaded and applied to a whole node or range; STRING variables and Text styles remain available | +| Font size | pixel font size of at least 1 | **Supported** on the whole node and ranges, including FLOAT variable binding | +| Font weight | selected font style and FLOAT `fontWeight` variable binding | **Supported** through exact whole-node/range `fontName` styles and whole-node/range FLOAT variable bindings; Figma exposes the resolved numeric weight itself read-only | +| Line height | auto, pixels, or percent | **Supported**: whole-node classes cover auto and positive values; typed ranges accept every finite API value; whole-node and range FLOAT bindings are supported | +| Letter spacing | pixels or percent | **Supported** for finite signed whole-node and range literals in both units and whole-node/range FLOAT variable binding | +| Horizontal alignment | left, center, right, justified | **Supported** | +| Vertical alignment | top, center, bottom | **Supported** through typed `figma.text.verticalAlign` | +| Auto resize | none, width-and-height, height, deprecated truncate | **Supported** for all current modes; the deprecated `TRUNCATE` value is deliberately not emitted | +| Truncation | disabled or ending ellipsis, with nullable `number` maximum lines | **Supported**: both modes and null are supported; non-null maximum lines use the native API's required integer range starting at one | +| Text case | original, upper, lower, title, small caps, forced small caps | **Supported** on the whole node and ranges | +| Paragraph formatting | paragraph indent, paragraph spacing, and list spacing | **Supported** on the whole node and ranges; indent and paragraph spacing accept whole-node and range FLOAT variables | +| Lists | ordered/unordered list options, indentation, list spacing, and hanging-list state | **Supported**: range patches carry every list type, indentation, and spacing; a full-range patch expresses whole-node list state | +| Hanging punctuation | `hangingPunctuation` | **Supported** on the whole node | +| Decoration | none/underline/strikethrough plus underline style, offset, thickness, color, and skip ink | **Supported**: basic values are available whole-node; ranges carry the complete writable decoration state, including variable-bound solid decoration color | +| Leading trim | cap-height or none | **Supported** on the whole node | +| Hyperlinks | URL or node target, or null | **Supported** on the whole node and ranges, including explicit removal; node targets accept an existing Figma ID or a stable canvas key from the desired result/current update scope, including forward references | +| Auto rename | derive the layer name from changed characters | **Supported** through `figma.text.autoRename`; fixed `figma.name` plus enabled auto-renaming is rejected as contradictory | +| Rich text ranges | per-range font, case, spacing, line height, fill, styles, lists, decoration, links, and variable bindings | **Supported** for every non-deprecated range setter for formatting, lists, links, styles, and variables in the pinned typings; declarative `characters` replaces procedural insertion/deletion; patches are ordered, non-overlapping UTF-16 intervals and omitted fields preserve live state | +| Text and fill styles | whole-node or range `textStyleId` and `fillStyleId` | **Supported** by local ID, published key, or stable local `styleKey`, including same-result authored styles and explicit `null` unlinking at whole-node or range scope | +| OpenType features | `openTypeFeatures` | Plugin API 1.130 exposes this state read-only; `apply_canvas` cannot author it without a Figma API addition | + +## Components and instances + +| Capability | Figma Design state | Current `apply_canvas` status | +| ------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Instantiate local component | component node ID | **Supported** | +| Instantiate library component | published component key | **Supported** through import | +| Variant property | string property value | **Supported** | +| Text property | string property value | **Supported** | +| Boolean property | boolean property value | **Supported** | +| Instance-swap property | component node ID encoded by Figma as a string value | **Supported** when the caller supplies a valid component ID | +| Component-property variable | `VariableAlias` value | **Supported** through typed `componentProperties` variable references on instances and typed BOOLEAN/TEXT/INSTANCE_SWAP defaults on authored definitions. Catalog component-tag attributes remain literal shorthands | +| Slot content | slot property and authored `SlotNode` children | **Supported** with native default-content children, the full frame surface, exact property metadata/settings, and update identity. The pinned API cannot attach a new Slot in another variant to an existing SLOT definition | +| Instance replacement | swap an existing instance's main component while preserving identity | **Supported** by using a different catalog component tag/ref for the same stable key. Omitted or true `figma.instance.preserveOverrides` uses Figma's normal override-preserving swap; false changes the main component without carrying old overrides, then applies the rest of the declared result | +| Exposed nested instance | `isExposedInstance` | **Supported** when updating an existing primary instance inside a component or component set | +| Instance scale factor | `scaleFactor` | **Supported** across the native range starting at `0.01`, independently from final instance size | +| Create component | new `ComponentNode` | **Supported** as a native frame-like authored component, including children, Auto Layout/Grid, appearance, styles, variables, guides, metadata, and exact update identity | +| Component variants | component sets, variant definitions, default variant | **Supported**: native non-empty component sets are created from ordered component children; exact variant names define property names/options and declared geometry determines Figma's top-left default. The dedicated property methods mutate that same derived state, so exposing them as a second path would create contradictory dual writes rather than add expression power | +| Component property definitions | create/edit/delete BOOLEAN, TEXT, INSTANCE_SWAP, VARIANT, SLOT definitions | **Partial**: BOOLEAN, TEXT, and INSTANCE_SWAP support stable-keyed create/edit/explicit delete, variable defaults, and preferred values; complete VARIANT state derives from exact variant names and geometry; SLOT definitions are created/edited through native Slot nodes and removed with the owned Slot node. The contract intentionally has no contradictory property-only SLOT deletion path; the pinned API cannot attach a new Slot in another variant to an existing definition | +| Component sublayer references | bind visibility, text, or nested-instance identity to a definition | **Supported** by stable or exact property name with type preflight, explicit null clearing, omit/preserve semantics, and deterministic precedence over conflicting literals | +| Publishable metadata | description Markdown and the single supported documentation link | **Supported** on authored components and component sets with omit/preserve, empty-description clear, and null-link clear semantics | + +Publish status is read-only in the pinned Plugin API, and publishing itself is not exposed by that +API; neither is counted as missing writable canvas state. + +The [Figma editor](https://help.figma.com/hc/en-us/articles/38231200344599) can use multi-edit to +apply one Slot property across variants. The pinned Plugin API exposes +[`ComponentNode.createSlot()`](https://developers.figma.com/docs/plugins/api/ComponentNode/), +which creates a new Slot and definition together, but no operation that attaches another variant's +Slot to that existing definition. The remaining cross-variant Slot limitation is therefore an API +boundary, not a deliberate reduction of the design model. + +## Variables and styles + +| Capability | Figma Design state | Current `apply_canvas` status | +| --------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Discover components | identities, dimensions, variants, usage guidance, and property definitions | **Partial**: local component definitions on pages Figma already makes accessible are found with one optimized `COMPONENT` type query per page, grouped by family, and deterministically paged. Inaccessible pages are skipped because Figma exposes no direct file-local component listing API and loading every page can block large files. Normal discovery returns a generated tag, short ref, bounded summary, native size, literal prop types/defaults/options, and semantic labels when generated prop names lose meaning; an exact-ref lookup in the same immutable catalog returns bounded native definition, variant, layout, anatomy, and preview-node detail. Unused subscribed-library components cannot be enumerated by the Plugin API | +| Discover variables | variable identities, collection identities and modes, types, scopes, descriptions, and values | **Partial**: local variables, definition dependencies from native styles, component defaults, shader defaults, aliases, and extended collections, plus enabled-library variables, are deterministically paged. Normal discovery returns short variable, collection, and mode refs plus type, scopes, and the default-mode literal value when available; an exact-ref lookup returns the complete captured definition. The Team Library API does not expose file-local IDs, modes, scopes, descriptions, or values for an unimported variable | +| Discover styles | local Paint/Text/Effect/Grid styles plus relevant imported styles | **Partial**: local style definitions are deterministically paged. Normal discovery returns short refs, type, signature, and summary; an exact-ref lookup returns the complete native Paint, Text, Effect, or Grid definition. Subscribed-library styles cannot be enumerated by the Plugin API | +| Discover shaders | owned and subscribed effect/fill shaders, import state, property definitions | **Supported** through `listAvailableShaders`: normal discovery returns deterministic short refs, names, and types, while exact-ref lookup returns the captured definition and defaults Figma exposes without importing or mutating the file | +| COLOR variable binding | fill/stroke solid colors and gradient-stop colors | **Supported** on every direct solid paint and gradient stop on current nodes; a compact whole-node shortcut covers one solid fill/stroke where applicable | +| FLOAT size binding | width, height, four min/max fields | **Supported** for section width/height and for width, height, and applicable bounds on frames, authored components, component sets, slots, text, instances, and basic shapes; a line supports width and width bounds but not its invariant zero height. Groups and Boolean operations derive bounds from children and reject independent size bindings | +| FLOAT Auto Layout binding | linear main/counter gap, grid row/column gap, and four padding fields | **Supported** | +| FLOAT appearance binding | opacity, corner radii, and stroke weights | **Supported** for opacity on current nodes that expose it, including authored components, component sets, groups, and Boolean operations; uniform stroke weight on every current node with stroke geometry; side weights on frame containers/instances/rectangles; uniform radius on every current corner node; side radii on sections/frame containers/instances/rectangles | +| FLOAT typography binding | font size, font weight, line height, letter spacing, paragraph spacing/indent | **Supported** for every field in the pinned whole-node `VariableBindableTextField` union | +| STRING typography binding | font family and style | **Supported** | +| STRING text-content binding | characters | **Supported** | +| BOOLEAN visibility binding | visible | **Supported** on sections, frames, authored components, component sets, slots, groups, Boolean operations, text, instances, and every current basic shape node | +| Stroke and corner bindings | four independent corners, uniform stroke weight, and four side stroke weights | **Supported** on every current node type that exposes each field in the pinned API | +| Range text bindings | the eight text fields above on character ranges | **Supported** for exact references and `null` unbinding on every field in the pinned `VariableBindableTextField` union | +| Paint/effect/layout-grid bindings | paint colors/stops, effect fields, layout-grid fields | **Supported** for direct and authored-style solid colors, every gradient-stop color, every shadow/blur field, and every valid field on row, column, or square layout grids; linked styles preserve their bindings | +| Component-property bindings | instance values and component defaults | **Supported** through typed `componentProperties` variable references and authored BOOLEAN/TEXT/INSTANCE_SWAP defaults. Catalog component-tag attributes set validated literal BOOLEAN, TEXT, VARIANT, and INSTANCE_SWAP values | +| Explicit variable mode | collection mode override on a node/page | **Supported**: catalog collection/mode refs or same-result stable authoring keys set or clear an explicit override on every current scene-node kind and on the page containing the result | +| Variable unbinding | remove an existing binding | **Supported** for every binding exposed by the current surface: `null` clears whole-node and range bindings, while replacing direct Paint/Effect/Layout Grid arrays clears bindings inside those entries. A direct catalog component prop replaces its previous literal or alias value | +| Local variable resources | create/edit/delete variable and collection, modes, aliases, scopes, code syntax | **Supported** for every writable field: one result can create or adopt stable-keyed local collections and variables; create, adopt, add, rename, or explicitly delete modes; edit names, descriptions, publishing visibility, scopes, and WEB/ANDROID/iOS code syntax; set typed literal or variable-alias values; and explicitly delete managed variables or collections. New variables require a value for every mode, and adding a mode copies each undeclared existing variable's default value. Null deletion runs last, scans every readable node/page, rich-text range, vector region, component default, local style, surviving variable alias, extended override, and shader default, and rejects any remaining consumer. Dependent extensions must also be explicitly removed. A variable's type and collection are immutable in the pinned native API and are therefore selected at creation rather than counted as writable gaps | +| Extended collections | extend a collection, inherit modes/variables, and override inherited values | **Supported**: in Enterprise files, one result can extend a local parent by native ID or managed key, or a published parent by library key; same-result parent chains are ordered and cycles fail before creation. Existing extensions expose parent/root and parent-mode identities, support name/publishing-visibility edits, typed literal or alias overrides, null override removal, node/page mode selection, consumer-safe child-before-parent deletion, automatic override cleanup for deleted variables, and deterministic orphan-mode cleanup down retained extension chains | +| Paint style | apply/import/create/edit/delete `PaintStyle` | **Supported**: apply by local ID, imported key, or local authoring key at whole-node, text-range, or vector-region scope. One result can create/adopt and edit a local style's name, Markdown/link metadata, and complete Paint stack with variables, media, patterns, and shaders; Pattern stable keys must already exist in the update scope. `styles[key]: null` removes a managed local style only after every live consumer is explicitly unlinked or removed | +| Text style | apply/import/create/edit/delete `TextStyle` | **Supported**: apply by local ID, imported key, or local authoring key at whole-node or range scope. One result can create/adopt and edit every writable `TextStyle` field and all eight variable bindings, plus name and Markdown/link metadata. Explicit unlinking is supported; `styles[key]: null` uses the same consumer-safe removal rule | +| Effect style | apply/import/create/edit/delete `EffectStyle` | **Supported**: apply by local ID, imported key, or local authoring key on every current effect-style consumer. One result can create/adopt and edit name, Markdown/link metadata, and the complete ordered Effect stack with variables and shaders; unlinking and direct replacement are supported; `styles[key]: null` uses the same consumer-safe removal rule | +| Grid style | apply/import/create/edit/delete `GridStyle` | **Supported**: apply by local ID, imported key, or local authoring key to frames, authored components, component sets, slots, and instances. One result can create/adopt and edit name, Markdown/link metadata, and the complete ordered Grid stack with variables; unlinking and direct replacement are supported; `styles[key]: null` uses the same consumer-safe removal rule | + +`backgroundStyleId` is the deprecated frame-background alias of the fill-style link, not a separate +visual capability. Style bindings are preflighted by exact style type. Repeating a binding is a +no-op; omitting an existing style preserves the link. + +## Document organization + +| Capability | Figma Design state | Current `apply_canvas` status | +| ---------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Page name | page `name` | **Supported** exactly on the page containing the result | +| Page canvas background | page `backgrounds` | **Supported** for Figma's single solid color, including alpha | +| Page variable modes | page explicit modes | **Supported** for set and null-clear | +| Page guides | page `guides` | **Supported** | +| Frame guides | frame-container/instance `guides` | **Supported** | +| Page identity | existing local `PageNode` | **Supported** by exact ID or a stable `pageKey`; an explicit ID can adopt a key and conflicting identities fail closed | +| Page creation | new `PageNode` | **Supported** for a missing named `pageKey` in create mode; the new page receives any declared root and becomes active with empty selection | +| Page order | position under `DocumentNode` | **Supported** through an exact zero-based `page.index`; omission preserves an existing position | + +## Consequence for the result language + +Restricted HTML and deterministic Tailwind utility classes cover the common linear UI composition path. They cannot +faithfully encode every row above without turning class syntax into a second Figma API. + +The result language keeps: + +- HTML for hierarchy, plain text, linear Auto Layout, basic sizing, and common literal appearance; +- design-system bindings for stable component, variable, and style identities; +- small typed Figma-native fields for state with no honest HTML/CSS equivalent, such as component + instances, authored reusable components and variant sets, variable modes, masks, intrinsic + groups, non-destructive Boolean operations, and vector geometry. + +Unknown fields must continue to fail closed until their mapping and reconciliation semantics are +implemented. diff --git a/docs/extension/mcp-canvas-authoring-design.md b/docs/extension/mcp-canvas-authoring-design.md new file mode 100644 index 00000000..e96de7e6 --- /dev/null +++ b/docs/extension/mcp-canvas-authoring-design.md @@ -0,0 +1,829 @@ +# MCP canvas authoring + +Status: implemented + +## Decision + +TemPad Dev gives an agent one declarative authoring language and keeps Figma operations inside the +extension: + +```txt +task intent + -> ground unresolved material design decisions in user / project / skill / research evidence + -> optional page-only apply_canvas create/activate when a fresh page is requested + -> optionally delegate isolated evidence, asset, inventory, or QA work + -> choose reuse or direct resources from the user's constraints + -> design-system authoring only when requested or established by the resolved plan + -> optional get_design_system() for permitted existing-resource reuse, or scope: fonts for environment availability + -> optional exact skill reference for authored Figma-only resources + -> optionally consume exact live component ids returned by earlier canvas work + -> apply_canvas(desired result) + -> resolve refs and resource classes + validate + -> diff latest canvas + -> one undoable native patch + -> structural verification + -> optional get_screenshot validation +``` + +The model never emits Plugin API calls or an operation sequence. It describes the result once. +TemPad Dev chooses the safe operations against the latest live document. + +User constraints govern routing. A request to avoid the file's design system skips +resource-catalog discovery, `catalogId`, catalog tags, and catalog refs. Environment-only +`get_design_system({ scope: 'fonts' })` remains available. Creating new local variables, +styles, or components also does not require a catalog. The agent creates them only when requested +or established as part of the resolved deliverable. A verified Direct result does not require an +unsolicited resource pass. Detailed modeling guidance and executable resource shapes remain in +progressive references rather than the core skill or server instructions. + +A current-page-only evidence constraint also keeps the agent from inspecting other pages or using +pre-existing file resources. It does not redefine Figma's file-wide variable, style, or authoring +identity scopes, and it does not prevent the extension from performing the file-wide identity +checks required for safe reconciliation. + +The model-visible surface contains six tools: + +- `get_code` reads visible design as implementation evidence; +- `get_structure` reads hierarchy and geometry when composition is ambiguous, exposes stable + authoring keys for managed nodes when an update resumes without prior call context, and can + optionally return compact live mask, IMAGE paint, layout-grid, and frame-guide state; +- `get_design_system` conditionally reads deterministic resource catalogs or bounded available-font queries; +- `apply_canvas` creates, updates, removes, or activates exact pages and managed roots and is the + only design-result/context mutating tool; +- `upload_asset` stores a programmatically composed generated PNG/JPEG/GIF data URL in the Hub and + returns only a content hash for a later Canvas IMAGE declaration; +- `get_screenshot` returns bounded visual evidence only when pixels affect the next decision. + +## Why this is the right level + +UI models have strong priors for HTML, common utility classes, and component props. They have much +weaker priors for large Figma node graphs and long imperative Plugin API traces. The public language +therefore uses: + +- `div` for frame-like composition; +- `span` for editable text; +- returned custom tags for real Figma component instances; +- a strict Tailwind utility subset for common layout and appearance, including native default + spacing, sizing, border, radius, opacity, rotation, and typography scales plus exact arbitrary + pixel/color values; +- CSS custom-property utility syntax mapped to exact variable identities, and `type-*` utility + classes mapped to native TextStyle identities; +- a typed `figma` extension for native state that HTML cannot represent honestly. + +This is one dialect, not parallel “simple” and “advanced” languages. The native extension is an +escape hatch inside the same desired-result document. The agent pays for advanced detail only when +the task needs it. + +Custom component tags are better than generic TemPad primitives because they are both familiar to +models and specific to the active design system. A returned `.

@@ -275,6 +300,7 @@ function getCopyTitle(action: AgentIntegrationAction): string { } .tp-agent-dialog-nav { + min-height: 0; padding: var(--spacer-1) 0; border-right: 1px solid var(--color-border); overflow-y: auto; @@ -306,6 +332,7 @@ function getCopyTitle(action: AgentIntegrationAction): string { .tp-agent-dialog-content { min-width: 0; + min-height: 0; padding: var(--spacer-3); overflow-y: auto; } diff --git a/packages/extension/components/Code.vue b/packages/extension/components/Code.vue index 42cbb976..f8ec8eae 100644 --- a/packages/extension/components/Code.vue +++ b/packages/extension/components/Code.vue @@ -37,20 +37,19 @@ 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]) { + // Recompute when asynchronously loaded Prism languages become available. + void prismRevision.value + const Prism = window.Prism + 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/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 f4961445..08e15bfc 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 { 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 @@ -107,7 +118,7 @@ export const useMcp = createSharedComposable(() => { status.value = state.status setAssetServerUrl(state.assetServerUrl ?? null) if (state.status !== 'connected') { - resetUploadedAssets() + resetAssetCache() } return } @@ -120,6 +131,7 @@ export const useMcp = createSharedComposable(() => { useEventListener(window, 'message', handleBridgeMessage) setAssetUploader(uploadAsset) + setAssetDownloader(downloadAsset) watch( canEnable, @@ -166,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 { @@ -228,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/composables/plugin.ts b/packages/extension/composables/plugin.ts index 003b12aa..64915cbc 100644 --- a/packages/extension/composables/plugin.ts +++ b/packages/extension/composables/plugin.ts @@ -1,5 +1,6 @@ import { shallowRef } from 'vue' +import { readBoundedResponseBytes } from '@/mcp/bounded-response' import SNAPSHOT_PLUGINS from '@/plugins/available-plugins.json' import { codegen } from '@/utils' @@ -196,40 +197,9 @@ function ensureScriptLikeResponse(response: Response): void { } async function readBoundedText(response: Response, maxBytes: number): Promise { - const contentLength = Number(response.headers.get('content-length')) const tooLarge = () => new Error(`Plugin content exceeds the ${maxBytes / 1024} KiB limit.`) - if (Number.isFinite(contentLength) && contentLength > maxBytes) { - throw tooLarge() - } - - if (!response.body) { - const text = await response.text() - if (new TextEncoder().encode(text).byteLength > maxBytes) { - throw tooLarge() - } - return text - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let bytes = 0 - let text = '' - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - bytes += value.byteLength - if (bytes > maxBytes) { - await reader.cancel() - throw tooLarge() - } - text += decoder.decode(value, { stream: true }) - } - text += decoder.decode() - return text - } finally { - reader.releaseLock() - } + const bytes = await readBoundedResponseBytes(response, maxBytes, tooLarge) + return new TextDecoder().decode(bytes) } async function sha256(value: string): Promise { diff --git a/packages/extension/entrypoints/ui/global.d.ts b/packages/extension/entrypoints/ui/global.d.ts index 5043bfe7..57115aba 100644 --- a/packages/extension/entrypoints/ui/global.d.ts +++ b/packages/extension/entrypoints/ui/global.d.ts @@ -1,5 +1,8 @@ interface Window { readonly __TEMPAD_PLUGIN_SANDBOX_URL__?: string + readonly INITIAL_OPTIONS?: { + readonly editor_type?: string + } DebuggingHelpers: { logSelected?: () => string logNode?: (id: string) => string 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..2781a0c8 --- /dev/null +++ b/packages/extension/mcp/bounded-response.ts @@ -0,0 +1,40 @@ +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 + try { + 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) + } + } finally { + reader.releaseLock() + } + + 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..0055392a 100644 --- a/packages/extension/mcp/broker/hub-client.ts +++ b/packages/extension/mcp/broker/hub-client.ts @@ -1,12 +1,17 @@ import type { MessageToExtension, RegisteredMessage, + RuntimeHelloMessage, StateMessage, ToolCallMessage, 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 +43,8 @@ type HubConnection = { ws: WebSocket } +class McpBridgeProtocolMismatchError extends Error {} + export class McpHubClient { private activeId: string | null = null private assetServerUrl: string | null = null @@ -55,7 +62,8 @@ export class McpHubClient { constructor( private readonly events: McpHubClientEvents = {}, - private readonly createWebSocket: WebSocketFactory = (url) => new WebSocket(url) + private readonly createWebSocket: WebSocketFactory = (url) => new WebSocket(url), + private readonly runtimeIdentity: RuntimeHelloMessage | null = null ) {} getSnapshot(): HubClientSnapshot { @@ -116,6 +124,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 { @@ -130,19 +139,24 @@ export class McpHubClient { } this.attachSocket(connection.ws) this.handleHubMessage(connection.registered) + if (this.runtimeIdentity) this.sendJson(this.runtimeIdentity) this.handleHubMessage(connection.state) + if (!this.isCurrentConnection(epoch) || this.ws !== connection.ws) return 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 +210,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 +266,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 +393,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..c0a776d6 100644 --- a/packages/extension/mcp/broker/service-worker.ts +++ b/packages/extension/mcp/broker/service-worker.ts @@ -2,11 +2,13 @@ import type { BridgeToPageMessage, McpBrowserStatePayload, PageToBridgeMessage, + RuntimeHelloMessage, TempadMcpErrorCode, ToolCallMessage } from '@tempad-dev/shared' import { + MCP_MAX_ASSET_BYTES, TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, TEMPAD_MCP_BROWSER_SOURCE, TEMPAD_MCP_ERROR_CODES, @@ -17,6 +19,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 +32,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, @@ -34,6 +43,7 @@ export type McpBrokerHubClient = Pick< > export class McpServiceWorkerBroker { + private connectedHubId: string | null = null private readonly hubClient: McpBrokerHubClient private readonly pendingToolCalls = new Map() private readonly portSessions = new WeakMap() @@ -42,10 +52,14 @@ export class McpServiceWorkerBroker { constructor(hubClient?: McpBrokerHubClient) { this.hubClient = hubClient ?? - new McpHubClient({ - onSnapshot: () => this.broadcastState(), - onToolCall: (message) => this.routeToolCall(message) - }) + new McpHubClient( + { + onSnapshot: (snapshot) => this.handleHubSnapshot(snapshot), + onToolCall: (message) => this.routeToolCall(message) + }, + undefined, + extensionRuntimeIdentity() + ) } start(): void { @@ -100,6 +114,9 @@ export class McpServiceWorkerBroker { case 'mcp.uploadAsset': void this.uploadAsset(port, message) break + case 'mcp.downloadAsset': + void this.downloadAsset(port, message) + break } } @@ -214,12 +231,42 @@ 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 this.disableSession(port, sessionId) } + private handleHubSnapshot(snapshot: ReturnType): void { + if (snapshot.registeredId && snapshot.registeredId !== this.connectedHubId) { + this.sessions.resetActive() + } + this.connectedHubId = snapshot.registeredId + this.broadcastState() + } + private routeToolCall(message: ToolCallMessage): void { const activeSession = this.sessions.getActive() if (!activeSession) { @@ -310,6 +357,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() @@ -358,12 +431,25 @@ export class McpServiceWorkerBroker { } } +function extensionRuntimeIdentity(): RuntimeHelloMessage | null { + const manifest = browser.runtime.getManifest() + const runtimeFingerprint = manifest.version_name + if (typeof runtimeFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(runtimeFingerprint)) { + return null + } + return { + type: 'runtimeHello', + extensionVersion: manifest.version, + extensionRuntimeFingerprint: runtimeFingerprint + } +} + async function uploadAssetToServer( assetServerUrl: string, 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 +459,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 +507,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..add0a231 100644 --- a/packages/extension/mcp/broker/sessions.ts +++ b/packages/extension/mcp/broker/sessions.ts @@ -30,7 +30,16 @@ export class McpSessionRegistry { } register(session: McpBrokerSession): void { + const isNew = !this.sessions.has(session.sessionId) this.sessions.set(session.sessionId, session) + if (isNew && this.sessions.size > 1) { + this.activeSessionId = null + } + this.autoActivateSoleSession() + } + + resetActive(): void { + this.activeSessionId = null this.autoActivateSoleSession() } @@ -53,6 +62,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/config.ts b/packages/extension/mcp/config.ts index c8c3be9c..681646ef 100644 --- a/packages/extension/mcp/config.ts +++ b/packages/extension/mcp/config.ts @@ -1,7 +1,8 @@ export { AGENT_INTEGRATIONS, AGENT_INTEGRATIONS_BY_ID, - AGENT_SKILL_INSTALL_COMMAND, + AGENT_PLUGIN_INSTALL_COMMAND, + AGENT_SKILLS_INSTALL_COMMAND, getMcpClientCopyPayload, getNextMcpClientCopyVariant, MCP_CLIENTS, 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/figma-readiness.ts b/packages/extension/mcp/figma-readiness.ts new file mode 100644 index 00000000..e6d4ba69 --- /dev/null +++ b/packages/extension/mcp/figma-readiness.ts @@ -0,0 +1,32 @@ +const CONNECTION_TIMEOUT_PATTERN = + /Unable to establish connection to Figma after \d+(?:\.\d+)? seconds/i + +const pageLoads = new WeakMap>() + +function isConnectionTimeout(error: unknown): boolean { + return CONNECTION_TIMEOUT_PATTERN.test(error instanceof Error ? error.message : String(error)) +} + +async function loadCurrentPage(): Promise { + const page = figma.currentPage + const pending = pageLoads.get(page) + if (pending) return pending + + const load = Promise.resolve() + .then(() => page.loadAsync()) + .finally(() => { + if (pageLoads.get(page) === load) pageLoads.delete(page) + }) + pageLoads.set(page, load) + return load +} + +export async function retryAfterFigmaConnectionTimeout( + operation: () => T | Promise, + error: unknown, + ...relatedErrors: unknown[] +): Promise { + if (![error, ...relatedErrors].some(isConnectionTimeout)) throw error + await loadCurrentPage() + return operation() +} diff --git a/packages/extension/mcp/local-resources.ts b/packages/extension/mcp/local-resources.ts new file mode 100644 index 00000000..92eb6cd5 --- /dev/null +++ b/packages/extension/mcp/local-resources.ts @@ -0,0 +1,120 @@ +import { retryAfterFigmaConnectionTimeout } from './figma-readiness' + +export function getContainingPage(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 readWithSyncFallback(readAsync: () => Promise, readSync: () => T): Promise { + try { + return await readAsync() + } catch (asyncError) { + // The rewritten editor runtime can expose the Plugin API before its async backend is ready. + try { + return readSync() + } catch (syncError) { + return retryAfterFigmaConnectionTimeout(readAsync, asyncError, syncError) + } + } +} + +export function getNodeById(id: string): Promise { + return readWithSyncFallback( + () => figma.getNodeByIdAsync(id), + () => figma.getNodeById(id) + ) +} + +export function getCurrentContextNodeById(id: string): BaseNode | null { + try { + const node = figma.getNodeById(id) + return node && !node.removed ? node : null + } catch { + return null + } +} + +export async function getMainComponent(instance: InstanceNode): Promise { + try { + const component = instance.mainComponent + if (component && !component.removed) return component + } catch { + // Dynamic-page access can make the synchronous relationship unavailable. + } + return instance.getMainComponentAsync() +} + +export function getStyleById(id: string): Promise { + return readWithSyncFallback( + () => figma.getStyleByIdAsync(id), + () => figma.getStyleById(id) + ) +} + +export function getVariableById(id: string): Promise { + return readWithSyncFallback( + () => figma.variables.getVariableByIdAsync(id), + () => figma.variables.getVariableById(id) + ) +} + +export function getVariableCollectionById(id: string): Promise { + return readWithSyncFallback( + () => figma.variables.getVariableCollectionByIdAsync(id), + () => figma.variables.getVariableCollectionById(id) + ) +} + +export function getLocalVariables(): Promise { + return readWithSyncFallback( + () => figma.variables.getLocalVariablesAsync(), + () => figma.variables.getLocalVariables() + ) +} + +export function getLocalVariableCollections(): Promise { + return readWithSyncFallback( + () => figma.variables.getLocalVariableCollectionsAsync(), + () => figma.variables.getLocalVariableCollections() + ) +} + +export function getLocalPaintStyles(): Promise { + return readWithSyncFallback( + () => figma.getLocalPaintStylesAsync(), + () => figma.getLocalPaintStyles() + ) +} + +export function getLocalTextStyles(): Promise { + return readWithSyncFallback( + () => figma.getLocalTextStylesAsync(), + () => figma.getLocalTextStyles() + ) +} + +export function getLocalEffectStyles(): Promise { + return readWithSyncFallback( + () => figma.getLocalEffectStylesAsync(), + () => figma.getLocalEffectStyles() + ) +} + +export function getLocalGridStyles(): Promise { + return readWithSyncFallback( + () => figma.getLocalGridStylesAsync(), + () => figma.getLocalGridStyles() + ) +} + +export async function getLocalStyles(): Promise { + return ( + await Promise.all([ + getLocalPaintStyles(), + getLocalTextStyles(), + getLocalEffectStyles(), + getLocalGridStyles() + ]) + ).flat() +} diff --git a/packages/extension/mcp/media.ts b/packages/extension/mcp/media.ts new file mode 100644 index 00000000..46489260 --- /dev/null +++ b/packages/extension/mcp/media.ts @@ -0,0 +1,38 @@ +import { isRenderablePaint } from '@/utils/figma-paint' + +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') && isRenderablePaint(paint) +} diff --git a/packages/extension/mcp/runtime.ts b/packages/extension/mcp/runtime.ts index 1ceb5994..a5c44d54 100644 --- a/packages/extension/mcp/runtime.ts +++ b/packages/extension/mcp/runtime.ts @@ -11,12 +11,13 @@ import type { import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' -import { selection } from '@/ui/state' - import type { GetCodeRuntimeOptions } from './tools/code' import { createCodedError } from './errors' +import { handleApplyCanvas } from './tools/canvas' +import { pageById, pageSnapshot, pagesByKey } from './tools/canvas/identity' 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' @@ -28,34 +29,44 @@ function isSceneNode(node: BaseNode | null): node is SceneNode { function resolveSingleNode(nodeId?: string): SceneNode { if (nodeId) { const node = figma.getNodeById(nodeId) - if (!isSceneNode(node) || !node.visible) { + if (!node) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + `Node "${nodeId}" does not exist in the current document.` + ) + } + if (!isSceneNode(node)) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + `Node "${nodeId}" exists but is not a supported scene node.` + ) + } + if (!node.visible) { throw createCodedError( TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, - 'No visible node found for the provided nodeId.' + `Node "${nodeId}" exists but is hidden.` ) } return node } - if (selection.value.length !== 1 || !selection.value[0].visible) { + const currentSelection = figma.currentPage.selection + const [selectedNode] = currentSelection + if (currentSelection.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 { @@ -66,7 +77,7 @@ async function dispatchGetCode( async function handleWindowGetCode(args?: WindowGetCodeParametersInput): Promise { const { _unbounded, ...rest } = args ?? {} - return dispatchGetCode(rest, { + return handleGetCode(rest, { unbounded: _unbounded }) } @@ -87,19 +98,51 @@ async function handleGetScreenshot( } async function handleGetStructure(args?: GetStructureParametersInput): Promise { - const { nodeId, options } = args ?? {} - const root = resolveSingleNode(nodeId) + const { nodeId, pageId, pageKey, options } = args ?? {} const depth = options?.depth - return runGetStructure([root], depth) + if (!pageId && !pageKey) { + const root = resolveSingleNode(nodeId) + return runGetStructure([root], depth, options?.native) + } + + const idMatch = pageId ? pageById(pageId) : undefined + const keyMatches = pageKey ? pagesByKey(pageKey) : [] + if (keyMatches.length > 1) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + `Page key "${pageKey}" identifies more than one local page.` + ) + } + const keyMatch = keyMatches[0] + if (idMatch && keyMatch && idMatch.id !== keyMatch.id) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + `Page key "${pageKey}" does not identify page "${pageId}".` + ) + } + const page = idMatch ?? keyMatch + if (!page) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + pageId ? `Page "${pageId}" does not exist.` : `Page key "${pageKey}" does not exist.` + ) + } + if (page.id !== figma.currentPage.id) await page.loadAsync() + const result = runGetStructure([...page.children], depth, options?.native) + return { ...result, page: pageSnapshot(page) } } -export type MCPHandlers = { - get_code: (args?: GetCodeParametersInput) => 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,22 +153,13 @@ 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 } -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..40cb266c 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 @@ -70,8 +71,6 @@ type TraversalContext = { cappedNodeIds: string[] } -type FlattenResult = SemanticNode[] - function assignIndexes(nodes: SemanticNode[]): void { nodes.forEach((node, idx) => { node.index = idx @@ -88,6 +87,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 +113,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 { @@ -190,15 +184,8 @@ function composeDataHint(node: SceneNode): DataHint | undefined { const hints: DataHint = {} if (node.type === 'INSTANCE') { - const { mainComponent } = node as InstanceNode - const name = - mainComponent?.parent?.type === 'COMPONENT_SET' - ? mainComponent.parent.name - : (mainComponent?.name ?? node.name) - const props = summarizeComponentProperties(node) ?? '' - if (name) { - hints['data-hint-design-component'] = `${toPascalCase(name)}${props}` - } + const componentHint = summarizeComponentHint(node) + if (componentHint) hints['data-hint-design-component'] = componentHint } const layoutHint = summarizeLayoutHint(node) @@ -269,6 +256,15 @@ function summarizeComponentProperties(node: InstanceNode): string | undefined { return entries.length ? entries.map((e) => `[${e}]`).join('') : undefined } +export function summarizeComponentHint(node: InstanceNode): string | undefined { + const { mainComponent } = node + const name = + mainComponent?.parent?.type === 'COMPONENT_SET' + ? mainComponent.parent.name + : (mainComponent?.name ?? node.name) + return name ? `${toPascalCase(name)}${summarizeComponentProperties(node) ?? ''}` : undefined +} + function summarizeLayoutHint(node: SceneNode): string | undefined { const layoutSource = resolveAutoLayoutSource(node) // Explicit auto layout is obvious; only hint when not explicitly set. @@ -291,12 +287,38 @@ function getLayoutKind(node: SceneNode): 'auto' | 'absolute' { return 'absolute' } +function createSemanticNode( + node: SceneNode, + depth: number, + index: number, + children: SemanticNode[], + capped = false +): SemanticNode { + const dataHint = composeDataHint(node) + return { + id: node.id, + name: node.name, + type: node.type, + tag: resolveSemanticTag(node), + depth, + index, + layout: getLayoutKind(node), + bounds: getBounds(node), + isComponentInstance: node.type === 'INSTANCE', + ...describeAsset(node), + ...(dataHint ? { dataHint } : {}), + autoLayout: extractAutoLayout(node), + ...(capped ? { capped: true } : {}), + children + } +} + function visit( node: SceneNode, depth: number, index: number, ctx: TraversalContext -): FlattenResult { +): SemanticNode[] { if (!node.visible) return [] if (ctx.depthLimit !== undefined && depth >= ctx.depthLimit) { @@ -305,28 +327,7 @@ function visit( ctx.stats.capped = true ctx.cappedNodeIds.push(node.id) - const semanticNode: SemanticNode = { - id: node.id, - name: node.name, - type: node.type, - tag: resolveTag(node), - depth, - index, - layout: getLayoutKind(node), - bounds: getBounds(node), - isComponentInstance: node.type === 'INSTANCE', - ...classifyAsset(node), - autoLayout: extractAutoLayout(node), - capped: true, - children: [] - } - - const hint = composeDataHint(node) - if (hint) { - semanticNode.dataHint = hint - } - - return [semanticNode] + return [createSemanticNode(node, depth, index, [], true)] } if (isWrapper(node)) { @@ -339,30 +340,10 @@ function visit( ) assignIndexes(children) - const semanticNode: SemanticNode = { - id: node.id, - name: node.name, - type: node.type, - tag: resolveTag(node), - depth, - index, - layout: getLayoutKind(node), - bounds: getBounds(node), - isComponentInstance: node.type === 'INSTANCE', - ...classifyAsset(node), - autoLayout: extractAutoLayout(node), - children - } - - const hint = composeDataHint(node) - if (hint) { - semanticNode.dataHint = hint - } - ctx.stats.totalNodes += 1 ctx.stats.maxDepth = Math.max(ctx.stats.maxDepth, depth) - return [semanticNode] + return [createSemanticNode(node, depth, index, children)] } function collectDepthCounts(nodes: SceneNode[], depth = 0, counts: number[] = []): number[] { @@ -385,8 +366,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/assets.ts b/packages/extension/mcp/tools/canvas/assets.ts new file mode 100644 index 00000000..7423bf86 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/assets.ts @@ -0,0 +1,317 @@ +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 = '2' +const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink' + +const BANNED_ELEMENTS = new Set([ + 'audio', + 'foreignobject', + 'iframe', + 'image', + 'script', + 'style', + 'video' +]) +const COLOR_ATTRIBUTES = new Set([ + 'color', + 'fill', + 'flood-color', + 'lighting-color', + 'solid-color', + 'stop-color', + 'stroke', + 'text-decoration-color' +]) + +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, inheritedNamespaces: Map): 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.`) + } + const namespaces = new Map(inheritedNamespaces) + for (const [attribute, rawValue] of Object.entries(node.attributes)) { + const separator = attribute.indexOf(':') + if (separator > 0 && attribute.slice(0, separator).toLowerCase() === 'xmlns') { + namespaces.set(attribute.slice(separator + 1), rawValue.trim()) + } + } + 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() + const separator = attribute.indexOf(':') + const namespacePrefix = separator > 0 ? attribute.slice(0, separator) : undefined + const localName = separator > 0 ? attribute.slice(separator + 1).toLowerCase() : name + const isLink = + name === 'href' || + name === 'xlink:href' || + (localName === 'href' && + namespacePrefix !== undefined && + namespaces.get(namespacePrefix) === XLINK_NAMESPACE) + if (isLink) { + 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 (COLOR_ATTRIBUTES.has(name) && /^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] = 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, namespaces) + } + visit(root, 1, new Map()) + + 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..37fe4bca --- /dev/null +++ b/packages/extension/mcp/tools/canvas/errors.ts @@ -0,0 +1,129 @@ +import type { ZodError, ZodIssue } 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 = 384 +const READ_ONLY_ERROR_PATTERN = /\b(?:read|view)[ -]?only\b|\bedit access\b|\bpermission to edit\b/i + +type SchemaIssue = { + issue: ZodIssue + path: PropertyKey[] + groupable: boolean +} + +type SchemaIssueGroup = SchemaIssue & { count: number; source: string } + +export function errorMessage(error: unknown, fallback = ''): string { + if (typeof error === 'string') return error || fallback + if (error && typeof error === 'object' && 'message' in error) { + const message = (error as { message?: unknown }).message + if (typeof message === 'string' && message) return message + } + return fallback +} + +export function formatSchemaError(error: ZodError): string { + const expanded = error.issues.flatMap((issue) => expandSchemaIssue(issue)) + const groups: SchemaIssueGroup[] = [] + const groupedBySource = new Map() + + for (const item of expanded) { + const source = schemaIssueSource(item.issue) + const existing = item.groupable ? groupedBySource.get(source) : undefined + if (existing) { + existing.count += 1 + continue + } + const group = { ...item, count: 1, source } + groups.push(group) + if (item.groupable) groupedBySource.set(source, group) + } + + const visible = groups.slice(0, MAX_SCHEMA_ISSUES) + const issues = visible.map(({ count, path, source }) => { + const message = + source.length <= MAX_SCHEMA_MESSAGE_CHARS + ? source + : `${source.slice(0, MAX_SCHEMA_MESSAGE_CHARS - 3)}...` + const repeats = count > 1 ? ` (${count - 1} similar validation issues)` : '' + return `${formatPath(path)}: ${message}${repeats}` + }) + const omitted = expanded.length - visible.reduce((count, issue) => count + issue.count, 0) + if (omitted > 0) + issues.push(`${omitted} more validation issue${omitted === 1 ? '' : 's'} omitted.`) + return issues.join('\n') || 'Canvas input is invalid.' +} + +function expandSchemaIssue( + issue: ZodIssue, + prefix: PropertyKey[] = [], + groupable = false +): SchemaIssue[] { + const path = [...prefix, ...issue.path] + // Zod can report a selected union branch directly, without an invalid_union wrapper. + if (issue.code !== 'invalid_union') + return [{ issue, path, groupable: groupable || issue.code === 'unrecognized_keys' }] + + const branches = issue.errors.map((branch) => + branch.flatMap((nested) => expandSchemaIssue(nested, path, true)) + ) + return branches.reduce((best, branch) => { + if (!best.length || branch.length < best.length) return branch + if (branch.length > best.length) return best + const branchDepth = branch.reduce((sum, item) => sum + item.path.length, 0) + const bestDepth = best.reduce((sum, item) => sum + item.path.length, 0) + return branchDepth > bestDepth ? branch : best + }, []) +} + +function schemaIssueSource(issue: ZodIssue): string { + if (issue.code === 'unrecognized_keys') { + return `Unrecognized key${issue.keys.length === 1 ? '' : 's'}: ${issue.keys + .map((key) => JSON.stringify(key)) + .join(', ')}` + } + if (issue.code === 'invalid_type' && issue.message === 'Invalid input') { + return `Expected ${issue.expected}.` + } + return issue.message +} + +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 || + (typeof error !== 'string' && typeof error !== 'object') || + (typeof error === 'object' && 'code' in error) || + !READ_ONLY_ERROR_PATTERN.test(errorMessage(error)) + ) { + 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..b7530321 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/html.ts @@ -0,0 +1,212 @@ +import { MAX_CANVAS_DEPTH, MAX_CANVAS_NODES } from '@tempad-dev/shared' + +export type CanvasMarkupElement = { + attributes: Record + lineBreakOffsets: number[] + 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 { + const chunks: string[] = [] + let copyStart = 0 + let entityStart = -1 + for (let index = 0; index < value.length; index += 1) { + const character = value[index]! + if (entityStart < 0) { + if (character === '&') { + chunks.push(value.slice(copyStart, index)) + entityStart = index + } + continue + } + if (/\s/.test(character)) { + chunks.push(value.slice(entityStart, index)) + copyStart = index + entityStart = -1 + continue + } + if (character !== ';') continue + + const entity = value.slice(entityStart + 1, index) + if (Object.hasOwn(HTML_ENTITIES, entity)) { + chunks.push(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};".`) + } + chunks.push(String.fromCodePoint(codePoint)) + } + copyStart = index + 1 + entityStart = -1 + } + if (entityStart >= 0) chunks.push(value.slice(entityStart)) + else chunks.push(value.slice(copyStart)) + return chunks.join('') +} + +class CanvasHtmlParser { + private elementCount = 0 + 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. For a partial update, include the target or a bounded ancestor as that root; omitted siblings are preserved.' + ) + } + 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.elementCount += 1 + if (this.elementCount > MAX_CANVAS_NODES) { + htmlError( + `Canvas markup contains more than ${MAX_CANVAS_NODES} elements. Keep one root and split the update at a meaningful screen or section boundary; omitted siblings are preserved.` + ) + } + 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.') + if (rawTag.toLowerCase() === 'br') { + htmlError('Canvas HTML supports
only inside span text.') + } + 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: [], lineBreakOffsets: [], tag, text: '' } + + const children: CanvasMarkupElement[] = [] + const lineBreakOffsets: number[] = [] + 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('<')) { + if (tag === 'span' && this.consumeLineBreak()) { + lineBreakOffsets.push(text.length) + continue + } + 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, lineBreakOffsets, tag, text } + } + + private consumeLineBreak(): boolean { + const match = /^/i.exec(this.source.slice(this.index)) + if (!match) return false + this.index += match[0].length + return true + } + + 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..35f08bf9 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/identity.ts @@ -0,0 +1,136 @@ +import type { CanvasDesignReference, CanvasPageSnapshot } from '@tempad-dev/shared' + +import { specError } from './errors' +import { isInsideInstance } from './traversal' + +export const CANVAS_KEY_NAMESPACE = 'tempad_dev' +export const CANVAS_NODE_KEY_NAME = 'canvas-key' +export const CANVAS_NODE_OWNER_NAME = 'canvas-owner' +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 pagesByKey(key: string): PageNode[] { + return figma.root.children.filter((page) => readAuthoringKey(page, CANVAS_PAGE_KEY_NAME) === key) +} + +export function pageById(id: string): PageNode | undefined { + return figma.root.children.find((page) => page.id === id) +} + +export function pageSnapshot( + page: PageNode, + overrides: Partial = {} +): CanvasPageSnapshot { + const pageKey = readAuthoringKey(page, CANVAS_PAGE_KEY_NAME) + return { + id: page.id, + ...(pageKey ? { pageKey } : {}), + name: page.name, + index: Math.max(0, figma.root.children.indexOf(page)), + active: !page.removed && figma.currentPage.id === page.id, + childCount: page.children.length, + selectionCount: page.selection.length, + ...overrides + } +} + +export function readOwnedNodeKey(node: SceneNode): string | undefined { + if (isInsideInstance(node)) return undefined + const key = readAuthoringKey(node, CANVAS_NODE_KEY_NAME) + if (!key || node.type !== 'INSTANCE') return key + + const owner = readAuthoringKey(node, CANVAS_NODE_OWNER_NAME) + if (owner) return owner === node.id ? key : undefined + + try { + const component = node.mainComponent + if (!component) return undefined + const definitionKey = readAuthoringKey(component, CANVAS_NODE_KEY_NAME) + return definitionKey === key ? undefined : key + } catch { + return undefined + } +} + +export function claimNodeKey(node: SceneNode, key: string): boolean { + if (isInsideInstance(node)) { + specError(`Node "${node.id}" is inside an instance and cannot own a canvas key.`) + } + const current = readOwnedNodeKey(node) + const owner = readAuthoringKey(node, CANVAS_NODE_OWNER_NAME) + if (current && current !== key) { + specError(`Node "${node.id}" is already owned by canvas key "${current}".`) + } + + const keyChanged = current !== key + const ownerChanged = node.type === 'INSTANCE' && owner !== node.id + if (keyChanged) node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME, key) + if (ownerChanged) { + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_OWNER_NAME, node.id) + } + return keyChanged || ownerChanged +} + +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..a04bf41a --- /dev/null +++ b/packages/extension/mcp/tools/canvas/index.ts @@ -0,0 +1,105 @@ +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 { errorMessage, formatSchemaError, specError } from './errors' +import { parseCanvasMarkup } from './markup' +import { collectUpdateNodeTypeHints, reconcileCanvas } from './reconcile' +import { resolveCanvasInput } from './resolve' +import { prepareThemeResources } from './theme' + +let applyInProgress = false + +function parseSpec(parse: () => Result, fallback = 'Canvas input is invalid.'): Result { + try { + return parse() + } catch (error) { + specError(errorMessage(error, fallback)) + } +} + +function assertCanvasAvailable(): void { + if (typeof window === 'undefined' || window.INITIAL_OPTIONS?.editor_type !== 'design') { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_UNSUPPORTED_EDITOR, + 'Canvas authoring is available only when the current Figma editor type is design.' + ) + } + 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 { + applyInProgress = true + try { + let themeResources + try { + themeResources = await prepareThemeResources(input, catalog) + } catch (error) { + specError(errorMessage(error, 'Canvas theme is invalid.')) + } + const existingNodeTypes = + input.mode === 'update' && input.markup !== undefined + ? await collectUpdateNodeTypeHints(input.targetNodeId!) + : undefined + const parsedInput = parseSpec(() => { + if (input.mode === 'remove' && input.targetNodeId) { + return { mode: 'remove' as const, targetNodeId: input.targetNodeId, root: null } + } + if (input.markup === undefined) { + if (input.mode === 'update' && input.targetNodeId) { + return { + mode: 'update' as const, + targetNodeId: input.targetNodeId, + bindings: input.bindings!, + ...(input.assets === undefined ? {} : { assets: input.assets }), + ...(input.styles === undefined ? {} : { styles: input.styles }), + ...(input.variableCollections === undefined + ? {} + : { variableCollections: input.variableCollections }) + } + } + if (!input.page) throw new Error('A page identity is required for this operation.') + return { + mode: input.mode, + page: input.page, + ...(input.selection === undefined ? {} : { selection: input.selection }) + } + } + return parseCanvasMarkup(input, catalog, existingNodeTypes, themeResources) + }, 'Canvas markup is invalid.') + return await reconcileCanvas(parsedInput) + } catch (error) { + if (error instanceof Error && 'code' in error) throw error + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + errorMessage(error, '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..b6cd5097 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/markup.ts @@ -0,0 +1,1957 @@ +import type { + CanvasAssets, + CanvasBinding, + CanvasFigmaPaint, + CanvasResolvedApplyParameters, + CanvasStyleReference, + CanvasStyleBindings, + CanvasVariableReference, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import { CanvasStableKeySchema, MAX_CANVAS_DEPTH } from '@tempad-dev/shared' + +import type { CatalogComponent, DesignSystemCatalog } from '../design-system-catalog' +import type { CanvasMarkupElement } from './html' +import type { + CanvasNodeTypeHints, + CanvasNodeSpec, + CanvasPreservedNodeType, + CanvasShapeNodeType, + CanvasSizingMode, + ParsedCanvasTreeInput +} from './model' +import type { CanvasClasses } from './tailwind' + +import { parseCanvasHtml } from './html' +import { + findUnsupportedCanvasClasses, + MAX_GRID_TRACKS, + parseCanvasClasses, + unsupportedCanvasClassGuidance +} from './tailwind' +import { createThemeResources, normalizeThemeClasses, type ThemeResources } from './theme' + +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: [], + lineBreakOffsets: [], + 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) +} + +const MAX_REPORTED_UNSUPPORTED_CLASSES = 16 +const MAX_REPORTED_MARKUP_ISSUES = 16 + +function unsupportedClassesIssue(root: CanvasMarkupElement): string | undefined { + const found = new Set() + let truncated = false + const visit = (element: CanvasMarkupElement): void => { + const scan = findUnsupportedCanvasClasses(element.attributes.class ?? '') + for (const token of scan.classes) found.add(token) + truncated ||= scan.truncated + for (const child of element.children) visit(child) + } + visit(root) + + if (found.size < 2) return undefined + const classes = [...found] + if (classes.length > MAX_REPORTED_UNSUPPORTED_CLASSES) truncated = true + const listedClasses = classes.slice(0, MAX_REPORTED_UNSUPPORTED_CLASSES) + const shown = listedClasses.map((token) => `"${token}"`) + const suffix = truncated ? ' Additional unsupported classes may remain.' : '' + const guidance = unsupportedCanvasClassGuidance(listedClasses) + return `Unsupported Canvas classes: ${shown.join(', ')}.${suffix}${guidance ? ` ${guidance}` : ''} Fix all listed classes before retrying.` +} + +type ParentLayoutMode = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'GRID' + +function parentLayoutMode(classes: CanvasClasses): ParentLayoutMode { + if (classes.grid) return 'GRID' + if (classes.flex) return classes.direction! + return 'NONE' +} + +function childLayoutIssues( + key: string, + classes: CanvasClasses, + parentMode: ParentLayoutMode +): string[] { + if (classes.absolute) return [] + + const issues: string[] = [] + if (classes.width?.mode === 'FILL' && parentMode !== 'VERTICAL' && parentMode !== 'GRID') { + issues.push( + parentMode === 'NONE' + ? `w-full on "${key}" cannot resolve in a freeform parent; add flex-col or grid to the parent, or use a fixed width with absolute offsets or a relative transform.` + : `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') { + issues.push( + parentMode === 'NONE' + ? `h-full on "${key}" cannot resolve in a freeform parent; add flex-row or grid to the parent, or use a fixed height with absolute offsets or a relative transform.` + : `h-full on "${key}" requires a flex-row parent; use grow on a column main axis.` + ) + } + if (classes.grow && parentMode === 'GRID') { + issues.push(`grow on "${key}" is not supported in grid; use w-full or h-full.`) + } + if (classes.grow && parentMode === 'NONE') { + issues.push(`grow on "${key}" requires a flex parent.`) + } + return issues +} + +function assertStaticMarkupLegality(root: CanvasMarkupElement): void { + const issues: string[] = [] + let issueCount = 0 + const add = (message: string): void => { + issueCount += 1 + if (issues.length < MAX_REPORTED_MARKUP_ISSUES) issues.push(message) + } + const unsupported = unsupportedClassesIssue(root) + if (unsupported) add(unsupported) + + const visit = (element: CanvasMarkupElement, parentClasses?: CanvasClasses): void => { + let className: string + let key: string + try { + ;({ className, key } = validateAttributes(element)) + } catch (error) { + add(error instanceof Error ? error.message : String(error)) + for (const child of element.children) visit(child) + return + } + let classes: CanvasClasses | undefined + if (!findUnsupportedCanvasClasses(className).classes.length) { + try { + classes = parseCanvasClasses(className) + if (!classes.width || !classes.height) { + add(`Element "${key}" requires exactly one width and one height class.`) + } + if (element.tag === 'span') { + if (element.children.length) add(`span "${key}" cannot contain elements.`) + if (classes.frameClass || classes.layoutClass) { + add( + `Class "${classes.frameClass ?? classes.layoutClass}" is not supported on span "${key}".` + ) + } + } else { + if (hasText(element.text)) add(`div "${key}" cannot contain direct text.`) + if (classes.textClass) { + add( + `Class "${classes.textClass}" is not supported on div "${key}". Canvas typography does not inherit; put text utilities on each span/TEXT node.` + ) + } + } + if (parentClasses) { + for (const issue of childLayoutIssues(key, classes, parentLayoutMode(parentClasses))) { + add(issue) + } + } + } catch (error) { + add(`Element "${key}": ${error instanceof Error ? error.message : String(error)}`) + } + } + for (const child of element.children) visit(child, classes) + } + visit(root) + + if (!issues.length) return + if (issues.length === 1) markupError(issues[0]!) + markupError( + `Canvas markup has multiple repairable issues:\n${issues.map((issue) => `- ${issue}`).join('\n')}${issueCount > issues.length ? '\n- Additional issues may remain.' : ''}\nFix all listed issues before retrying.` + ) +} + +function textContent(value: string, preserve: boolean, lineBreakOffsets: number[]): string { + const normalize = preserve + ? (segment: string) => segment + : (segment: string) => segment.replace(/[\t\n\f\r ]+/g, ' ').trim() + if (!lineBreakOffsets.length) return normalize(value) + + let start = 0 + const segments = lineBreakOffsets.map((offset) => { + const segment = normalize(value.slice(start, offset)) + start = offset + return segment + }) + segments.push(normalize(value.slice(start))) + return segments.join('\n') +} + +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, + existingNodeType?: CanvasPreservedNodeType +): CanvasNodeSpec['type'] { + if (element.tag === 'span') return 'TEXT' + if (binding?.component || binding?.componentProperties || binding?.figma?.instance) { + 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 ?? existingNodeType ?? '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 applyClassEffects( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined, + classes: CanvasClasses +): CanvasBinding | undefined { + const hasBoxShadows = classes.boxShadows !== undefined || classes.insetShadows !== undefined + const hasTextShadows = classes.textShadows !== undefined + if (!hasBoxShadows && !hasTextShadows) return binding + if (type === 'TEXT' ? hasBoxShadows : hasTextShadows) { + markupError( + `${type === 'TEXT' ? 'Box' : 'Text'} shadow classes are not supported on ${type} node "${key}".` + ) + } + if (binding?.figma?.effects !== undefined) { + markupError(`Shadow classes and direct effects cannot be combined on "${key}".`) + } + if (binding?.styles?.effect) { + markupError(`Shadow classes and an effect style cannot be combined on "${key}".`) + } + const effects = + type === 'TEXT' + ? classes.textShadows! + : [...(classes.boxShadows ?? []), ...(classes.insetShadows ?? [])] + return { ...binding, figma: { ...binding?.figma, effects } } +} + +function applyClassPaints( + key: string, + type: CanvasNodeSpec['type'], + binding: CanvasBinding | undefined, + classes: CanvasClasses +): CanvasBinding | undefined { + if (classes.fillPaints === undefined) return binding + if (type === 'GROUP') { + markupError(`Gradient classes are not supported on GROUP node "${key}".`) + } + if (binding?.figma?.fills !== undefined) { + markupError(`Gradient classes and direct fill paints cannot be combined on "${key}".`) + } + if (binding?.styles?.fill) { + markupError(`Gradient classes and a fill style cannot be combined on "${key}".`) + } + if (binding?.variables?.fill !== undefined) { + markupError(`Gradient classes and a fill variable cannot be combined on "${key}".`) + } + return { ...binding, figma: { ...binding?.figma, fills: classes.fillPaints } } +} + +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 + existingNodeTypes?: CanvasNodeTypeHints + 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.`) + } +} + +function hasStrokePaint(binding: CanvasBinding | undefined, classes: CanvasClasses): boolean { + if (binding?.figma?.strokes !== undefined) return binding.figma.strokes.length > 0 + return ( + classes.stroke !== undefined || + binding?.styles?.stroke != null || + binding?.variables?.stroke != null + ) +} + +function includedStrokeSize( + axis: 'height' | 'width', + binding: CanvasBinding | undefined, + classes: CanvasClasses +): number { + if ( + classes.strokesIncluded === false || + (binding?.figma?.stroke?.align !== undefined && binding.figma.stroke.align !== 'INSIDE') || + !hasStrokePaint(binding, classes) + ) { + return 0 + } + const appearance = strokeAppearance(binding, classes, true) + if (appearance.strokeWeight !== undefined) return appearance.strokeWeight * 2 + return axis === 'width' + ? (appearance.strokeLeftWeight ?? 0) + (appearance.strokeRightWeight ?? 0) + : (appearance.strokeTopWeight ?? 0) + (appearance.strokeBottomWeight ?? 0) +} + +function validateNewAutoLayoutMinimum( + key: string, + binding: CanvasBinding | undefined, + classes: CanvasClasses +): void { + for (const [axis, start, end] of [ + ['width', 'left', 'right'], + ['height', 'top', 'bottom'] + ] as const) { + const size = classes[axis]! + if (size.mode !== 'FIXED' || size.value === undefined) continue + const minimum = + (classes.padding[start] ?? 0) + + (classes.padding[end] ?? 0) + + includedStrokeSize(axis, binding, classes) + if (size.value < minimum) { + markupError( + `${axis} on new Auto Layout "${key}" must be at least ${minimum}px to fit its padding and included inside stroke.` + ) + } + } +} + +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 { + 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 declaredBinding = state.bindings[key] + const existingNodeType = + state.mode === 'update' + ? depth === 1 + ? state.existingNodeTypes?.root + : ((nodeId === undefined ? undefined : state.existingNodeTypes?.byNodeId.get(nodeId)) ?? + state.existingNodeTypes?.byKey.get(key)) + : undefined + const type = nodeType(element, declaredBinding, existingNodeType) + const binding = applyClassEffects( + key, + type, + applyClassPaints(key, type, declaredBinding, classes), + classes + ) + const shapeType = binding?.figma?.shape?.type + 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, element.lineBreakOffsets) + : '' + + 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}". Canvas typography does not inherit; put text utilities on each span/TEXT node.` + ) + } + } + + 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 ( + state.mode === 'create' && + (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.`) + } + + const parentMode = parent?.layout?.mode ?? 'NONE' + const horizontalMode = classes.grow && parentMode === 'HORIZONTAL' ? 'FILL' : classes.width.mode + const verticalMode = classes.grow && parentMode === 'VERTICAL' ? 'FILL' : classes.height.mode + + 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 direction on "${key}" requires flex.`) + } + 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 ((horizontalMode === 'HUG' || verticalMode === '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 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 { + for (const issue of childLayoutIssues(key, classes, parentMode)) markupError(issue) + } + 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.right !== undefined || + classes.top !== undefined || + classes.bottom !== 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.right === undefined)) { + markupError(`Absolute node "${key}" requires exactly one of left-* or right-*.`) + } + if ((classes.top === undefined) === (classes.bottom === undefined)) { + markupError(`Absolute node "${key}" requires exactly one of top-* or bottom-*.`) + } + if ( + (classes.right !== undefined && + (parent?.size.width === undefined || classes.width.value === undefined)) || + (classes.bottom !== undefined && + (parent?.size.height === undefined || classes.height.value === undefined)) + ) { + markupError( + `Right-* and bottom-* on absolute node "${key}" require fixed parent and child bounds on their axes.` + ) + } + 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.right !== undefined || + classes.top !== undefined || + classes.bottom !== undefined + ) { + markupError(`Position classes on "${key}" require absolute.`) + } + + const absolutePosition = classes.absolute + ? { + x: classes.left ?? parent!.size.width! - classes.right! - classes.width.value!, + y: classes.top ?? parent!.size.height! - classes.bottom! - classes.height.value! + } + : undefined + + 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) + if (state.mode === 'create' && isFrameContainerType(type) && (classes.flex || classes.grid)) { + validateNewAutoLayoutMinimum(key, binding, classes) + } + + 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 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) } + : {}), + ...(absolutePosition ? { position: absolutePosition } : {}), + ...(classes.right !== undefined || classes.bottom !== undefined + ? { + absoluteOffsets: { + ...(classes.right === undefined ? {} : { right: classes.right }), + ...(classes.bottom === undefined ? {} : { bottom: classes.bottom }) + } + } + : {}), + ...(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' } + : {}), + ...(classes.fontStyleMatching ? { fontStyleMatching: true as const } : {}), + ...(fontName === undefined && classes.portableFontFamily + ? { portableFontFamily: classes.portableFontFamily } + : {}), + ...(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 } + : {}) + }, + ...(binding?.component ? { 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 ?? true } + : {}) + } + : 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 ?? true } + : {}) + } + : ({ 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, + existingNodeTypes?: CanvasNodeTypeHints, + themeResources?: ThemeResources +): ParsedCanvasTreeInput { + if (input.mode !== 'create' && input.mode !== 'update') { + markupError('Canvas HTML is valid only in create or update mode.') + } + if (input.markup === undefined) markupError('Canvas HTML markup is required for this operation.') + const state: CompileState = { + bindings: Object.assign(Object.create(null) as Record, input.bindings), + ...(catalog ? { catalog } : {}), + ...(existingNodeTypes ? { existingNodeTypes } : {}), + keys: new Set(), + mode: input.mode, + nodeIds: new Set() + } + const parsedElement = parseCanvasHtml(input.markup) + const resources = themeResources ?? createThemeResources(input, catalog) + const themedElement = normalizeThemeClasses( + parsedElement, + state.bindings, + input, + catalog, + resources + ) + const rootElement = normalizeCatalogElement(themedElement, state.bindings, catalog) + assertStaticMarkupLegality(rootElement) + const root = compileElement(rootElement, state, 1) + const markThemeFields = (node: CanvasNodeSpec): void => { + const fields = resources.boundFields.get(node.key) + if (fields?.length) node.themeVariableFields = fields + node.children?.forEach(markThemeFields) + } + markThemeFields(root) + validateAssetReferences(root, input.assets, input.styles) + 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, + styles: CanvasResolvedApplyParameters['styles'] +): 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, style] of Object.entries(styles ?? {})) { + if (style?.type === 'PAINT') visitPaints(style.paints, `style "${key}"`) + } + 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..83c9dec2 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/model.ts @@ -0,0 +1,191 @@ +import type { + CanvasAssets, + CanvasBinding, + 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 CanvasPreservedNodeType = + | CanvasShapeNodeType + | 'COMPONENT' + | 'COMPONENT_SET' + | 'INSTANCE' +export type CanvasNodeTypeHints = { + byKey: ReadonlyMap + byNodeId: ReadonlyMap + root?: CanvasPreservedNodeType +} +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 + themeVariableFields?: Array + 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 + } + absoluteOffsets?: { + right?: number + bottom?: 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 + fontStyleMatching?: true + portableFontFamily?: 'mono' | 'sans' | 'serif' + fontStyle?: string + fontSize?: number + lineHeight?: LineHeight + letterSpacing?: LetterSpacing + alignHorizontal?: 'CENTER' | 'JUSTIFIED' | 'LEFT' | 'RIGHT' + alignVertical?: 'BOTTOM' | 'CENTER' | 'TOP' + autoResize: TextNode['textAutoResize'] + 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 +} + +export type ParsedCanvasNativeUpdateInput = { + mode: 'update' + targetNodeId: string + bindings: Record + assets?: CanvasAssets + styles?: CanvasStyles + variableCollections?: CanvasVariableCollections +} + +type ParsedCanvasRootRemovalInput = { + mode: 'remove' + targetNodeId: string + root: null +} + +export type ParsedCanvasPageInput = { + mode: 'activate' | 'create' | 'remove' | 'update' + page: CanvasPageProperties + selection?: string[] +} + +export type ParsedCanvasInput = + | ParsedCanvasTreeInput + | ParsedCanvasNativeUpdateInput + | ParsedCanvasRootRemovalInput + | ParsedCanvasPageInput diff --git a/packages/extension/mcp/tools/canvas/reconcile.ts b/packages/extension/mcp/tools/canvas/reconcile.ts new file mode 100644 index 00000000..bb468df1 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile.ts @@ -0,0 +1,7509 @@ +import { + type ApplyCanvasResult, + type CanvasBinding, + type CanvasDesignReference, + type CanvasFigmaComponentPropertyDefinition, + type CanvasFigmaEffect, + type CanvasFigmaLayoutGrid, + type CanvasFigmaPaint, + type CanvasFigmaShaderPropertyValue, + type CanvasFigmaSlotProperty, + type CanvasFigmaTextRange, + type CanvasFigmaVectorNetwork, + type CanvasHyperlink, + type CanvasPageProperties, + type CanvasPageSnapshot, + type CanvasStyleBindings, + type CanvasStyleReference, + type CanvasStyleResource, + type CanvasVariableBindings, + type CanvasVariableReference, + MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES, + MCP_TOOL_INLINE_BUDGET_BYTES, + buildApplyCanvasToolResult, + measureCallToolResultBytes, + TEMPAD_MCP_ERROR_CODES +} from '@tempad-dev/shared' + +import type { + CanvasGridLayout, + CanvasGridTrack, + CanvasNodeTypeHints, + CanvasNodeSpec, + CanvasSizingMode, + CanvasPreservedNodeType, + ParsedCanvasInput, + ParsedCanvasNativeUpdateInput, + ParsedCanvasPageInput, + ParsedCanvasTreeInput +} from './model' + +import { readBoundedResponseBytes } from '../../bounded-response' +import { createCodedError } from '../../errors' +import { retryAfterFigmaConnectionTimeout } from '../../figma-readiness' +import { + getContainingPage, + getCurrentContextNodeById, + getLocalEffectStyles, + getLocalPaintStyles, + getMainComponent, + getNodeById +} from '../../local-resources' +import { + type ResolvedCanvasAssets, + resolveCanvasAssets, + resolvedImageAsset, + resolvedSvgAsset, + SVG_POLICY_VERSION +} from './assets' +import { canvasReadOnlyError, errorMessage, scopeError, specError } from './errors' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_NODE_KEY_NAME, + CANVAS_NODE_OWNER_NAME, + CANVAS_PAGE_KEY_NAME, + type MutationCounter, + claimNodeKey, + designReferenceCacheKey, + pageById, + pageSnapshot, + pagesByKey, + readOwnedNodeKey +} from './identity' +import { + type CanvasStyleState, + createStyleState, + prepareStyleResources, + removeStyleResources, + resolveStyle +} from './styles' +import { normalizePortableFontStyle } from './tailwind' +import { + isComponentPropertyOwner, + isInsideInstance, + walkAuthoringNodes, + walkPhysicalNodes +} from './traversal' +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 CONTENT_OVERFLOW_TOLERANCE = 0.5 +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' +]) +const PRESERVED_NODE_TYPES = new Set([ + 'COMPONENT', + 'COMPONENT_SET', + 'INSTANCE', + '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 ProtectedNodeSnapshot = { + childIds: string[] | null + componentPropertyNames: string[] | null + geometry: { height: number; width: number; x: number; y: number } | null + key: string + owner: string + parentId: string | null + type: BaseNode['type'] +} + +type ApplyState = { + assets: ResolvedCanvasAssets + availableFonts?: Promise + claimedNodeIds: Set + componentCache: Map + componentPropertyKeys: Map> + createdNodeIds: Set + createdPageIds: Set + desiredKeys: Set + explicitNodes: Map + fontLoads: Map> + imageHashes: Map + imageAssetKeys: Set + imageUrls: Map + keyedNodes: Map + mutations: MutationCounter + nodeIdsByKey: Record + pendingGridRemovalCleanupNodeIds: Set + protectedNodes: Map + removalNodeIds: Set + referencedNodeIds: Set + scope: SupportedCanvasNode | null + shaderCache: Map + stabilizedCrossAxisFillNodeIds: Set + 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 +} + +async function lookupNodeById(id: string): Promise { + const node = getCurrentContextNodeById(id) ?? (await getNodeById(id)) + return node && !node.removed ? node : null +} + +export async function collectUpdateNodeTypeHints( + targetNodeId: string +): Promise { + const target = await lookupNodeById(targetNodeId) + if (!isSupportedSceneNode(target)) return undefined + + const byKey = new Map() + const byNodeId = new Map() + for (const node of walkAuthoringNodes([target])) { + if (!PRESERVED_NODE_TYPES.has(node.type as CanvasPreservedNodeType)) continue + const type = node.type as CanvasPreservedNodeType + byNodeId.set(node.id, type) + const key = readOwnedNodeKey(node) + if (key && !byKey.has(key)) byKey.set(key, type) + } + + const root = PRESERVED_NODE_TYPES.has(target.type as CanvasPreservedNodeType) + ? (target.type as CanvasPreservedNodeType) + : undefined + return { byKey, byNodeId, ...(root ? { root } : {}) } +} + +function snapshotProtectedNode(node: BaseNode): ProtectedNodeSnapshot { + return { + childIds: 'children' in node ? node.children.map((child) => child.id) : null, + componentPropertyNames: isComponentPropertyOwner(node) + ? Object.keys(node.componentPropertyDefinitions).sort() + : null, + geometry: isSceneNode(node) + ? { height: node.height, width: node.width, x: node.x, y: node.y } + : null, + key: node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_KEY_NAME), + owner: node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_OWNER_NAME), + parentId: node.parent?.id ?? null, + type: node.type + } +} + +function protectNode(state: ApplyState, node: BaseNode, snapshot = true): void { + if (state.createdNodeIds.has(node.id)) return + if (snapshot && !state.protectedNodes.get(node.id)) { + state.protectedNodes.set(node.id, snapshotProtectedNode(node)) + } else if (!state.protectedNodes.has(node.id)) { + state.protectedNodes.set(node.id, null) + } +} + +function topLevelPageChild(node: BaseNode, page: PageNode): BaseNode | null { + let current: BaseNode | null = node + while (current?.parent && current.parent.id !== page.id) current = current.parent + return current?.parent?.id === page.id ? current : null +} + +function protectUnrelatedPageRoots(state: ApplyState, page: PageNode): void { + const mutableRoot = state.scope ? topLevelPageChild(state.scope, page) : null + for (const child of page.children) { + if (child.id !== mutableRoot?.id) protectNode(state, child) + } +} + +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 assertOutsideInstance(node: BaseNode): void { + if (isInsideInstance(node)) { + scopeError(`Node "${node.id}" is inside an instance and cannot be targeted by apply_canvas.`) + } +} + +function containingPage(node: BaseNode): PageNode { + const page = getContainingPage(node) + if (page) return page + scopeError(`Node "${node.id}" is not attached to a page.`) +} + +function pageByKey(key: string): PageNode | undefined { + const matches = pagesByKey(key) + if (matches.length > 1) specError(`Page key "${key}" identifies more than one local page.`) + return matches[0] +} + +async function resolveResultPage( + properties: CanvasPageProperties | undefined, + target: SupportedCanvasNode | null, + state: ApplyState +): Promise<{ created: boolean; page: PageNode }> { + 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.createdPageIds.add(page.id) + state.mutations.count += 1 + } + page ??= containing + if (page.id !== figma.currentPage.id) await page.loadAsync() + if (!createsPage && !state.scope) protectUnrelatedPageRoots(state, page) + + 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 (key && !currentKey) { + page.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME, key) + markMutation(state, page) + } + return { created: createsPage, page } +} + +function collectKeyedNodes(scope: SupportedCanvasNode): Map { + const keyed = new Map() + for (const node of walkAuthoringNodes([scope])) { + if (!isSupportedSceneNode(node)) continue + const key = readOwnedNodeKey(node) + if (!key) continue + if (keyed.has(key)) { + scopeError(`Canvas key "${key}" is duplicated inside the update scope.`) + } + keyed.set(key, node) + } + return keyed +} + +function* walkSpecs(spec: CanvasNodeSpec): Generator { + yield spec + for (const child of spec.children ?? []) yield* walkSpecs(child) +} + +function collectDesiredKeys(root: CanvasNodeSpec): Set { + return new Set([...walkSpecs(root)].map((spec) => spec.key)) +} + +async function resolveExplicitNodes(root: CanvasNodeSpec, state: ApplyState): Promise { + const ids = new Set() + for (const spec of walkSpecs(root)) { + if (spec.nodeId) ids.add(spec.nodeId) + } + const orderedIds = [...ids] + const nodes = await Promise.all(orderedIds.map(lookupNodeById)) + for (const [index, id] of orderedIds.entries()) { + const node = nodes[index] ?? null + const supported = isSupportedSceneNode(node) ? node : null + if (supported) assertOutsideInstance(supported) + state.explicitNodes.set(id, supported) + } +} + +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 lookupNodeById(reference.nodeId) + if (!node || (sceneOnly && !isSceneNode(node))) { + specError( + `${context} "${reference.nodeId}" does not exist${sceneOnly ? ' or is not a scene node' : ''}.` + ) + } + protectNode(state, node) + state.referencedNodeIds.add(node.id) +} + +function resolveCanvasKey(key: string, state: ApplyState): SupportedCanvasNode { + const node = 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* walkRemovalOwnershipNodes( + roots: Iterable, + state: ApplyState +): Generator { + const stack = [...roots] + while (stack.length) { + const node = stack.pop()! + const svgWrapper = isOwnedSvgChild(node) && node.parent?.type === 'FRAME' ? node.parent : null + const wrapperKey = svgWrapper && readOwnedNodeKey(svgWrapper) + if (wrapperKey && state.keyedNodes.get(wrapperKey)?.id === svgWrapper.id) continue + yield node + if ('children' in node && node.type !== 'INSTANCE') stack.push(...node.children) + } +} + +function validateRemovalOwnership(root: SupportedCanvasNode, state: ApplyState): void { + for (const node of walkRemovalOwnershipNodes([root], state)) { + if (!isSupportedSceneNode(node)) { + scopeError(`Removing "${root.id}" would also remove an unsupported canvas node.`) + } + const key = readOwnedNodeKey(node) + 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 walkAuthoringNodes(roots)) { + 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 (isComponentPropertyOwner(node)) { + 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([...walkPhysicalNodes(roots)].map((node) => node.id)) + for (const node of walkAuthoringNodes(roots)) { + if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') && node.key) { + componentKeys.add(node.key) + } + } + return { componentKeys, nodeIds } +} + +const fullyLoadedRemovalDocuments = new WeakSet() + +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: [] + } + if ( + !fullyLoadedRemovalDocuments.has(figma.root) && + figma.root.children.some((page) => page.id !== figma.currentPage.id) + ) { + try { + await figma.loadAllPagesAsync() + fullyLoadedRemovalDocuments.add(figma.root) + } catch { + scopeError('Document pages could not be inspected before node removal.') + } + } + for (const page of figma.root.children) { + collectReferences(page.backgrounds, references) + for (const node of page.findAll()) { + if (removed.nodeIds.has(node.id)) continue + collectSceneReferences(node, references) + } + } + const removedStyleIds = new Set(state.styles.removals.map(({ style }) => style.id)) + const [paintStyles, effectStyles] = await Promise.all([ + getLocalPaintStyles(), + getLocalEffectStyles() + ]) + 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 walkPhysicalNodes([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 changed = claimNodeKey(node, key) + state.keyedNodes.set(key, node) + if (changed) 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 componentDefinitionOwner(component: ComponentNode): ComponentPropertyOwner { + return component.parent?.type === 'COMPONENT_SET' ? component.parent : component +} + +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) { + return state.explicitNodes.get(spec.nodeId) ?? null + } + return state.keyedNodes.get(spec.key) ?? null +} + +function validateExistingNodeIdentity( + spec: CanvasNodeSpec, + state: ApplyState, + node: SupportedCanvasNode +): void { + 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) { + const recovery = + spec.type === 'INSTANCE' + ? ' A keyed primitive cannot become an instance in place; give the instance a new key and remove the primitive key in the same update.' + : node.type === 'INSTANCE' + ? ' Omit this keyed subtree to preserve the instance in a partial ancestor update, or include its component binding when the instance itself is part of the desired result.' + : '' + specError( + `Canvas key "${spec.key}" expects ${spec.type}, but node "${node.id}" is ${node.type}.${recovery}` + ) + } +} + +function preflightExistingNodeIdentities( + root: CanvasNodeSpec, + state: ApplyState, + target: SupportedCanvasNode | null +): void { + const claimed = new Set() + const visit = (spec: CanvasNodeSpec, forcedNode?: SupportedCanvasNode): void => { + 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.`) + } + } else { + validateExistingNodeIdentity(spec, state, node) + if (claimed.has(node.id)) { + specError(`Node "${node.id}" is referenced more than once in the desired result.`) + } + claimed.add(node.id) + } + for (const child of spec.children ?? []) visit(child) + } + visit(root, target ?? undefined) +} + +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 + } + + validateExistingNodeIdentity(spec, state, node) + 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 = await lookupNodeById(reference.id) + if (node?.type === 'COMPONENT') { + component = node + protectNode(state, node) + } else if (node?.type === 'COMPONENT_SET') { + component = node.defaultVariant + protectNode(state, node) + protectNode(state, component) + } + } else { + try { + component = await figma.importComponentByKeyAsync(reference.key) + } catch { + specError(`Component key "${reference.key}" could not be imported.`) + } + } + + 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 = Promise.resolve() + .then(() => figma.loadFontAsync(font)) + .catch((error) => retryAfterFigmaConnectionTimeout(() => figma.loadFontAsync(font), error)) + .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))) +} + +const PORTABLE_FONT_CANDIDATES = { + mono: ['Noto Sans Mono', 'Roboto Mono', 'IBM Plex Mono', 'Source Code Pro', 'Space Mono'], + sans: ['Inter'], + serif: ['Noto Serif', 'Source Serif 4', 'Roboto Serif', 'Merriweather', 'Georgia'] +} as const + +function normalizedFontStyle(style: string): string { + return style.toLowerCase().replaceAll(/[^a-z]/g, '') +} + +function fontStyleWeight(style: string): number { + const normalized = normalizedFontStyle(style) + if (normalized.includes('thin')) return 100 + if (normalized.includes('extralight') || normalized.includes('ultralight')) return 200 + if (normalized.includes('light')) return 300 + if (normalized.includes('medium')) return 500 + if (normalized.includes('semibold') || normalized.includes('demibold')) return 600 + if (normalized.includes('extrabold') || normalized.includes('ultrabold')) return 800 + if (normalized.includes('black') || normalized.includes('heavy')) return 900 + if (normalized.includes('bold')) return 700 + return 400 +} + +function closestFontStyle(fonts: Font[], desiredStyle: string, weight?: number): FontName { + const normalizedDesired = normalizedFontStyle(desiredStyle) + const exact = fonts.find( + ({ fontName }) => normalizedFontStyle(fontName.style) === normalizedDesired + ) + if (exact && weight === undefined) return exact.fontName + + const desiredWeight = weight ?? fontStyleWeight(desiredStyle) + const desiredItalic = /italic/i.test(desiredStyle) + let closest = fonts[0]! + let closestScore = Infinity + for (const font of fonts) { + const score = + Math.abs(fontStyleWeight(font.fontName.style) - desiredWeight) + + (/italic/i.test(font.fontName.style) === desiredItalic ? 0 : 1000) + if (score < closestScore) { + closest = font + closestScore = score + } + } + return closest.fontName +} + +async function resolveFamilyFont( + family: string, + desiredStyle: string, + state: ApplyState, + weight?: number +): Promise { + state.availableFonts ??= figma.listAvailableFontsAsync() + const fonts = (await state.availableFonts).filter(({ fontName }) => fontName.family === family) + if (!fonts.length) + specError( + `Font family "${family}" is unavailable. Query get_design_system with scope: "fonts" for available families and styles.` + ) + return closestFontStyle(fonts, desiredStyle, weight) +} + +async function resolvePortableFont( + family: NonNullable['portableFontFamily']>, + desiredStyle: string, + state: ApplyState +): Promise { + state.availableFonts ??= figma.listAvailableFontsAsync() + let available: Font[] + try { + available = await state.availableFonts + } catch { + specError('Available Figma fonts could not be listed for a portable font utility.') + } + + for (const candidate of PORTABLE_FONT_CANDIDATES[family]) { + const matching = available.filter(({ fontName }) => fontName.family === candidate) + if (matching.length) return closestFontStyle(matching, desiredStyle) + } + specError( + `No portable ${family} font is available in the current Figma context; use an exact available font.` + ) +} + +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) || + isCanvasKeyHyperlink(spec.figma?.text?.hyperlink) || + 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) { + const usages = state.imageUrls.get(paint.imageUrl) ?? [] + usages.push(`${field} paint ${index} on "${key}"`) + state.imageUrls.set(paint.imageUrl, usages) + } 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 owner = componentDefinitionOwner(component) + for (const [key, value] of Object.entries(spec.componentProperties ?? {})) { + const name = componentPropertyName(owner, key, state) ?? key + const definition = owner.componentPropertyDefinitions[name] + if (!definition) { + specError(`Component "${component.id}" has no property "${key}" for "${spec.key}".`) + } + if (definition.type === 'SLOT') { + specError(`Slot property "${name}" on "${spec.key}" cannot be set with componentProperties.`) + } + if (isComponentPropertyVariable(value)) { + if (definition.type === 'VARIANT' || definition.type === 'INSTANCE_SWAP') { + specError( + `Component property "${name}" on "${spec.key}" cannot bind a variable because it is ${definition.type}.` + ) + } + const variable = await resolveVariable(value.variable, state.variables) + const expected = expectedComponentPropertyVariableType(definition.type) + if (variable.resolvedType !== expected) { + specError( + `Variable "${variable.id}" for component property "${name}" on "${spec.key}" is ${variable.resolvedType}, expected ${expected}.` + ) + } + 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 lookupNodeById(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 { + const isDesiredMask = desiredMaskState(spec, existing) + if (isRoot && isDesiredMask) { + 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) => desiredMaskState(child, findExistingNode(child, state))) + const lastChild = children.at(-1) + if (lastChild && desiredMaskState(lastChild, findExistingNode(lastChild, state))) { + 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 desiredMaskState(spec: CanvasNodeSpec, existing: SupportedCanvasNode | null): boolean { + const desired = spec.figma?.mask + if (desired !== undefined) return desired !== null + return !!existing && 'isMask' in existing && existing.isMask +} + +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) + } else if (spec.componentProperties || spec.figma?.instance) { + const instance = existing ?? findExistingNode(spec, state) + if (instance?.type !== 'INSTANCE') { + specError( + `Instance state on "${spec.key}" requires an existing instance or a component reference.` + ) + } + if (spec.componentProperties) { + const instanceComponent = await getMainComponent(instance) + if (!instanceComponent) { + specError(`Existing instance "${spec.key}" has no main component.`) + } + 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) + } + } + if (spec.type === 'TEXT' && spec.text) { + const textNode = existing?.type === 'TEXT' ? existing : null + const hasTextStyle = !!(spec.styles?.text || textNode?.textStyleId) + const hasFontFamilyVariable = !!( + spec.variables?.fontFamily || + (textNode && currentBoundVariableId(textNode, 'fontFamily')) + ) + const hasFontStyleVariable = !!( + spec.variables?.fontStyle || + (textNode && currentBoundVariableId(textNode, 'fontStyle')) + ) + const fontFamily = hasTextStyle || hasFontFamilyVariable ? undefined : spec.text.fontFamily + const fontStyle = hasTextStyle || hasFontStyleVariable ? undefined : spec.text.fontStyle + if ( + (fontFamily !== undefined || fontStyle !== undefined) && + !(spec.text.fontStyleMatching && (hasFontFamilyVariable || hasFontStyleVariable)) + ) { + const currentFont = textNode?.fontName ?? { family: 'Inter', style: 'Regular' } + if (currentFont === figma.mixed && (!fontFamily || !fontStyle)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + const desiredFamily = fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family) + const desiredStyle = fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) + const desiredFont = spec.text.portableFontFamily + ? await resolvePortableFont(spec.text.portableFontFamily, desiredStyle, state) + : spec.text.fontStyleMatching + ? await resolveFamilyFont(desiredFamily, desiredStyle, state) + : { + family: desiredFamily, + style: spec.figma?.text?.fontName + ? desiredStyle + : normalizePortableFontStyle(desiredFamily, desiredStyle) + } + if (spec.text.portableFontFamily) { + spec.text.fontFamily = desiredFont.family + spec.text.fontStyle = desiredFont.style + } + await loadFont(desiredFont, 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, usages] of state.imageUrls) { + try { + state.imageHashes.set(url, (await figma.createImageAsync(url)).hash) + } catch { + const usage = usages[0] ?? 'an IMAGE paint' + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.IMAGE_IMPORT_FAILED, + `Image URL for ${usage} could not be imported as a PNG, JPEG, or GIF up to 4096 by 4096 px. Use a direct public image URL in one of those formats, or a resolved image asset for exact bytes.` + ) + } + } +} + +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 = componentDefinitionOwner(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) + if ( + (node.type === 'FRAME' || node.type === 'COMPONENT') && + spec.appearance?.clipsContent === undefined && + node.clipsContent + ) { + node.clipsContent = false + markMutation(state, node) + } + if ( + (node.type === 'FRAME' || node.type === 'COMPONENT') && + spec.appearance?.fill === undefined && + node.fills !== figma.mixed && + node.fills.length > 0 + ) { + node.fills = [] + markMutation(state, node) + } + return node +} + +function moveIntoParent( + node: Child, + parent: BaseNode & { + readonly children: readonly Child[] + insertChild(index: number, child: Child): void + }, + index: number, + state: ApplyState +): void { + const sameParent = node.parent?.id === parent.id + if (sameParent && parent.children.indexOf(node) === index) return + parent.insertChild(index, node) + if (sameParent && parent.children.indexOf(node) !== index) { + const currentIndex = parent.children.indexOf(node) + parent.insertChild(currentIndex < index ? index + 1 : index, node) + } + if (parent.children.indexOf(node) !== index) { + specError(`Node "${node.id}" could not be placed at child index ${index}.`) + } + 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, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if (isIntrinsicNode(node) || !supportsLayoutSizing(node, parent)) 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' + recoverySize: 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 layoutStrokeWeight( + node: CanvasFrameContainerNode, + field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' +): number { + const value = node[field] + return typeof value === 'number' + ? value + : typeof node.strokeWeight === 'number' + ? node.strokeWeight + : 0 +} + +function includedLayoutEdgeStroke( + node: CanvasFrameContainerNode, + field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' +): number { + if ( + !node.strokesIncludedInLayout || + node.strokeAlign !== 'INSIDE' || + !Array.isArray(node.strokes) || + !node.strokes.some((stroke) => stroke.visible !== false) + ) { + return 0 + } + return layoutStrokeWeight(node, field) +} + +function includedLayoutStroke( + node: CanvasFrameContainerNode, + axis: 'horizontal' | 'vertical' +): number { + return axis === 'horizontal' + ? includedLayoutEdgeStroke(node, 'strokeLeftWeight') + + includedLayoutEdgeStroke(node, 'strokeRightWeight') + : includedLayoutEdgeStroke(node, 'strokeTopWeight') + + includedLayoutEdgeStroke(node, 'strokeBottomWeight') +} + +function crossAxisFill( + 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', + recoverySize: Math.max( + GEOMETRY_TOLERANCE, + clampSize( + parent.width - + parent.paddingLeft - + parent.paddingRight - + includedLayoutStroke(parent, 'horizontal'), + node.minWidth, + node.maxWidth + ) + ) + } + } + if (parent.layoutMode === 'HORIZONTAL' && spec.size.vertical === 'FILL') { + return { + axis: 'vertical', + recoverySize: Math.max( + GEOMETRY_TOLERANCE, + clampSize( + parent.height - + parent.paddingTop - + parent.paddingBottom - + includedLayoutStroke(parent, 'vertical'), + node.minHeight, + node.maxHeight + ) + ) + } + } + return null +} + +function stabilizeCrossAxisFill( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if (!('layoutSizingHorizontal' in node)) return + const fill = crossAxisFill(node, spec, parent) + if (!fill) return + const current = fill.axis === 'horizontal' ? node.width : node.height + if ( + current >= GEOMETRY_TOLERANCE && + (!state.createdNodeIds.has(node.id) || state.stabilizedCrossAxisFillNodeIds.has(node.id)) + ) { + return + } + + if (fill.axis === 'horizontal') { + node.layoutSizingHorizontal = 'FIXED' + node.resize(fill.recoverySize, node.height) + node.layoutSizingHorizontal = 'FILL' + } else { + node.layoutSizingVertical = 'FIXED' + node.resize(node.width, fill.recoverySize) + node.layoutSizingVertical = 'FILL' + } + state.stabilizedCrossAxisFillNodeIds.add(node.id) + markMutation(state, node) +} + +function stabilizeGrowingTextWidth( + node: SupportedCanvasNode, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if ( + node.type !== 'TEXT' || + node.characters.length === 0 || + node.textAutoResize !== 'HEIGHT' || + node.layoutGrow <= 0 || + node.width > GEOMETRY_TOLERANCE + ) { + return + } + + let width = Math.max(node.minWidth ?? 0, 1) + if (parent && isFrameContainer(parent)) { + if (parent.layoutMode === 'VERTICAL') { + width = Math.max( + width, + parent.width - + parent.paddingLeft - + parent.paddingRight - + includedLayoutStroke(parent, 'horizontal') + ) + } else if (parent.layoutMode === 'HORIZONTAL') { + const flow = parent.children.filter( + ( + child + ): child is SupportedCanvasNode & { + layoutGrow: number + layoutPositioning: 'ABSOLUTE' | 'AUTO' + } => + isSupportedSceneNode(child) && + 'layoutGrow' in child && + 'layoutPositioning' in child && + child.layoutPositioning !== 'ABSOLUTE' + ) + const fixedWidth = flow.reduce( + (total, child) => total + (child.id !== node.id && child.layoutGrow <= 0 ? child.width : 0), + 0 + ) + const growCount = Math.max(1, flow.filter((child) => child.layoutGrow > 0).length) + const available = + parent.width - + parent.paddingLeft - + parent.paddingRight - + includedLayoutStroke(parent, 'horizontal') - + Math.max(0, flow.length - 1) * parent.itemSpacing - + fixedWidth + width = Math.max(width, available / growCount) + } + } + width = Math.max(GEOMETRY_TOLERANCE, clampSize(width, node.minWidth, node.maxWidth)) + + node.layoutSizingHorizontal = 'FIXED' + node.resize(width, node.height) + node.layoutSizingHorizontal = 'FILL' + markMutation(state, node) +} + +function supportsLayoutSizing( + node: SupportedCanvasNode, + parent: SupportedCanvasNode | CanvasParentNode | undefined +): node is SupportedCanvasNode & LayoutMixin { + return ( + 'layoutSizingHorizontal' in node && + (('layoutMode' in node && node.layoutMode !== 'NONE') || + (!!parent && 'layoutMode' in parent && parent.layoutMode !== 'NONE')) + ) +} + +function applySize( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + 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, parent, 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 + const { right, bottom } = spec.absoluteOffsets ?? {} + const x = + right !== undefined && 'width' in parent ? parent.width - right - node.width : spec.position.x + const y = + bottom !== undefined && 'height' in parent + ? parent.height - bottom - node.height + : spec.position.y + setValue(node, node.x, x, (value) => (node.x = value), state) + setValue(node, node.y, y, (value) => (node.y = value), state) +} + +function finalizeAbsolutePositions(root: CanvasNodeSpec, state: ApplyState): void { + for (const spec of walkSpecs(root)) { + const offsets = spec.absoluteOffsets + if (!offsets) continue + const node = state.keyedNodes.get(spec.key) + const parent = node?.parent + if (!node || !parent || !('width' in parent)) continue + // Bindings, consumer modes, and layout may change either bound after markup parsing. + if (offsets.right !== undefined) { + setValue(node, node.x, parent.width - offsets.right - node.width, (x) => (node.x = x), state) + } + if (offsets.bottom !== undefined) { + setValue( + node, + node.y, + parent.height - offsets.bottom - node.height, + (y) => (node.y = y), + 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) { + 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, numberTolerance = 0): boolean { + if (Object.is(current, desired)) return true + if (typeof current === 'number' && typeof desired === 'number') { + return Math.abs(current - desired) <= numberTolerance + } + 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], numberTolerance)) + ) + } + 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], numberTolerance)) + ) +} + +const FIGMA_NATIVE_TOLERANCE = 1 / 255 + Number.EPSILON + +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, expected: LayoutGrid): Record { + const comparable = Object.fromEntries( + Object.keys(expected) + .filter((field) => !['boundVariables', 'color', 'visible'].includes(field)) + .map((field) => [field, grid[field as keyof LayoutGrid]]) + ) + return { + ...comparable, + visible: grid.visible ?? true, + ...(expected.color === undefined ? {} : { color: grid.color }), + boundVariables: grid.boundVariables ?? {} + } +} + +function layoutGridsEqual(current: readonly LayoutGrid[], desired: readonly LayoutGrid[]): boolean { + return ( + current.length === desired.length && + current.every((grid, index) => + nativeValueEqual( + comparableLayoutGrid(grid, desired[index]!), + comparableLayoutGrid(desired[index]!, desired[index]!), + FIGMA_NATIVE_TOLERANCE + ) + ) + ) +} + +const IMAGE_FILTER_FIELDS = [ + 'exposure', + 'contrast', + 'saturation', + 'temperature', + 'tint', + 'highlights', + 'shadows' +] as const satisfies ReadonlyArray + +function comparablePaint(paint: Paint, expected: Paint): unknown { + if (paint.type === 'IMAGE' && expected.type === 'IMAGE') { + return { + type: paint.type, + imageHash: paint.imageHash, + scaleMode: paint.scaleMode, + ...paintDefaults(paint), + filters: Object.fromEntries( + IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) + ), + ...(paint.scaleMode === 'CROP' && expected.imageTransform !== undefined + ? { imageTransform: paint.imageTransform } + : {}), + ...(paint.scaleMode === 'TILE' && expected.scalingFactor !== undefined + ? { scalingFactor: paint.scalingFactor } + : {}), + ...(paint.scaleMode === 'CROP' ? {} : { rotation: paint.rotation ?? 0 }) + } + } + if (paint.type === 'VIDEO' && expected.type === 'VIDEO') { + return { + type: paint.type, + videoHash: paint.videoHash, + scaleMode: paint.scaleMode, + ...paintDefaults(paint), + filters: Object.fromEntries( + IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) + ), + ...(paint.scaleMode === 'CROP' && expected.videoTransform !== undefined + ? { videoTransform: paint.videoTransform } + : {}), + ...(paint.scaleMode === 'TILE' && expected.scalingFactor !== undefined + ? { scalingFactor: paint.scalingFactor } + : {}), + ...(paint.scaleMode === 'CROP' ? {} : { rotation: paint.rotation ?? 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, expected), + comparablePaint(expected, expected), + FIGMA_NATIVE_TOLERANCE + ) + ) + }) + ) +} + +function isShadowEffect(effect: Effect): effect is DropShadowEffect | InnerShadowEffect { + return effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW' +} + +function comparableShadow( + effect: DropShadowEffect | InnerShadowEffect, + expected: DropShadowEffect | InnerShadowEffect +): Effect { + if (effect.type !== 'DROP_SHADOW' || expected.type !== 'DROP_SHADOW') { + return { ...effect, spread: effect.spread ?? 0 } + } + if (expected.showShadowBehindNode !== undefined) { + return { + ...effect, + spread: effect.spread ?? 0, + showShadowBehindNode: effect.showShadowBehindNode ?? false + } + } + const { showShadowBehindNode: _showShadowBehindNode, ...withoutBehindNode } = effect + return { ...withoutBehindNode, spread: effect.spread ?? 0 } +} + +function comparableEffect(effect: Effect, expected: Effect): unknown { + return isShadowEffect(effect) && isShadowEffect(expected) + ? comparableShadow(effect, expected) + : effect +} + +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 + return nativeValueEqual( + comparableEffect(effect, expected), + comparableEffect(expected, expected), + FIGMA_NATIVE_TOLERANCE + ) + }) +} + +function summarizeNativeValue(value: unknown): string { + const serialized = JSON.stringify(value) + if (serialized === undefined) return String(value) + return serialized.length <= 400 ? serialized : `${serialized.slice(0, 397)}...` +} + +function describeEffectMismatch(current: readonly Effect[], desired: readonly Effect[]): string { + if (current.length !== desired.length) { + return `expected ${desired.length} effect${desired.length === 1 ? '' : 's'}, found ${current.length}.` + } + const index = current.findIndex((effect, effectIndex) => { + const expected = desired[effectIndex]! + return ( + effect.type !== expected.type || + !nativeValueEqual( + comparableEffect(effect, expected), + comparableEffect(expected, expected), + FIGMA_NATIVE_TOLERANCE + ) + ) + }) + if (index < 0) return 'effect stack changed before verification completed.' + const expected = desired[index]! + const found = current[index]! + return `effect ${index} does not match; expected ${summarizeNativeValue(comparableEffect(expected, expected))}, found ${summarizeNativeValue(comparableEffect(found, 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 + const authoredFills = spec.figma?.fills + const hasLiveVisibleFill = + 'fills' in node && + node.fills !== figma.mixed && + node.fills.some((paint) => paint.visible ?? true) + const hasVisibleFill = + authoredFills === undefined + ? hasLiveVisibleFill + : authoredFills.some((paint) => paint.visible ?? true) + if ((isFrameContainer(node) || node.type === 'INSTANCE') && node.clipsContent && hasVisibleFill) { + 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, expected: 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 }, regionIndex) => { + const expectedFills = expected.regions?.[regionIndex]?.fills + return { + ...region, + ...(fillStyleId + ? { fillStyleId } + : fills === undefined + ? {} + : { + fills: fills.map((paint, paintIndex) => + comparablePaint(paint, expectedFills?.[paintIndex] ?? paint) + ) + }) + } + }) + } +} + +function vectorNetworksEqual(current: VectorNetwork, desired: VectorNetwork): boolean { + return nativeValueEqual( + comparableVectorNetwork(current, desired), + comparableVectorNetwork(desired, desired), + FIGMA_NATIVE_TOLERANCE + ) +} + +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) + if (text?.fontStyleMatching && !hasTextStyle) { + const familyReference = spec.variables?.fontFamily + const styleReference = spec.variables?.fontStyle + const weightReference = spec.variables?.fontWeight + const family = familyReference + ? resolvedFontVariableValue(node, familyReference, state) + : currentBoundVariableId(node, 'fontFamily') && currentFont !== figma.mixed + ? currentFont.family + : (text.fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family)) + const style = styleReference + ? resolvedFontVariableValue(node, styleReference, state) + : currentBoundVariableId(node, 'fontStyle') && currentFont !== figma.mixed + ? currentFont.style + : (text.fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style)) + if (currentFont === figma.mixed && (!family || !style)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + const weight = weightReference + ? resolvedVariable(weightReference, state.variables).resolveForConsumer(node).value + : undefined + if ( + weight !== undefined && + (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000) + ) { + specError(`Font weight on "${spec.key}" must resolve to a number between 1 and 1000.`) + } + const desiredFont = styleReference + ? { family, style } + : await resolveFamilyFont(family, style, state, weight) + await loadFont(desiredFont, state) + return desiredFont + } + 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 desiredFamily = fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family) + const desiredStyle = fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) + const desiredFont: FontName | null = hasExplicitFont + ? text?.portableFontFamily + ? await resolvePortableFont(text.portableFontFamily, desiredStyle, state) + : { + family: desiredFamily, + style: spec.figma?.text?.fontName + ? desiredStyle + : normalizePortableFontStyle(desiredFamily, desiredStyle) + } + : null + if (desiredFont && text?.portableFontFamily) { + text.fontFamily = desiredFont.family + text.fontStyle = desiredFont.style + } + 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.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.textAutoResize, + text.autoResize, + (value) => (node.textAutoResize = 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 + const component = spec.component + ? resolvedComponent(spec.component, state) + : await getMainComponent(node) + if (!component) { + specError(`Existing instance "${spec.key}" has no main component.`) + } + if (spec.component && !preservesComponentPropertyReference(node, spec, 'mainComponent')) { + const currentComponent = await getMainComponent(node) + if (!componentLinkMatches(spec, node, component, currentComponent, state)) { + 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 owner = componentDefinitionOwner(component) + const desiredProperties = Object.entries(spec.componentProperties ?? {}).map( + ([key, value]) => [componentPropertyName(owner, key, state) ?? key, value] as const + ) + const changedProperties = desiredProperties.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) { + moveIntoParent(page, figma.root, properties.index, state) + } + 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 + if (!metadata) return + 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) + markMutation(state, node) + 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 ?? (state.createdNodeIds.has(node.id) ? spec.key : undefined)), + (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, parent, 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, parent, state) + stabilizeCrossAxisFill(node, spec, parent, state) + stabilizeGrowingTextWidth(node, parent, state) +} + +function collectSvgColorsFromSpecs( + specs: Iterable +): Map> { + const colors = new Map>() + for (const spec of specs) { + const svg = spec.figma?.svg + if (svg) { + const values = colors.get(svg.assetKey) ?? new Set() + values.add(svg.color) + colors.set(svg.assetKey, values) + } + } + return colors +} + +function collectSvgColors(root: CanvasNodeSpec): Map> { + return collectSvgColorsFromSpecs(walkSpecs(root)) +} + +async function applyCanvasKeyReferences( + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode +): Promise { + const node = state.keyedNodes.get(spec.key) ?? null + if (!isSupportedSceneNode(node)) { + specError(`Desired node "${spec.key}" was not reconciled.`) + } + if (hasCanvasKeyVectorPattern(spec)) { + await applyShape(node, spec, state, true) + applySize(node, spec, parent, 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) +} + +function gridAreasOverlap( + left: { column: number; columnSpan: number; row: number; rowSpan: number }, + right: { column: number; columnSpan: number; row: number; rowSpan: number } +): boolean { + return ( + left.row < right.row + right.rowSpan && + left.row + left.rowSpan > right.row && + left.column < right.column + right.columnSpan && + left.column + left.columnSpan > right.column + ) +} + +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, + forceFinalRowCount = false +): 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 + ) + }) + const desiredAreas = children.map(({ spec }) => { + const grid = spec.gridChild! + return { + column: grid.column!, + columnSpan: grid.columnSpan, + row: grid.row!, + rowSpan: grid.rowSpan + } + }) + const removalBlockers = node.children.filter((child): child is GridChildNode => { + if ( + !state.removalNodeIds.has(child.id) || + !isSupportedSceneNode(child) || + child.type === 'SECTION' + ) { + return false + } + const liveArea = { + column: child.gridColumnAnchorIndex, + columnSpan: child.gridColumnSpan, + row: child.gridRowAnchorIndex, + rowSpan: child.gridRowSpan + } + return desiredAreas.some((area) => gridAreasOverlap(liveArea, area)) + }) + const staging = [...moving.map(({ node: child }) => child), ...removalBlockers] + if (staging.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 + staging.length, + (value) => (node.gridRowCount = value), + state + ) + for (const [index, child] of staging.entries()) { + setGridChildSpans(child, 1, 1, state) + setGridChildPosition(child, stagingStart + index, 0, state) + } + if (removalBlockers.length) state.pendingGridRemovalCleanupNodeIds.add(node.id) + } + + 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 || forceFinalRowCount) { + 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 && staging.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, + forceFinalRowCount = false +): 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, forceFinalRowCount) + } else { + finalizeFlowGrid(node, layout, gridChildren, state) + } + for (const child of gridChildren) { + applyGridChildAlignment(child.node, child.spec, state) + // Figma can reset a grid child's sizing modes while changing its cell or span. + applySizingModes(child.node, child.spec, node, state) + } +} + +function finalizeGridsAfterRemovals(rootSpec: CanvasNodeSpec, state: ApplyState): void { + if (!state.pendingGridRemovalCleanupNodeIds.size) return + for (const spec of walkSpecs(rootSpec)) { + const node = state.keyedNodes.get(spec.key) + if ( + !node || + !isFrameContainer(node) || + !state.pendingGridRemovalCleanupNodeIds.delete(node.id) + ) { + continue + } + const children = (spec.children ?? []).map((childSpec) => { + const child = state.keyedNodes.get(childSpec.key) + if (!child || child.removed) { + specError(`Desired grid child "${childSpec.key}" was unavailable after node removal.`) + } + return { node: child, spec: childSpec } + }) + finalizeGrid(node, spec, children, state, true) + } + if (state.pendingGridRemovalCleanupNodeIds.size) { + specError('A grid staged for node removal was unavailable for final layout cleanup.') + } +} + +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] = '' + // Keep staged descendants inside the update scope when their existing ancestors move. + const stagingParent = parent ?? figma.currentPage + if (spec.type === 'COMPONENT_SET') { + const children = spec.children! + const variants = children.map((child) => { + const existing = findExistingNode(child, state) + if (existing && existing.type !== 'COMPONENT') { + specError(`Component set "${spec.key}" can contain only component nodes.`) + } + const variant = existing ?? figma.createComponent() + if (!existing) 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 + return currentIndex >= 0 && currentIndex < previousIndex ? previousIndex : previousIndex + 1 +} + +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.`) + } + } + + if (obstacles.some((obstacle) => placementOverlap(candidate, obstacle))) { + specError(`Created root "${node.id}" could not be placed without overlap.`) + } + + const deltaX = candidate.x - bounds.x + const deltaY = candidate.y - bounds.y + if (deltaX !== 0 || deltaY !== 0) { + const transform = node.relativeTransform + node.relativeTransform = [ + [transform[0][0], transform[0][1], transform[0][2] + deltaX], + [transform[1][0], transform[1][1], transform[1][2] + deltaY] + ] + markMutation(state, node) + } +} + +function createApplyState( + target: SupportedCanvasNode | null, + desiredKeys: Set, + assets: ResolvedCanvasAssets = new Map() +): ApplyState { + const state: ApplyState = { + assets, + claimedNodeIds: new Set(), + componentCache: new Map(), + componentPropertyKeys: new Map(), + createdNodeIds: new Set(), + createdPageIds: new Set(), + desiredKeys, + explicitNodes: new Map(), + fontLoads: new Map(), + imageHashes: new Map(), + imageAssetKeys: new Set(), + imageUrls: new Map(), + keyedNodes: target ? collectKeyedNodes(target) : new Map(), + mutations: { count: 0 }, + nodeIdsByKey: Object.create(null) as Record, + pendingGridRemovalCleanupNodeIds: new Set(), + protectedNodes: new Map(), + removalNodeIds: new Set(), + referencedNodeIds: new Set(), + scope: target, + shaderCache: new Map(), + stabilizedCrossAxisFillNodeIds: new Set(), + styles: createStyleState(), + updatedNodeIds: new Set(), + variables: createVariableState(), + videoHashes: new Map(), + videoUrls: new Set() + } + if (target) protectNode(state, target, false) + return state +} + +type VerificationWarning = ApplyCanvasResult['verification']['warnings'][number] + +function buildVerification( + nodesChecked = 0, + referencesChecked = 0, + nativeFieldsChecked = 0, + warnings: VerificationWarning[] = [] +) { + return { + status: warnings.length ? ('warning' as const) : ('passed' as const), + nodesChecked, + referencesChecked, + nativeFieldsChecked, + warnings + } +} + +function collectKeyReferences(value: unknown, field: string, references: Set): void { + if (Array.isArray(value)) { + for (const item of value) collectKeyReferences(item, field, references) + return + } + if (!isRecord(value)) return + if (typeof value[field] === 'string') references.add(value[field]) + for (const nested of Object.values(value)) collectKeyReferences(nested, field, references) +} + +function desiredKeyReferences(values: unknown[], field: string): Set { + const references = new Set() + for (const value of values) collectKeyReferences(value, field, references) + return references +} + +function unboundCreatedResourceWarnings( + desiredValues: unknown[], + state: ApplyState +): VerificationWarning[] { + return [ + { + code: 'unbound-created-variable' as const, + consumer: 'node or style', + keys: state.variables.createdVariableKeys, + referenceField: 'variableKey', + resource: 'variable' + }, + { + code: 'unbound-created-style' as const, + consumer: 'node', + keys: state.styles.createdStyleKeys, + referenceField: 'styleKey', + resource: 'style' + } + ].flatMap(({ code, consumer, keys, referenceField, resource }) => { + if (!keys.size) return [] + const references = desiredKeyReferences(desiredValues, referenceField) + return [...keys] + .filter((key) => !references.has(key)) + .map((key) => ({ + code, + key, + message: `This new ${resource} is not referenced by the desired result. Bind it to a representative ${consumer}, or remove it if it is speculative.` + })) + }) +} + +type VariableFallback = boolean | number | string | RGBA + +function parseFallbackColor(value: string): RGBA | undefined { + const hex = value.slice(1) + const expanded = + hex.length === 3 || hex.length === 4 + ? [...hex].map((character) => `${character}${character}`).join('') + : hex + if (expanded.length !== 6 && expanded.length !== 8) return undefined + const channels = expanded.match(/.{2}/g)?.map((channel) => Number.parseInt(channel, 16)) + if (!channels || channels.some((channel) => Number.isNaN(channel))) return undefined + return { + r: channels[0]! / 255, + g: channels[1]! / 255, + b: channels[2]! / 255, + a: channels[3] === undefined ? 1 : channels[3] / 255 + } +} + +function paddingFallback( + layout: CanvasNodeSpec['layout'], + side: 'bottom' | 'left' | 'right' | 'top' +): number | undefined { + if (!layout || layout.mode === 'NONE' || layout.padding === undefined) return undefined + return typeof layout.padding === 'number' ? layout.padding : layout.padding[side] +} + +function metricFallback(value: unknown): number | undefined { + return isRecord(value) && value.unit === 'PIXELS' && typeof value.value === 'number' + ? value.value + : undefined +} + +function variableFallback( + spec: CanvasNodeSpec, + field: keyof CanvasVariableBindings +): VariableFallback | undefined { + switch (field) { + case 'fill': + case 'stroke': { + const value = spec.appearance?.[field] + return typeof value === 'string' ? parseFallbackColor(value) : undefined + } + case 'characters': + return spec.text?.characters + case 'visible': + return spec.visible + case 'width': + case 'height': + case 'minWidth': + case 'maxWidth': + case 'minHeight': + case 'maxHeight': + return spec.size[field] ?? undefined + case 'gap': + return spec.layout?.mode === 'HORIZONTAL' || spec.layout?.mode === 'VERTICAL' + ? spec.layout.gap + : undefined + case 'counterAxisSpacing': + return spec.layout?.mode === 'HORIZONTAL' || spec.layout?.mode === 'VERTICAL' + ? spec.layout.counterGap + : undefined + case 'gridRowGap': + return spec.layout?.mode === 'GRID' ? spec.layout.rowGap : undefined + case 'gridColumnGap': + return spec.layout?.mode === 'GRID' ? spec.layout.columnGap : undefined + case 'paddingTop': + return paddingFallback(spec.layout, 'top') + case 'paddingRight': + return paddingFallback(spec.layout, 'right') + case 'paddingBottom': + return paddingFallback(spec.layout, 'bottom') + case 'paddingLeft': + return paddingFallback(spec.layout, 'left') + case 'cornerRadius': + case 'topLeftRadius': + case 'topRightRadius': + case 'bottomRightRadius': + case 'bottomLeftRadius': + case 'strokeWeight': + case 'strokeTopWeight': + case 'strokeRightWeight': + case 'strokeBottomWeight': + case 'strokeLeftWeight': + case 'opacity': + return spec.appearance?.[field] + case 'fontFamily': + return spec.text?.fontFamily + case 'fontStyle': + return spec.text?.fontStyle + case 'fontSize': + return spec.text?.fontSize + case 'lineHeight': + return metricFallback(spec.text?.lineHeight) + case 'letterSpacing': + return metricFallback(spec.text?.letterSpacing) + case 'fontWeight': + case 'paragraphIndent': + case 'paragraphSpacing': + return undefined + } +} + +function colorChannels(value: unknown): RGBA | null { + if ( + !isRecord(value) || + typeof value.r !== 'number' || + typeof value.g !== 'number' || + typeof value.b !== 'number' + ) { + return null + } + return { + r: value.r, + g: value.g, + b: value.b, + a: typeof value.a === 'number' ? value.a : 1 + } +} + +function fallbackMatches(value: unknown, fallback: VariableFallback): boolean { + if (typeof fallback === 'number') { + return typeof value === 'number' && Math.abs(value - fallback) <= GEOMETRY_TOLERANCE + } + if (typeof fallback === 'string' || typeof fallback === 'boolean') return value === fallback + const channels = colorChannels(value) + if (!channels) return false + return ( + Math.abs(channels.r - fallback.r) <= 1 / 255 && + Math.abs(channels.g - fallback.g) <= 1 / 255 && + Math.abs(channels.b - fallback.b) <= 1 / 255 && + Math.abs(channels.a - fallback.a) <= 1 / 255 + ) +} + +function formatFallback(value: unknown): string { + if (typeof value !== 'object' || value === null) return String(value) + const channels = colorChannels(value) + if (!channels) return JSON.stringify(value) + const bytes = [channels.r, channels.g, channels.b, channels.a].map((channel) => + Math.round(channel * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase() + ) + return `#${bytes.slice(0, channels.a === 1 ? 3 : 4).join('')}` +} + +function authoredVariableFallbackWarnings( + specs: Iterable, + variableCollections: ParsedCanvasTreeInput['variableCollections'] +): VerificationWarning[] { + const authoredValues = new Map() + for (const collection of Object.values(variableCollections ?? {})) { + if (!collection) continue + for (const [key, variable] of Object.entries(collection.variables ?? {})) { + const values = Object.values(variable?.values ?? {}) + if (values.length) authoredValues.set(key, values) + } + } + + const directValues = (key: string, seen = new Set()): unknown[] => { + if (seen.has(key)) return [] + seen.add(key) + return (authoredValues.get(key) ?? []).flatMap((value) => { + if (!isRecord(value) || !isRecord(value.variable)) return [value] + const alias = value.variable.variableKey + return typeof alias === 'string' ? directValues(alias, new Set(seen)) : [] + }) + } + + const warnings: VerificationWarning[] = [] + for (const spec of specs) { + for (const [field, reference] of Object.entries(spec.variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableReference | null] + >) { + if (!reference || !('variableKey' in reference)) continue + if (spec.themeVariableFields?.includes(field)) continue + const values = directValues(reference.variableKey) + const fallback = variableFallback(spec, field) + if ( + !values.length || + fallback === undefined || + values.some((value) => fallbackMatches(value, fallback)) + ) { + continue + } + const authoredValues = [...new Set(values.map(formatFallback))].join(', ') + warnings.push({ + code: 'variable-fallback-mismatch', + key: spec.key, + message: `"${spec.key}" binds ${field} ${formatFallback(fallback)} to authored variable "${reference.variableKey}", whose direct mode values are ${authoredValues}. Use a matching literal fallback or bind the variable that owns this value.` + }) + } + } + return warnings +} + +function layoutAffectingVisibilityWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const spec of walkSpecs(root)) { + const property = spec.figma?.componentPropertyReferences?.visible + if (!property) continue + const node = state.keyedNodes.get(spec.key) + const parent = node?.parent + if ( + !node || + !parent || + !isSupportedSceneNode(parent) || + !isFrameContainer(parent) || + parent.layoutMode === 'NONE' || + ('layoutPositioning' in node && node.layoutPositioning === 'ABSOLUTE') + ) { + continue + } + const flowSibling = parent.children.some( + (child) => + child.id !== node.id && + isSceneNode(child) && + (!('layoutPositioning' in child) || child.layoutPositioning !== 'ABSOLUTE') + ) + if ( + !flowSibling && + parent.primaryAxisSizingMode !== 'AUTO' && + parent.counterAxisSizingMode !== 'AUTO' + ) { + continue + } + warnings.push({ + code: 'layout-affecting-visibility-property', + key: spec.key, + message: `Boolean component property "${property}" controls an Auto Layout flow child. Hiding it can resize its parent or move siblings. If geometry must stay stable, put the visible layer inside an always-present fixed slot, make it absolute, or use geometry-equivalent variants; otherwise verify both states and accept the intended reflow.` + }) + } + return warnings +} + +type ContentOverflow = { + bottom: number + left: number + right: number + top: number +} + +function finiteRect(value: Rect | null | undefined): Rect | null { + return value && + [value.x, value.y, value.width, value.height].every(Number.isFinite) && + value.width >= 0 && + value.height >= 0 + ? value + : null +} + +function overflowFromRects(child: Rect, parent: Rect): ContentOverflow | null { + const overflow = { + bottom: Math.max(0, child.y + child.height - (parent.y + parent.height)), + left: Math.max(0, parent.x - child.x), + right: Math.max(0, child.x + child.width - (parent.x + parent.width)), + top: Math.max(0, parent.y - child.y) + } + return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > + CONTENT_OVERFLOW_TOLERANCE + ? overflow + : null +} + +function localNodeBounds(node: SupportedCanvasNode): Rect | null { + if (![node.width, node.height].every(Number.isFinite) || node.width < 0 || node.height < 0) { + return null + } + const transform = node.relativeTransform + const corners = [ + { x: 0, y: 0 }, + { x: node.width, y: 0 }, + { x: 0, y: node.height }, + { x: node.width, y: node.height } + ].map(({ x, y }) => ({ + x: transform[0][0] * x + transform[0][1] * y + transform[0][2], + y: transform[1][0] * x + transform[1][1] * y + transform[1][2] + })) + const x = Math.min(...corners.map((corner) => corner.x)) + const y = Math.min(...corners.map((corner) => corner.y)) + const right = Math.max(...corners.map((corner) => corner.x)) + const bottom = Math.max(...corners.map((corner) => corner.y)) + return finiteRect({ x, y, width: right - x, height: bottom - y }) +} + +function contentOverflow( + node: SupportedCanvasNode, + parent: CanvasFrameContainerNode +): ContentOverflow | null { + const parentBounds = finiteRect( + 'absoluteBoundingBox' in parent ? parent.absoluteBoundingBox : null + ) + const childBounds = [ + node.type === 'TEXT' && 'absoluteRenderBounds' in node ? node.absoluteRenderBounds : null, + 'absoluteBoundingBox' in node ? node.absoluteBoundingBox : null + ].map(finiteRect) + if (parentBounds && childBounds.some(Boolean)) { + const overflow: ContentOverflow = { bottom: 0, left: 0, right: 0, top: 0 } + for (const bounds of childBounds) { + if (!bounds) continue + const current = overflowFromRects(bounds, parentBounds) + if (!current) continue + overflow.bottom = Math.max(overflow.bottom, current.bottom) + overflow.left = Math.max(overflow.left, current.left) + overflow.right = Math.max(overflow.right, current.right) + overflow.top = Math.max(overflow.top, current.top) + } + return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > + CONTENT_OVERFLOW_TOLERANCE + ? overflow + : null + } + if (parent.layoutMode !== 'NONE') return null + const local = localNodeBounds(node) + return local + ? overflowFromRects(local, { x: 0, y: 0, width: parent.width, height: parent.height }) + : null +} + +function formatOverflow(overflow: ContentOverflow): string { + const edges: Array<[string, number]> = [ + ['top', overflow.top], + ['right', overflow.right], + ['bottom', overflow.bottom], + ['left', overflow.left] + ] + return edges + .filter(([, value]) => value > CONTENT_OVERFLOW_TOLERANCE) + .map(([edge, value]) => `${edge} ${Math.round(value * 10) / 10}px`) + .join(', ') +} + +function instanceDescendantOverflow(node: InstanceNode): ContentOverflow | null { + const absoluteRootBounds = finiteRect(node.absoluteBoundingBox) + const rootBounds = absoluteRootBounds ?? { x: 0, y: 0, width: node.width, height: node.height } + const overflow: ContentOverflow = { bottom: 0, left: 0, right: 0, top: 0 } + const stack: SceneNode[] = [...node.children] + while (stack.length) { + const descendant = stack.pop()! + if (descendant.visible === false) continue + const bounds = absoluteRootBounds + ? finiteRect( + descendant.type === 'TEXT' + ? descendant.absoluteRenderBounds + : descendant.absoluteBoundingBox + ) + : descendant.parent === node && isSupportedSceneNode(descendant) + ? localNodeBounds(descendant) + : null + if (bounds) { + const current = overflowFromRects(bounds, rootBounds) + if (current) { + overflow.bottom = Math.max(overflow.bottom, current.bottom) + overflow.left = Math.max(overflow.left, current.left) + overflow.right = Math.max(overflow.right, current.right) + overflow.top = Math.max(overflow.top, current.top) + } + } + if ('children' in descendant) stack.push(...descendant.children) + } + return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > + CONTENT_OVERFLOW_TOLERANCE + ? overflow + : null +} + +function managedContentOverflowWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const spec of walkSpecs(root)) { + const node = state.keyedNodes.get(spec.key) + if (!node) continue + const parent = node.parent + if (!parent || !isSupportedSceneNode(parent) || !isFrameContainer(parent)) continue + const isFlowChild = + parent.layoutMode !== 'NONE' && + (!('layoutPositioning' in node) || node.layoutPositioning !== 'ABSOLUTE') + if (node.type !== 'TEXT' && node.type !== 'INSTANCE' && !isFlowChild) continue + const parentKey = readOwnedNodeKey(parent) + if (!parentKey) continue + const overflow = contentOverflow(node, parent) + if (overflow) { + warnings.push({ + code: 'managed-content-overflow', + key: spec.key, + message: `"${spec.key}" extends beyond parent "${parentKey}" by ${formatOverflow(overflow)}. Parent clipping is ${parent.clipsContent ? 'enabled, so the content will be clipped' : 'disabled, so the content may overlap adjacent content'}. Resize or realign the content when unintended; otherwise verify and retain the intentional overflow.` + }) + } + if (node.type !== 'INSTANCE') continue + const instanceOverflow = instanceDescendantOverflow(node) + if (!instanceOverflow) continue + warnings.push({ + code: 'managed-content-overflow', + key: spec.key, + message: `"${spec.key}" contains instance content extending beyond its root by ${formatOverflow(instanceOverflow)}. Figma can paint this overflow without expanding the native INSTANCE bounds. Resize the component definition for the real property values, add a size variant, or choose a smaller stable component boundary.` + }) + } + return warnings +} + +type RoundedCorner = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' + +function hasVisibleAreaFill(node: SupportedCanvasNode): boolean { + if ( + node.type === 'TEXT' || + node.type === 'LINE' || + node.type === 'VECTOR' || + !('fills' in node) || + node.fills === figma.mixed + ) { + return false + } + return node.fills.some( + (paint) => paint.visible !== false && (paint.opacity === undefined || paint.opacity > 0) + ) +} + +function roundedCornerRadius(node: SupportedCanvasNode, corner: RoundedCorner): number { + if (!('topLeftRadius' in node)) return 0 + if ('cornerRadius' in node && typeof node.cornerRadius === 'number') { + return Number.isFinite(node.cornerRadius) ? node.cornerRadius : 0 + } + const field = + corner === 'top-left' + ? 'topLeftRadius' + : corner === 'top-right' + ? 'topRightRadius' + : corner === 'bottom-right' + ? 'bottomRightRadius' + : 'bottomLeftRadius' + const value = node[field] + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function managedRoundedStrokeOcclusionWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const parentSpec of walkSpecs(root)) { + const parent = state.keyedNodes.get(parentSpec.key) + if ( + !parent || + !isFrameContainer(parent) || + !parent.clipsContent || + parent.strokeAlign === 'OUTSIDE' || + !parent.strokes.some( + (paint) => paint.visible !== false && (paint.opacity === undefined || paint.opacity > 0) + ) + ) { + continue + } + + const innerStrokeWeight = ( + field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' + ): number => layoutStrokeWeight(parent, field) * (parent.strokeAlign === 'CENTER' ? 0.5 : 1) + + const strokes = { + bottom: innerStrokeWeight('strokeBottomWeight'), + left: innerStrokeWeight('strokeLeftWeight'), + right: innerStrokeWeight('strokeRightWeight'), + top: innerStrokeWeight('strokeTopWeight') + } + for (const child of parent.children) { + if ( + child.visible === false || + !isSupportedSceneNode(child) || + !hasVisibleAreaFill(child) || + ('rotation' in child && Math.abs(child.rotation) > GEOMETRY_TOLERANCE) + ) { + continue + } + const childKey = readOwnedNodeKey(child) + const bounds = localNodeBounds(child) + if (!childKey || !bounds) continue + const touches = { + bottom: + bounds.y + bounds.height >= parent.height - strokes.bottom - CONTENT_OVERFLOW_TOLERANCE, + left: bounds.x <= strokes.left + CONTENT_OVERFLOW_TOLERANCE, + right: bounds.x + bounds.width >= parent.width - strokes.right - CONTENT_OVERFLOW_TOLERANCE, + top: bounds.y <= strokes.top + CONTENT_OVERFLOW_TOLERANCE + } + const affected = ( + [ + ['top-left', touches.top && touches.left, Math.max(strokes.top, strokes.left)], + ['top-right', touches.top && touches.right, Math.max(strokes.top, strokes.right)], + [ + 'bottom-right', + touches.bottom && touches.right, + Math.max(strokes.bottom, strokes.right) + ], + ['bottom-left', touches.bottom && touches.left, Math.max(strokes.bottom, strokes.left)] + ] as Array<[RoundedCorner, boolean, number]> + ).filter(([corner, touching, stroke]) => { + if (!touching) return false + const requiredRadius = Math.max(0, roundedCornerRadius(parent, corner) - stroke) + return ( + requiredRadius > CONTENT_OVERFLOW_TOLERANCE && + roundedCornerRadius(child, corner) + CONTENT_OVERFLOW_TOLERANCE < requiredRadius + ) + }) + if (!affected.length) continue + warnings.push({ + code: 'managed-rounded-stroke-occlusion', + key: childKey, + message: `"${childKey}" has a visible fill reaching the ${affected + .map(([corner]) => corner) + .join( + ' and ' + )} of rounded, stroked parent "${parentSpec.key}" without a corresponding inner radius. Figma clips to the outer frame but paints the parent stroke behind its children, so the fill can square off or hide that boundary. Inset the child, give its touching corners an inner radius that follows the parent, or add a dedicated foreground boundary; then inspect the rendered pixels.` + }) + } + } + return warnings +} + +function includedLayoutTrailingStroke(node: CanvasFrameContainerNode): number { + return includedLayoutEdgeStroke( + node, + node.layoutMode === 'HORIZONTAL' ? 'strokeRightWeight' : 'strokeBottomWeight' + ) +} + +function formatGeometry(value: number): string { + return `${Math.round(value * 10) / 10}px` +} + +function managedAutoLayoutInsetWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const spec of walkSpecs(root)) { + const node = state.keyedNodes.get(spec.key) + if ( + !node || + !isFrameContainer(node) || + (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') || + node.primaryAxisSizingMode === 'AUTO' || + node.primaryAxisAlignItems !== 'MIN' || + node.layoutWrap !== 'NO_WRAP' || + (node.layoutMode === 'HORIZONTAL' + ? spec.size.horizontal !== 'FIXED' + : spec.size.vertical !== 'FIXED') || + Math.abs(node.rotation) > GEOMETRY_TOLERANCE + ) { + continue + } + const children = node.children.filter( + (child) => + child.visible !== false && + (!('layoutPositioning' in child) || child.layoutPositioning !== 'ABSOLUTE') + ) + if ( + !children.length || + children.some( + (child) => + ('layoutGrow' in child && child.layoutGrow > 0) || + ('rotation' in child && Math.abs(child.rotation) > GEOMETRY_TOLERANCE) + ) + ) { + continue + } + const bounds = finiteRect(node.absoluteBoundingBox) + const childBounds = children.map((child) => finiteRect(child.absoluteBoundingBox)) + if (!bounds || childBounds.some((child) => !child)) continue + const horizontal = node.layoutMode === 'HORIZONTAL' + const childEnd = Math.max( + ...childBounds.map((child) => + horizontal ? child!.x + child!.width : child!.y + child!.height + ) + ) + const actual = (horizontal ? bounds.x + bounds.width : bounds.y + bounds.height) - childEnd + const padding = horizontal ? node.paddingRight : node.paddingBottom + const expected = padding + includedLayoutTrailingStroke(node) + if (Math.abs(actual - expected) <= CONTENT_OVERFLOW_TOLERANCE) continue + const side = horizontal ? 'right' : 'bottom' + const diagnosis = + actual > expected + ? `The fixed start-aligned main axis leaves ${formatGeometry(actual - expected)} of unmodeled trailing space.` + : `Resolved content consumes ${formatGeometry(expected - actual)} of the intended trailing inset.` + warnings.push({ + code: 'managed-auto-layout-inset-mismatch', + key: spec.key, + message: `"${spec.key}" resolves to a ${formatGeometry(actual)} ${side} inset after its last in-flow child, while its padding and included inside stroke account for ${formatGeometry(expected)}. ${diagnosis} Use a hugging main axis or resize after content resolves; if the empty space is intentional, express it with main-axis alignment or a growing spacer.` + }) + } + return 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: buildVerification() + } +} + +function pageApplyResult(page: CanvasPageSnapshot, state: ApplyState): ApplyCanvasResult { + return boundedApplyResult({ + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [...state.createdNodeIds], + updatedNodeIds: [...state.updatedNodeIds], + removedNodeIds: [], + page, + mutationCount: state.mutations.count, + verification: buildVerification() + }) +} + +function boundedApplyResult(result: ApplyCanvasResult): ApplyCanvasResult { + const bytes = measureCallToolResultBytes(buildApplyCanvasToolResult(result)) + if (bytes > MCP_TOOL_INLINE_BUDGET_BYTES - MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES) { + specError( + 'apply_canvas result exceeds the 64 KiB inline budget. Reduce the desired subtree or split the operation.' + ) + } + return result +} + +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 (parent && spec.absoluteOffsets) { + const { right, bottom } = spec.absoluteOffsets + if ( + (right !== undefined && + Math.abs(parent.width - node.x - node.width - right) > GEOMETRY_TOLERANCE) || + (bottom !== undefined && + Math.abs(parent.height - node.y - node.height - bottom) > GEOMETRY_TOLERANCE) + ) { + specError(`Verification failed for "${spec.key}": absolute edge placement does not match.`) + } + } + if (!isIntrinsicNode(node) && supportsLayoutSizing(node, parent)) { + 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 (declared horizontal=${spec.size.horizontal}, vertical=${spec.size.vertical}, grow=${spec.grow ?? false}; applied horizontal=${node.layoutSizingHorizontal}, vertical=${node.layoutSizingVertical}, grow=${node.layoutGrow === 1}).` + ) + } + } + + 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 = crossAxisFill(node, spec, parent) + if (fill) { + const actual = fill.axis === 'horizontal' ? node.width : node.height + if (actual < 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.`) + } + if ( + node.characters.length > 0 && + node.textAutoResize === 'HEIGHT' && + node.layoutGrow > 0 && + node.width <= GEOMETRY_TOLERANCE && + (node.minWidth ?? 0) <= GEOMETRY_TOLERANCE + ) { + specError( + `Verification failed for "${spec.key}": growing text collapsed to zero width; provide a positive min-width.` + ) + } +} + +function componentLinkMatches( + spec: CanvasNodeSpec, + node: InstanceNode, + expected: ComponentNode, + actual: ComponentNode | null, + state: ApplyState +): boolean { + if (actual?.id === expected.id) return true + const componentSet = expected.parent + if ( + !actual || + componentSet?.type !== 'COMPONENT_SET' || + actual.parent?.type !== 'COMPONENT_SET' || + actual.parent.id !== componentSet.id + ) { + return false + } + + const variantProperties = Object.entries(spec.componentProperties ?? {}).flatMap( + ([key, value]) => { + const name = componentPropertyName(componentSet, key, state) ?? key + return componentSet.componentPropertyDefinitions[name]?.type === 'VARIANT' + ? ([[name, value]] as const) + : [] + } + ) + return ( + variantProperties.length > 0 && + variantProperties.every(([name, value]) => { + const applied = node.componentProperties[name] + return isComponentPropertyVariable(value) + ? applied?.boundVariables?.value?.id === + resolvedVariable(value.variable, state.variables).id + : applied?.value === value && applied.boundVariables?.value === undefined + }) + ) +} + +async function verifyInstanceState( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState +): Promise { + if (!spec.componentProperties && !spec.figma?.instance) return + if (node.type !== 'INSTANCE') { + specError(`Verification failed for "${spec.key}": expected an instance.`) + } + const instance = spec.figma?.instance + if ( + instance?.scaleFactor !== undefined && + Math.abs(node.scaleFactor - instance.scaleFactor) > GEOMETRY_TOLERANCE + ) { + specError(`Verification failed for "${spec.key}": instance scale does not match.`) + } + if (instance?.exposed !== undefined && node.isExposedInstance !== instance.exposed) { + specError(`Verification failed for "${spec.key}": instance exposure does not match.`) + } + if (!spec.componentProperties) return + const component = await getMainComponent(node) + if (!component) { + specError(`Verification failed for "${spec.key}": instance has no main component.`) + } + const owner = componentDefinitionOwner(component) + for (const [key, value] of Object.entries(spec.componentProperties)) { + const name = componentPropertyName(owner, key, state) ?? key + const applied = node.componentProperties[name] + const matches = isComponentPropertyVariable(value) + ? applied?.boundVariables?.value?.id === resolvedVariable(value.variable, state.variables).id + : applied?.value === value && applied.boundVariables?.value === undefined + if (!matches) { + specError( + `Verification failed for "${spec.key}": component property "${name}" does not match.` + ) + } + } +} + +function verifyNativeNodeState( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState +): number { + let fields = 0 + for (const [property, styleProperty] of [ + ['fills', 'fillStyleId'], + ['strokes', 'strokeStyleId'] + ] as const) { + const paints = spec.figma?.[property] + if (paints === undefined) continue + fields += 1 + if (!('fills' in node)) { + specError(`Verification failed for "${spec.key}": direct ${property} are unsupported.`) + } + const desired = paints.map((paint) => nativePaint(paint, state)) + const current = node[property] + if (current === figma.mixed || !!node[styleProperty] || !paintStacksEqual(current, desired)) { + specError(`Verification failed for "${spec.key}": direct ${property} do not match.`) + } + } + + const effects = spec.figma?.effects + if (effects !== undefined) { + fields += 1 + if (!('effects' in node)) { + specError(`Verification failed for "${spec.key}": direct effects are unsupported.`) + } + const desired = effects.map((effect) => nativeEffect(effect, state)) + if (node.effectStyleId) { + specError( + `Verification failed for "${spec.key}": expected direct effects, but effect style "${node.effectStyleId}" remains applied.` + ) + } + if (!effectsEqual(node.effects, desired)) { + specError( + `Verification failed for "${spec.key}": direct ${describeEffectMismatch(node.effects, desired)}` + ) + } + } + + const layoutGrids = spec.figma?.layoutGrids + if (layoutGrids !== undefined) { + fields += 1 + if ((!isFrameContainer(node) && node.type !== 'INSTANCE') || !('layoutGrids' in node)) { + specError(`Verification failed for "${spec.key}": layout grids are unsupported.`) + } + const desired = layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) + if (!!node.gridStyleId || !layoutGridsEqual(node.layoutGrids, desired)) { + specError(`Verification failed for "${spec.key}": layout grids do not match.`) + } + } + + const guides = spec.figma?.guides + if (guides !== undefined) { + fields += 1 + if ((!isFrameContainer(node) && node.type !== 'INSTANCE') || !('guides' in node)) { + specError(`Verification failed for "${spec.key}": guides are unsupported.`) + } + if (!nativeValueEqual(node.guides, guides)) { + specError(`Verification failed for "${spec.key}": guides do not match.`) + } + } + + return fields +} + +async function verifyAppliedNode( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState, + parent?: SupportedCanvasNode +): Promise<{ nodes: number; references: number; nativeFields: 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 (readOwnedNodeKey(node) !== 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 + let nativeFields = verifyNativeNodeState(spec, node, state) + if (spec.component) { + const expected = resolvedComponent(spec.component, state) + const actual = node.type === 'INSTANCE' ? await getMainComponent(node) : null + references += 1 + if (node.type !== 'INSTANCE' || !componentLinkMatches(spec, node, expected, actual, state)) { + specError(`Verification failed for "${spec.key}": component link does not match.`) + } + } + await verifyInstanceState(spec, node, state) + 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) { + nativeFields += 1 + 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) { + nativeFields += 1 + 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 = state.keyedNodes.get(child.key) ?? null + 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 + nativeFields += verified.nativeFields + } + return { nodes, references, nativeFields } +} + +async function verifyRollbackProtectedNodes(state: ApplyState): Promise { + const protectedEntries = [...state.protectedNodes] + const protectedIds = protectedEntries.map(([id]) => id) + const nodes = await Promise.all(protectedIds.map(lookupNodeById)) + const missing = protectedIds.filter((_, index) => !nodes[index]) + if (missing.length) { + const shown = missing.slice(0, 8) + const suffix = missing.length > shown.length ? ` and ${missing.length - shown.length} more` : '' + throw new Error( + `Rollback did not preserve pre-existing node${missing.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` + ) + } + + const changed = protectedEntries + .filter(([, expected], index) => { + const node = nodes[index] + return expected && node && !nativeValueEqual(snapshotProtectedNode(node), expected) + }) + .map(([id]) => id) + if (!changed.length) return + const shown = changed.slice(0, 8) + const suffix = changed.length > shown.length ? ` and ${changed.length - shown.length} more` : '' + throw new Error( + `Rollback changed pre-existing node${changed.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` + ) +} + +async function removeRollbackCreatedNodes(state: ApplyState): Promise { + const created = ( + await Promise.all([...state.createdNodeIds].map((id) => lookupNodeById(id))) + ).filter(isSupportedSceneNode) + if (!created.length) return + + const liveIds = new Set(created.map((node) => node.id)) + const outermost = created.filter((node) => { + let parent = node.parent + while (parent) { + if (liveIds.has(parent.id)) return false + parent = parent.parent + } + return true + }) + for (const node of outermost) node.remove() + + const remaining = ( + await Promise.all([...state.createdNodeIds].map((id) => lookupNodeById(id))) + ).filter(isSupportedSceneNode) + if (!remaining.length) return + const shown = remaining.slice(0, 8).map((node) => node.id) + const suffix = + remaining.length > shown.length ? ` and ${remaining.length - shown.length} more` : '' + throw new Error( + `Rollback did not remove created node${remaining.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` + ) +} + +async function removeRollbackCreatedPages( + state: ApplyState, + previousPage: PageNode +): Promise { + const created = figma.root.children.filter((page) => state.createdPageIds.has(page.id)) + if (state.createdPageIds.has(figma.currentPage.id) && !previousPage.removed) { + await figma.setCurrentPageAsync(previousPage) + } + if (!created.length) return + for (const page of created) { + if (!page.removed) page.remove() + } + const remaining = figma.root.children.filter((page) => state.createdPageIds.has(page.id)) + if (remaining.length) { + throw new Error(`Rollback did not remove created page ${remaining[0]!.id}`) + } +} + +async function withUndoBoundary(apply: () => Promise, state: ApplyState): Promise { + const previousPage = figma.currentPage + try { + if (state.scope) protectUnrelatedPageRoots(state, containingPage(state.scope)) + figma.commitUndo() + const result = await apply() + figma.commitUndo() + return result + } catch (error) { + const readOnly = canvasReadOnlyError(error) + if (state.mutations.count > 0) { + try { + // Make this partial attempt the newest history entry before undoing. In a long-lived + // plugin session, the preceding entry may be an earlier successful apply. + figma.commitUndo() + figma.triggerUndo() + // Figma's undo boundary is authoritative for existing content and resources. Remove any + // newly created nodes that remain visible after the undo so a failed apply cannot leave + // partial component or screen roots behind in a long-lived MCP session. + await removeRollbackCreatedNodes(state) + await removeRollbackCreatedPages(state, previousPage) + await verifyRollbackProtectedNodes(state) + } catch (rollbackError) { + if (readOnly) throw readOnly + const rollbackMessage = errorMessage(rollbackError) + const detail = rollbackMessage ? ` ${rollbackMessage}.` : '' + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + `Canvas apply failed and automatic rollback was not available.${detail} Use Figma Undo.` + ) + } + } + if (readOnly) throw readOnly + throw error + } +} + +async function resolveExactPage(properties: CanvasPageProperties): Promise { + const explicit = properties.id ? pageById(properties.id) : undefined + if (properties.id && !explicit) specError(`Page "${properties.id}" does not exist.`) + const keyed = properties.pageKey ? pageByKey(properties.pageKey) : undefined + if (properties.pageKey && !keyed) { + specError(`Page key "${properties.pageKey}" does not identify a local page.`) + } + if (explicit && keyed && explicit.id !== keyed.id) { + specError(`Page key "${properties.pageKey}" does not identify "${explicit.id}".`) + } + const page = explicit ?? keyed + if (!page) specError('An exact page id or pageKey is required.') + if (page.id !== figma.currentPage.id) await page.loadAsync() + return page +} + +async function resolvePageSelection(page: PageNode, ids: string[]): Promise { + const nodes: SceneNode[] = [] + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) specError(`Selection node "${id}" is duplicated.`) + seen.add(id) + const node = await lookupNodeById(id) + if (!isSceneNode(node) || node.removed || !node.visible) { + scopeError(`Selection node "${id}" does not exist, is hidden, or is not a scene node.`) + } + if (containingPage(node).id !== page.id) { + scopeError(`Selection node "${id}" does not belong to page "${page.id}".`) + } + nodes.push(node) + } + return nodes +} + +function collectPageRemovalRoots(page: PageNode, state: ApplyState): SupportedCanvasNode[] { + const roots: SupportedCanvasNode[] = [] + for (const child of page.children) { + if (!isSupportedSceneNode(child)) { + scopeError(`Page "${page.id}" contains an unsupported canvas root.`) + } + roots.push(child) + for (const node of walkRemovalOwnershipNodes([child], state)) { + if (!isSupportedSceneNode(node)) { + scopeError(`Page "${page.id}" contains an unsupported canvas node.`) + } + const key = readOwnedNodeKey(node) + if (!key) { + scopeError(`Page "${page.id}" contains content not owned by apply_canvas.`) + } + const existing = state.keyedNodes.get(key) + if (existing && existing.id !== node.id) { + scopeError(`Canvas key "${key}" is duplicated on page "${page.id}".`) + } + state.keyedNodes.set(key, node) + } + } + for (const root of roots) { + validateRemovalAncestors(root) + validateRemovalOwnership(root, state) + } + return roots +} + +async function createPageOnly(input: ParsedCanvasPageInput): Promise { + if (pageByKey(input.page.pageKey!)) { + specError(`Page key "${input.page.pageKey}" already identifies a local page.`) + } + const state = createApplyState(null, new Set()) + return withUndoBoundary(async () => { + const { created, page } = await resolveResultPage(input.page, null, state) + if (!created) specError('Page-only create requires a new pageKey.') + await preflightVariableModes(input.page.variableModes, state) + applyPage(page, input.page, state) + await figma.setCurrentPageAsync(page) + page.selection = [] + if (figma.currentPage.id !== page.id || page.selection.length !== 0) { + specError( + `Verification failed: page "${page.id}" is not the active page with empty selection.` + ) + } + return pageApplyResult(pageSnapshot(page), state) + }, state) +} + +async function updatePageOnly(input: ParsedCanvasPageInput): Promise { + const page = await resolveExactPage(input.page) + const state = createApplyState(null, new Set()) + protectUnrelatedPageRoots(state, page) + return withUndoBoundary(async () => { + await preflightVariableModes(input.page.variableModes, state) + applyPage(page, input.page, state) + return pageApplyResult(pageSnapshot(page), state) + }, state) +} + +async function activatePage(input: ParsedCanvasPageInput): Promise { + const page = await resolveExactPage(input.page) + const selection = + input.selection === undefined ? undefined : await resolvePageSelection(page, input.selection) + await figma.setCurrentPageAsync(page) + if (selection) page.selection = selection + if (figma.currentPage.id !== page.id) { + specError(`Verification failed: page "${page.id}" is not active.`) + } + if ( + selection && + (page.selection.length !== selection.length || + page.selection.some((node, index) => node.id !== selection[index]?.id)) + ) { + specError(`Verification failed: page "${page.id}" selection does not match.`) + } + return pageApplyResult(pageSnapshot(page), createApplyState(null, new Set())) +} + +async function removePage(input: ParsedCanvasPageInput): Promise { + const page = await resolveExactPage(input.page) + const ownedKey = page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) + if (!ownedKey || ownedKey !== input.page.pageKey) { + scopeError(`Page "${page.id}" is not owned by pageKey "${input.page.pageKey}".`) + } + if (figma.root.children.length <= 1) { + scopeError('The last Figma page cannot be removed.') + } + const state = createApplyState(null, new Set()) + const roots = collectPageRemovalRoots(page, state) + await validateRemovalComponents(roots) + await validateRemovalReferences(roots, state) + const previousPage = figma.currentPage + const fallback = figma.root.children.find((candidate) => candidate.id !== page.id)! + const snapshot = pageSnapshot(page, { active: false, removed: true, selectionCount: 0 }) + const removedNodeIds = roots.flatMap((root) => + [...walkPhysicalNodes([root])].map((node) => node.id) + ) + + try { + return await withUndoBoundary(async () => { + if (figma.currentPage.id === page.id) await figma.setCurrentPageAsync(fallback) + page.remove() + state.mutations.count += 1 + if (figma.root.children.some((candidate) => candidate.id === page.id)) { + specError(`Verification failed: page "${page.id}" is still present.`) + } + return boundedApplyResult({ + nodeIdsByKey: {}, + createdNodeIds: [], + updatedNodeIds: [], + removedNodeIds, + page: snapshot, + mutationCount: state.mutations.count, + verification: buildVerification() + }) + }, state) + } catch (error) { + if (!previousPage.removed && figma.currentPage.id !== previousPage.id) { + try { + await figma.setCurrentPageAsync(previousPage) + } catch { + // Preserve the apply error; rollback state remains authoritative. + } + } + throw error + } +} + +async function reconcilePageOperation(input: ParsedCanvasPageInput): Promise { + if (input.mode === 'create') return createPageOnly(input) + if (input.mode === 'update') return updatePageOnly(input) + if (input.mode === 'remove') return removePage(input) + return activatePage(input) +} + +async function removeUpdateRoot(targetNodeId: string): Promise { + const candidate = await lookupNodeById(targetNodeId) + if (!candidate) return removedRootResult(targetNodeId) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested removal target is not a supported scene node.') + } + assertOutsideInstance(candidate) + + const state = createApplyState(candidate, new Set()) + validateRemovalAncestors(candidate) + validateRemovalOwnership(candidate, state) + state.removalNodeIds.add(candidate.id) + const parent = candidate.parent + + return withUndoBoundary(async () => { + const removedNodeIds = await applyRemovals([candidate], state) + if ( + !candidate.removed && + (!parent || + !('children' in parent) || + parent.children.some((child) => child.id === candidate.id)) + ) { + specError(`Verification failed: root "${candidate.id}" is still present.`) + } + return boundedApplyResult(removedRootResult(candidate.id, removedNodeIds, state)) + }, state) +} + +function preservedSizingMode(value: unknown): CanvasSizingMode { + return value === 'FILL' || value === 'HUG' ? value : 'FIXED' +} + +function validateNativeUpdateBinding( + key: string, + binding: CanvasBinding, + node: SupportedCanvasNode +): void { + const properties = binding.figma + const declaredTypes: Array = [ + binding.component || binding.componentProperties || properties?.instance + ? 'INSTANCE' + : undefined, + properties?.text ? 'TEXT' : undefined, + properties?.shape?.type, + properties?.section ? 'SECTION' : undefined, + properties?.group ? 'GROUP' : undefined, + properties?.booleanOperation ? 'BOOLEAN_OPERATION' : undefined, + properties?.component?.type, + properties?.slot ? 'SLOT' : undefined, + properties?.svg ? 'FRAME' : undefined + ] + for (const type of declaredTypes) { + if (type !== undefined && type !== node.type) { + specError(`Native state on "${key}" requires ${type}, but the existing node is ${node.type}.`) + } + } + const requiredFields: Array<[unknown, string]> = [ + [properties?.stroke, 'strokeAlign'], + [properties?.stroke?.weights, 'strokeTopWeight'], + [properties?.stroke?.cap, 'strokeCap'], + [properties?.stroke?.miterLimit, 'strokeMiterLimit'], + [properties?.corners, 'cornerRadius'], + [properties?.corners?.radii, 'topLeftRadius'], + [properties?.corners?.smoothing, 'cornerSmoothing'], + [properties?.layoutGrids, 'layoutGrids'], + [properties?.guides, 'guides'], + [properties?.aspectRatioLocked, 'targetAspectRatio'] + ] + for (const [value, field] of requiredFields) { + if (value !== undefined && !(field in node)) { + specError(`Native field "${field}" is not supported on ${node.type} node "${key}".`) + } + } + if (properties?.text?.fontName) { + if (binding.variables?.fontFamily || binding.variables?.fontStyle) { + specError(`Font on "${key}" cannot use both variables and an exact Figma font name.`) + } + if (binding.styles?.text) { + specError(`Font on "${key}" cannot use both a Text style and an exact Figma font name.`) + } + } +} + +function nativeUpdateLayout( + key: string, + binding: CanvasBinding, + node: SupportedCanvasNode +): CanvasNodeSpec['layout'] { + const autoLayout = binding.figma?.autoLayout + if (!autoLayout) return + + if ( + !isFrameContainer(node) || + (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') + ) { + specError( + `Figma Auto Layout properties on "${key}" require an existing linear Auto Layout container.` + ) + } + if (autoLayout.counterAxisSpacing !== undefined && node.layoutWrap !== 'WRAP') { + specError( + `Figma counter-axis spacing on "${key}" requires an existing wrapping Auto Layout container.` + ) + } + if (autoLayout.counterAxisSpacing === null && binding.variables?.counterAxisSpacing) { + specError( + `Synchronized counter-axis spacing and a counter-axis variable cannot be combined on "${key}".` + ) + } + return { mode: node.layoutMode } +} + +function nativeUpdateSpec( + key: string, + binding: CanvasBinding, + node: SupportedCanvasNode +): CanvasNodeSpec { + validateNativeUpdateBinding(key, binding, node) + const layout = nativeUpdateLayout(key, binding, node) + const stroke = binding.figma?.stroke + const corners = binding.figma?.corners + const size: CanvasNodeSpec['size'] = { + width: node.width, + height: node.height, + horizontal: preservedSizingMode( + 'layoutSizingHorizontal' in node ? node.layoutSizingHorizontal : undefined + ), + vertical: preservedSizingMode( + 'layoutSizingVertical' in node ? node.layoutSizingVertical : undefined + ), + ...('minWidth' in node ? { minWidth: node.minWidth } : {}), + ...('maxWidth' in node ? { maxWidth: node.maxWidth } : {}), + ...('minHeight' in node ? { minHeight: node.minHeight } : {}), + ...('maxHeight' in node ? { maxHeight: node.maxHeight } : {}) + } + return { + key, + type: node.type, + ...(binding.figma?.name !== undefined ? { displayName: binding.figma.name } : {}), + size, + ...(layout ? { layout } : {}), + ...(stroke || corners + ? { + appearance: { + strokeWeight: stroke?.weight, + strokeTopWeight: stroke?.weights?.top, + strokeRightWeight: stroke?.weights?.right, + strokeBottomWeight: stroke?.weights?.bottom, + strokeLeftWeight: stroke?.weights?.left, + cornerRadius: corners?.radius, + topLeftRadius: corners?.radii?.topLeft, + topRightRadius: corners?.radii?.topRight, + bottomRightRadius: corners?.radii?.bottomRight, + bottomLeftRadius: corners?.radii?.bottomLeft + } + } + : {}), + ...('layoutGrow' in node ? { grow: node.layoutGrow > 0 } : {}), + ...(node.type === 'TEXT' + ? { + text: { + characters: node.characters, + autoResize: node.textAutoResize, + ...(binding.figma?.text?.fontName + ? { + fontFamily: binding.figma.text.fontName.family, + fontStyle: binding.figma.text.fontName.style + } + : {}), + ...(binding.figma?.text?.verticalAlign !== undefined + ? { alignVertical: binding.figma.text.verticalAlign } + : {}) + } + } + : {}), + ...binding + } +} + +function nativeUpdateParent(node: SupportedCanvasNode): CanvasParentNode | undefined { + const parent = node.parent + if (!parent || parent.type === 'DOCUMENT' || parent.type === 'INSTANCE') return undefined + return parent as CanvasParentNode +} + +async function reconcileNativeUpdate( + input: ParsedCanvasNativeUpdateInput +): Promise { + const candidate = await lookupNodeById(input.targetNodeId) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested update target does not exist or is not a supported scene node.') + } + assertOutsideInstance(candidate) + if (!readOwnedNodeKey(candidate)) { + scopeError('A markup-less native update requires an exact managed root.') + } + + const keyedNodes = collectKeyedNodes(candidate) + const specsByKey = new Map() + for (const [key, binding] of Object.entries(input.bindings)) { + const node = keyedNodes.get(key) + if (!node) scopeError(`Canvas key "${key}" does not exist inside the update scope.`) + assertOutsideInstance(node) + if (binding.figma?.mask !== undefined) { + specError( + `Mask state on "${key}" requires a structural markup update that describes its sibling scope.` + ) + } + specsByKey.set(key, nativeUpdateSpec(key, binding, node)) + } + // Apply owners before sublayers, regardless of the binding order. + const specs: CanvasNodeSpec[] = [] + for (const key of keyedNodes.keys()) { + const spec = specsByKey.get(key) + if (spec) specs.push(spec) + } + const assets = await resolveCanvasAssets(input.assets, collectSvgColorsFromSpecs(specs)) + const state = createApplyState(candidate, new Set(Object.keys(input.bindings)), assets) + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + state.nodeIdsByKey[spec.key] = node.id + } + + return withUndoBoundary(async () => { + await reconcileVariableCollections(input.variableCollections, state.variables, state.mutations) + await prepareStyleResources(input.styles, state.styles, state.mutations) + await preflightStyleResources(state) + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + preflightContainers(spec, state, node) + const owner = componentPropertyOwner(node) + const inherited = owner + ? { existing: owner, spec: specsByKey.get(readOwnedNodeKey(owner) ?? '') } + : undefined + await preflightResources(spec, state, node, inherited) + } + await resolveImageUrls(state) + resolveImageAssets(state) + await resolveVideoUrls(state) + applyStyleResources(state) + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + await applyNodeProperties(node, spec, state, nativeUpdateParent(node)) + } + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + await applyCanvasKeyReferences(spec, state, nativeUpdateParent(node)) + } + await removeStyleResources(state.styles, state.mutations) + await removeVariableResources(state.variables, state.mutations) + + const verified = { nodes: 0, references: 0, nativeFields: 0 } + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + const result = await verifyAppliedNode( + spec, + node, + state, + isSupportedSceneNode(node.parent) ? node.parent : undefined + ) + verified.nodes += result.nodes + verified.references += result.references + verified.nativeFields += result.nativeFields + } + const warnings = [ + ...unboundCreatedResourceWarnings([...specs, input.styles, input.variableCollections], state), + ...authoredVariableFallbackWarnings(specs, input.variableCollections), + ...specs.flatMap((spec) => layoutAffectingVisibilityWarnings(spec, state)), + ...specs.flatMap((spec) => managedContentOverflowWarnings(spec, state)), + ...specs.flatMap((spec) => managedRoundedStrokeOcclusionWarnings(spec, state)), + ...specs.flatMap((spec) => managedAutoLayoutInsetWarnings(spec, state)) + ] + return boundedApplyResult({ + rootNodeId: candidate.id, + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [], + updatedNodeIds: [...state.updatedNodeIds], + removedNodeIds: [], + page: pageSnapshot(containingPage(candidate)), + mutationCount: state.mutations.count, + verification: buildVerification( + verified.nodes, + verified.references, + verified.nativeFields, + warnings + ) + }) + }, state) +} + +export async function reconcileCanvas(input: ParsedCanvasInput): Promise { + if ('bindings' in input) return reconcileNativeUpdate(input) + if (!('root' in input)) return reconcilePageOperation(input) + if (input.root === null) return removeUpdateRoot(input.targetNodeId) + + const rootSpec = input.root + let target: SupportedCanvasNode | null = null + if (input.mode === 'update') { + const candidate = await lookupNodeById(input.targetNodeId!) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested update target does not exist or is not a supported scene node.') + } + assertOutsideInstance(candidate) + target = candidate + } + if ( + target && + rootSpec.type === 'FRAME' && + (target.type === 'COMPONENT' || target.type === 'COMPONENT_SET') + ) { + rootSpec.type = target.type + } + if (target && target.type !== rootSpec.type) { + const recovery = + rootSpec.type === 'INSTANCE' + ? ' A keyed update root cannot become an instance in place. Update a bounded ancestor instead, give the instance a new key, and remove the old keyed node in the same ancestor update.' + : ' A keyed update root cannot change type in place. Update a bounded ancestor instead, give the replacement a new key, and remove the old keyed node in the same ancestor update.' + specError( + `The update root expects ${rootSpec.type}, but target "${target.id}" is ${target.type}.${recovery}` + ) + } + const assets = await resolveCanvasAssets(input.assets, collectSvgColors(rootSpec)) + const state = createApplyState(target, collectDesiredKeys(rootSpec), assets) + const removalNodes = resolveRemovalNodes(input, state) + + return withUndoBoundary(async () => { + await resolveExplicitNodes(rootSpec, state) + preflightExistingNodeIdentities(rootSpec, state, target) + const { created: createdPage, 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') { + placeCreatedRoot(root, page, state) + } + applyMask(root, rootSpec, state) + const removedNodeIds = await applyRemovals(removalNodes, state) + finalizeGridsAfterRemovals(rootSpec, state) + finalizeAbsolutePositions(rootSpec, state) + await removeStyleResources(state.styles, state.mutations) + await removeVariableResources(state.variables, state.mutations) + const verified = await verifyAppliedNode(rootSpec, root, state) + if (createdPage) { + await figma.setCurrentPageAsync(page) + page.selection = [] + } + const warnings = [ + ...unboundCreatedResourceWarnings( + [rootSpec, input.styles, input.variableCollections, input.page], + state + ), + ...authoredVariableFallbackWarnings(walkSpecs(rootSpec), input.variableCollections), + ...layoutAffectingVisibilityWarnings(rootSpec, state), + ...managedContentOverflowWarnings(rootSpec, state), + ...managedRoundedStrokeOcclusionWarnings(rootSpec, state), + ...managedAutoLayoutInsetWarnings(rootSpec, state) + ] + return boundedApplyResult({ + rootNodeId: root.id, + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [...state.createdNodeIds], + updatedNodeIds: [...state.updatedNodeIds], + removedNodeIds, + page: pageSnapshot(page), + mutationCount: state.mutations.count, + verification: buildVerification( + verified.nodes, + verified.references, + verified.nativeFields, + warnings + ) + }) + }, state) +} diff --git a/packages/extension/mcp/tools/canvas/resolve.ts b/packages/extension/mcp/tools/canvas/resolve.ts new file mode 100644 index 00000000..98c4d56f --- /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: inputModes, figma: inputFigma, ...direct } = binding + const variableModes = inputModes + ? Object.fromEntries( + Object.entries(inputModes).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 { + ...direct, + ...(variableModes ? { variableModes } : {}), + ...(inputFigma ? { figma: resolveDeep(inputFigma, 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.theme === undefined ? {} : { theme: input.theme }), + ...(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) }), + ...(input.selection === undefined ? {} : { selection: input.selection }) + } + 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..e714f1c0 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/styles.ts @@ -0,0 +1,162 @@ +import type { CanvasStyleReference, CanvasStyleResource, CanvasStyles } from '@tempad-dev/shared' + +import { getLocalStyles, getStyleById } from '../../local-resources' +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 + createdStyleKeys: Set + 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 getStyleById(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 + const isNew = !style + 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) + if (isNew) state.createdStyleKeys.add(key) + return style +} + +export function createStyleState(): CanvasStyleState { + return { + byKey: new Map(), + cache: new Map(), + createdStyleKeys: new Set(), + 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 getStyleById(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..caf99585 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/tailwind.ts @@ -0,0 +1,1248 @@ +import type { CanvasFigmaEffect, CanvasFigmaPaint } from '@tempad-dev/shared' + +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 +const LINEAR_GRADIENT_TRANSFORMS = { + t: [ + [0, -1, 1], + [1, 0, 0] + ], + tr: [ + [0.5, -0.5, 0.5], + [0.5, 0.5, 0] + ], + r: [ + [1, 0, 0], + [0, 1, 0] + ], + br: [ + [0.5, 0.5, 0], + [-0.5, 0.5, 0.5] + ], + b: [ + [0, 1, 0], + [-1, 0, 1] + ], + bl: [ + [-0.5, 0.5, 0.5], + [-0.5, -0.5, 1] + ], + l: [ + [-1, 0, 1], + [0, -1, 1] + ], + tl: [ + [-0.5, -0.5, 1], + [0.5, -0.5, 0.5] + ] +} as const satisfies Record +type CanvasShadowEffect = Extract + +const SHADOW_FAMILIES = { + shadow: { field: 'boxShadows', options: {} }, + 'inset-shadow': { + field: 'insetShadows', + options: { type: 'INNER_SHADOW' as const } + }, + 'text-shadow': { + field: 'textShadows', + options: { type: 'DROP_SHADOW' as const, text: true } + } +} 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_FAMILY_CLASSES = { + 'font-mono': 'Noto Sans Mono', + 'font-sans': 'Inter', + 'font-serif': 'Noto Serif' +} as const +type PortableFontFamily = 'mono' | 'sans' | 'serif' +const PORTABLE_FONT_STYLE_ALIASES = { + Inter: { + ExtraBold: 'Extra Bold', + ExtraLight: 'Extra Light', + SemiBold: 'Semi Bold' + }, + 'Noto Sans Mono': { + 'Extra Bold': 'ExtraBold', + 'Extra Light': 'ExtraLight', + 'Semi Bold': 'SemiBold' + }, + 'Noto Serif': { + 'Extra Bold': 'ExtraBold', + 'Extra Light': 'ExtraLight', + 'Semi Bold': 'SemiBold' + } +} as const +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 + right?: number + top?: number + bottom?: number + fill?: `#${string}` | null + fillPaints?: CanvasFigmaPaint[] + stroke?: `#${string}` + strokeWeight?: number + strokeWeights: Partial> + cornerRadius?: number + cornerRadii: Partial> + clipsContent?: boolean + opacity?: number + visible?: boolean + blendMode?: BlendMode + rotation?: number + boxShadows?: CanvasShadowEffect[] + insetShadows?: CanvasShadowEffect[] + textShadows?: CanvasShadowEffect[] + fontFamily?: string + fontStyleMatching?: true + portableFontFamily?: PortableFontFamily + 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 + assignedTokens: Map +} + +export function normalizePortableFontStyle(fontFamily: string, fontStyle: string): string { + const aliases = PORTABLE_FONT_STYLE_ALIASES[ + fontFamily as keyof typeof PORTABLE_FONT_STYLE_ALIASES + ] as Record | undefined + return aliases?.[fontStyle] ?? fontStyle +} + +function classError(message: string): never { + throw new Error(message) +} + +const MAX_UNSUPPORTED_CLASS_SCAN = 16 +const UNSUPPORTED_CLASS_MESSAGE = /^Unsupported class "([^"]+)"\.(?: .*)?$/ +const SHRINK_FAMILY_GUIDANCE = + 'Canvas does not support shrink utilities; remove the class instead of trying another spelling.' + +export function unsupportedCanvasClassGuidance(tokens: Iterable): string | null { + for (const token of tokens) { + if (/^shrink(?:-|$)/.test(token)) return SHRINK_FAMILY_GUIDANCE + } + return null +} + +export function findUnsupportedCanvasClasses( + value: string, + limit = MAX_UNSUPPORTED_CLASS_SCAN +): { classes: string[]; truncated: boolean } { + let remaining = value.trim() ? value.trim().split(/\s+/) : [] + const classes: string[] = [] + + while (remaining.length && classes.length < limit) { + try { + parseCanvasClasses(remaining.join(' ')) + return { classes, truncated: false } + } catch (error) { + const match = error instanceof Error ? UNSUPPORTED_CLASS_MESSAGE.exec(error.message) : null + const token = match?.[1] + if (!token || classes.includes(token) || !remaining.includes(token)) { + return { classes, truncated: false } + } + classes.push(token) + remaining = remaining.filter((candidate) => candidate !== token) + } + } + + if (!remaining.length) return { classes, truncated: false } + try { + parseCanvasClasses(remaining.join(' ')) + return { classes, truncated: false } + } catch (error) { + return { + classes, + truncated: error instanceof Error && UNSUPPORTED_CLASS_MESSAGE.test(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 splitShadowValue(value: string, separator: ',' | ' '): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + for (let index = 0; index < value.length; index += 1) { + const character = value[index]! + if (character === '(') depth += 1 + else if (character === ')') depth -= 1 + else if (depth === 0 && (separator === ',' ? character === ',' : /\s/.test(character))) { + const part = value.slice(start, index).trim() + if (part) parts.push(part) + start = index + 1 + } + } + const part = value.slice(start).trim() + if (part) parts.push(part) + return parts +} + +function cssChannel(raw: string): number | null { + if (!raw) return null + const percentage = /^(\d+(?:\.\d+)?|\.\d+)%$/.exec(raw) + const value = percentage ? Number(percentage[1]) / 100 : Number(raw) / 255 + return Number.isFinite(value) && value >= 0 && value <= 1 ? value : null +} + +function cssAlpha(raw: string): number | null { + if (!raw) return null + const percentage = /^(\d+(?:\.\d+)?|\.\d+)%$/.exec(raw) + const value = percentage ? Number(percentage[1]) / 100 : Number(raw) + return Number.isFinite(value) && value >= 0 && value <= 1 ? value : null +} + +function cssColor(raw: string): RGBA | null { + if (raw === 'black') return { r: 0, g: 0, b: 0, a: 1 } + if (raw === 'white') return { r: 1, g: 1, b: 1, a: 1 } + if (raw === 'transparent') return { r: 0, g: 0, b: 0, a: 0 } + + const hex = /^#([\dA-Fa-f]{3}|[\dA-Fa-f]{4}|[\dA-Fa-f]{6}|[\dA-Fa-f]{8})$/.exec(raw) + if (hex) { + const compact = hex[1]! + const expanded = + compact.length < 6 ? [...compact].map((character) => character.repeat(2)).join('') : compact + return { + r: Number.parseInt(expanded.slice(0, 2), 16) / 255, + g: Number.parseInt(expanded.slice(2, 4), 16) / 255, + b: Number.parseInt(expanded.slice(4, 6), 16) / 255, + a: expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1 + } + } + + const functional = /^rgba?\((.*)\)$/.exec(raw) + if (!functional) return null + const body = functional[1]!.trim() + let channels: string[] + let alpha = '1' + if (body.includes(',')) { + const parts = body.split(',').map((part) => part.trim()) + if (parts.length !== 3 && parts.length !== 4) return null + channels = parts.slice(0, 3) + alpha = parts[3] ?? alpha + } else { + const [channelValue, alphaValue, ...rest] = body.split('/').map((part) => part.trim()) + if (rest.length || !channelValue || (!alphaValue && body.includes('/'))) return null + channels = channelValue.split(/\s+/) + alpha = alphaValue ?? alpha + } + if (channels.length !== 3) return null + const r = cssChannel(channels[0]!) + const g = cssChannel(channels[1]!) + const b = cssChannel(channels[2]!) + const a = cssAlpha(alpha) + return r === null || g === null || b === null || a === null ? null : { r, g, b, a } +} + +function shadowLength(raw: string): number | null { + if (/^-?0(?:\.0+)?$/.test(raw)) return 0 + const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))px$/.exec(raw) + return match ? Number(match[1]) : null +} + +function parseShadowEffects( + raw: string, + token: string, + options: { type?: CanvasShadowEffect['type']; text?: boolean } = {} +): CanvasShadowEffect[] { + const layers = splitShadowValue(raw.replaceAll('_', ' '), ',') + if (!layers.length) classError(`Invalid shadow class "${token}".`) + return layers.map((layer) => { + const values: number[] = [] + let color: RGBA | undefined + let inset = false + for (const part of splitShadowValue(layer, ' ')) { + if (part === 'inset') { + if (options.text || inset || options.type === 'DROP_SHADOW') { + classError(`Invalid shadow class "${token}".`) + } + inset = true + continue + } + const parsedColor = cssColor(part) + if (parsedColor) { + if (color) classError(`Shadow class "${token}" has more than one color per layer.`) + color = parsedColor + continue + } + const length = shadowLength(part) + if (length === null) classError(`Invalid shadow value "${part}" in class "${token}".`) + values.push(length) + } + + const maximum = options.text ? 3 : 4 + if (values.length < 2 || values.length > maximum || !color || (values[2] ?? 0) < 0) { + classError( + `Shadow class "${token}" requires a color and ${options.text ? 'two or three' : 'two to four'} px lengths per layer.` + ) + } + const type = options.type ?? (inset ? 'INNER_SHADOW' : 'DROP_SHADOW') + return { + type, + color, + offset: { x: values[0]!, y: values[1]! }, + radius: values[2] ?? 0, + ...(values[3] === undefined ? {} : { spread: values[3] }), + ...(options.text ? { showShadowBehindNode: true } : {}) + } as CanvasShadowEffect + }) +} + +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)) { + const previous = classes.assignedTokens.get(assignment) + classError( + `Class "${token}" conflicts${previous ? ` with "${previous}"` : ''} for ${assignment}.` + ) + } + } + for (const field of fields) { + const assignment = `${group}-${field}` + classes.assigned.add(assignment) + classes.assignedTokens.set(assignment, token) + values[field] = value + } +} + +function assign( + classes: CanvasClasses, + field: T, + value: CanvasClasses[T], + token: string +): void { + if (classes.assigned.has(field)) { + const previous = classes.assignedTokens.get(field) + const hint = + field === 'fill' + ? ' Use one fill class per node; a label with a background needs a parent div and a child span for its text color.' + : field === 'stroke' + ? ' Figma supports one stroke paint per node across its enabled sides; use one border color or separate edge layers for different side colors.' + : '' + classError( + `Class "${token}" conflicts${previous ? ` with "${previous}"` : ''} for ${field}.${hint}` + ) + } + classes.assigned.add(field) + classes.assignedTokens.set(field, token) + 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)) { + const previous = classes.assignedTokens.get(field) + classError(`Class "${token}" conflicts${previous ? ` with "${previous}"` : ''} for ${field}.`) + } + } + for (const side of sides) { + const field = `padding-${side}` + classes.assigned.add(field) + classes.assignedTokens.set(field, token) + 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(), + assignedTokens: new Map() + } + let defaultLineHeight: LineHeight | undefined + let gradientDirection: keyof typeof LINEAR_GRADIENT_TRANSFORMS | undefined + let gradientDirectionToken: string | undefined + let gradientFrom: RGBA | undefined + let gradientFromToken: string | undefined + let gradientVia: RGBA | undefined + let gradientViaToken: string | undefined + let gradientTo: RGBA | undefined + let gradientToToken: string | 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|right|top|bottom)-(.+)$/.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 'bottom' | 'left' | 'right' | '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' + let value = + boundedSize[3] === 'none' ? null : fixedSize(boundedSize[3]!, token, boundedSize[2] === 'w') + if (value === null && boundedSize[3] !== 'none') { + classError(`Unsupported class "${token}".`) + } + if (boundedSize[1] === 'min' && value === 0) value = null + 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 + } + const gradient = /^bg-(?:linear|gradient)-to-(t|tr|r|br|b|bl|l|tl)$/.exec(token) + if (gradient) { + if (gradientDirectionToken) { + classError( + `Class "${token}" conflicts with gradient direction class "${gradientDirectionToken}".` + ) + } + gradientDirection = gradient[1] as keyof typeof LINEAR_GRADIENT_TRANSFORMS + gradientDirectionToken = token + classes.frameClass ??= token + continue + } + const gradientStop = /^(from|via|to)-(.+)$/.exec(token) + if (gradientStop) { + const value = color(gradientStop[2]!) + const parsed = value ? cssColor(value) : null + if (!parsed) classError(`Unsupported gradient stop class "${token}".`) + const kind = gradientStop[1]! + if (kind === 'from') { + if (gradientFromToken) { + classError(`Class "${token}" conflicts with gradient stop class "${gradientFromToken}".`) + } + gradientFrom = parsed + gradientFromToken = token + } else if (kind === 'via') { + if (gradientViaToken) { + classError(`Class "${token}" conflicts with gradient stop class "${gradientViaToken}".`) + } + gradientVia = parsed + gradientViaToken = token + } else { + if (gradientToToken) { + classError(`Class "${token}" conflicts with gradient stop class "${gradientToToken}".`) + } + gradientTo = parsed + gradientToToken = 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 + } + + const shadow = /^(shadow|inset-shadow|text-shadow)-(?:\[(.+)\]|none)$/.exec(token) + if (shadow) { + const family = SHADOW_FAMILIES[shadow[1] as keyof typeof SHADOW_FAMILIES] + const effects = shadow[2] ? parseShadowEffects(shadow[2], token, family.options) : [] + assign(classes, family.field, effects, token) + continue + } + if (/^(?:shadow|inset-shadow|text-shadow)-/.test(token)) { + classError( + `Shadow class "${token}" needs an exact bracketed value or "none"; use a native effect style or binding for a reusable token.` + ) + } + + const exactFamily = /^font-\[family-name:([^[\]]+)\]$/.exec(token) + if (exactFamily) { + const family = exactFamily[1]!.replaceAll( + /\\([_\\])|_/g, + (_, escaped: string | undefined) => escaped ?? ' ' + ) + assign(classes, 'fontFamily', family, token) + classes.fontStyleMatching = true + classes.textClass ??= token + continue + } + const numericWeight = /^font-\[(\d+(?:\.\d+)?)\]$/.exec(token) + if (numericWeight) { + const weight = Number(numericWeight[1]) + if (weight < 1 || weight > 1000) + classError(`Font weight in "${token}" must be between 1 and 1000.`) + const nearest = String( + Math.max(100, Math.min(900, Math.round(weight / 100) * 100)) + ) as keyof typeof FONT_STYLES + assign(classes, 'fontStyle', FONT_STYLES[nearest], token) + classes.fontStyleMatching = true + classes.textClass ??= token + continue + } + const fontFamily = FONT_FAMILY_CLASSES[token as keyof typeof FONT_FAMILY_CLASSES] + if (fontFamily) { + assign(classes, 'fontFamily', fontFamily, token) + classes.portableFontFamily = token.slice(5) as PortableFontFamily + 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 + } + } + + const guidance = unsupportedCanvasClassGuidance([token]) + if (guidance) classError(`Unsupported class "${token}". ${guidance}`) + classError(`Unsupported class "${token}".`) + } + if (gradientDirectionToken || gradientFromToken || gradientViaToken || gradientToToken) { + if (!gradientDirection || !gradientFrom || !gradientTo) { + classError( + 'A linear gradient requires one bg-linear-to-* direction plus exact from-* and to-* colors; via-* is optional.' + ) + } + if (classes.fill !== undefined) { + classError('Linear gradient classes cannot be combined with a solid background class.') + } + classes.fillPaints = [ + { + type: 'GRADIENT_LINEAR', + gradientTransform: LINEAR_GRADIENT_TRANSFORMS[gradientDirection], + gradientStops: gradientVia + ? [ + { position: 0, color: gradientFrom }, + { position: 0.5, color: gradientVia }, + { position: 1, color: gradientTo } + ] + : [ + { position: 0, color: gradientFrom }, + { position: 1, color: gradientTo } + ] + } + ] + } + if (classes.flex && classes.direction === undefined) classes.direction = 'HORIZONTAL' + if (classes.fontFamily && classes.fontStyle) { + classes.fontStyle = normalizePortableFontStyle(classes.fontFamily, classes.fontStyle) + } + classes.lineHeight ??= defaultLineHeight + return classes +} diff --git a/packages/extension/mcp/tools/canvas/theme.ts b/packages/extension/mcp/tools/canvas/theme.ts new file mode 100644 index 00000000..4ddc30c2 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/theme.ts @@ -0,0 +1,534 @@ +import type { + CanvasBinding, + CanvasResolvedApplyParameters, + CanvasStyleReference, + CanvasVariableBindings, + CanvasVariableReference, + CanvasVariableValue +} from '@tempad-dev/shared' + +import type { DesignSystemCatalog } from '../design-system-catalog' +import type { CanvasMarkupElement } from './html' + +import { getVariableCollectionById } from '../../local-resources' +import { parseCanvasHtml } from './html' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_VARIABLE_MODE_KEYS_NAME, + designReferenceCacheKey, + parseVariableModeKeys +} from './identity' +import { parseCanvasClasses } from './tailwind' +import { + type CanvasVariableState, + createVariableState, + resolveCollection, + resolveVariable +} from './variables' + +type VariableField = keyof CanvasVariableBindings +export type ThemeResources = { + variables: Map + textStyles: Map + values: Map + boundFields: Map +} + +const CSS_VARIABLE = + /^([a-z-]+)-(?:\((?:(color|length|number|family-name):)?(--[a-zA-Z0-9_-]+)\)|\[(?:(color|length|number|family-name):)?var\((--[a-zA-Z0-9_-]+)\)\])$/ + +function variableClass(token: string) { + const match = CSS_VARIABLE.exec(token) + return match + ? { utility: match[1]!, hint: match[2] ?? match[4], name: (match[3] ?? match[5])! } + : undefined +} + +function resourceIdentity( + reference: CanvasVariableReference | CanvasStyleReference, + input: CanvasResolvedApplyParameters, + catalog?: DesignSystemCatalog +): string { + if (!('variableKey' in reference) && !('styleKey' in reference)) + return designReferenceCacheKey(reference) + const variable = 'variableKey' in reference + const key = variable ? reference.variableKey : reference.styleKey + const identities = new Set() + if (variable) { + for (const collection of Object.values(input.variableCollections ?? {})) { + const id = collection?.variables?.[key]?.id + if (id) identities.add(`id:${id}`) + } + } else { + const id = input.styles?.[key]?.id + if (id) identities.add(`id:${id}`) + } + for (const entry of catalog?.entries.values() ?? []) { + if (entry.kind !== 'variable' && entry.kind !== 'style') continue + if ( + entry.kind !== (variable ? 'variable' : 'style') || + (entry.definition as { authoringKey?: string } | undefined)?.authoringKey !== key + ) + continue + if ('variableKey' in entry.reference || 'styleKey' in entry.reference) continue + identities.add(designReferenceCacheKey(entry.reference)) + } + if (identities.size > 1) + throw new Error(`Authoring key "${key}" identifies more than one native resource.`) + return identities.values().next().value ?? `${variable ? 'variable' : 'style'}-key:${key}` +} + +function addAlias( + map: Map, + name: string, + reference: T, + identity: (reference: T) => string +): void { + const previous = map.get(name) + if (previous && identity(previous) !== identity(reference)) { + throw new Error( + `Theme alias "${name}" maps to more than one resource; choose a distinct alias.` + ) + } + map.set(name, reference) +} + +export function createThemeResources( + input: CanvasResolvedApplyParameters, + catalog?: DesignSystemCatalog +): ThemeResources { + const resources: ThemeResources = { + variables: new Map(), + textStyles: new Map(), + values: new Map(), + boundFields: new Map() + } + const identity = (reference: CanvasVariableReference | CanvasStyleReference): string => + resourceIdentity(reference, input, catalog) + for (const entry of catalog?.entries.values() ?? []) { + if (entry.kind === 'variable' && entry.cssName) + resources.variables.set(entry.cssName, entry.reference) + if (entry.kind === 'style' && entry.styleType === 'TEXT' && entry.className) { + resources.textStyles.set(entry.className, entry.reference) + } + } + for (const [name, source] of Object.entries(input.theme?.variables ?? {})) { + if ('variableKey' in source) addAlias(resources.variables, name, source, identity) + else { + const entry = catalog?.entries.get(source.ref) + if (entry?.kind !== 'variable') + throw new Error(`Theme variable "${name}" requires a variable ref from catalogId.`) + addAlias(resources.variables, name, entry.reference, identity) + } + } + for (const [name, source] of Object.entries(input.theme?.textStyles ?? {})) { + if ('styleKey' in source) { + const style = input.styles?.[source.styleKey] + if (style === null || (style && style.type !== 'TEXT')) + throw new Error(`Theme class "${name}" requires a TEXT style.`) + addAlias(resources.textStyles, name, source, identity) + } else { + const entry = catalog?.entries.get(source.ref) + if (entry?.kind !== 'style' || entry.styleType !== 'TEXT') + throw new Error(`Theme class "${name}" requires a text-style ref from catalogId.`) + addAlias(resources.textStyles, name, entry.reference, identity) + } + } + return resources +} + +class NeedsVariableRead extends Error { + constructor(readonly reference: CanvasVariableReference) { + super('A theme variable requires its current native value.') + } +} + +function variableValue( + reference: CanvasVariableReference, + input: CanvasResolvedApplyParameters, + catalog: DesignSystemCatalog | undefined, + resources: ThemeResources, + seen = new Set() +): Exclude { + const key = resourceIdentity(reference, input, catalog) + if (seen.has(key) || seen.size >= 64) + throw new Error('Theme variable aliases contain a cycle or exceed 64 levels.') + seen.add(key) + let value: CanvasVariableValue | undefined + if ('variableKey' in reference) { + for (const collection of Object.values(input.variableCollections ?? {})) { + if (!collection?.variables || !Object.hasOwn(collection.variables, reference.variableKey)) + continue + const variable = collection.variables[reference.variableKey] + if (!variable) + throw new Error(`Theme variable "${reference.variableKey}" is removed in this call.`) + // New collections use their first declared mode as the native default. + const mode = Object.entries(collection.modes ?? {}).find(([, spec]) => spec !== null) + if (!collection.id && !collection.extends && mode) + value = + variable.values?.[mode[0]] ?? (mode[1]?.id ? variable.values?.[mode[1].id] : undefined) + break + } + } + if (value === undefined) { + for (const entry of catalog?.entries.values() ?? []) { + if (entry.kind === 'variable' && resourceIdentity(entry.reference, input, catalog) === key) { + value = entry.defaultValue + break + } + } + } + value ??= resources.values.get(key) + if (value === undefined) throw new NeedsVariableRead(reference) + if (typeof value === 'object' && 'variable' in value) { + return variableValue(value.variable, input, catalog, resources, seen) + } + return value +} + +async function readVariableDefaultValue( + reference: CanvasVariableReference, + input: CanvasResolvedApplyParameters, + state: CanvasVariableState +): Promise { + let nativeReference = reference + if ('variableKey' in reference) { + for (const [collectionKey, spec] of Object.entries(input.variableCollections ?? {})) { + if (!spec) continue + const variable = spec.variables?.[reference.variableKey] + if (!variable) continue + if (variable.id) nativeReference = { id: variable.id } + if (variable.values) { + // Incremental declarations can add a variable before its native identity exists. + const collection = await resolveCollection(spec.id ?? collectionKey, state) + const modes = parseVariableModeKeys( + collection.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_MODE_KEYS_NAME), + collection.modes + ) + if (!modes) + throw new Error( + `Variable mode identity data on collection "${collection.id}" is invalid.` + ) + for (const [mode, value] of Object.entries(variable.values)) { + const modeId = spec.modes?.[mode]?.id ?? modes.get(mode) ?? mode + if (modeId === collection.defaultModeId) return value + } + } + break + } + } + if (!('variableKey' in nativeReference) && !nativeReference.id) { + throw new Error('Theme fallbacks require a materialized variable id or local variableKey.') + } + const variable = await resolveVariable(nativeReference, state) + if (variable.resolvedType === 'EASING' || variable.resolvedType === 'TIMING') { + throw new Error( + `Theme variable "${variable.name}" has unsupported type ${variable.resolvedType}.` + ) + } + const collection = await getVariableCollectionById(variable.variableCollectionId) + const value = collection ? variable.valuesByMode[collection.defaultModeId] : undefined + if (value === undefined) + throw new Error(`Theme variable "${variable.name}" has no default-mode value.`) + if (typeof value === 'object' && 'type' in value) { + if (value.type !== 'VARIABLE_ALIAS') + throw new Error(`Theme variable "${variable.name}" has an unsupported default-mode value.`) + return { variable: { id: value.id } } + } + return value +} + +// Hydrate only aliases used in markup. Reads never import or create a resource. +export async function prepareThemeResources( + input: CanvasResolvedApplyParameters, + catalog?: DesignSystemCatalog +): Promise { + const resources = createThemeResources(input, catalog) + if (!input.markup) return resources + const names = new Set() + const visit = (element: CanvasMarkupElement): void => { + for (const token of (element.attributes.class ?? '').split(/\s+/)) { + const parsed = variableClass(token) + if (parsed) names.add(parsed.name) + } + element.children.forEach(visit) + } + visit(parseCanvasHtml(input.markup)) + const state = createVariableState() + for (const name of names) { + const reference = resources.variables.get(name) + if (!reference) + throw new Error( + `Unknown CSS variable "${name}". Use a catalog cssName or declare theme.variables.` + ) + for (let reads = 0; ; reads += 1) { + try { + variableValue(reference, input, catalog, resources) + break + } catch (error) { + if (!(error instanceof NeedsVariableRead) || reads >= 64) throw error + resources.values.set( + resourceIdentity(error.reference, input, catalog), + await readVariableDefaultValue(error.reference, input, state) + ) + } + } + } + return resources +} + +function colorLiteral(value: unknown, token: string): string { + if ( + !value || + typeof value !== 'object' || + !('r' in value) || + !('g' in value) || + !('b' in value) + ) { + throw new Error(`Class "${token}" requires a COLOR variable.`) + } + const color = value as RGBA + return `#${[color.r, color.g, color.b, color.a ?? 1] + .map((channel) => + Math.round(channel * 255) + .toString(16) + .padStart(2, '0') + ) + .join('')}` +} + +const NUMBER_FIELDS: Record = { + w: ['width'], + h: ['height'], + size: ['width', 'height'], + 'min-w': ['minWidth'], + 'max-w': ['maxWidth'], + 'min-h': ['minHeight'], + 'max-h': ['maxHeight'], + p: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + px: ['paddingLeft', 'paddingRight'], + py: ['paddingTop', 'paddingBottom'], + pt: ['paddingTop'], + pr: ['paddingRight'], + pb: ['paddingBottom'], + pl: ['paddingLeft'], + rounded: ['cornerRadius'], + 'rounded-tl': ['topLeftRadius'], + 'rounded-tr': ['topRightRadius'], + 'rounded-br': ['bottomRightRadius'], + 'rounded-bl': ['bottomLeftRadius'], + leading: ['lineHeight'], + tracking: ['letterSpacing'], + opacity: ['opacity'] +} + +function utilityBinding( + token: string, + parsed: NonNullable>, + value: ReturnType, + className: string +): { fields: VariableField[]; literal: string } { + const { utility, hint } = parsed + if ( + utility === 'bg' || + (utility === 'text' && hint !== 'length') || + (utility === 'border' && hint !== 'length') + ) { + if (hint && hint !== 'color') throw new Error(`Class "${token}" requires the color type hint.`) + return { + fields: [utility === 'border' ? 'stroke' : 'fill'], + literal: `${utility}-[${colorLiteral(value, token)}]` + } + } + if (utility === 'font' && hint === 'family-name') { + if (typeof value !== 'string' || /[[\]"'\r\n\t]/.test(value)) + throw new Error(`Class "${token}" requires a STRING font-family variable.`) + return { + fields: ['fontFamily'], + literal: `font-[family-name:${value.replaceAll('\\', '\\\\').replaceAll('_', '\\_').replaceAll(' ', '_')}]` + } + } + let fields = Object.hasOwn(NUMBER_FIELDS, utility) ? NUMBER_FIELDS[utility] : undefined + if (utility === 'text' && hint === 'length') fields = ['fontSize'] + if (utility === 'border' && hint === 'length') fields = ['strokeWeight'] + if (utility === 'font' && (!hint || hint === 'number')) fields = ['fontWeight'] + if (utility === 'gap' || utility === 'gap-x' || utility === 'gap-y') { + const tokens = new Set(className.split(/\s+/)) + if (tokens.has('grid')) + fields = + utility === 'gap' + ? ['gridRowGap', 'gridColumnGap'] + : utility === 'gap-x' + ? ['gridColumnGap'] + : ['gridRowGap'] + else { + const vertical = tokens.has('flex-col') + fields = + utility === 'gap' + ? tokens.has('flex-wrap') + ? ['gap', 'counterAxisSpacing'] + : ['gap'] + : (utility === 'gap-x') === vertical + ? ['counterAxisSpacing'] + : ['gap'] + } + } + if (!fields || (hint && hint !== 'length' && hint !== 'number')) + throw new Error(`Unsupported variable utility "${token}".`) + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new Error(`Class "${token}" requires a FLOAT variable.`) + return { + fields, + literal: + utility === 'opacity' + ? `opacity-[${value}]` + : utility === 'font' + ? `font-[${value}]` + : `${utility}-[${value}px]` + } +} + +const TYPOGRAPHY_FIELDS = [ + 'fontFamily', + 'fontStyle', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textCase', + 'textDecoration' +] as const + +const TYPOGRAPHY_VARIABLE_FIELDS = [ + 'fontFamily', + 'fontStyle', + 'fontWeight', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing' +] as const + +function variableAttribute(field: string): string { + return `data-var-${field.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}` +} + +export function normalizeThemeClasses( + element: CanvasMarkupElement, + bindings: Record, + input: CanvasResolvedApplyParameters, + catalog: DesignSystemCatalog | undefined, + resources: ThemeResources +): CanvasMarkupElement { + const key = element.attributes['data-key'] ?? '' + const className = element.attributes.class ?? '' + const variables: Partial> = {} + let textStyle: CanvasStyleReference | undefined + const classes: string[] = [] + for (const token of className.split(/\s+/).filter(Boolean)) { + const style = resources.textStyles.get(token) + if (style) { + if (textStyle) throw new Error(`Node "${key}" has more than one text-style class.`) + textStyle = style + continue + } + const parsed = variableClass(token) + if (!parsed) { + if (token.startsWith('type-')) + throw new Error( + `Unknown text-style class "${token}". Use a catalog className or declare theme.textStyles.` + ) + classes.push(token) + continue + } + const reference = resources.variables.get(parsed.name) + if (!reference) + throw new Error( + `Unknown CSS variable "${parsed.name}". Use a catalog cssName or declare theme.variables.` + ) + const mapped = utilityBinding( + token, + parsed, + variableValue(reference, input, catalog, resources), + className + ) + for (const field of mapped.fields) { + if ( + variables[field] !== undefined || + bindings[key]?.variables?.[field] !== undefined || + element.attributes[variableAttribute(field)] !== undefined + ) { + throw new Error( + `Variable class "${token}" conflicts with another binding for ${field} on "${key}".` + ) + } + variables[field] = reference + } + classes.push(mapped.literal) + } + const variableFields = Object.keys(variables) as VariableField[] + if (textStyle || variableFields.length) { + const parsed = parseCanvasClasses(classes.join(' ')) + if ( + (bindings[key]?.styles?.text || + (element.attributes['data-style-text'] && + element.attributes['data-style-text'] !== 'none')) && + TYPOGRAPHY_VARIABLE_FIELDS.some((field) => variables[field] !== undefined) + ) { + throw new Error(`Typography variable classes on "${key}" conflict with its text style.`) + } + if (textStyle) { + if ( + bindings[key]?.styles?.text !== undefined || + element.attributes['data-style-text'] !== undefined + ) + throw new Error(`Text-style class on "${key}" conflicts with another text style.`) + if ( + TYPOGRAPHY_FIELDS.some((field) => parsed[field] !== undefined) || + TYPOGRAPHY_VARIABLE_FIELDS.some((field) => variables[field] !== undefined) + ) { + throw new Error( + `Text-style class on "${key}" owns typography; remove conflicting font, size, leading, tracking, case, or decoration classes.` + ) + } + const binding = bindings[key] + if ( + ( + [ + 'fontName', + 'case', + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing', + 'leadingTrim', + 'hangingPunctuation', + 'hangingList' + ] as const + ).some((field) => binding?.figma?.text?.[field] !== undefined) || + TYPOGRAPHY_VARIABLE_FIELDS.some( + (field) => + binding?.variables?.[field] != null || + (element.attributes[variableAttribute(field)] !== undefined && + element.attributes[variableAttribute(field)] !== 'none') + ) + ) { + throw new Error(`Text-style class on "${key}" conflicts with native typography fields.`) + } + } + resources.boundFields.set(key, variableFields) + bindings[key] = { + ...bindings[key], + ...(variableFields.length + ? { variables: { ...bindings[key]?.variables, ...variables } } + : {}), + ...(textStyle ? { styles: { ...bindings[key]?.styles, text: textStyle } } : {}) + } + } + return { + ...element, + attributes: { ...element.attributes, class: classes.join(' ') }, + children: element.children.map((child) => + normalizeThemeClasses(child, bindings, input, catalog, resources) + ) + } +} diff --git a/packages/extension/mcp/tools/canvas/traversal.ts b/packages/extension/mcp/tools/canvas/traversal.ts new file mode 100644 index 00000000..f03c7863 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/traversal.ts @@ -0,0 +1,35 @@ +function* walkNodes( + roots: Iterable, + shouldDescend: (node: SceneNode) => boolean +): Generator { + const stack = [...roots] + while (stack.length) { + const node = stack.pop()! + yield node + if ('children' in node && shouldDescend(node)) stack.push(...node.children) + } +} + +export function walkAuthoringNodes(roots: Iterable): Generator { + return walkNodes(roots, (node) => node.type !== 'INSTANCE') +} + +export function walkPhysicalNodes(roots: Iterable): Generator { + return walkNodes(roots, () => true) +} + +export function isInsideInstance(node: BaseNode): boolean { + let parent = node.parent + while (parent) { + if (parent.type === 'INSTANCE') return true + parent = parent.parent + } + return false +} + +export function isComponentPropertyOwner(node: BaseNode): node is ComponentNode | ComponentSetNode { + return ( + node.type === 'COMPONENT_SET' || + (node.type === 'COMPONENT' && node.parent?.type !== 'COMPONENT_SET') + ) +} diff --git a/packages/extension/mcp/tools/canvas/variables.ts b/packages/extension/mcp/tools/canvas/variables.ts new file mode 100644 index 00000000..34d16ec1 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/variables.ts @@ -0,0 +1,1125 @@ +import type { + CanvasVariableCollectionReference, + CanvasVariableCollections, + CanvasVariableReference, + CanvasVariableValue +} from '@tempad-dev/shared' + +import { + getLocalStyles, + getLocalVariableCollections, + getLocalVariables, + getVariableById, + getVariableCollectionById +} from '../../local-resources' +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' +import { isComponentPropertyOwner } from './traversal' + +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[] + createdVariableKeys: Set + 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: [], + createdVariableKeys: new Set(), + 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([ + getLocalVariableCollections(), + getLocalVariables() + ]) + 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 getVariableById(reference.id) + : await figma.variables.importVariableByKeyAsync(reference.key) + } + if (!variable) { + let identity: string + if ('variableKey' in reference) identity = `authoring key "${reference.variableKey}"` + else if (reference.id !== undefined) identity = `id "${reference.id}"` + else identity = `library key "${reference.key}"` + specError(`Variable ${identity} 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 getVariableCollectionById(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 getVariableCollectionById(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 getVariableCollectionById(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 getVariableById(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 isNew = !variable + 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 removedModeIds = new Set( + state.modeRemovals + .filter((removal) => removal.collection.id === collection.id) + .map((removal) => removal.modeId) + ) + const missingMode = collection.modes.find( + (mode) => !removedModeIds.has(mode.modeId) && !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) { + specError(`Variable "${variable.id}" is not editable in collection "${collection.id}".`) + } + if (variable.variableCollectionId !== collection.id) { + if (keyed?.id === variable.id) { + specError( + `Variable authoring key "${key}" already identifies variable "${variable.id}" in collection "${variable.variableCollectionId}" and cannot be reused in collection "${collection.id}". Authoring keys are file-wide; use a namespaced key.` + ) + } + specError( + `Variable "${variable.id}" belongs to collection "${variable.variableCollectionId}", not "${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) + if (isNew) state.createdVariableKeys.add(key) + 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 (isComponentPropertyOwner(node)) { + assertNoRemovedVariable( + node.componentPropertyDefinitions, + removedVariableIds, + `component properties on node "${node.id}"` + ) + } else if (node.type === 'INSTANCE') { + assertNoRemovedVariable( + node.componentProperties, + removedVariableIds, + `component properties on node "${node.id}"` + ) + } + if (node.type !== 'TEXT') return + try { + const segments = node.getStyledTextSegments(['boundVariables', 'fills', 'textDecorationColor']) + 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([ + getLocalVariableCollections(), + getLocalVariables(), + 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/image.ts b/packages/extension/mcp/tools/code/assets/image.ts deleted file mode 100644 index 95d37d71..00000000 --- a/packages/extension/mcp/tools/code/assets/image.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { AssetDescriptor } from '@tempad-dev/shared' - -import type { CodegenConfig } from '@/utils/codegen' - -import { ensureAssetUploaded } from '@/mcp/assets' -import { BG_URL_RE } from '@/utils/css' -import { logger } from '@/utils/log' -import { toDecimalPlace } from '@/utils/number' - -import type { GetCodeCacheContext } from '../cache' - -import { getNodeSemanticsCached } from '../cache' - -const imageBytesCache = new Map>() - -export function hasImageFills(node: SceneNode, ctx?: GetCodeCacheContext): boolean { - if (ctx) { - return getNodeSemanticsCached(node, ctx).paint.hasImageFill - } - return ( - 'fills' in node && - Array.isArray(node.fills) && - node.fills.some((f) => f.type === 'IMAGE' && f.visible !== false) - ) -} - -export async function replaceImageUrlsWithAssets( - 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 result = { ...style } - const regex = new RegExp(BG_URL_RE.source, 'gi') - - 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)] - index++ - return `url('${asset.url}')` - }) - } - - return result -} - -function replaceImageUrlsWithPlaceholder( - style: Record, - node: SceneNode, - config: CodegenConfig -): Record { - const { scale = 1 } = config - let w = 100 - let h = 100 - - if ('width' in node && typeof node.width === 'number') { - w = Math.round(toDecimalPlace(node.width) * scale) - } - if ('height' in node && typeof node.height === 'number') { - h = Math.round(toDecimalPlace(node.height) * scale) - } - - const placeholderUrl = `https://placehold.co/${w}x${h}` - const result = { ...style } - const regex = new RegExp(BG_URL_RE.source, 'gi') - - for (const key of ['background', 'background-image']) { - if (result[key]) { - result[key] = result[key].replace(regex, `url('${placeholderUrl}')`) - } - } - - return result -} - -async function collectImageFillAssets( - node: SceneNode, - assetRegistry: Map -): Promise { - if (!('fills' in node)) return [] - const fills = Array.isArray(node.fills) ? (node.fills as Paint[]) : null - if (!fills?.length) return [] - - const assets: AssetDescriptor[] = [] - for (const fill of fills) { - if (!isRenderableImagePaint(fill)) 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) - assets.push(asset) - } catch (error) { - logger.warn('Failed to process image fill asset, falling back to node export.') - 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) - assets.push(asset) - continue - } catch (fallbackError) { - logger.warn('Failed to export node for image fill fallback:', fallbackError) - } - } - } - - return assets -} - -function isRenderableImagePaint(paint: Paint): paint is ImagePaint { - return paint.type === 'IMAGE' && paint.visible !== false -} - -function loadImageBytes(hash: string): Promise { - let promise = imageBytesCache.get(hash) - if (!promise) { - const image = figma.getImageByHash(hash) - 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 - }) - 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/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/media.ts b/packages/extension/mcp/tools/code/assets/media.ts new file mode 100644 index 00000000..d240db3f --- /dev/null +++ b/packages/extension/mcp/tools/code/assets/media.ts @@ -0,0 +1,185 @@ +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' + +import type { GetCodeCacheContext } from '../cache' + +import { getNodeSemanticsCached } from '../cache' + +const imageBytesCache = new Map>() + +export function hasMediaFills(node: SceneNode, ctx?: GetCodeCacheContext): boolean { + if (ctx) { + return getNodeSemanticsCached(node, ctx).paint.hasMediaFill + } + return 'fills' in node && Array.isArray(node.fills) && node.fills.some(isVisibleMediaPaint) +} + +export async function replaceMediaUrlsWithAssets( + style: Record, + node: SceneNode, + config: CodegenConfig, + assetRegistry: Map, + videoPreviewAssetHashes?: Set +): Promise> { + if (!style['background-color'] && !style['background-image'] && !style.background) return style + const fills = await collectMediaFillAssets(node, assetRegistry, videoPreviewAssetHashes) + 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[index] ?? lastAsset + index++ + return `url('${asset.url}')` + }) + } + + return result +} + +function replaceMediaUrlsWithPlaceholder( + style: Record, + node: SceneNode, + config: CodegenConfig +): Record { + const { scale = 1 } = config + let w = 100 + let h = 100 + + if ('width' in node && typeof node.width === 'number') { + w = Math.round(toDecimalPlace(node.width) * scale) + } + if ('height' in node && typeof node.height === 'number') { + h = Math.round(toDecimalPlace(node.height) * scale) + } + + const placeholderUrl = `https://placehold.co/${w}x${h}` + const result = { ...style } + const regex = new RegExp(BG_URL_RE.source, 'gi') + + for (const key of ['background', 'background-image']) { + if (result[key]) { + result[key] = result[key].replace(regex, `url('${placeholderUrl}')`) + } + } + + return result +} + +async function collectMediaFillAssets( + node: SceneNode, + assetRegistry: Map, + videoPreviewAssetHashes?: Set +): Promise { + if (!('fills' in node)) return [] + 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 (!isVisibleMediaPaint(fill)) continue + + if (fill.type === 'VIDEO') { + if (!fill.videoHash) continue + try { + const asset = { + ...(await getPreview()), + figmaVideoHashes: videoHashes + } + registerAsset(assetRegistry, asset) + videoPreviewAssetHashes?.add(asset.hash) + 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 asset = { + ...(await ensureAssetUploaded(bytes, detectImageMime(bytes) ?? 'application/octet-stream')), + figmaImageHash: hash + } + registerAsset(assetRegistry, asset) + assets.push(asset) + } catch (error) { + logger.warn(`Image bytes unavailable for hash ${hash}, falling back to node export.`, error) + try { + const asset = { + ...(await getPreview()), + figmaImageHashes: imageHashes + } + registerAsset(assetRegistry, asset) + assets.push(asset) + } catch (fallbackError) { + logger.warn('Failed to export node for image fill fallback:', fallbackError) + } + } + } + + return assets +} + +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 { + let promise = imageBytesCache.get(hash) + if (!promise) { + const image = figma.getImageByHash(hash) + if (!image) { + throw new Error(`Unable to resolve image for hash ${hash}.`) + } + promise = image.getBytesAsync().catch((error) => { + imageBytesCache.delete(hash) + throw error + }) + imageBytesCache.set(hash, promise) + } + return promise +} diff --git a/packages/extension/mcp/tools/code/assets/paint.ts b/packages/extension/mcp/tools/code/assets/paint.ts index cdab2692..222f2977 100644 --- a/packages/extension/mcp/tools/code/assets/paint.ts +++ b/packages/extension/mcp/tools/code/assets/paint.ts @@ -1,4 +1,5 @@ import { formatHexAlpha } from '@/utils/css' +import { isRenderablePaint } from '@/utils/figma-paint' import { getVariableCssExpr } from '@/utils/figma-variables' import type { GetCodeCacheContext } from '../cache' @@ -53,10 +54,10 @@ export function resolveStylePaintChannel( const style = figma.getStyleById(styleId) if (!style || !('paints' in style) || !Array.isArray(style.paints)) return null - const visible = style.paints.filter(isVisiblePaint) + const visible = style.paints.filter(isRenderablePaint) 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 { @@ -64,53 +65,6 @@ export function resolveStylePaintChannel( } } -export function hasRenderableStrokes(node: SceneNode): boolean { - const typed = node as { - strokeWeight?: number | symbol - strokeTopWeight?: number | symbol - strokeRightWeight?: number | symbol - strokeBottomWeight?: number | symbol - strokeLeftWeight?: number | symbol - } - - const uniform = typed.strokeWeight - if (typeof uniform === 'number') return uniform > 0 - - const perSide = [ - typed.strokeTopWeight, - typed.strokeRightWeight, - typed.strokeBottomWeight, - typed.strokeLeftWeight - ] - const numeric = perSide.filter((value): value is number => typeof value === 'number') - if (!numeric.length) return true - return numeric.some((value) => value > 0) -} - -export function hasVisibleEffects(node: SceneNode, ctx?: GetCodeCacheContext): boolean { - if (ctx) { - return getNodeSemanticsCached(node, ctx).paint.hasVisibleEffect - } - if (!('effects' in node)) return false - const effects = (node as { effects?: unknown }).effects - if (effects == null) return false - if (!Array.isArray(effects)) return true - - return effects.some((effect) => { - if (!effect || typeof effect !== 'object') return false - return !('visible' in effect) || effect.visible !== false - }) -} - -export function isVisiblePaint(paint: Paint | null | undefined): paint is Paint { - if (!paint || paint.visible === false) return false - if (typeof paint.opacity === 'number' && paint.opacity <= 0) return false - if ('gradientStops' in paint && Array.isArray(paint.gradientStops)) { - return paint.gradientStops.some((stop) => (stop.color?.a ?? 1) > 0) - } - return true -} - function getPaintStyleId(node: SceneNode, kind: keyof typeof PAINT_STYLE_KEYS): string | null { const key = PAINT_STYLE_KEYS[kind] if (!(key in node)) return null diff --git a/packages/extension/mcp/tools/code/assets/plan.ts b/packages/extension/mcp/tools/code/assets/plan.ts index 91246f18..868e4b03 100644 --- a/packages/extension/mcp/tools/code/assets/plan.ts +++ b/packages/extension/mcp/tools/code/assets/plan.ts @@ -1,8 +1,10 @@ +import { isRenderablePaint } from '@/utils/figma-paint' + import type { GetCodeCacheContext } from '../cache' import type { NodeSnapshot, VisibleTree } from '../model' -import { getNodeSemanticsCached } from '../cache' -import { hasVisibleEffects, isVisiblePaint } from './paint' +import { getNodeSemanticsCached, hasVisibleEffects } from '../cache' +import { addSubtreeIds } from '../tree' export type AssetPlan = { vectorRoots: Set @@ -56,7 +58,7 @@ export function planAssets( if (isVectorGroup) { vectorRoots.add(id) - children.forEach((child) => skipDescendants(child.id, tree, skipped)) + children.forEach((child) => addSubtreeIds(child.id, tree, skipped)) continue } @@ -75,8 +77,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 @@ -170,17 +171,9 @@ function hasVisiblePaints(node: SceneNode, kind: 'fills' | 'strokes'): boolean { if (!(kind in node)) return false const paints = (node as { fills?: unknown; strokes?: unknown })[kind] if (!Array.isArray(paints)) return false - return paints.some((paint) => isVisiblePaint(paint)) + return paints.some((paint) => isRenderablePaint(paint)) } function hasClipping(node: SceneNode): boolean { return 'clipsContent' in node && node.clipsContent === true } - -function skipDescendants(id: string, tree: VisibleTree, skipped: Set): void { - const node = tree.nodes.get(id) - if (!node) return - if (skipped.has(id)) return - skipped.add(id) - node.children.forEach((childId) => skipDescendants(childId, tree, skipped)) -} 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/assets/vector-semantics.ts b/packages/extension/mcp/tools/code/assets/vector-semantics.ts index f51d9511..ac9bd686 100644 --- a/packages/extension/mcp/tools/code/assets/vector-semantics.ts +++ b/packages/extension/mcp/tools/code/assets/vector-semantics.ts @@ -1,15 +1,15 @@ +import { isRenderablePaint } from '@/utils/figma-paint' + import type { GetCodeCacheContext } from '../cache' import type { NodeSnapshot, VisibleTree } from '../model' -import { getNodeSemanticsCached, getPaintsFromState } from '../cache' import { - type PaintChannel, + getNodeSemanticsCached, + getPaintsFromState, hasRenderableStrokes, - hasVisibleEffects, - isVisiblePaint, - resolveSolidPaintChannel, - resolveStylePaintChannel -} from './paint' + hasVisibleEffects +} from '../cache' +import { type PaintChannel, resolveSolidPaintChannel, resolveStylePaintChannel } from './paint' const PAINT_KINDS = ['fills', 'strokes'] as const @@ -75,7 +75,7 @@ function breaksThemeable(snapshot: NodeSnapshot, ctx?: GetCodeCacheContext): boo snapshot.assetKind === 'image' || semantics?.layout.isMask === true || (!semantics && isMaskNode(snapshot.node)) || - hasVisibleEffects(snapshot.node, ctx) + (semantics?.paint.hasVisibleEffect ?? hasVisibleEffects(snapshot.node)) ) } @@ -99,7 +99,7 @@ function collectPaintChannels( return [] } - const visiblePaints = paints.filter(isVisiblePaint) + const visiblePaints = paints.filter(isRenderablePaint) const styleChannel = visiblePaints.length === 1 ? resolveStylePaintChannel(node, kind, ctx) : null const channels: PaintChannel[] = [] for (const paint of visiblePaints) { diff --git a/packages/extension/mcp/tools/code/cache/context.ts b/packages/extension/mcp/tools/code/cache/context.ts index 9ccc70ad..10d87287 100644 --- a/packages/extension/mcp/tools/code/cache/context.ts +++ b/packages/extension/mcp/tools/code/cache/context.ts @@ -1,3 +1,5 @@ +import { isRenderablePaint } from '@/utils/figma-paint' + import type { CacheMetrics, GetCodeCacheContext, PaintStyleSummary } from './types' function createDefaultMetrics(): CacheMetrics { @@ -85,7 +87,7 @@ export function getPaintStyleCached( return null } - const visiblePaints = style.paints.filter(isVisiblePaint) + const visiblePaints = style.paints.filter(isRenderablePaint) const singleVisiblePaint = visiblePaints.length === 1 ? (visiblePaints[0] ?? null) : null const singleVisibleSolidPaint = singleVisiblePaint?.type === 'SOLID' && singleVisiblePaint.color ? singleVisiblePaint : null @@ -109,12 +111,3 @@ function incrementMetric(metrics: CacheMetrics | undefined, key: keyof CacheMetr metrics[key] += 1 } - -function isVisiblePaint(paint: Paint | null | undefined): paint is Paint { - if (!paint || paint.visible === false) return false - if (typeof paint.opacity === 'number' && paint.opacity <= 0) return false - if ('gradientStops' in paint && Array.isArray(paint.gradientStops)) { - return paint.gradientStops.some((stop) => (stop.color?.a ?? 1) > 0) - } - return true -} diff --git a/packages/extension/mcp/tools/code/cache/node-semantics.ts b/packages/extension/mcp/tools/code/cache/node-semantics.ts index 93afbf51..7c2b70c1 100644 --- a/packages/extension/mcp/tools/code/cache/node-semantics.ts +++ b/packages/extension/mcp/tools/code/cache/node-semantics.ts @@ -1,3 +1,6 @@ +import { isVisibleMediaPaint } from '@/mcp/media' +import { isRenderablePaint } from '@/utils/figma-paint' + import type { GetCodeCacheContext, NodeSemanticSnapshot, PaintArrayState } from './types' export function getNodeSemanticsCached( @@ -25,7 +28,7 @@ export function getNodeSemanticsCached( hasVisibleFill: hasVisiblePaints(fillsState), hasVisibleStroke: hasVisiblePaints(strokesState), hasRenderableStroke: hasRenderableStrokes(node), - hasImageFill: hasImageFill(fillsState), + hasMediaFill: hasMediaFill(fillsState), hasVisibleEffect: hasVisibleEffects(node) }, layout: { @@ -139,15 +142,15 @@ function readConstraints(node: SceneNode): Constraints | null { function hasVisiblePaints(state: PaintArrayState): boolean { if (state.kind !== 'array') return false - return state.paints.some(isVisiblePaint) + return state.paints.some(isRenderablePaint) } -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 { +export function hasRenderableStrokes(node: SceneNode): boolean { const typed = node as { strokeWeight?: number | symbol strokeTopWeight?: number | symbol @@ -170,7 +173,7 @@ function hasRenderableStrokes(node: SceneNode): boolean { return numeric.some((value) => value > 0) } -function hasVisibleEffects(node: SceneNode): boolean { +export function hasVisibleEffects(node: SceneNode): boolean { if (!('effects' in node)) return false const effects = (node as { effects?: unknown }).effects if (effects == null) return false @@ -181,12 +184,3 @@ function hasVisibleEffects(node: SceneNode): boolean { return !('visible' in effect) || effect.visible !== false }) } - -function isVisiblePaint(paint: Paint | null | undefined): paint is Paint { - if (!paint || paint.visible === false) return false - if (typeof paint.opacity === 'number' && paint.opacity <= 0) return false - if ('gradientStops' in paint && Array.isArray(paint.gradientStops)) { - return paint.gradientStops.some((stop) => (stop.color?.a ?? 1) > 0) - } - return true -} 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..4210ac8c 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' @@ -26,6 +26,9 @@ export async function collectNodeData( ): Promise { const styles = new Map>() const textSegments = new Map() + const videoPreviewAssetHashes = new Set() + const rootVideoPreviewAssetHashes = new Set() + const rootIds = new Set(tree.rootIds) for (const id of tree.order) { if (skipIds?.has(id)) continue @@ -54,8 +57,19 @@ 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)) { + const nodeVideoPreviewAssetHashes = new Set() + processed = await replaceMediaUrlsWithAssets( + processed, + node, + config, + assetRegistry, + nodeVideoPreviewAssetHashes + ) + for (const hash of nodeVideoPreviewAssetHashes) { + videoPreviewAssetHashes.add(hash) + if (rootIds.has(id)) rootVideoPreviewAssetHashes.add(hash) + } } stripInertShadows(processed, node, cache) @@ -65,7 +79,13 @@ export async function collectNodeData( } } - return { nodes: tree.nodes, styles, textSegments } + return { + nodes: tree.nodes, + rootVideoPreviewAssetHashes, + styles, + textSegments, + videoPreviewAssetHashes + } } function preprocessRawStyle(style: Record): Record { diff --git a/packages/extension/mcp/tools/code/index.ts b/packages/extension/mcp/tools/code/index.ts index d9d79c29..ffc4d479 100644 --- a/packages/extension/mcp/tools/code/index.ts +++ b/packages/extension/mcp/tools/code/index.ts @@ -5,7 +5,7 @@ import type { GetTokenDefsResult } from '@tempad-dev/shared' -import { buildGetCodeToolResult } from '@tempad-dev/shared' +import { MCP_TOOL_INLINE_BUDGET_BYTES, buildGetCodeToolResult } from '@tempad-dev/shared' import type { DevComponent } from '@/types/plugin' import type { CodegenConfig } from '@/utils/codegen' @@ -27,18 +27,17 @@ import { planAssets } from './assets/plan' import { preflightGetCodeBudget } from './budget-preflight' import { createGetCodeCacheContext } from './cache' import { collectNodeData } from './collect' +import { collectUnboundColorLiteralClusters } from './literal-clusters' import { + CodeBudgetExceededError, assertToolResponseWithinBudget, - buildGetCodeWarnings, - isCodeBudgetExceededError, - resolveCodeBudget, - resolveUnlimitedCodeBudget + buildGetCodeWarnings } from './messages' import { getOrderedChildIds, renderShellTree, renderTree } from './render' import { resolvePluginComponents } from './render/plugin' import { buildLayoutStyles, prepareStyles } from './styles' import { createStyleVarResolver, processTokens, resolveStyleMap } from './tokens' -import { buildVisibleTree } from './tree' +import { addSubtreeIds, buildVisibleTree } from './tree' // Tags that should render children without extra whitespace/newlines. const COMPACT_TAGS = new Set([ @@ -137,11 +136,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.') } @@ -160,9 +159,11 @@ export async function handleGetCode( const config = currentCodegenConfig() const pluginCode = activePlugin.value?.code - const codeBudget = runtimeOptions.unbounded ? resolveUnlimitedCodeBudget() : resolveCodeBudget() + const maxResultBytes = runtimeOptions.unbounded + ? Number.MAX_SAFE_INTEGER + : MCP_TOOL_INLINE_BUDGET_BYTES const budgetPreflight = preflightGetCodeBudget(tree, rootId, { - maxResultBytes: codeBudget.maxResultBytes, + maxResultBytes, pluginEnabled: !!pluginCode, unbounded: !!runtimeOptions.unbounded }) @@ -251,6 +252,8 @@ export async function handleGetCode( trace: traceInfo } const allAssets = Array.from(assetRegistry.values()) + const videoPreviewAssetHashes = collected.videoPreviewAssetHashes ?? new Set() + const rootVideoPreviewAssetHashes = collected.rootVideoPreviewAssetHashes ?? new Set() if (earlyShell) { const shellMode = createShellMode(rootId, tree, ctx) @@ -268,9 +271,9 @@ export async function handleGetCode( cappedNodeIds: tree.stats.cappedNodeIds, shell: true }) - const assets = filterAssetsReferencedInCode(allAssets, shell.code) - const result = buildCodeResult(shell, codegen, assets, warnings) - assertToolResponseWithinBudget(buildGetCodeToolResult(result), codeBudget) + const assets = selectAssetsForCode(allAssets, shell.code, videoPreviewAssetHashes) + const result = buildCodeResult(shell, codegen, assets, undefined, warnings) + assertToolResponseWithinBudget(buildGetCodeToolResult(result), maxResultBytes) logTrace( trace, `nodes=${tree.order.length} collected=1 assets=${assets.length} shell=early preflightNodes=${budgetPreflight.scannedDescendants}${formatCacheMetrics(cache)}` @@ -283,20 +286,25 @@ export async function handleGetCode( ...baseInput, mode: { kind: 'full' } }) + const literalClusters = resolveTokens + ? undefined + : collectUnboundColorLiteralClusters(collected.styles, tree) const warnings = buildGetCodeWarnings(output.code, { - cappedNodeIds: tree.stats.cappedNodeIds + cappedNodeIds: tree.stats.cappedNodeIds, + literalClusters }) - const result = buildCodeResult(output, codegen, allAssets, warnings) - assertToolResponseWithinBudget(buildGetCodeToolResult(result), codeBudget) + const assets = selectAssetsForCode(allAssets, output.code, videoPreviewAssetHashes) + const result = buildCodeResult(output, codegen, assets, literalClusters, warnings) + assertToolResponseWithinBudget(buildGetCodeToolResult(result), maxResultBytes) 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 } catch (error) { - if (!isCodeBudgetExceededError(error)) { + if (!(error instanceof CodeBudgetExceededError)) { throw error } @@ -317,13 +325,13 @@ export async function handleGetCode( cappedNodeIds: tree.stats.cappedNodeIds, shell: true }) - const assets = filterAssetsReferencedInCode(allAssets, shell.code) - const result = buildCodeResult(shell, codegen, assets, warnings) + const assets = selectAssetsForCode(allAssets, shell.code, rootVideoPreviewAssetHashes) + const result = buildCodeResult(shell, codegen, assets, undefined, warnings) try { - assertToolResponseWithinBudget(buildGetCodeToolResult(result), codeBudget) + assertToolResponseWithinBudget(buildGetCodeToolResult(result), maxResultBytes) } catch (shellError) { - if (isCodeBudgetExceededError(shellError)) { + if (shellError instanceof CodeBudgetExceededError) { throw error } throw shellError @@ -589,21 +597,13 @@ async function collectPluginOutput( if (!component) continue const snapshot = tree.nodes.get(id) if (!snapshot) continue - snapshot.children.forEach((childId) => skipDescendants(childId, tree, pluginSkipped)) + snapshot.children.forEach((childId) => addSubtreeIds(childId, tree, pluginSkipped)) } } return { pluginComponents, pluginSkipped } } -function skipDescendants(id: string, tree: VisibleTree, skipped: Set): void { - const node = tree.nodes.get(id) - if (!node) return - if (skipped.has(id)) return - skipped.add(id) - node.children.forEach((childId) => skipDescendants(childId, tree, skipped)) -} - function buildSkipIds(base: Set, extra: Set): Set { if (!base.size && !extra.size) return base if (!extra.size) return base @@ -743,14 +743,24 @@ function stampRenderPhase( trace.stamp(label, start) } -function filterAssetsReferencedInCode(assets: AssetDescriptor[], code: string): AssetDescriptor[] { - return assets.filter((asset) => code.includes(asset.url) || code.includes(asset.hash)) +function selectAssetsForCode( + assets: AssetDescriptor[], + code: string, + supplementalAssetHashes?: ReadonlySet +): AssetDescriptor[] { + return assets.filter( + (asset) => + code.includes(asset.url) || + code.includes(asset.hash) || + supplementalAssetHashes?.has(asset.hash) + ) } function buildCodeResult( output: PipelineOutput, codegen: GetCodeResult['codegen'], assets: AssetDescriptor[], + literalClusters?: GetCodeResult['literalClusters'], warnings?: GetCodeResult['warnings'] ): GetCodeResult { return { @@ -758,6 +768,7 @@ function buildCodeResult( code: output.code, ...(assets.length ? { assets } : {}), ...(output.tokens ? { tokens: output.tokens } : {}), + ...(literalClusters?.length ? { literalClusters } : {}), codegen, ...(warnings?.length ? { warnings } : {}) } diff --git a/packages/extension/mcp/tools/code/literal-clusters.ts b/packages/extension/mcp/tools/code/literal-clusters.ts new file mode 100644 index 00000000..fa839071 --- /dev/null +++ b/packages/extension/mcp/tools/code/literal-clusters.ts @@ -0,0 +1,135 @@ +import type { GetCodeLiteralCluster } from '@tempad-dev/shared' + +import { canonicalizeColor } from '@/utils/css' + +import type { VisibleTree } from './model' + +const MAX_CLUSTERS = 6 +const MAX_CONSUMERS_PER_CLUSTER = 3 +const EXACT_HEX_COLOR = /^#(?:[\dA-Fa-f]{3}|[\dA-Fa-f]{4}|[\dA-Fa-f]{6}|[\dA-Fa-f]{8})$/ + +type MutableConsumer = { + nodeId: string + nodeName: string + properties: Set +} + +type MutableCluster = { + occurrences: number + consumers: Map +} + +export function collectUnboundColorLiteralClusters( + styles: ReadonlyMap>, + tree: VisibleTree +): GetCodeLiteralCluster[] | undefined { + const clusters = new Map() + + for (const [nodeId, style] of styles) { + const snapshot = tree.nodes.get(nodeId) + if (!snapshot) continue + + for (const [property, rawValue] of Object.entries(style)) { + if (!isColorProperty(property) || rawValue.includes('var(')) continue + const value = normalizeColorLiteral(rawValue) + if (!value) continue + + const cluster = clusters.get(value) ?? { + occurrences: 0, + consumers: new Map() + } + cluster.occurrences += 1 + + const consumer = cluster.consumers.get(nodeId) ?? { + nodeId, + nodeName: normalizeNodeName(snapshot.name, snapshot.type), + properties: new Set() + } + consumer.properties.add(property) + cluster.consumers.set(nodeId, consumer) + clusters.set(value, cluster) + } + } + + const order = new Map(tree.order.map((nodeId, index) => [nodeId, index])) + const result = Array.from(clusters, ([value, cluster]) => ({ value, cluster })) + .filter(({ cluster }) => cluster.consumers.size >= 2) + .sort( + (left, right) => + right.cluster.occurrences - left.cluster.occurrences || + left.value.localeCompare(right.value, 'en') + ) + .slice(0, MAX_CLUSTERS) + .map(({ value, cluster }): GetCodeLiteralCluster => { + const allConsumers = Array.from(cluster.consumers.values()).sort( + (left, right) => + (order.get(left.nodeId) ?? Number.MAX_SAFE_INTEGER) - + (order.get(right.nodeId) ?? Number.MAX_SAFE_INTEGER) || + left.nodeId.localeCompare(right.nodeId, 'en') + ) + const consumers = allConsumers.slice(0, MAX_CONSUMERS_PER_CLUSTER).map((consumer) => ({ + nodeId: consumer.nodeId, + nodeName: consumer.nodeName, + properties: Array.from(consumer.properties).sort((left, right) => + left.localeCompare(right, 'en') + ) + })) + const omittedConsumers = allConsumers.length - consumers.length + + return { + kind: 'color', + value, + occurrences: cluster.occurrences, + consumers, + ...(omittedConsumers ? { omittedConsumers } : {}) + } + }) + + return result.length ? result : undefined +} + +function isColorProperty(property: string): boolean { + return ( + property === 'color' || + property === 'fill' || + property === 'stroke' || + property.endsWith('-color') + ) +} + +function normalizeColorLiteral(value: string): string | undefined { + const trimmed = value.trim() + if (EXACT_HEX_COLOR.test(trimmed)) { + return expandCanonicalHex(trimmed) + } + + const canonical = canonicalizeColor(trimmed.toLowerCase()) + if (!canonical) return undefined + const [hex, opacity] = canonical.split('/') + if (!hex || !EXACT_HEX_COLOR.test(hex)) return undefined + const expanded = expandCanonicalHex(hex) + if (opacity === undefined) return expanded + + const percent = Number(opacity) + if (!Number.isFinite(percent) || percent < 0 || percent > 100) return undefined + if (percent === 100) return expanded + const alpha = Math.round((percent / 100) * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase() + return `${expanded}${alpha}` +} + +function expandCanonicalHex(value: string): string { + const digits = value.slice(1) + const expanded = + digits.length === 3 || digits.length === 4 + ? Array.from(digits, (character) => `${character}${character}`).join('') + : digits + return `#${expanded.toUpperCase()}` +} + +function normalizeNodeName(name: string, type: SceneNode['type']): string { + const normalized = name.replace(/\s+/g, ' ').trim() + return (normalized || type).slice(0, 80) +} diff --git a/packages/extension/mcp/tools/code/messages.ts b/packages/extension/mcp/tools/code/messages.ts index e5fc260e..1591f58a 100644 --- a/packages/extension/mcp/tools/code/messages.ts +++ b/packages/extension/mcp/tools/code/messages.ts @@ -1,20 +1,9 @@ -import type { GetCodeWarning, ToolResponseLike } from '@tempad-dev/shared' +import type { GetCodeLiteralCluster, GetCodeWarning, ToolResponseLike } from '@tempad-dev/shared' -import { MCP_TOOL_INLINE_BUDGET_BYTES, measureCallToolResultBytes } from '@tempad-dev/shared' +import { measureCallToolResultBytes } from '@tempad-dev/shared' const AUTO_LAYOUT_REGEX = /data-hint-auto-layout\s*=\s*["']?inferred["']?/i -const SHELL_WARNING_MESSAGE = - 'Shell response: omitted direct child ids are listed in the inline comment. Call get_code for them in that order, then fill the results back into this shell instead of re-creating the parent layout.' - -export type CodeBudget = { - maxResultBytes: number -} - -const UNBOUNDED_CODE_BUDGET: CodeBudget = { - maxResultBytes: Number.MAX_SAFE_INTEGER -} - export class CodeBudgetExceededError extends Error { constructor(message: string) { super(message) @@ -22,21 +11,14 @@ export class CodeBudgetExceededError extends Error { } } -export function resolveCodeBudget(): CodeBudget { - return { - maxResultBytes: MCP_TOOL_INLINE_BUDGET_BYTES - } -} - -export function resolveUnlimitedCodeBudget(): CodeBudget { - return UNBOUNDED_CODE_BUDGET -} - -export function assertToolResponseWithinBudget(result: ToolResponseLike, budget: CodeBudget): void { +export function assertToolResponseWithinBudget( + result: ToolResponseLike, + maxResultBytes: number +): void { const size = measureCallToolResultBytes(result) - if (size <= budget.maxResultBytes) return + if (size <= maxResultBytes) return throw new CodeBudgetExceededError( - `Tool result exceeds inline budget (${size} UTF-8 bytes > ${budget.maxResultBytes} UTF-8 bytes). Reduce selection size and retry, or call get_code on a smaller nodeId subtree.` + `Tool result exceeds inline budget (${size} UTF-8 bytes > ${maxResultBytes} UTF-8 bytes). Reduce selection size and retry, or call get_code on a smaller nodeId subtree.` ) } @@ -44,6 +26,7 @@ export function buildGetCodeWarnings( code: string, options?: { cappedNodeIds?: string[] + literalClusters?: GetCodeLiteralCluster[] shell?: boolean } ): GetCodeWarning[] | undefined { @@ -57,37 +40,29 @@ export function buildGetCodeWarnings( }) } - const depthCapWarning = buildDepthCapWarning(options?.cappedNodeIds ?? []) - if (depthCapWarning) { - warnings.push(depthCapWarning) - } - - if (options?.shell) { - warnings.push(buildShellWarning()) + if (options?.cappedNodeIds?.length) { + warnings.push({ + type: 'depth-cap', + message: + 'Tree depth capped; some subtree roots were omitted. Use returned data-hint-id values to continue with narrower get_code calls.' + }) } - return warnings.length ? warnings : undefined -} - -export function isCodeBudgetExceededError(error: unknown): error is CodeBudgetExceededError { - return error instanceof CodeBudgetExceededError -} - -function buildDepthCapWarning(nodeIds: string[]): GetCodeWarning | undefined { - if (!nodeIds.length) { - return undefined + if (options?.literalClusters?.length) { + warnings.push({ + type: 'literal-cluster', + message: + 'Repeated unbound color literals are listed in structuredContent.literalClusters with concrete consumer nodes. Classify each cluster before propagating a system: bind consumers that should change together, or keep them literal only when independently owned.' + }) } - return { - type: 'depth-cap', - message: - 'Tree depth capped; some subtree roots were omitted. Use returned data-hint-id values to continue with narrower get_code calls.' + if (options?.shell) { + warnings.push({ + type: 'shell', + message: + 'Shell response: omitted direct child ids are listed in the inline comment. Call get_code for them in that order, then fill the results back into this shell instead of re-creating the parent layout.' + }) } -} -function buildShellWarning(): GetCodeWarning { - return { - type: 'shell', - message: SHELL_WARNING_MESSAGE - } + return warnings.length ? warnings : undefined } diff --git a/packages/extension/mcp/tools/code/model.ts b/packages/extension/mcp/tools/code/model.ts index b65db3a9..2b4bbd62 100644 --- a/packages/extension/mcp/tools/code/model.ts +++ b/packages/extension/mcp/tools/code/model.ts @@ -35,6 +35,8 @@ export type VisibleTree = { export type CollectedData = { nodes: Map + rootVideoPreviewAssetHashes: Set styles: Map> textSegments: Map + videoPreviewAssetHashes: Set } diff --git a/packages/extension/mcp/tools/code/render/index.ts b/packages/extension/mcp/tools/code/render/index.ts index 0a151307..c7a881f8 100644 --- a/packages/extension/mcp/tools/code/render/index.ts +++ b/packages/extension/mcp/tools/code/render/index.ts @@ -3,6 +3,7 @@ import { raw } from '@tempad-dev/plugins' import type { DevComponent } from '@/types/plugin' import { stripDefaultTextStyles } from '@/utils/css' +import { isRenderablePaint } from '@/utils/figma-paint' import type { NodeSnapshot, VisibleTree } from '../model' import type { PluginComponent } from './plugin' @@ -87,7 +88,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 @@ -464,13 +466,13 @@ function hasVisibleTextFill( ): boolean { const nodeFills = Array.isArray(node.fills) ? (node.fills as Paint[]) : null if (nodeFills) { - return nodeFills.some((fill) => isVisiblePaint(fill)) + return nodeFills.some((fill) => isRenderablePaint(fill)) } if (Array.isArray(segments)) { for (const seg of segments) { const fills = Array.isArray(seg.fills) ? (seg.fills as Paint[]) : null - if (fills && fills.some((fill) => isVisiblePaint(fill))) return true + if (fills && fills.some((fill) => isRenderablePaint(fill))) return true } return false } @@ -478,15 +480,6 @@ function hasVisibleTextFill( return true } -function isVisiblePaint(paint?: Paint): boolean { - if (!paint || paint.visible === false) return false - if (typeof paint.opacity === 'number' && paint.opacity <= 0) return false - if ('gradientStops' in paint && Array.isArray(paint.gradientStops)) { - return paint.gradientStops.some((stop) => (stop.color?.a ?? 1) > 0) - } - return true -} - function ensureSvgSize(svgProps: Record, snapshot: NodeSnapshot): void { const hasWidth = typeof svgProps.width === 'string' && svgProps.width.trim().length > 0 const hasHeight = typeof svgProps.height === 'string' && svgProps.height.trim().length > 0 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..d7032173 100644 --- a/packages/extension/mcp/tools/code/styles/background.ts +++ b/packages/extension/mcp/tools/code/styles/background.ts @@ -1,4 +1,4 @@ -import type { FigmaLookupReaders } from '@/utils/figma-style/types' +import type { FigmaLookupReaders, PaintResolutionSize } from '@/utils/figma-style/types' import { canonicalizeColor, @@ -9,6 +9,7 @@ import { splitByTopLevelComma, stripFallback } from '@/utils/css' +import { getPaintResolutionSize, isVisiblePaint } from '@/utils/figma-paint' import { resolveBackgroundFillFromPaints, resolveGradientPaintCss @@ -24,7 +25,6 @@ const BG_URL_LIGHTGRAY_RE = /url\(.*?\)\s+lightgray/i const GRADIENT_FN_RE = /(linear-gradient|radial-gradient|conic-gradient)\s*\(/i type PaintList = Paint[] | ReadonlyArray | null | undefined -type GradientSize = { width: number; height: number } const DEFAULT_READERS: FigmaLookupReaders = { getStyleById: (id: string) => figma.getStyleById(id), @@ -42,7 +42,7 @@ export function cleanFigmaSpecificStyles( const fills = getNodeFills(node, ctx) const styleFillPaints = getFillStylePaints(node, ctx) const activeFillPaints = styleFillPaints ?? fills - const gradientSize = getGradientSizeFromNode(node) + const gradientSize = getPaintResolutionSize(node) const readers = ctx?.readers ?? DEFAULT_READERS const backgroundFill = resolveBackgroundFillFromPaints(activeFillPaints, gradientSize, readers, { resolveGradientPaint: resolveGradientPaintValue, @@ -128,26 +128,10 @@ function getNodeFills(node: SceneNode, ctx?: GetCodeCacheContext): ReadonlyArray return null } -function getGradientSizeFromNode(node: SceneNode): GradientSize | undefined { - if (!('width' in node) || !('height' in node)) return undefined - - const width = node.width - const height = node.height - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - return undefined - } - - return { width, height } -} - function isPaintStyle(style: BaseStyle | null): style is PaintStyle { return !!style && 'paints' in style && Array.isArray(style.paints) } -function isVisiblePaint(paint: Paint | null | undefined): paint is Paint { - return !!paint && paint.visible !== false -} - function isSolidPaint(paint: Paint): paint is SolidPaint { return paint.type === 'SOLID' } @@ -218,7 +202,7 @@ function resolveVisibleSolidPaintColor( function resolveGradientPaintValue( gradientPaint: GradientPaint, - size?: GradientSize, + size?: PaintResolutionSize, readers: FigmaLookupReaders = DEFAULT_READERS ): string | null { return resolveGradientPaintCss(gradientPaint, size, readers, formatGradientStopColor) @@ -266,6 +250,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..3c2b7128 100644 --- a/packages/extension/mcp/tools/code/styles/normalize.ts +++ b/packages/extension/mcp/tools/code/styles/normalize.ts @@ -1,7 +1,18 @@ import type { CodegenConfig } from '@/utils/codegen' import type { NestedStyleMap } from '@/utils/tailwind' -import { expandShorthands, normalizeStyleValue, normalizeStyleValues } from '@/utils/css' +import { + expandShorthands, + extractLeadingGradient, + hasOverflowClipping, + isZeroBorderWidth, + negateLengthLiteral, + normalizeStyleValue, + normalizeStyleValues, + parseBorderShorthand, + parseBoxValues +} from '@/utils/css' +import { isRenderablePaint } from '@/utils/figma-paint' import { cssToClassNames, nestedCssToClassNames } from '@/utils/tailwind' import type { GetCodeCacheContext } from '../cache' @@ -13,16 +24,6 @@ import { inferResizingStyles, mergeInferredAutoLayout } from './layout' import { applyOverflowStyles } from './overflow' const BORDER_SIDES = ['top', 'right', 'bottom', 'left'] as const -const OVERFLOW_CLIPPING_VALUES = new Set(['hidden', 'clip']) -const GRADIENT_FUNCTION_PREFIXES = [ - 'linear-gradient(', - 'radial-gradient(', - 'conic-gradient(', - 'repeating-linear-gradient(', - 'repeating-radial-gradient(', - 'repeating-conic-gradient(' -] -const LENGTH_LITERAL_RE = /^(-?(?:\d+\.?\d*|\.\d+))([a-z%]+)$/i const RING_MASK_IMAGE = 'linear-gradient(#000 0 0), linear-gradient(#000 0 0)' const RING_MASK_BOX = 'content-box, border-box' @@ -68,20 +69,7 @@ function hasRenderableFill(node: SceneNode, ctx?: GetCodeCacheContext): boolean ? node.fills : null if (!fills) return false - return fills.some(isFillRenderable) -} - -function isFillRenderable(fill: Paint | undefined): boolean { - if (!fill || fill.visible === false) { - return false - } - if (typeof fill.opacity === 'number' && fill.opacity <= 0) { - return false - } - if ('gradientStops' in fill && Array.isArray(fill.gradientStops)) { - return fill.gradientStops.some((stop) => (stop.color?.a ?? 1) > 0) - } - return true + return fills.some(isRenderablePaint) } const LAYOUT_KEYS = new Set([ @@ -153,7 +141,7 @@ export function buildLayoutStyles( } export function styleToClassNames(style: StyleMap, config: CodegenConfig): string[] { - const normalizedStyle = normalizeStyleValues(style, config) + const normalizedStyle = canonicalizeUniformVisibleBorderColor(normalizeStyleValues(style, config)) const resolved = resolveGradientBorderClasses(normalizedStyle) if (!resolved) { return cssToClassNames(normalizedStyle) @@ -162,6 +150,24 @@ export function styleToClassNames(style: StyleMap, config: CodegenConfig): strin return nestedCssToClassNames(resolved.style) } +function canonicalizeUniformVisibleBorderColor(style: StyleMap): StyleMap { + const visibleSides = BORDER_SIDES.filter((side) => { + const width = style[`border-${side}-width`] + return width ? !isZeroBorderWidth(width) : false + }) + if (visibleSides.length === 0) return style + + const colors = visibleSides.map((side) => style[`border-${side}-color`]) + const [color] = colors + if (!color || colors.some((candidate) => candidate !== color)) return style + + const next: StyleMap = { ...style } + for (const side of BORDER_SIDES) { + next[`border-${side}-color`] = color + } + return next +} + type GradientBorderClassResult = { style: NestedStyleMap } @@ -171,7 +177,7 @@ function resolveGradientBorderClasses(style: StyleMap): GradientBorderClassResul if (!gradient) return null const borderWidth = getBorderWidth(style) - if (!borderWidth || isZeroValue(borderWidth)) return null + if (!borderWidth || isZeroBorderWidth(borderWidth)) return null const preserveBorder = !hasOverflowClipping(style) const inset = preserveBorder @@ -221,67 +227,6 @@ function resolveGradientBorderClasses(style: StyleMap): GradientBorderClassResul } } -function extractLeadingGradient(value: string): string | null { - const input = value.trim() - if (!input) return null - - const lower = input.toLowerCase() - if (!GRADIENT_FUNCTION_PREFIXES.some((prefix) => lower.startsWith(prefix))) { - return null - } - - let depth = 0 - let quote: '"' | "'" | null = null - - for (let i = 0; i < input.length; i++) { - const ch = input[i] - if (quote) { - if (ch === '\\') { - i++ - continue - } - if (ch === quote) quote = null - continue - } - - if (ch === '"' || ch === "'") { - quote = ch - continue - } - - if (ch === '(') { - depth++ - continue - } - - if (ch === ')') { - depth = Math.max(0, depth - 1) - if (depth === 0) { - return input.slice(0, i + 1).trim() - } - } - } - - return null -} - -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() } - } - - const parts = normalized.split(/\s+/).filter(Boolean) - return { width: parts[0] } -} - -function parseBoxValues(value: string): [string, string, string, string] { - const parts = value.trim().split(/\s+/) - const [t, r = t, b = t, l = r] = parts - return [t, r, b, l] -} - function getBorderWidth(style: StyleMap): string | null { const sideWidths = BORDER_SIDES.map((side) => { const width = style[`border-${side}-width`] @@ -292,11 +237,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'] @@ -313,36 +256,10 @@ function getBorderWidth(style: StyleMap): string | null { return null } -function hasOverflowClipping(style: StyleMap): boolean { - const overflowValues = [style.overflow, style['overflow-x'], style['overflow-y']] - return overflowValues.some((value) => { - if (!value) return false - const parts = normalizeStyleValue(value).toLowerCase().split(/\s+/).filter(Boolean) - return parts.some((part) => OVERFLOW_CLIPPING_VALUES.has(part)) - }) -} - function isNonRadiusBorderProperty(name: string): boolean { return /^border(?:$|-)/.test(name) && !name.includes('radius') } -function isZeroValue(value: string): boolean { - return /^0(?:\.0+)?(?:[a-z%]+)?$/i.test(normalizeStyleValue(value)) -} - -function negateLengthLiteral(value: string): string | null { - const normalized = normalizeStyleValue(value) - const matched = normalized.match(LENGTH_LITERAL_RE) - if (!matched) return null - - const [, amount, unit] = matched - if (amount.startsWith('-')) { - return `${amount.slice(1)}${unit}` - } - - return `-${amount}${unit}` -} - function stripSvgLayout(style: StyleMap): StyleMap { if ( !style.width && 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..170160f5 100644 --- a/packages/extension/mcp/tools/code/text/style.ts +++ b/packages/extension/mcp/tools/code/text/style.ts @@ -1,4 +1,5 @@ import { canonicalizeValue, formatHexAlpha, toFigmaVarExpr } from '@/utils/css' +import { isRenderablePaint } from '@/utils/figma-paint' import { resolveTextSegmentVariable, resolveVariableAlias } from '@/utils/figma-variables' import { toDecimalPlace } from '@/utils/number' @@ -20,7 +21,7 @@ export function resolveRunAttrs( let visibleSolid: Extract | undefined let hasVisiblePaint = false for (const fill of fills) { - if (!isVisiblePaint(fill.raw)) continue + if (!isRenderablePaint(fill.raw)) continue hasVisiblePaint = true if (fill.type === 'SOLID') { visibleSolid = fill @@ -30,7 +31,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 +50,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) } @@ -96,15 +99,6 @@ export function resolveTokens(textNode: TextNode, seg: StyledTextSegmentSubset) return { typography, fills } } -function isVisiblePaint(paint?: Paint): boolean { - if (!paint || paint.visible === false) return false - if (typeof paint.opacity === 'number' && paint.opacity <= 0) return false - if ('gradientStops' in paint && Array.isArray(paint.gradientStops)) { - return paint.gradientStops.some((stop) => (stop.color?.a ?? 1) > 0) - } - return true -} - export function computeDominantStyle(runStyles: RunStyleEntry[]): Record { if (!runStyles.length) return {} @@ -115,12 +109,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 +148,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 +199,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 string } } - ).variables - const getter = variablesApi?.getVariableModeId - if (typeof getter !== 'function') return undefined - try { - return getter(collectionId) - } catch { - return undefined - } -} - -function pickPreferredModeId( - variable: Variable, - collection?: VariableCollectionInfo | null, - desiredModeId?: string -): string | undefined { - const valuesByMode = variable.valuesByMode ?? {} - if (desiredModeId && desiredModeId in valuesByMode) return desiredModeId - if (collection?.activeModeId && collection.activeModeId in valuesByMode) { - return collection.activeModeId - } - if (collection?.defaultModeId && collection.defaultModeId in valuesByMode) { - return collection.defaultModeId - } - return Object.keys(valuesByMode)[0] -} - -function resolveFallbackValue( - valuesByMode: Variable['valuesByMode'], - modeId: string, - collection: VariableCollectionInfo | null -): unknown { - if (valuesByMode[modeId] !== undefined) return valuesByMode[modeId] - if (collection?.defaultModeId && collection.defaultModeId !== modeId) { - const fallback = valuesByMode[collection.defaultModeId] - if (fallback !== undefined) return fallback - } - return valuesByMode[modeId] -} - -function isVariableAlias(value: unknown): value is VariableAlias { - if (!value || typeof value !== 'object') return false - const alias = value as VariableAlias - return typeof alias.id === 'string' -} - -function serializeVariableValue( - value: unknown, - resolvedType: Variable['resolvedType'], - config: CodegenConfig, - canonicalName?: string -): string | Record | null { - if (value == null) return null - - switch (resolvedType) { - case 'COLOR': - return formatHexAlpha(value as RGBA, (value as RGBA).a) - case 'FLOAT': - if (isUnitlessFloatToken(canonicalName)) { - return String(value) - } - return normalizeCssValue(`${value}px`, config) - case 'BOOLEAN': - return (value as boolean).toString() - case 'STRING': - return String(value) - default: - if (typeof value === 'object') { - return value as Record - } - return null - } -} - -function isUnitlessFloatToken(canonicalName?: string): boolean { - if (!canonicalName) return false - const lower = canonicalName.trim().toLowerCase() - if (!lower.startsWith('--')) return false - - if (lower.startsWith('--font-weight')) return true - if (lower.startsWith('--fontweight')) return true - if (lower.startsWith('--opacity')) return true - if (lower.startsWith('--z-index')) return true - if (lower === '--z') return true - if (lower.startsWith('--z-')) return true - - return false -} - function toLiteralString(value: unknown): string | undefined { if (typeof value === 'string') return value if (typeof value === 'symbol') return undefined diff --git a/packages/extension/mcp/tools/code/tokens/used.ts b/packages/extension/mcp/tools/code/tokens/used.ts index 7bc27e66..24c06e43 100644 --- a/packages/extension/mcp/tools/code/tokens/used.ts +++ b/packages/extension/mcp/tools/code/tokens/used.ts @@ -30,15 +30,22 @@ export async function buildUsedTokens( .map((id) => 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..ff50354b 100644 --- a/packages/extension/mcp/tools/code/tree.ts +++ b/packages/extension/mcp/tools/code/tree.ts @@ -1,26 +1,14 @@ -import { suggestDepthLimit } from '@/mcp/semantic-tree' +import { + classifySemanticAsset, + resolveSemanticTag, + suggestDepthLimit, + summarizeComponentHint +} 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 } - | { type: 'VARIANT'; value: string } - | { type: 'INSTANCE_SWAP'; value: string } - | { type: string; value: unknown } - export function buildVisibleTree(roots: SceneNode[]): VisibleTree { const depthLimit = suggestDepthLimit(roots) const stats: TreeStats = { @@ -106,7 +94,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 +106,7 @@ export function buildVisibleTree(roots: SceneNode[]): VisibleTree { height: toDecimalPlace(node.height) }, renderBounds: getRenderBounds(node), - assetKind: classifyAsset(node), + assetKind: classifySemanticAsset(node), node } @@ -167,30 +155,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 { @@ -228,65 +192,16 @@ function composeDataHint(node: SceneNode): DataHint | undefined { const hints: DataHint = {} if (node.type === 'INSTANCE') { - const instance = node as InstanceNode - const { mainComponent } = instance - const name = - mainComponent?.parent?.type === 'COMPONENT_SET' - ? mainComponent.parent.name - : (mainComponent?.name ?? node.name) - const props = summarizeComponentProperties(instance) ?? '' - if (name) { - hints['data-hint-design-component'] = `${toPascalCase(name)}${props}` - } + const componentHint = summarizeComponentHint(node) + if (componentHint) hints['data-hint-design-component'] = componentHint } return Object.keys(hints).length ? hints : undefined } -function getComponentProperties( - node: InstanceNode -): Record | undefined { - try { - const { componentProperties: props } = node - if (!props || typeof props !== 'object') return undefined - return props as Record - } catch { - return undefined - } -} - -function summarizeComponentProperties(node: InstanceNode): string | undefined { - const properties = getComponentProperties(node) - if (!properties) return undefined - - const variants: string[] = [] - const others: string[] = [] - - for (const [rawKey, prop] of Object.entries(properties)) { - if (!prop) continue - const key = rawKey.split('#')[0] - - switch (prop.type) { - case 'BOOLEAN': - others.push(`${key}=${prop.value ? 'on' : 'off'}`) - break - case 'TEXT': - if (typeof prop.value === 'string' && prop.value.trim()) { - others.push(`${key}=${prop.value}`) - } - break - case 'VARIANT': - if (typeof prop.value === 'string' && prop.value.trim()) { - variants.push(`${key}=${prop.value}`) - } - break - case 'INSTANCE_SWAP': - break - default: - break - } - } - - const entries = [...variants, ...others] - return entries.length ? entries.map((e) => `[${e}]`).join('') : undefined +export function addSubtreeIds(id: string, tree: VisibleTree, target: Set): void { + const node = tree.nodes.get(id) + if (!node || target.has(id)) return + target.add(id) + node.children.forEach((childId) => addSubtreeIds(childId, tree, target)) } 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..719cd85c --- /dev/null +++ b/packages/extension/mcp/tools/design-system-catalog.ts @@ -0,0 +1,186 @@ +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 + cssName?: 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 + className?: 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 + +function resourceSlug(name: string): string { + return name + .normalize('NFKD') + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-|-$/g, '') +} + +function withResourceAliases(entries: CatalogEntry[]): CatalogEntry[] { + const proposed = entries.map((entry) => { + if (entry.kind === 'variable') { + const definition = entry.definition as { codeSyntax?: { WEB?: string } } | undefined + const syntax = definition?.codeSyntax?.WEB + const cssName = + syntax?.match(/^var\((--[a-zA-Z0-9_-]+)\)$/)?.[1] ?? + (syntax && /^--[a-zA-Z0-9_-]+$/.test(syntax) ? syntax : undefined) + return cssName ?? `--${resourceSlug(entry.name) || 'variable'}` + } + return entry.kind === 'style' && entry.styleType === 'TEXT' + ? `type-${resourceSlug(entry.name) || 'text'}` + : undefined + }) + const reserved = new Set(proposed.filter((name): name is string => name !== undefined)) + const counts = new Map() + for (const name of proposed) if (name) counts.set(name, (counts.get(name) ?? 0) + 1) + return entries.map((entry, index) => { + let name = proposed[index] + if (!name) return entry + if (counts.get(name)! > 1) { + const base = `${name}-${entry.ref}` + name = base + for (let suffix = 2; reserved.has(name); suffix += 1) name = `${base}-${suffix}` + reserved.add(name) + } + return entry.kind === 'variable' ? { ...entry, cssName: name } : { ...entry, className: name } + }) +} + +export function registerDesignSystemCatalog( + entries: CatalogEntry[], + fileKey?: string, + orderedRefs = entries.filter((entry) => entry.kind !== 'mode').map((entry) => entry.ref), + warnings: string[] = [] +): DesignSystemCatalog { + entries = withResourceAliases(entries) + 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 new file mode 100644 index 00000000..e093bea7 --- /dev/null +++ b/packages/extension/mcp/tools/design-system.ts @@ -0,0 +1,1457 @@ +import type { + CanvasFigmaEffect, + CanvasFigmaLayoutGrid, + CanvasFigmaPaint, + CanvasFigmaShaderPropertyValue, + CanvasVariableValue, + DesignSystemCatalogCollection, + DesignSystemCatalogComponent, + DesignSystemCatalogShader, + DesignSystemCatalogStyle, + DesignSystemCatalogVariable, + GetDesignSystemParametersInput, + DesignSystemResourcesResult, + DesignSystemFontsResult, + GetDesignSystemResult +} from '@tempad-dev/shared' + +import { + MCP_TOOL_INLINE_BUDGET_BYTES, + buildGetDesignSystemToolResult, + measureCallToolResultBytes, + utf8Bytes +} from '@tempad-dev/shared' + +import { + getContainingPage, + getLocalStyles, + getLocalVariableCollections, + getLocalVariables, + getNodeById, + getVariableById, + getVariableCollectionById +} from '../local-resources' +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' +import { queryAvailableFonts } from './fonts' + +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 compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function sortByName(items: T[]): T[] { + return items.toSorted((left, right) => compareText(left.name, right.name)) +} + +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 { + try { + return await read() + } catch { + return null + } +} + +function describeVariableAlias(alias: VariableAlias): { id: string } { + return { id: alias.id } +} + +function isSupportedVariable(variable: Variable): variable is Variable & { + resolvedType: Extract['resolvedType'] +} { + return ( + variable.resolvedType === 'BOOLEAN' || + variable.resolvedType === 'COLOR' || + variable.resolvedType === 'FLOAT' || + variable.resolvedType === 'STRING' + ) +} + +function describeVariableValue(value: VariableValue): CanvasVariableValue | undefined { + if (typeof value !== 'object' || !('type' in value)) return value + return value.type === 'VARIABLE_ALIAS' ? { variable: describeVariableAlias(value) } : undefined +} + +function describeVariableValues( + values: Record +): Record { + return Object.fromEntries( + Object.entries(values).flatMap(([modeId, value]) => { + const described = describeVariableValue(value) + return described === undefined ? [] : [[modeId, described]] + }) + ) +} + +function componentProperties(definitions: ComponentPropertyDefinitions) { + const entries = Object.entries(definitions).map(([name, definition]) => { + 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 } : {}), + ...(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 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 = 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 } : {}), + ...(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 + } +} + +function collectComponents(warnings: string[]) { + const components: ReturnType[] = [] + let unreadablePages = 0 + let pages: readonly PageNode[] + try { + pages = figma.root.children + } catch { + pages = [figma.currentPage] + } + 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 components +} + +async function collectVariables(referencedDefinitionIds: Set, warnings: string[]) { + try { + const [localVariables, localCollections] = await Promise.all([ + getLocalVariables(), + getLocalVariableCollections() + ]) + const variablesById = new Map(localVariables.map((variable) => [variable.id, variable])) + 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 attemptedVariableIds = new Set(variablesById.keys()) + let pendingVariableIds = [...referencedVariableIds].filter( + (id) => !attemptedVariableIds.has(id) + ) + let unreadableVariable = false + while (pendingVariableIds.length) { + pendingVariableIds.forEach((id) => attemptedVariableIds.add(id)) + const remoteVariables = await Promise.all( + pendingVariableIds.map((id) => readOrNull(() => getVariableById(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 supportedVariables = variables.filter(isSupportedVariable) + if (supportedVariables.length !== variables.length) { + warnings.push( + 'Variables with unsupported types were skipped; only BOOLEAN, COLOR, FLOAT, and STRING are supported.' + ) + } + const collectionsById = new Map( + localCollections.map((collection) => [collection.id, collection]) + ) + const remoteCollectionIds = [ + ...new Set( + variables + .map((variable) => variable.variableCollectionId) + .filter((id) => !collectionsById.has(id)) + ) + ] + const remoteCollections = await Promise.all( + remoteCollectionIds.map((id) => readOrNull(() => getVariableCollectionById(id))) + ) + for (const collection of remoteCollections) { + if (collection) collectionsById.set(collection.id, collection) + } + + return { + variables: supportedVariables.map((variable) => { + const description = boundedText(variable.description) + 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, + ...(variable.codeSyntax?.WEB ? { codeSyntax: { WEB: variable.codeSyntax.WEB } } : {}), + ...(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) + .filter(([variableId]) => { + const variable = variablesById.get(variableId) + return !variable || isSupportedVariable(variable) + }) + .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 } : {}) + } + }) + } + } 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 { + ...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 = boundedText(style.description) + const descriptionMarkdown = boundedText(style.descriptionMarkdown) + 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('Styles could not be read in the current Figma context.') + 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 ('stops' in value) { + return { + stops: value.stops.map((stop) => ({ + position: stop.position, + color: describeShaderColor(stop.color) + })) + } + } + return value as CanvasFigmaShaderPropertyValue +} + +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, + ...(entry.cssName ? { cssName: entry.cssName } : {}), + 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), + ...(entry.className ? { className: entry.className } : {}), + ...(summary ? { summary } : {}) + } + } + case 'shader': + return { + ref: entry.ref, + name: entry.name, + type: entry.shaderType + } + case 'mode': + return undefined + } +} + +async function resolveCatalogComponent(entry: CatalogComponent): Promise { + const node = entry.reference.id ? await readOrNull(() => getNodeById(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 = getContainingPage(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 + } +} + +type ComponentDetail = Awaited> +type ComponentDetailProperty = NonNullable[string] +type MutableComponentDetailProperty = Omit< + ComponentDetailProperty, + 'description' | 'omittedOptions' | 'options' | 'preferredValues' +> & { + description?: string + omittedOptions?: number + options?: string[] + preferredValues?: Array[number]> +} +type CompactComponentDetail = ComponentDetail & { + detailTruncated?: true + omittedProperties?: number +} + +function exactCatalogPayload( + catalogId: string, + entry: CatalogEntry, + definition: unknown +): DesignSystemResourcesResult { + return { + catalogId, + components: [], + variables: [], + collections: [], + styles: [], + details: { + ref: entry.ref, + kind: entry.kind, + definition + } + } +} + +function exactResultFits(result: DesignSystemResourcesResult): boolean { + return ( + measureCallToolResultBytes(buildGetDesignSystemToolResult(result)) <= + MCP_TOOL_INLINE_BUDGET_BYTES + ) +} + +function removeTailHalf(items: T[]): T[] { + return items.splice(Math.floor(items.length / 2)) +} + +function compactComponentDetailResult( + catalogId: string, + entry: CatalogComponent, + source: ComponentDetail +): DesignSystemResourcesResult { + const mutableProperties = source.properties + ? (Object.fromEntries( + Object.entries(source.properties).map(([name, property]) => [ + name, + { + ...property, + ...(property.options ? { options: [...property.options] } : {}), + ...(property.preferredValues ? { preferredValues: [...property.preferredValues] } : {}) + } + ]) + ) as Record) + : undefined + const detail: CompactComponentDetail = { + ...source, + ...(mutableProperties ? { properties: mutableProperties } : {}), + variants: source.variants.map((variant) => ({ + ...variant, + ...(variant.properties ? { properties: { ...variant.properties } } : {}) + })), + anatomy: { ...source.anatomy, nodes: [...source.anatomy.nodes] } + } + const originalAnatomyCount = source.anatomy.nodes.length + (source.anatomy.omitted ?? 0) + const result = () => exactCatalogPayload(catalogId, entry, detail) + const fits = () => exactResultFits(result()) + + while (!fits() && detail.anatomy.nodes.length) { + removeTailHalf(detail.anatomy.nodes) + detail.anatomy.omitted = originalAnatomyCount - detail.anatomy.nodes.length + detail.anatomy.truncated = true + detail.detailTruncated = true + } + while (!fits() && detail.variants.length) { + removeTailHalf(detail.variants) + detail.omittedVariants = detail.variantCount - detail.variants.length + detail.detailTruncated = true + } + + const verboseFields = [ + 'descriptionMarkdown', + 'componentSetDescriptionMarkdown', + 'documentationLinks', + 'componentSetDocumentationLinks', + 'description', + 'componentSetDescription' + ] as const + for (const field of verboseFields) { + if (fits()) break + delete detail[field] + detail.detailTruncated = true + } + + if (!fits()) { + for (const property of Object.values(mutableProperties ?? {})) { + delete property.description + delete property.preferredValues + } + detail.detailTruncated = true + } + while (!fits()) { + let removed = 0 + for (const property of Object.values(mutableProperties ?? {})) { + if (!property.options?.length) continue + const removeCount = removeTailHalf(property.options).length + property.omittedOptions = (property.omittedOptions ?? 0) + removeCount + removed += removeCount + } + if (!removed) break + detail.detailTruncated = true + } + const propertyNames = Object.keys(mutableProperties ?? {}) + while (!fits() && propertyNames.length) { + const removedNames = removeTailHalf(propertyNames) + for (const name of removedNames) { + delete mutableProperties?.[name] + } + detail.omittedProperties = (detail.omittedProperties ?? 0) + removedNames.length + detail.detailTruncated = true + } + if (fits()) return result() + + const minimalDefinition = { + id: source.id, + key: source.key, + name: boundedText(source.name, MAX_SUMMARY_LENGTH) ?? source.name, + pageId: source.pageId, + pageName: boundedText(source.pageName, MAX_SUMMARY_LENGTH) ?? source.pageName, + width: source.width, + height: source.height, + remote: source.remote, + ...('componentSetId' in source + ? { + componentSetId: source.componentSetId, + componentSetKey: source.componentSetKey, + componentSetName: + boundedText(source.componentSetName, MAX_SUMMARY_LENGTH) ?? source.componentSetName + } + : {}), + variantCount: source.variantCount, + variants: [], + ...(source.variantCount ? { omittedVariants: source.variantCount } : {}), + anatomy: { + nodes: [], + ...(originalAnatomyCount ? { omitted: originalAnatomyCount } : {}), + truncated: true as const + }, + previewNodeId: source.previewNodeId, + detailTruncated: true as const, + ...(source.properties ? { omittedProperties: Object.keys(source.properties).length } : {}) + } + const minimalResult = exactCatalogPayload(catalogId, entry, minimalDefinition) + if (!exactResultFits(minimalResult)) { + throw new Error(`Design-system component identity "${entry.ref}" exceeds the inline budget.`) + } + return minimalResult +} + +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 = exactCatalogPayload(catalogId, entry, definition) + if (exactResultFits(result)) return result + if (entry.kind === 'component') { + return compactComponentDetailResult(catalogId, entry, definition as ComponentDetail) + } + throw new Error(`Design-system definition "${ref}" exceeds the 64 KiB inline result budget.`) +} + +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 +): DesignSystemResourcesResult { + const selected: CatalogEntry[] = [] + const build = (): DesignSystemResourcesResult => { + const result: DesignSystemResourcesResult = { + 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): DesignSystemResourcesResult { + 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 styleWarnings: string[] = [] + const shaderWarnings: string[] = [] + const [components, styles, availableShaders] = await Promise.all([ + collectComponents(componentWarnings), + collectStyles(styleWarnings), + collectShaders(shaderWarnings) + ]) + 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, + catalog.orderedRefs.map((ref) => catalog.entries.get(ref)!), + warnings + ) +} + +let pendingCatalog: Promise | undefined + +export function handleGetDesignSystem( + args: GetDesignSystemParametersInput & { scope: 'fonts' } +): Promise +export function handleGetDesignSystem( + args?: GetDesignSystemParametersInput & { scope?: 'resources' } +): Promise +export function handleGetDesignSystem( + args: GetDesignSystemParametersInput +): Promise +export async function handleGetDesignSystem( + args: GetDesignSystemParametersInput = {} +): Promise { + if (args.scope === 'fonts') return queryAvailableFonts(args) + 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/fonts.ts b/packages/extension/mcp/tools/fonts.ts new file mode 100644 index 00000000..540dc391 --- /dev/null +++ b/packages/extension/mcp/tools/fonts.ts @@ -0,0 +1,55 @@ +import type { DesignSystemFontsResult, GetDesignSystemParametersInput } from '@tempad-dev/shared' + +import { utf8Bytes } from '@tempad-dev/shared' + +// Query the environment only: no file nodes, styles, components, or library imports. +export async function queryAvailableFonts( + args: GetDesignSystemParametersInput +): Promise { + const available = await figma.listAvailableFontsAsync() + const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0) + const families = [...new Set(available.map(({ fontName }) => fontName.family))].sort(compare) + const cursor = args.cursor ?? 0 + const result: DesignSystemFontsResult = { scope: 'fonts' } + const rows = args.families + ? [ + ...new Map( + available + .filter(({ fontName }) => args.families!.includes(fontName.family)) + .map(({ fontName }) => [JSON.stringify(fontName), fontName]) + ).values() + ].sort((a, b) => compare(a.family, b.family) || compare(a.style, b.style)) + : families.filter( + (family) => !args.query || family.toLowerCase().includes(args.query.toLowerCase()) + ) + if (cursor > rows.length || (cursor > 0 && cursor === rows.length)) { + throw new Error('Font cursor is outside the current query. Restart the font query.') + } + if (args.families) { + result.fonts = [] + const missing = [...new Set(args.families)].filter((family) => !families.includes(family)) + if (missing.length) result.missingFamilies = missing + } else result.families = [] + if (utf8Bytes(result) > 12 * 1024) { + throw new Error('Missing font-family names exceed the response budget; query fewer families.') + } + let index = cursor + for (; index < rows.length; index += 1) { + const row = rows[index]! + if (typeof row === 'string') result.families!.push(row) + else result.fonts!.push(row) + if (utf8Bytes(result) > 12 * 1024) { + if (typeof row === 'string') result.families!.pop() + else result.fonts!.pop() + if (index === cursor) + throw new Error('A native font name exceeds the bounded font response budget.') + break + } + if (index - cursor >= 31) { + index += 1 + break + } + } + if (index < rows.length) result.nextCursor = index + return result +} diff --git a/packages/extension/mcp/tools/screenshot.ts b/packages/extension/mcp/tools/screenshot.ts index 75f7887a..7f9f3d02 100644 --- a/packages/extension/mcp/tools/screenshot.ts +++ b/packages/extension/mcp/tools/screenshot.ts @@ -1,30 +1,260 @@ 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 } +interface PngCropRect { + x: number + y: number + width: number + height: number +} + +interface CropSource { + sourceBounds: Rect + targetBounds: Rect +} + +interface AncestorCropSource extends CropSource { + ancestor: SceneNode +} + +interface ScreenshotRuntimeOptions { + cropPng?: (bytes: Uint8Array, rect: PngCropRect) => Promise +} + +function finiteRect(value: Rect | null | undefined): Rect | null { + if ( + !value || + !Number.isFinite(value.x) || + !Number.isFinite(value.y) || + !Number.isFinite(value.width) || + !Number.isFinite(value.height) || + value.width <= 0 || + value.height <= 0 + ) { + return null + } + return value +} + +function overlapBounds(node: SceneNode): Rect | null { + const absoluteRenderBounds = + 'absoluteRenderBounds' in node ? node.absoluteRenderBounds : undefined + return finiteRect(absoluteRenderBounds) ?? finiteRect(node.absoluteBoundingBox) +} + +function intersects(a: Rect, b: Rect): boolean { + return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y +} + +function contains(outer: Rect, inner: Rect): boolean { + return ( + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height + ) +} + +function sameRect(a: Rect, b: Rect): boolean { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height +} + +function resolveDirectCropSource(node: SceneNode): CropSource | null { + const sourceBounds = overlapBounds(node) + const targetBounds = finiteRect(node.absoluteBoundingBox) + if ( + !sourceBounds || + !targetBounds || + sameRect(sourceBounds, targetBounds) || + !contains(sourceBounds, targetBounds) + ) { + return null + } + return { sourceBounds, targetBounds } +} + +function resolveAncestorCropSource(node: SceneNode): AncestorCropSource | null { + const targetBounds = finiteRect(node.absoluteBoundingBox) + if (!targetBounds) return null + + let branch: BaseNode = node + let parent = node.parent + let ancestor: SceneNode | null = null + + while (parent) { + if (!('children' in parent)) return null + + for (const sibling of parent.children) { + const siblingBounds = + sibling === branch || sibling.visible === false ? null : overlapBounds(sibling) + if (siblingBounds && intersects(targetBounds, siblingBounds)) return null + } + + if (parent.type === 'PAGE') { + if (!ancestor || ancestor === node) return null + const ancestorBounds = finiteRect(ancestor.absoluteBoundingBox) + if (!ancestorBounds || !contains(ancestorBounds, targetBounds)) return null + return { ancestor, sourceBounds: ancestorBounds, targetBounds } + } + + if (!('visible' in parent) || !('absoluteBoundingBox' in parent)) return null + ancestor = parent as SceneNode + branch = parent + parent = parent.parent + } + + return null +} + +function resolveCropRect( + source: CropSource, + image: { width: number; height: number } +): PngCropRect | null { + const scaleX = image.width / source.sourceBounds.width + const scaleY = image.height / source.sourceBounds.height + if ( + !Number.isFinite(scaleX) || + !Number.isFinite(scaleY) || + scaleX <= 0 || + scaleY <= 0 || + Math.abs(scaleX - scaleY) > 0.01 + ) { + return null + } + + const left = Math.round((source.targetBounds.x - source.sourceBounds.x) * scaleX) + const top = Math.round((source.targetBounds.y - source.sourceBounds.y) * scaleY) + const right = Math.round( + (source.targetBounds.x + source.targetBounds.width - source.sourceBounds.x) * scaleX + ) + const bottom = Math.round( + (source.targetBounds.y + source.targetBounds.height - source.sourceBounds.y) * scaleY + ) + const rect = { x: left, y: top, width: right - left, height: bottom - top } + + if ( + rect.x < 0 || + rect.y < 0 || + rect.width <= 0 || + rect.height <= 0 || + rect.x + rect.width > image.width || + rect.y + rect.height > image.height + ) { + return null + } + return rect +} + +function canvasToPng(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob) + else reject(new Error('Browser canvas could not encode the cropped PNG screenshot.')) + }, 'image/png') }) } -export async function handleGetScreenshot(node: SceneNode): Promise { +export async function cropPngWithCanvas(bytes: Uint8Array, rect: PngCropRect): Promise { + const sourceBlob = new Blob([bytes.slice().buffer], { type: 'image/png' }) + const bitmap = await createImageBitmap(sourceBlob) + + try { + if ( + rect.x < 0 || + rect.y < 0 || + rect.width <= 0 || + rect.height <= 0 || + rect.x + rect.width > bitmap.width || + rect.y + rect.height > bitmap.height + ) { + throw new Error('Requested PNG crop is outside the exported source bounds.') + } + + const canvas = document.createElement('canvas') + canvas.width = rect.width + canvas.height = rect.height + const context = canvas.getContext('2d') + if (!context) throw new Error('Browser canvas 2D context is unavailable.') + context.drawImage( + bitmap, + rect.x, + rect.y, + rect.width, + rect.height, + 0, + 0, + rect.width, + rect.height + ) + const cropped = await canvasToPng(canvas) + return new Uint8Array(await cropped.arrayBuffer()) + } finally { + bitmap.close() + } +} + +function readPngDimensions(bytes: Uint8Array): { width: number; height: number } { + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + const isPng = + bytes.byteLength >= 24 && + signature.every((value, index) => bytes[index] === value) && + bytes[12] === 0x49 && + bytes[13] === 0x48 && + bytes[14] === 0x44 && + bytes[15] === 0x52 + + if (!isPng) { + throw new Error('Figma returned an invalid PNG screenshot.') + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const width = view.getUint32(16) + const height = view.getUint32(20) + + if (width === 0 || height === 0) { + throw new Error('Figma returned a PNG screenshot with invalid dimensions.') + } + + return { width, height } +} + +export async function handleGetScreenshot( + node: SceneNode, + options: ScreenshotRuntimeOptions = {} +): Promise { + const directCropSource = resolveDirectCropSource(node) + const ancestorCropSource = resolveAncestorCropSource(node) + const cropPng = options.cropPng ?? cropPngWithCanvas + for (const scale of SCALE_STEPS) { - const bytes = await exportAtScale(node, scale) + const directBytes = await node.exportAsync({ + format: 'PNG', + constraint: { type: 'SCALE', value: scale } + }) + const directCropRect = directCropSource + ? resolveCropRect(directCropSource, readPngDimensions(directBytes)) + : null + let bytes = directCropRect ? await cropPng(directBytes, directCropRect) : directBytes + + if (!directCropRect && ancestorCropSource) { + const ancestorBytes = await ancestorCropSource.ancestor.exportAsync({ + format: 'PNG', + constraint: { type: 'SCALE', value: scale }, + useAbsoluteBounds: true + }) + const ancestorDimensions = readPngDimensions(ancestorBytes) + const ancestorCropRect = resolveCropRect(ancestorCropSource, ancestorDimensions) + if (ancestorCropRect) bytes = await cropPng(ancestorBytes, ancestorCropRect) + } const { byteLength } = bytes - if (byteLength <= SCREENSHOT_MAX_BYTES) { - const width = Math.round(node.width * scale) - const height = Math.round(node.height * scale) + if (byteLength <= MCP_MAX_ASSET_BYTES) { + const { width, height } = readPngDimensions(bytes) const asset = await ensureAssetUploaded(bytes, 'image/png', { width, height }) return { @@ -39,6 +269,6 @@ export async function handleGetScreenshot(node: SceneNode): Promise, + nativeById: ReadonlyMap +): GetStructureResult { + if (!roots.length) return { roots } + + const totalNodes = countStructureNodes(roots) + const result = (compactRoots: StructureNode[]): GetStructureResult => ({ + roots: compactRoots, + ...(countStructureNodes(compactRoots) < totalNodes ? { truncated: true as const } : {}) + }) - const initial = compactByNodeLimit(roots, STRUCTURE_NODE_LIMIT_STEPS[0]) - if (estimateToolResultBytes(initial) <= MCP_TOOL_INLINE_BUDGET_BYTES) { - return initial + const initial = compactByNodeLimit( + roots, + STRUCTURE_NODE_LIMIT_STEPS[0], + authoringKeys, + nativeById + ) + if (estimateToolResultBytes(result(initial)) <= MCP_TOOL_INLINE_BUDGET_BYTES) { + return result(initial) } for (const nodeLimit of STRUCTURE_NODE_LIMIT_STEPS.slice(1)) { - const candidate = compactByNodeLimit(roots, nodeLimit) - if (estimateToolResultBytes(candidate) <= MCP_TOOL_INLINE_BUDGET_BYTES) { - return candidate + const candidate = compactByNodeLimit(roots, nodeLimit, authoringKeys, nativeById) + if (estimateToolResultBytes(result(candidate)) <= MCP_TOOL_INLINE_BUDGET_BYTES) { + return result(candidate) } } - return [] + return { roots: [], truncated: true } } -function compactByNodeLimit(roots: StructureNode[], nodeLimit: number): StructureNode[] { +function countStructureNodes(roots: StructureNode[]): number { + let count = 0 + const pending = [...roots] + while (pending.length) { + const node = pending.pop()! + count += 1 + if (node.children) pending.push(...node.children) + } + return count +} + +function compactByNodeLimit( + roots: StructureNode[], + nodeLimit: number, + authoringKeys: ReadonlyMap, + nativeById: 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 native = nativeById.get(node.id) const compact: StructureNode = { id: sanitizeId(node.id, `node-${seen}`), @@ -61,7 +109,9 @@ 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 } : {}), + ...(native ? { native } : {}) } if (Array.isArray(node.children) && node.children.length && seen < nodeLimit) { @@ -86,6 +136,99 @@ function compactByNodeLimit(roots: StructureNode[], nodeLimit: number): Structur return compactRoots } +function collectStructureMetadata( + roots: SceneNode[], + outline: StructureNode[], + nodeLimit: number, + includeNative: boolean +): { + authoringKeys: Map + nativeById: Map +} { + const authoringKeys = new Map() + const nativeById = new Map() + const remaining = collectOutlineIds(outline, nodeLimit) + if (!remaining.size) return { authoringKeys, nativeById } + + for (const node of walkPhysicalNodes(roots)) { + if (!remaining.delete(node.id)) continue + + const key = readOwnedNodeKey(node) + if (key) authoringKeys.set(node.id, key) + + if (includeNative) { + const native = describeNativeProperties(node) + if (native) nativeById.set(node.id, native) + } + if (!remaining.size) break + } + + return { authoringKeys, nativeById } +} + +function describeNativeProperties(node: SceneNode): OutlineNativeProperties | undefined { + const native: OutlineNativeProperties = {} + + if ('isMask' in node && node.isMask && 'maskType' in node) { + native.mask = node.maskType + } + + if ('fills' in node && Array.isArray(node.fills)) { + const imageFills = node.fills + .filter((fill): fill is ImagePaint => fill.type === 'IMAGE') + .map((fill) => ({ + imageHash: fill.imageHash, + scaleMode: fill.scaleMode, + visible: fill.visible ?? true, + opacity: fill.opacity ?? 1 + })) + if (imageFills.length) native.imageFills = imageFills + } + + if ('layoutGrids' in node && Array.isArray(node.layoutGrids) && node.layoutGrids.length) { + native.layoutGrids = node.layoutGrids.map(describeLayoutGrid) + } + + if ('guides' in node && Array.isArray(node.guides) && node.guides.length) { + native.guides = node.guides.map(({ axis, offset }) => ({ axis, offset })) + } + + return Object.keys(native).length ? native : undefined +} + +function describeLayoutGrid(grid: LayoutGrid): CanvasFigmaLayoutGrid { + const { boundVariables, ...fields } = grid + const variableEntries = Object.entries(boundVariables ?? {}).flatMap(([field, variable]) => + variable ? [[field, { id: variable.id }] as const] : [] + ) + const variables = variableEntries.length ? Object.fromEntries(variableEntries) : undefined + + return { + ...fields, + ...(grid.pattern === 'GRID' + ? {} + : { + count: grid.count === Infinity ? ('AUTO' as const) : grid.count + }), + ...(variables ? { variables } : {}) + } as CanvasFigmaLayoutGrid +} + +function collectOutlineIds(outline: StructureNode[], nodeLimit: number): Set { + const ids = new Set() + + const addIds = (nodes: StructureNode[]): boolean => { + for (const node of nodes) { + ids.add(node.id) + if (ids.size >= nodeLimit || (node.children && addIds(node.children))) return true + } + return false + } + + addIds(outline) + return ids +} + function sanitizeName(value: unknown): string { if (typeof value !== 'string') return '' const normalized = value.replace(/\s+/g, ' ').trim() @@ -111,6 +254,6 @@ function sanitizeNumber(value: unknown): number { return Math.round(value * STRUCTURE_COORD_PRECISION) / STRUCTURE_COORD_PRECISION } -function estimateToolResultBytes(roots: StructureNode[]): number { - return measureCallToolResultBytes(buildGetStructureToolResult({ roots })) +function estimateToolResultBytes(result: GetStructureResult): number { + return measureCallToolResultBytes(buildGetStructureToolResult(result)) } diff --git a/packages/extension/mcp/tools/token/defs.ts b/packages/extension/mcp/tools/token/defs.ts index 89a6facc..ed992007 100644 --- a/packages/extension/mcp/tools/token/defs.ts +++ b/packages/extension/mcp/tools/token/defs.ts @@ -9,11 +9,17 @@ import { import type { CodegenConfig } from '@/utils/codegen' import { activePlugin } from '@/ui/state' -import { formatHexAlpha, normalizeCssValue } from '@/utils/css' import { logger } from '@/utils/log' import { currentCodegenConfig } from '../config' import { canonicalizeName, canonicalizeNames, getTokenIndex, getVariableRawName } from './indexer' +import { + isVariableAlias, + pickPreferredModeId, + readActiveModeId, + resolveFallbackValue, + serializeVariableValue +} from './value' type TokenModeValue = { modeId: string @@ -23,7 +29,6 @@ type TokenModeValue = { aliasChain?: string[] } -type VariableAlias = { id?: string } | { type?: string; id?: string } type VariableWithCollection = Variable & { variableCollectionId?: string; resolvedType?: string } type VariableCollectionInfo = { id?: string @@ -135,8 +140,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 +261,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 @@ -286,7 +292,9 @@ function resolveVariableCollection(variable: Variable): VariableCollectionInfo | id: collection.id, name: collection.name, defaultModeId: collection.defaultModeId, - activeModeId: readActiveModeId(collection.id), + activeModeId: readActiveModeId(collection.id, (error) => + logger.warn('Failed to read active mode id:', error) + ), modes: Array.isArray(collection.modes) ? collection.modes.map((m) => ({ id: m.modeId, name: m.name })) : undefined @@ -310,37 +318,6 @@ function trackCollectionName(collection: VariableCollection): void { } } -function readActiveModeId(collectionId?: string): string | undefined { - if (!collectionId) return undefined - const variablesApi = ( - figma as unknown as { variables?: { getVariableModeId?: (id: string) => string } } - ).variables - const getter = variablesApi?.getVariableModeId - if (typeof getter !== 'function') return undefined - try { - return getter(collectionId) - } catch (error) { - logger.warn('Failed to read active mode id:', error) - return undefined - } -} - -function pickPreferredModeId( - variable: Variable, - collection?: VariableCollectionInfo | null, - desiredModeId?: string -): string | undefined { - const { valuesByMode = {} } = variable - if (desiredModeId && desiredModeId in valuesByMode) return desiredModeId - if (collection?.activeModeId && collection.activeModeId in valuesByMode) { - return collection.activeModeId - } - if (collection?.defaultModeId && collection.defaultModeId in valuesByMode) { - return collection.defaultModeId - } - return Object.keys(valuesByMode)[0] -} - async function resolveModeValue( variable: Variable, modeId: string, @@ -415,74 +392,6 @@ async function resolveModeValue( } } -function resolveFallbackValue( - valuesByMode: Variable['valuesByMode'], - modeId: string, - collection: VariableCollectionInfo | null -): unknown { - if (valuesByMode[modeId] !== undefined) return valuesByMode[modeId] - if (collection?.defaultModeId && collection.defaultModeId !== modeId) { - const fallback = valuesByMode[collection.defaultModeId] - if (fallback !== undefined) return fallback - } - return valuesByMode[modeId] -} - -function isVariableAlias(value: unknown): value is VariableAlias { - if (!value || typeof value !== 'object') return false - const alias = value as VariableAlias - return typeof alias.id === 'string' -} - -function serializeVariableValue( - value: unknown, - resolvedType: Variable['resolvedType'], - config: CodegenConfig, - canonicalName?: string -): string | Record | null { - if (value == null) return null - - switch (resolvedType) { - case 'COLOR': - return formatHexAlpha(value as RGBA, (value as RGBA).a) - case 'FLOAT': - if (isUnitlessFloatToken(canonicalName)) { - return String(value) - } - // Default: treat numbers as pixels and normalize (e.g. 16 -> 1rem) - return normalizeCssValue(`${value}px`, config) - case 'BOOLEAN': - return (value as boolean).toString() - case 'STRING': - return String(value) - default: - if (typeof value === 'object') { - return value as Record - } - return null - } -} - -function isUnitlessFloatToken(canonicalName?: string): boolean { - if (!canonicalName) return false - const lower = canonicalName.trim().toLowerCase() - if (!lower.startsWith('--')) return false - - // Typography weights are unitless. - if (lower.startsWith('--font-weight')) return true - if (lower.startsWith('--fontweight')) return true - - // Opacity values are unitless. - if (lower.startsWith('--opacity')) return true - - // z-index values are unitless. - if (lower.startsWith('--z-index')) return true - if (lower === '--z') return true - if (lower.startsWith('--z-')) return true - - return false -} - async function resolveAliasName( id: string, index: Awaited>, diff --git a/packages/extension/mcp/tools/token/indexer.ts b/packages/extension/mcp/tools/token/indexer.ts index f9c9156e..89cdfc10 100644 --- a/packages/extension/mcp/tools/token/indexer.ts +++ b/packages/extension/mcp/tools/token/indexer.ts @@ -1,5 +1,6 @@ import type { CodegenConfig } from '@/utils/codegen' +import { getLocalVariables } from '@/mcp/local-resources' import { runTransformVariableBatch } from '@/mcp/transform-variables/requester' import { workerUnitOptions } from '@/utils/codegen' import { canonicalizeVarName as canonicalizeCssVarName, normalizeFigmaVarName } from '@/utils/css' @@ -87,9 +88,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) }) } @@ -112,7 +112,7 @@ export async function getTokenIndex( } const promise = (async (): Promise => { - const variables = await figma.variables.getLocalVariablesAsync() + const variables = await getLocalVariables() const byCanonicalName = new Map() const canonicalNameById = new Map() @@ -123,8 +123,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/tools/token/value.ts b/packages/extension/mcp/tools/token/value.ts new file mode 100644 index 00000000..eb392d58 --- /dev/null +++ b/packages/extension/mcp/tools/token/value.ts @@ -0,0 +1,97 @@ +import type { CodegenConfig } from '@/utils/codegen' + +import { formatHexAlpha, normalizeCssValue } from '@/utils/css' + +type VariableAlias = { id: string; type?: string } + +type VariableModeContext = { + activeModeId?: string + defaultModeId?: string +} + +export function isVariableAlias(value: unknown): value is VariableAlias { + return !!value && typeof value === 'object' && typeof (value as VariableAlias).id === 'string' +} + +export function readActiveModeId( + collectionId?: string, + onError?: (error: unknown) => void +): string | undefined { + if (!collectionId) return undefined + const getter = ( + figma as unknown as { variables?: { getVariableModeId?: (id: string) => string } } + ).variables?.getVariableModeId + if (typeof getter !== 'function') return undefined + try { + return getter(collectionId) + } catch (error) { + onError?.(error) + return undefined + } +} + +export function pickPreferredModeId( + variable: Variable, + collection?: VariableModeContext | null, + desiredModeId?: string +): string | undefined { + const valuesByMode = variable.valuesByMode ?? {} + if (desiredModeId && desiredModeId in valuesByMode) return desiredModeId + if (collection?.activeModeId && collection.activeModeId in valuesByMode) { + return collection.activeModeId + } + if (collection?.defaultModeId && collection.defaultModeId in valuesByMode) { + return collection.defaultModeId + } + return Object.keys(valuesByMode)[0] +} + +export function resolveFallbackValue( + valuesByMode: Variable['valuesByMode'], + modeId: string, + collection: VariableModeContext | null +): unknown { + if (valuesByMode[modeId] !== undefined) return valuesByMode[modeId] + if (collection?.defaultModeId && collection.defaultModeId !== modeId) { + const fallback = valuesByMode[collection.defaultModeId] + if (fallback !== undefined) return fallback + } + return valuesByMode[modeId] +} + +export function serializeVariableValue( + value: unknown, + resolvedType: Variable['resolvedType'], + config: CodegenConfig, + canonicalName?: string +): string | Record | null { + if (value == null) return null + + switch (resolvedType) { + case 'COLOR': + return formatHexAlpha(value as RGBA, (value as RGBA).a) + case 'FLOAT': + return isUnitlessFloatToken(canonicalName) + ? String(value) + : normalizeCssValue(`${value}px`, config) + case 'BOOLEAN': + return (value as boolean).toString() + case 'STRING': + return String(value) + default: + return typeof value === 'object' ? (value as Record) : null + } +} + +function isUnitlessFloatToken(canonicalName?: string): boolean { + const name = canonicalName?.trim().toLowerCase() + if (!name?.startsWith('--')) return false + return ( + name.startsWith('--font-weight') || + name.startsWith('--fontweight') || + name.startsWith('--opacity') || + name.startsWith('--z-index') || + name === '--z' || + name.startsWith('--z-') + ) +} 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 9eb45e2c..58480eb7 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,8 +1,8 @@ { "name": "@tempad-dev/extension", - "version": "0.20.0", + "version": "0.21.0", "private": true, - "description": "Open handoff tooling for Figma", + "description": "Inspect Figma designs, customize code output, and connect your coding agent to read and edit the canvas.", "type": "module", "scripts": { "dev": "wxt", @@ -17,6 +17,12 @@ "check:rewrite": "tsx ./scripts/check-rewrite.ts", "check:plugin-sandbox": "wxt build && tsx ./scripts/check-plugin-sandbox.ts", "check:worker-sandbox": "tsx ./scripts/check-worker-sandbox.ts", + "codex-host:switch": "tsx ./scripts/switch-codex-host.ts", + "codex-plugin:reinstall": "tsx ./scripts/reinstall-codex-dev-plugin.ts", + "agent-eval:authoring": "tsx ./scripts/inspect-agent-authoring-rollout.ts", + "agent-eval:log": "tsx ./scripts/agent-authoring-run-log.ts", + "agent-eval:preflight": "tsx ./scripts/agent-authoring-runtime-preflight.ts", + "agent-eval:skills": "tsx ./scripts/inspect-agent-skill-catalog.ts", "screenshots": "tsx ./scripts/screenshots.ts", "test": "vitest --config vitest.config.ts", "test:run": "vitest run --config vitest.config.ts && pnpm run check:worker-sandbox && pnpm run check:plugin-sandbox", diff --git a/packages/extension/public/icon-128.png b/packages/extension/public/icon-128.png index 67c2135c..5d1f6bf2 100644 Binary files a/packages/extension/public/icon-128.png and b/packages/extension/public/icon-128.png differ diff --git a/packages/extension/public/icon.svg b/packages/extension/public/icon.svg new file mode 100644 index 00000000..2e8c6946 --- /dev/null +++ b/packages/extension/public/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/extension/screenshots/codex-setup-capture.mjs b/packages/extension/screenshots/codex-setup-capture.mjs new file mode 100644 index 00000000..98362aba --- /dev/null +++ b/packages/extension/screenshots/codex-setup-capture.mjs @@ -0,0 +1,166 @@ +/* global document, innerWidth, innerHeight, devicePixelRatio */ +import { Buffer } from 'node:buffer' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { fileURLToPath, URL } from 'node:url' + +import { captureDialogPng } from './dialog-capture.mjs' + +const { AGENT_INTEGRATIONS } = createRequire(new URL('../../shared/package.json', import.meta.url))( + './dist/index.js' +) + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) + +// Adapter for a tab already selected through Codex's supported browser API. +// All browser actions stay on that tab; this does not create a second browser/profile. +export async function captureSetupCandidate(tab, { scenarioId, theme, outputDir }) { + const manifest = JSON.parse(await readFile(new URL('./scenarios.json', import.meta.url), 'utf8')) + const scenario = manifest.scenarios.find(({ id }) => id === scenarioId) + if (!scenario || scenario.group !== 'setup') + throw new Error('Select a setup scenario from scenarios.json.') + if (!manifest.capture.themes.includes(theme)) throw new Error(`Unknown theme: ${theme}`) + const currentUrl = new URL(await tab.url()) + if ( + currentUrl.hostname !== 'www.figma.com' || + !currentUrl.pathname.split('/').includes(manifest.fixture.file.key) + ) { + throw new Error('Use the canonical Figma fixture tab.') + } + // The operator stages the manifest's target/theme/scroll through the visible UI. + // Capture only verifies and saves, so a configuration-only run never resets the panel. + const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel.setupTarget) + if (!target) throw new Error('Unknown setup target.') + const pluginActions = target.actions.filter(({ id }) => id.startsWith('plugin-')) + const commands = (pluginActions.length ? pluginActions : target.actions).filter( + ({ kind }) => kind !== 'deep-link' + ) + const evidence = await tab.playwright.evaluate(() => { + const panel = document.querySelector('#tp-agent-setup-panel') + const dialog = document.querySelector('tempad [role="dialog"]') + const viewport = panel?.querySelector('[data-overlayscrollbars-viewport]') + const rect = (element) => { + const r = element.getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height } + } + if (!panel || !dialog || !viewport) throw new Error('Setup dialog is incomplete.') + return { + viewport: { width: innerWidth, height: innerHeight, scale: devicePixelRatio }, + theme: document.body.getAttribute('data-preferred-theme'), + dialog: rect(dialog), + content: rect(viewport), + text: panel.innerText, + panelText: document.querySelector('tempad')?.innerText ?? '', + mcpEnabled: + document.querySelector('.tp-agent-integration input[type="radio"][value="true"]') + ?.checked === true, + selectedId: document.querySelector('tempad [role="tab"][aria-selected="true"]')?.id, + code: [...panel.querySelectorAll('code')].map((node) => ({ + text: node.textContent, + rect: rect(node) + })) + } + }) + if ( + evidence.viewport.width !== manifest.capture.viewport.width || + evidence.viewport.height !== manifest.capture.viewport.height || + evidence.viewport.scale !== manifest.capture.sourceScale + ) { + throw new Error('Viewport or device scale does not match the screenshot contract.') + } + if (evidence.theme !== theme) + throw new Error(`Expected ${theme} Figma theme, got ${evidence.theme}.`) + if (evidence.selectedId !== `tp-agent-tab-${target.id}`) + throw new Error('Wrong selected setup target.') + for (const assertion of scenario.assertions) { + if ( + assertion.kind === 'panel-control-state' && + assertion.name === 'MCP access' && + evidence.mcpEnabled !== assertion.value + ) { + throw new Error('MCP access does not match the scenario.') + } + if (assertion.kind === 'panel-text' && !evidence.panelText.includes(assertion.text)) { + throw new Error(`Missing panel text: ${assertion.text}`) + } + if (assertion.kind === 'dialog-text' && !evidence.text.includes(assertion.text)) { + throw new Error(`Setup text is stale: ${assertion.text}`) + } + } + for (const action of commands) { + if (!evidence.text.includes(action.value)) throw new Error(`Stale ${action.id} command.`) + if (!scenario.panel.visibleActionIds.includes(action.id)) continue + const code = evidence.code.find(({ text }) => text === action.value) + const visibleTop = Math.max(evidence.dialog.y, evidence.content.y) + const visibleBottom = Math.min( + evidence.dialog.y + evidence.dialog.height, + evidence.content.y + evidence.content.height + ) + if ( + !code || + code.rect.y < visibleTop - 1 || + code.rect.y + code.rect.height > visibleBottom + 1 || + code.rect.x < evidence.content.x - 1 || + code.rect.x + code.rect.width > evidence.content.x + evidence.content.width + 1 + ) { + throw new Error(`${action.id} is not fully visible. Check the scenario scroll position.`) + } + } + if ( + evidence.dialog.width !== scenario.clip.width || + evidence.dialog.height !== scenario.clip.height + ) { + throw new Error('The dialog dimensions no longer match the capture contract.') + } + if ( + evidence.dialog.x < 0 || + evidence.dialog.y < 0 || + evidence.dialog.x + evidence.dialog.width > evidence.viewport.width || + evidence.dialog.y + evidence.dialog.height > evidence.viewport.height + ) { + throw new Error('The setup dialog extends outside the browser viewport.') + } + const cdp = await tab.capabilities.get('cdp') + const captured = await captureDialogPng((method, params) => cdp.send(method, params), { + ...evidence.dialog, + scale: manifest.capture.clipScale + }) + const buffer = Buffer.from(captured.data, 'base64') + if (buffer.readUInt32BE(16) !== scenario.width || buffer.readUInt32BE(20) !== scenario.height) { + throw new Error('Captured dimensions do not match the scenario.') + } + const directory = resolve(outputDir ?? `${repoRoot}.artifacts/marketing-screenshots`) + await mkdir(directory, { recursive: true }) + const outputPath = resolve(directory, `${scenario.id}-${theme}.png`) + await writeFile(outputPath, buffer) + await writeFile( + resolve(directory, `${scenario.id}-${theme}.json`), + JSON.stringify( + { + scenario: scenario.id, + theme, + fileKey: manifest.fixture.file.key, + capturedAt: new Date().toISOString(), + commands: commands.map(({ id, value }) => ({ id, value })), + evidence + }, + null, + 2 + ) + '\n' + ) + return outputPath +} + +export async function setSetupCaptureTheme(tab, theme) { + if (!['light', 'dark'].includes(theme)) throw new Error(`Unknown theme: ${theme}`) + const dialog = tab.playwright.getByRole('dialog', { name: 'Set up agents', exact: true }) + if (await dialog.count()) await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await tab.playwright.getByRole('button', { name: 'Main menu', exact: true }).click() + await tab.playwright.getByRole('menuitem', { name: 'Preferences', exact: true }).click() + await tab.playwright.getByRole('menuitem', { name: 'Theme', exact: true }).click() + await tab.playwright + .getByRole('menuitemcheckbox', { name: theme === 'dark' ? 'Dark' : 'Light', exact: true }) + .click() + await tab.getAXState({ emit: false }) +} diff --git a/packages/extension/screenshots/dialog-capture.d.mts b/packages/extension/screenshots/dialog-capture.d.mts new file mode 100644 index 00000000..59183aef --- /dev/null +++ b/packages/extension/screenshots/dialog-capture.d.mts @@ -0,0 +1,6 @@ +import type { CDPSession } from 'playwright' + +export function captureDialogPng( + send: CDPSession['send'], + clip: { x: number; y: number; width: number; height: number; scale: number } +): Promise<{ data: string }> diff --git a/packages/extension/screenshots/dialog-capture.mjs b/packages/extension/screenshots/dialog-capture.mjs new file mode 100644 index 00000000..051569a6 --- /dev/null +++ b/packages/extension/screenshots/dialog-capture.mjs @@ -0,0 +1,33 @@ +// Capture the real dialog on a transparent surface. No raster masking or UI reconstruction. +// Every temporary browser override is removed before returning, including after failure. +const captureStyle = ` +html, body { background: transparent !important; } +body * { visibility: hidden !important; } +tempad .tp-dialog-panel, tempad .tp-dialog-panel * { visibility: visible !important; } +tempad .tp-dialog-overlay { background: transparent !important; } +tempad .tp-dialog-panel { box-shadow: none !important; } +` + +export async function captureDialogPng(send, clip) { + const expression = `(() => { + const style = document.createElement('style'); + style.id = 'tempad-dialog-capture'; + style.textContent = ${JSON.stringify(captureStyle)}; + document.head.append(style); + })()` + try { + const prepared = await send('Runtime.evaluate', { expression }) + if (prepared.exceptionDetails) + throw new Error('Could not isolate the setup dialog for capture.') + await send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }) + return await send('Page.captureScreenshot', { format: 'png', fromSurface: true, clip }) + } finally { + try { + await send('Runtime.evaluate', { + expression: "document.querySelector('#tempad-dialog-capture')?.remove()" + }) + } finally { + await send('Emulation.setDefaultBackgroundColorOverride', {}) + } + } +} diff --git a/packages/extension/screenshots/scenarios.json b/packages/extension/screenshots/scenarios.json index cf7ae2a4..d923769d 100644 --- a/packages/extension/screenshots/scenarios.json +++ b/packages/extension/screenshots/scenarios.json @@ -1,10 +1,10 @@ { - "version": 2, + "version": 3, "fixture": { "file": { - "key": "4HPsWWxVESGJ9ka4CDdVMx", + "key": "vJBML2e6g7btKGytwiiyvn", "title": "TemPad Dev fixtures", - "url": "https://www.figma.com/design/4HPsWWxVESGJ9ka4CDdVMx/TemPad-Dev-fixtures" + "url": "https://www.figma.com/design/vJBML2e6g7btKGytwiiyvn/TemPad-Dev-fixtures" }, "kongButton": { "componentKey": "1ee41f133f277b708cf54a74a6c2e294be6664fb", @@ -131,12 +131,23 @@ } }, "assertions": [ - { "kind": "panel-title", "text": "Frame 1" }, - { "kind": "panel-heading", "text": "CSS" }, - { "kind": "panel-heading", "text": "JavaScript" } + { + "kind": "panel-title", + "text": "Frame 1" + }, + { + "kind": "panel-heading", + "text": "CSS" + }, + { + "kind": "panel-heading", + "text": "JavaScript" + } ], "width": 1440, - "height": 960 + "height": 960, + "group": "inspect", + "consumers": ["readme", "site"] }, { "id": "unit", @@ -188,9 +199,20 @@ } }, "assertions": [ - { "kind": "panel-control-value", "name": "CSS unit", "value": "rem" }, - { "kind": "panel-control-value", "name": "Root font size", "value": "16" }, - { "kind": "panel-text", "text": "7.5rem" } + { + "kind": "panel-control-value", + "name": "CSS unit", + "value": "rem" + }, + { + "kind": "panel-control-value", + "name": "Root font size", + "value": "16" + }, + { + "kind": "panel-text", + "text": "7.5rem" + } ], "clip": { "x": 300, @@ -199,7 +221,9 @@ "height": 480 }, "width": 1440, - "height": 960 + "height": 960, + "group": "inspect", + "consumers": ["readme", "site"] }, { "id": "deep", @@ -238,12 +262,23 @@ } }, "assertions": [ - { "kind": "panel-tool-active", "tool": "deepSelect" }, - { "kind": "panel-text", "text": "No selection" }, - { "kind": "figma-selection-count", "value": 0 } + { + "kind": "panel-tool-active", + "tool": "deepSelect" + }, + { + "kind": "panel-text", + "text": "No selection" + }, + { + "kind": "figma-selection-count", + "value": 0 + } ], "width": 1440, - "height": 960 + "height": 960, + "group": "inspect", + "consumers": ["readme", "site"] }, { "id": "measure", @@ -291,12 +326,23 @@ } }, "assertions": [ - { "kind": "panel-tool-active", "tool": "measure" }, - { "kind": "panel-title", "text": "Frame 2" }, - { "kind": "measurement-labels", "values": [20, 20, 20, 20] } + { + "kind": "panel-tool-active", + "tool": "measure" + }, + { + "kind": "panel-title", + "text": "Frame 2" + }, + { + "kind": "measurement-labels", + "values": [20, 20, 20, 20] + } ], "width": 1440, - "height": 960 + "height": 960, + "group": "inspect", + "consumers": ["readme", "site"] }, { "id": "scroll", @@ -331,13 +377,27 @@ "tooltip": "Scroll into view" }, "assertions": [ - { "kind": "panel-title", "text": "Frame 1" }, - { "kind": "panel-heading", "text": "CSS" }, - { "kind": "panel-text-absent", "text": "Kong UI" }, - { "kind": "tooltip", "text": "Scroll into view" } + { + "kind": "panel-title", + "text": "Frame 1" + }, + { + "kind": "panel-heading", + "text": "CSS" + }, + { + "kind": "panel-text-absent", + "text": "Kong UI" + }, + { + "kind": "tooltip", + "text": "Scroll into view" + } ], "width": 1440, - "height": 960 + "height": 960, + "group": "inspect", + "consumers": ["readme", "site"] }, { "id": "plugins", @@ -373,29 +433,144 @@ } }, "assertions": [ - { "kind": "panel-title", "text": "Button" }, - { "kind": "panel-badge", "text": "Kong UI" }, - { "kind": "panel-heading", "text": "Component" }, - { "kind": "panel-text", "text": " Number.isFinite(Date.parse(value)), { + message: 'Expected an ISO UTC timestamp.' + }) +const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) +const DevelopmentPluginCacheVersionPattern = + /(\/plugins\/cache\/tempad-dev-dev\/tempad-dev-dev\/)[^/]+(\/skills\/)/ + +const AgentSettingsSchema = z + .object({ model: z.string().min(1), reasoningEffort: z.string().min(1) }) + .strict() + +const RunExecutionSchema = z + .object({ + taskId: z.string().min(1).nullable(), + model: z.string().min(1).nullable(), + reasoningEffort: z.string().min(1).nullable(), + promptCount: z.number().int().nonnegative(), + promptSha256: Sha256Schema.nullable(), + issues: z.array(z.string()) + }) + .strict() + +const ComparisonSchema = z + .object({ + id: z.string().min(1), + arm: z.enum(['baseline', 'candidate']), + subject: z.string().min(1) + }) + .strict() + +export const AuthoringRunNoteSchema = z + .object({ + schemaVersion: z.literal(1), + id: z.string().min(1), + createdAt: IsoDateSchema, + kind: z.enum(['open', 'probe', 'comparison']), + intent: z.string().min(1), + task: z + .object({ + prompt: z.string().min(1), + expectedPageName: z.string().min(1).optional() + }) + .strict(), + agent: AgentSettingsSchema.optional(), + comparison: ComparisonSchema.optional() + }) + .strict() + .superRefine((note, context) => { + if (note.kind === 'comparison' && !note.comparison) { + context.addIssue({ + code: 'custom', + message: 'Comparison runs require comparison identity, arm, and subject.', + path: ['comparison'] + }) + } + if (note.kind !== 'comparison' && note.comparison) { + context.addIssue({ + code: 'custom', + message: 'Only comparison runs may carry comparison metadata.', + path: ['comparison'] + }) + } + }) + +const RunArtifactsSchema = z + .object({ + taskId: z.string().min(1).nullable(), + pageId: z.string().min(1).nullable(), + pageName: z.string().min(1).nullable(), + evidence: z.array(z.string().min(1)) + }) + .strict() + +const RunRolloutSchema = z + .object({ + execution: RunExecutionSchema.optional(), + source: z.string().min(1), + sha256: Sha256Schema, + startedAt: IsoDateSchema.nullable(), + promptSha256: Sha256Schema.nullable(), + runtime: z + .object({ + locked: z.boolean(), + valid: z.boolean(), + hubFingerprint: Sha256Schema.nullable(), + extensionFingerprint: Sha256Schema.nullable() + }) + .strict(), + skills: z + .object({ + catalogFingerprint: Sha256Schema, + runtimeFingerprint: Sha256Schema, + contextFingerprint: Sha256Schema, + authoringSkillLocator: z.string().min(1).nullable() + }) + .strict() + .nullable() + }) + .strict() + +export const AuthoringRunReviewDraftSchema = z + .object({ + schemaVersion: z.literal(1), + noteId: z.string().min(1), + status: z.enum(['valid', 'invalid']), + assessment: z.string().min(1), + nextAction: z.string().min(1), + artifacts: RunArtifactsSchema, + skillChangeRationale: z.string().min(1).optional() + }) + .strict() + +export type AuthoringRunNote = z.infer +export type AuthoringRunReviewDraft = z.infer + +const AuthoringRunStartEventSchema = z + .object({ + schemaVersion: z.literal(1), + type: z.literal('start'), + recordedAt: IsoDateSchema, + noteSha256: Sha256Schema, + note: AuthoringRunNoteSchema, + preflight: z.custom() + }) + .strict() + +const AuthoringRunFinishEventSchema = z + .object({ + schemaVersion: z.literal(1), + type: z.literal('finish'), + recordedAt: IsoDateSchema, + noteId: z.string().min(1), + noteSha256: Sha256Schema, + review: AuthoringRunReviewDraftSchema, + rollout: RunRolloutSchema.nullable() + }) + .strict() + +const AuthoringRunAbandonEventSchema = z + .object({ + schemaVersion: z.literal(1), + type: z.literal('abandon'), + recordedAt: IsoDateSchema, + noteId: z.string().min(1), + reason: z.string().min(1) + }) + .strict() + +type AuthoringRunRollout = z.infer +type AuthoringRunStartEvent = z.infer +type AuthoringRunFinishEvent = z.infer +type AuthoringRunAbandonEvent = z.infer + +interface AuthoringRunRecord { + start: AuthoringRunStartEvent + finish: AuthoringRunFinishEvent +} + +interface AuthoringRunLogState { + starts: AuthoringRunStartEvent[] + records: AuthoringRunRecord[] + pending: AuthoringRunStartEvent[] + abandoned: AuthoringRunAbandonEvent[] +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function normalizeDevelopmentPluginLocator(locator: string): string { + return locator.replace(DevelopmentPluginCacheVersionPattern, '$1$2') +} + +function comparisonSkillLocator(name: string, locator: string): string { + return name.startsWith('tempad-dev-dev:') ? normalizeDevelopmentPluginLocator(locator) : locator +} + +function hasSameComparisonSkillContext( + first: AuthoringRunRollout['skills'], + second: AuthoringRunRollout['skills'], + legacy: boolean +): boolean { + if (!first || !second) return first === second + if (first.contextFingerprint === second.contextFingerprint) return true + // Old records used a version-sensitive context hash. Keep them readable, but + // never use that fallback for newly recorded execution evidence. + return ( + legacy && + first.catalogFingerprint === second.catalogFingerprint && + first.authoringSkillLocator !== null && + second.authoringSkillLocator !== null && + normalizeDevelopmentPluginLocator(first.authoringSkillLocator) === + normalizeDevelopmentPluginLocator(second.authoringSkillLocator) + ) +} + +function promptFingerprint(prompt: string): string { + return sha256(prompt.trim().replaceAll(/\s+/g, ' ')) +} + +export function fingerprintRunNote(noteInput: unknown): string { + return sha256(JSON.stringify(AuthoringRunNoteSchema.parse(noteInput))) +} + +function validatePreflight(input: unknown): asserts input is AuthoringPreflightResult { + if (!input || typeof input !== 'object') throw new Error('Run start has no preflight evidence.') + const preflight = input as Partial + const extension = preflight.runtime?.extension + const plugin = preflight.plugin + if ( + preflight.valid !== true || + !Array.isArray(preflight.issues) || + preflight.issues.length > 0 || + typeof preflight.checkedAt !== 'string' || + !Number.isFinite(Date.parse(preflight.checkedAt)) || + typeof preflight.checkout !== 'string' || + !extension || + !Sha256Schema.safeParse(extension.checkoutFingerprint).success || + !plugin || + typeof plugin.generatedVersion !== 'string' || + !plugin.generatedVersion + ) { + throw new Error('Run start requires one successful current-checkout preflight.') + } +} + +export function buildStartEvent( + noteInput: unknown, + preflightInput: unknown, + recordedAt = new Date().toISOString() +): AuthoringRunStartEvent { + const note = AuthoringRunNoteSchema.parse(noteInput) + validatePreflight(preflightInput) + if (!note.agent) throw new Error('New run notes must freeze model and reasoningEffort in agent.') + const event = AuthoringRunStartEventSchema.parse({ + schemaVersion: 1, + type: 'start', + recordedAt, + noteSha256: fingerprintRunNote(note), + note, + preflight: preflightInput + }) + if (Date.parse(note.createdAt) > Date.parse(event.recordedAt)) { + throw new Error('Run note cannot be created after its start event.') + } + if (Date.parse(preflightInput.checkedAt) > Date.parse(event.recordedAt)) { + throw new Error('Run start cannot precede its preflight.') + } + return event +} + +export function validateFreshRunPrompt( + noteInput: AuthoringRunNote, + priorStarts: AuthoringRunStartEvent[] +): void { + const note = AuthoringRunNoteSchema.parse(noteInput) + const duplicate = priorStarts.find(({ note: prior }) => { + if (promptFingerprint(prior.task.prompt) !== promptFingerprint(note.task.prompt)) return false + return !( + note.kind === 'comparison' && + prior.kind === 'comparison' && + note.comparison?.id === prior.comparison?.id && + note.comparison?.arm !== prior.comparison?.arm + ) + }) + if (duplicate) { + throw new Error( + `Live prompt duplicates prior run ${duplicate.note.id}; write a fresh realistic task.` + ) + } +} + +function buildRolloutEvidence( + input?: { + source: string + text: string + }, + inspectionInput?: ReturnType +): AuthoringRunRollout | null { + if (!input) return null + const inspection = inspectionInput ?? inspectAuthoringRollout(input.text) + const catalog = (() => { + try { + return fingerprintSkillCatalog(extractSkillCatalog(input.text)) + } catch { + return null + } + })() + const authoringSkill = catalog?.skills.find( + ({ name }) => name === 'tempad-dev-dev:figma-canvas-authoring' + ) + return { + execution: inspection.execution, + source: basename(input.source), + sha256: sha256(input.text), + startedAt: inspection.timing.rolloutStartedAt, + promptSha256: inspection.prompt.sha256, + runtime: { + locked: inspection.runtime.locked, + valid: inspection.runtime.valid, + hubFingerprint: + inspection.runtime.hubFingerprints.length === 1 + ? (inspection.runtime.hubFingerprints[0] ?? null) + : null, + extensionFingerprint: + inspection.runtime.extensionFingerprints.length === 1 + ? (inspection.runtime.extensionFingerprints[0] ?? null) + : null + }, + skills: catalog + ? { + catalogFingerprint: catalog.catalogFingerprint, + runtimeFingerprint: catalog.runtimeFingerprint, + contextFingerprint: sha256( + JSON.stringify( + catalog.skills + .filter(({ name }) => name !== 'tempad-dev-dev:figma-canvas-authoring') + .map(({ name, description, locatorKind, locator }) => ({ + name, + description, + locatorKind, + locator: comparisonSkillLocator(name, locator) + })) + ) + ), + authoringSkillLocator: authoringSkill?.locator ?? null + } + : null + } +} + +const crossTaskDispatchTools = new Set([ + 'codex_app.create_thread', + 'codex_app.fork_thread', + 'codex_app.handoff_thread', + 'codex_app.send_message_to_thread' +]) + +function crossTaskDispatch( + inspection: ReturnType +): string | undefined { + return Object.keys(inspection.tools.byName).find((name) => crossTaskDispatchTools.has(name)) +} + +function validatesExpectedTempadSkill( + start: AuthoringRunStartEvent, + locator: string | null | undefined +): boolean { + const version = start.preflight.plugin.generatedVersion + return Boolean( + locator + ?.replaceAll('\\', '/') + .includes(`/tempad-dev-dev/${version}/skills/figma-canvas-authoring/SKILL.md`) + ) +} + +export function validateRunRecord(record: AuthoringRunRecord): void { + const start = record.start + const finish = record.finish + validatePreflight(start.preflight) + if (finish.noteId !== start.note.id || finish.review.noteId !== start.note.id) { + throw new Error('Run finish does not match its start note.') + } + if ( + finish.noteSha256 !== start.noteSha256 || + fingerprintRunNote(start.note) !== start.noteSha256 + ) { + throw new Error('Run note changed after start.') + } + if (Date.parse(finish.recordedAt) < Date.parse(start.recordedAt)) { + throw new Error('Run finish predates its start.') + } + if (finish.review.status === 'invalid') return + if (!finish.rollout) throw new Error('A valid live run requires rollout evidence.') + const rolloutStartedAt = finish.rollout.startedAt + if (!rolloutStartedAt || Date.parse(rolloutStartedAt) < Date.parse(start.recordedAt)) { + throw new Error('A valid rollout must start after the run note is frozen.') + } + if (finish.rollout.promptSha256 !== promptFingerprint(start.note.task.prompt)) { + throw new Error('Rollout prompt does not match the frozen live task.') + } + const runtime = finish.rollout.runtime + if (!runtime.locked || !runtime.valid) { + throw new Error('A valid live run requires locked runtime evidence from the rollout.') + } + const expectedExtension = start.preflight.runtime.extension.checkoutFingerprint + if (runtime.extensionFingerprint !== expectedExtension) { + throw new Error('Rollout extension runtime differs from the successful preflight checkout.') + } + if (!validatesExpectedTempadSkill(start, finish.rollout.skills?.authoringSkillLocator)) { + throw new Error('Rollout did not present the TemPad authoring skill verified at run start.') + } + const execution = finish.rollout.execution + if (execution) { + if ( + execution.issues.length || + !execution.taskId || + !execution.model || + !execution.reasoningEffort + ) { + throw new Error( + `Run execution identity is incomplete or changed: ${execution.issues.join(' ')}` + ) + } + if ( + execution.promptCount !== 1 || + execution.promptSha256 !== promptFingerprint(start.note.task.prompt) + ) { + throw new Error('Run execution does not contain exactly the frozen task prompt.') + } + if (execution.taskId !== finish.review.artifacts.taskId) { + throw new Error('Reviewed task identity differs from the rollout session.') + } + if ( + start.note.agent && + (execution.model !== start.note.agent.model || + execution.reasoningEffort !== start.note.agent.reasoningEffort) + ) { + throw new Error('Rollout model or reasoning effort differs from the frozen run settings.') + } + } else if (start.note.agent) { + throw new Error('A run with frozen agent settings requires execution identity evidence.') + } + const artifacts = finish.review.artifacts + if ( + !artifacts.taskId || + !artifacts.pageId || + !artifacts.pageName || + artifacts.evidence.length === 0 + ) { + throw new Error('A valid live review must retain task, page, and reviewable artifact evidence.') + } + if (start.note.task.expectedPageName && artifacts.pageName !== start.note.task.expectedPageName) { + throw new Error('Reviewed page name differs from the frozen live task.') + } +} + +export function buildFinishEvent( + start: AuthoringRunStartEvent, + reviewInput: unknown, + rolloutInput?: { source: string; text: string }, + recordedAt = new Date().toISOString() +): AuthoringRunFinishEvent { + const review = AuthoringRunReviewDraftSchema.parse(reviewInput) + const inspection = rolloutInput ? inspectAuthoringRollout(rolloutInput.text) : undefined + const dispatch = inspection && crossTaskDispatch(inspection) + if (review.status === 'valid' && dispatch) { + throw new Error(`A valid live run cannot dispatch work through ${dispatch}.`) + } + const event = AuthoringRunFinishEventSchema.parse({ + schemaVersion: 1, + type: 'finish', + recordedAt, + noteId: start.note.id, + noteSha256: start.noteSha256, + review, + rollout: buildRolloutEvidence(rolloutInput, inspection) + }) + validateRunRecord({ start, finish: event }) + return event +} + +export function buildAbandonEvent( + noteId: string, + reason: string, + recordedAt = new Date().toISOString() +): AuthoringRunAbandonEvent { + return AuthoringRunAbandonEventSchema.parse({ + schemaVersion: 1, + type: 'abandon', + recordedAt, + noteId, + reason + }) +} + +function parseStartEvent(input: unknown): AuthoringRunStartEvent { + const event = AuthoringRunStartEventSchema.parse(input) + validatePreflight(event.preflight) + if (fingerprintRunNote(event.note) !== event.noteSha256) { + throw new Error(`Run note hash mismatch for ${event.note.id}.`) + } + if (Date.parse(event.preflight.checkedAt) > Date.parse(event.recordedAt)) { + throw new Error('Run start cannot precede its preflight.') + } + if (Date.parse(event.note.createdAt) > Date.parse(event.recordedAt)) { + throw new Error(`Run note ${event.note.id} was created after its start event.`) + } + return event +} + +function parseFinishEvent(input: unknown): AuthoringRunFinishEvent { + return AuthoringRunFinishEventSchema.parse(input) +} + +export function validateComparisonRecords(records: AuthoringRunRecord[]): void { + const groups = new Map() + for (const record of records) { + const comparison = record.start.note.comparison + if (!comparison) continue + groups.set(comparison.id, [...(groups.get(comparison.id) ?? []), record]) + } + for (const [id, group] of groups) { + const arms = group.map(({ start }) => start.note.comparison!.arm) + if (new Set(arms).size !== arms.length) { + throw new Error(`Comparison ${id} contains a duplicate arm.`) + } + if (group.length < 2 || group.some(({ finish }) => finish.review.status === 'invalid')) continue + const [first, second] = group + if (!first || !second) continue + if ( + first.start.note.task.prompt !== second.start.note.task.prompt || + first.start.note.comparison?.subject !== second.start.note.comparison?.subject + ) { + throw new Error(`Comparison ${id} changed its prompt or subject between arms.`) + } + const firstRollout = first.finish.rollout + const secondRollout = second.finish.rollout + if (firstRollout?.execution || secondRollout?.execution) { + const firstExecution = firstRollout?.execution + const secondExecution = secondRollout?.execution + if ( + !firstExecution?.model || + !firstExecution.reasoningEffort || + firstExecution.model !== secondExecution?.model || + firstExecution.reasoningEffort !== secondExecution.reasoningEffort + ) { + throw new Error(`Comparison ${id} changed its model or reasoning effort.`) + } + } + if ( + !firstRollout || + !secondRollout || + firstRollout.runtime.hubFingerprint !== secondRollout.runtime.hubFingerprint || + firstRollout.runtime.extensionFingerprint !== secondRollout.runtime.extensionFingerprint || + !hasSameComparisonSkillContext( + firstRollout.skills, + secondRollout.skills, + !firstRollout.execution && !secondRollout.execution + ) + ) { + throw new Error(`Comparison ${id} changed its supporting context or runtime.`) + } + } +} + +export function parseRunLogState(input: string): AuthoringRunLogState { + const starts: AuthoringRunStartEvent[] = [] + const pending = new Map() + const records: AuthoringRunRecord[] = [] + const abandoned: AuthoringRunAbandonEvent[] = [] + + for (const [index, line] of input.split('\n').entries()) { + if (!line.trim()) continue + let value: unknown + try { + value = JSON.parse(line) as unknown + } catch (error) { + throw new Error(`Invalid run-log JSON on line ${String(index + 1)}: ${String(error)}`, { + cause: error + }) + } + if (!value || typeof value !== 'object' || !('type' in value)) { + throw new Error(`Unknown run-log event on line ${String(index + 1)}.`) + } + if (value.type === 'start') { + const event = parseStartEvent(value) + if (starts.some(({ note }) => note.id === event.note.id)) { + throw new Error(`Duplicate run note id: ${event.note.id}.`) + } + validateFreshRunPrompt(event.note, starts) + starts.push(event) + pending.set(event.note.id, event) + continue + } + if (value.type === 'finish') { + const event = parseFinishEvent(value) + const start = pending.get(event.noteId) + if (!start) throw new Error(`Run finish has no pending note ${event.noteId}.`) + const record = { start, finish: event } + validateRunRecord(record) + pending.delete(event.noteId) + records.push(record) + continue + } + if (value.type === 'abandon') { + const event = AuthoringRunAbandonEventSchema.parse(value) + const start = pending.get(event.noteId) + if (!start) throw new Error(`Run abandonment has no pending note ${event.noteId}.`) + if (Date.parse(event.recordedAt) < Date.parse(start.recordedAt)) { + throw new Error(`Run abandonment predates note ${event.noteId}.`) + } + pending.delete(event.noteId) + abandoned.push(event) + continue + } + throw new Error(`Unknown run-log event on line ${String(index + 1)}.`) + } + validateComparisonRecords(records) + return { starts, records, pending: [...pending.values()], abandoned } +} + +export function summarizeRunLog(state: AuthoringRunLogState) { + const byKind: Record = {} + for (const { start } of state.records) { + byKind[start.note.kind] = (byKind[start.note.kind] ?? 0) + 1 + } + return { + runs: state.records.length, + valid: state.records.filter(({ finish }) => finish.review.status === 'valid').length, + invalid: state.records.filter(({ finish }) => finish.review.status === 'invalid').length, + pending: state.pending.length, + abandoned: state.abandoned.length, + byKind + } +} + +export function resolveRunLogPath(path: string): string { + return isAbsolute(path) ? path : join(repositoryRoot, path) +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(resolveRunLogPath(path), 'utf8')) as unknown +} + +function flag(args: string[], name: string): string | undefined { + const index = args.indexOf(name) + if (index < 0) return undefined + const value = args[index + 1] + if (!value) throw new Error(`${name} requires a value.`) + return value +} + +function usage(): string { + return [ + 'Record live authoring evidence without turning the log into a rubric:', + '', + ' pnpm agent-eval:log start --note --log [--checkout ] [--app-path ]', + ' pnpm agent-eval:log finish --note-id --review --log [--rollout ]', + ' pnpm agent-eval:log abandon --note-id --reason --log ', + ' pnpm agent-eval:log check ', + ' pnpm agent-eval:log summary ' + ].join('\n') +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2) + if (!command || command === '--help' || command === 'help') { + process.stdout.write(`${usage()}\n`) + return + } + if (command === 'start') { + const appPath = flag(args, '--app-path') + const notePath = flag(args, '--note') + const logPath = flag(args, '--log') + const checkout = flag(args, '--checkout') ?? repositoryRoot + if (!notePath || !logPath) throw new Error(usage()) + const resolvedLog = resolveRunLogPath(logPath) + const existingText = existsSync(resolvedLog) ? readFileSync(resolvedLog, 'utf8') : '' + const state = parseRunLogState(existingText) + const note = AuthoringRunNoteSchema.parse(readJson(notePath)) + validateFreshRunPrompt(note, state.starts) + const preflight = await runPreflight({ appPath, checkout: resolveRunLogPath(checkout) }) + if (!preflight.valid) { + throw new Error( + `Runtime preflight failed:\n${preflight.issues.map(({ code, message }) => `- ${code}: ${message}`).join('\n')}` + ) + } + const event = buildStartEvent(note, preflight) + parseRunLogState(`${existingText}${JSON.stringify(event)}\n`) + appendFileSync(resolvedLog, `${JSON.stringify(event)}\n`) + process.stdout.write( + `${JSON.stringify({ started: note.id, recordedAt: event.recordedAt }, null, 2)}\n` + ) + return + } + if (command === 'finish') { + const noteId = flag(args, '--note-id') + const reviewPath = flag(args, '--review') + const logPath = flag(args, '--log') + const rolloutPath = flag(args, '--rollout') + if (!noteId || !reviewPath || !logPath) throw new Error(usage()) + const resolvedLog = resolveRunLogPath(logPath) + if (!existsSync(resolvedLog)) throw new Error('Run log does not exist.') + const existingText = readFileSync(resolvedLog, 'utf8') + const state = parseRunLogState(existingText) + const start = state.pending.find(({ note }) => note.id === noteId) + if (!start) throw new Error(`No pending run note ${noteId}.`) + const resolvedRollout = rolloutPath ? resolveRunLogPath(rolloutPath) : undefined + const event = buildFinishEvent( + start, + readJson(reviewPath), + resolvedRollout + ? { source: resolvedRollout, text: readFileSync(resolvedRollout, 'utf8') } + : undefined + ) + parseRunLogState(`${existingText}${JSON.stringify(event)}\n`) + appendFileSync(resolvedLog, `${JSON.stringify(event)}\n`) + process.stdout.write( + `${JSON.stringify({ finished: noteId, status: event.review.status }, null, 2)}\n` + ) + return + } + if (command === 'abandon') { + const noteId = flag(args, '--note-id') + const reason = flag(args, '--reason') + const logPath = flag(args, '--log') + if (!noteId || !reason || !logPath) throw new Error(usage()) + const resolvedLog = resolveRunLogPath(logPath) + if (!existsSync(resolvedLog)) throw new Error('Run log does not exist.') + const existingText = readFileSync(resolvedLog, 'utf8') + const state = parseRunLogState(existingText) + if (!state.pending.some(({ note }) => note.id === noteId)) { + throw new Error(`No pending run note ${noteId}.`) + } + const event = buildAbandonEvent(noteId, reason) + parseRunLogState(`${existingText}${JSON.stringify(event)}\n`) + appendFileSync(resolvedLog, `${JSON.stringify(event)}\n`) + process.stdout.write(`${JSON.stringify({ abandoned: noteId }, null, 2)}\n`) + return + } + const logPath = args[0] + if ((command === 'check' || command === 'summary') && logPath) { + const state = parseRunLogState(readFileSync(resolveRunLogPath(logPath), 'utf8')) + const result = + command === 'summary' ? summarizeRunLog(state) : { ok: true, ...summarizeRunLog(state) } + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + return + } + throw new Error(usage()) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/packages/extension/scripts/agent-authoring-runtime-preflight.ts b/packages/extension/scripts/agent-authoring-runtime-preflight.ts new file mode 100644 index 00000000..7e3ffad0 --- /dev/null +++ b/packages/extension/scripts/agent-authoring-runtime-preflight.ts @@ -0,0 +1,559 @@ +import { execFile } from 'node:child_process' +import { access, readFile, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, normalize, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const defaultRepoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const defaultCodexAppPath = '/Applications/ChatGPT.app' +const tempadPluginId = 'tempad-dev-dev@tempad-dev-dev' +const tempadPluginName = 'tempad-dev-dev' +const monthIndexes = new Map( + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map( + (month, index) => [month, index] + ) +) + +interface RuntimeProcess { + command: string + pid: number + ppid: number + startedAtMs: number +} + +interface RuntimePaths { + cli: string + hub: string +} + +interface RuntimeBundleMtimes { + cli: number + hub: number +} + +interface EnabledPlugin { + id: string + path: string + version: string +} + +interface PreflightIssue { + code: string + message: string +} + +interface AuthoringPreflightArguments { + appPath?: string + checkout: string +} + +interface RuntimeConfiguration { + generatedVersion: string + hubRuntimeIdentityPath: string + paths: RuntimePaths +} + +interface ActiveExtensionRuntimeIdentity { + connectedAt: string + fingerprint: string | null + id: string + version: string | null +} + +interface HubRuntimeIdentitySnapshot { + activeExtension: ActiveExtensionRuntimeIdentity | null + expectedExtensionRuntimeFingerprint: string | null + processId: number +} + +export interface AuthoringPreflightResult { + valid: boolean + checkedAt: string + checkout: string + runtime: { + cli: { + bundle: string + bundleModifiedAt: string + processes: Array<{ pid: number; startedAt: string }> + } + hub: { + bundle: string + bundleModifiedAt: string + processes: Array<{ pid: number; startedAt: string }> + } + extension: { + checkoutFingerprint: string + identityFile: string + hubProcessId: number | null + active: ActiveExtensionRuntimeIdentity | null + } + } + plugin: { + codexExecutable: string + generatedVersion: string + installedVersion: string | null + installedPath: string | null + } + issues: PreflightIssue[] +} + +function fail(message: string): never { + throw new Error(message) +} + +function usage(): string { + return [ + 'Verify the live authoring runtime before page creation:', + '', + ' pnpm agent-eval:preflight [--checkout ] [--app-path ]', + '', + 'Options:', + ' --checkout TemPad checkout (default: current repository)', + ' --app-path Codex host app (default: CODEX_APP_PATH or /Applications/ChatGPT.app)', + ' --help Show this help' + ].join('\n') +} + +function parseArguments(argv: string[]): AuthoringPreflightArguments | null { + if (argv.includes('--help')) return null + let appPath = process.env.CODEX_APP_PATH ?? defaultCodexAppPath + let checkout = defaultRepoRoot + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (!argument) continue + if (argument !== '--checkout' && argument !== '--app-path') { + fail(`Unknown option: ${argument}\n\n${usage()}`) + } + const value = argv[index + 1] + if (!value || value.startsWith('--')) fail(`Missing value for ${argument}.`) + index += 1 + if (argument === '--checkout') checkout = normalize(resolve(value)) + if (argument === '--app-path') appPath = normalize(resolve(value)) + } + + return { appPath, checkout } +} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be an object.`) + } + return value as Record +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as unknown +} + +async function readOptionalJson(path: string): Promise { + try { + return await readJson(path) + } catch { + return null + } +} + +function isSha256(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) +} + +function parseHubRuntimeIdentitySnapshot(value: unknown): HubRuntimeIdentitySnapshot | null { + if (!value || typeof value !== 'object') return null + const candidate = value as Record + if ( + !Number.isInteger(candidate.processId) || + (candidate.expectedExtensionRuntimeFingerprint !== null && + !isSha256(candidate.expectedExtensionRuntimeFingerprint)) || + !Object.hasOwn(candidate, 'activeExtension') + ) { + return null + } + if (candidate.activeExtension === null) { + return { + activeExtension: null, + expectedExtensionRuntimeFingerprint: candidate.expectedExtensionRuntimeFingerprint as + | string + | null, + processId: candidate.processId as number + } + } + if (!candidate.activeExtension || typeof candidate.activeExtension !== 'object') return null + const active = candidate.activeExtension as Record + if ( + typeof active.id !== 'string' || + typeof active.connectedAt !== 'string' || + !Number.isFinite(Date.parse(active.connectedAt)) || + (active.version !== null && typeof active.version !== 'string') || + (active.fingerprint !== null && !isSha256(active.fingerprint)) + ) { + return null + } + return { + activeExtension: active as unknown as ActiveExtensionRuntimeIdentity, + expectedExtensionRuntimeFingerprint: candidate.expectedExtensionRuntimeFingerprint as + | string + | null, + processId: candidate.processId as number + } +} + +export function evaluateActiveExtensionRuntime( + expectedFingerprint: string, + hubProcesses: RuntimeProcess[], + identityInput: unknown +): { + activeExtension: ActiveExtensionRuntimeIdentity | null + hubProcessId: number | null + issues: PreflightIssue[] +} { + const issues: PreflightIssue[] = [] + const identity = parseHubRuntimeIdentitySnapshot(identityInput) + if (!identity) { + issues.push({ + code: 'RUNTIME_IDENTITY_RECORD_MISSING', + message: + 'The active Hub did not publish a readable active-extension runtime record. Refresh the MCP runtime before dispatch.' + }) + return { activeExtension: null, hubProcessId: null, issues } + } + if (!hubProcesses.some(({ pid }) => pid === identity.processId)) { + issues.push({ + code: 'RUNTIME_IDENTITY_RECORD_STALE', + message: `The runtime identity record belongs to Hub PID ${String(identity.processId)}, not the exact-checkout Hub selected by preflight.` + }) + } + if (identity.expectedExtensionRuntimeFingerprint !== expectedFingerprint) { + issues.push({ + code: 'RUNTIME_HUB_EXTENSION_EXPECTATION_MISMATCH', + message: + 'The active Hub extension-source expectation differs from the current checkout. Refresh the MCP runtime before dispatch.' + }) + } + if (!identity.activeExtension) { + issues.push({ + code: 'RUNTIME_EXTENSION_INACTIVE', + message: + 'The Hub has no active extension connection. Activate the intended Figma file and repeat preflight before page creation.' + }) + } else if (!identity.activeExtension.fingerprint) { + issues.push({ + code: 'RUNTIME_EXTENSION_IDENTITY_MISSING', + message: + 'The active extension has not published a runtime fingerprint. Reload the browser extension and Figma tab before dispatch.' + }) + } else if (identity.activeExtension.fingerprint !== expectedFingerprint) { + issues.push({ + code: 'RUNTIME_EXTENSION_FINGERPRINT_MISMATCH', + message: `The active extension fingerprint ${identity.activeExtension.fingerprint} differs from the current checkout fingerprint ${expectedFingerprint}. Rebuild/reload the browser extension and Figma tab before dispatch.` + }) + } + return { + activeExtension: identity.activeExtension, + hubProcessId: identity.processId, + issues + } +} + +export function commandIncludesExactPath(command: string, path: string): boolean { + const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`(?:^|[\\s'"])${escapedPath}(?=$|[\\s'"])`).test(command) +} + +export function parseProcessTable(output: string): RuntimeProcess[] { + return output + .split('\n') + .map((line): RuntimeProcess | null => { + const match = line.match( + /^\s*(\d+)\s+(\d+)\s+\S+\s+(\S+)\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s+(.*)$/ + ) + if (!match) return null + const [, pid, ppid, month, day, hour, minute, second, year, command] = match + const monthIndex = month ? monthIndexes.get(month) : undefined + if ( + !pid || + !ppid || + monthIndex === undefined || + !day || + !hour || + !minute || + !second || + !year || + !command + ) { + return null + } + const startedAtMs = new Date( + Number(year), + monthIndex, + Number(day), + Number(hour), + Number(minute), + Number(second) + ).getTime() + if (!Number.isFinite(startedAtMs)) return null + return { command: command.trim(), pid: Number(pid), ppid: Number(ppid), startedAtMs } + }) + .filter((process): process is RuntimeProcess => process !== null) +} + +export function evaluateRuntimeFreshness( + paths: RuntimePaths, + bundleMtimes: RuntimeBundleMtimes, + processes: RuntimeProcess[] +): { + cli: RuntimeProcess[] + hub: RuntimeProcess[] + issues: PreflightIssue[] +} { + const cli = processes.filter(({ command }) => commandIncludesExactPath(command, paths.cli)) + const hub = processes.filter(({ command }) => commandIncludesExactPath(command, paths.hub)) + const issues: PreflightIssue[] = [] + + if (cli.length === 0 && hub.length === 0) { + issues.push({ + code: 'RUNTIME_ABSENT', + message: + 'No exact-checkout TemPad CLI or Hub process is running. Repair or reinstall the plugin before creating an evaluation page.' + }) + } else if (cli.length === 0 || hub.length === 0) { + issues.push({ + code: 'RUNTIME_PARTIAL', + message: `The exact-checkout runtime is partial: CLI=${String(cli.length)}, Hub=${String(hub.length)}. Repair or reinstall it before dispatch.` + }) + } + + if (hub.length > 1) { + issues.push({ + code: 'RUNTIME_MULTIPLE_HUBS', + message: `Found ${String(hub.length)} exact-checkout Hub processes; require exactly one unambiguous Hub before dispatch.` + }) + } + + // macOS `ps lstart` has one-second precision. Treat an equal-second process + // as stale rather than guessing that it started after a sub-second bundle write. + const staleCli = cli.filter(({ startedAtMs }) => startedAtMs <= bundleMtimes.cli) + const staleHub = hub.filter(({ startedAtMs }) => startedAtMs <= bundleMtimes.hub) + if (staleCli.length > 0) { + issues.push({ + code: 'RUNTIME_STALE_CLI', + message: `${String(staleCli.length)} exact-checkout CLI process(es) predate the current CLI bundle. Replace the plugin runtime before dispatch.` + }) + } + if (staleHub.length > 0) { + issues.push({ + code: 'RUNTIME_STALE_HUB', + message: + 'The exact-checkout Hub predates the current Hub bundle. Replace the plugin runtime before dispatch; a fresh task alone may reuse this stale Hub.' + }) + } + + return { cli, hub, issues } +} + +export function parseEnabledPlugins(output: string): EnabledPlugin[] { + return output + .split('\n') + .map((line): EnabledPlugin | null => { + const match = line.match(/^\s*(\S+@\S+)\s+installed,\s+enabled\s+(\S+)\s+(.+?)\s*$/) + if (!match) return null + const [, id, version, path] = match + return id && version && path ? { id, path, version } : null + }) + .filter((plugin): plugin is EnabledPlugin => plugin !== null) +} + +export function evaluateTempadPluginIdentity( + generatedVersion: string, + plugins: EnabledPlugin[] +): { installed: EnabledPlugin | null; issues: PreflightIssue[] } { + const matches = plugins.filter(({ id }) => id === tempadPluginId) + const issues: PreflightIssue[] = [] + if (matches.length !== 1) { + issues.push({ + code: 'TEMPAD_PLUGIN_NOT_ENABLED', + message: `Expected exactly one enabled ${tempadPluginId} installation, found ${String(matches.length)}.` + }) + return { installed: null, issues } + } + const installed = matches[0]! + if (installed.version !== generatedVersion) { + issues.push({ + code: 'TEMPAD_PLUGIN_VERSION_MISMATCH', + message: `Generated TemPad cachebuster ${generatedVersion} differs from installed enabled version ${installed.version}. Replace the plugin before dispatch.` + }) + } + return { installed, issues } +} + +async function resolveRuntimeConfiguration(checkout: string): Promise { + const pluginRoot = join(checkout, '.dev/plugins/tempad-dev-dev') + const manifestPath = join(pluginRoot, '.codex-plugin/plugin.json') + const manifest = objectValue(await readJson(manifestPath), manifestPath) + if (manifest.name !== tempadPluginName || typeof manifest.version !== 'string') { + fail(`Unexpected generated plugin identity in ${manifestPath}.`) + } + + const mcpPath = join(pluginRoot, '.mcp.json') + const mcp = objectValue(await readJson(mcpPath), mcpPath) + const servers = objectValue(mcp.mcpServers, `${mcpPath}#mcpServers`) + const server = objectValue(servers[tempadPluginName], `${mcpPath}#mcpServers.${tempadPluginName}`) + if (!Array.isArray(server.args) || typeof server.args[0] !== 'string') { + fail(`Missing CLI entry in ${mcpPath}.`) + } + const cliArgument = server.args[0] + const cli = normalize(isAbsolute(cliArgument) ? cliArgument : resolve(pluginRoot, cliArgument)) + const hub = join(dirname(cli), 'hub.mjs') + const serverEnv = server.env === undefined ? {} : objectValue(server.env, `${mcpPath}#env`) + const configuredRuntimeDir = serverEnv.TEMPAD_MCP_RUNTIME_DIR + if (configuredRuntimeDir !== undefined && typeof configuredRuntimeDir !== 'string') { + fail(`Invalid TEMPAD_MCP_RUNTIME_DIR in ${mcpPath}.`) + } + const runtimeDir = configuredRuntimeDir + ? normalize( + isAbsolute(configuredRuntimeDir) + ? configuredRuntimeDir + : resolve(pluginRoot, configuredRuntimeDir) + ) + : join(tmpdir(), 'tempad-dev', 'run') + await Promise.all([access(cli), access(hub)]) + return { + generatedVersion: manifest.version, + hubRuntimeIdentityPath: join(runtimeDir, 'hub-runtime.json'), + paths: { cli, hub } + } +} + +async function resolveCheckoutExtensionFingerprint(checkout: string): Promise { + const modulePath = join(checkout, 'scripts/extension-runtime-fingerprint.mjs') + const fingerprintModule = (await import(pathToFileURL(modulePath).href)) as { + computeExtensionRuntimeFingerprint?: (root: string) => string + } + if (typeof fingerprintModule.computeExtensionRuntimeFingerprint !== 'function') { + fail(`Invalid extension runtime fingerprint module: ${modulePath}`) + } + return fingerprintModule.computeExtensionRuntimeFingerprint(checkout) +} + +async function listProcesses(): Promise { + const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,lstart=,command='], { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024 + }) + return parseProcessTable(stdout) +} + +export function resolveCodexExecutable( + appPath = process.env.CODEX_APP_PATH ?? defaultCodexAppPath, + platform: NodeJS.Platform = process.platform +): string { + return platform === 'darwin' + ? join(normalize(resolve(appPath)), 'Contents/Resources/codex') + : 'codex' +} + +async function listEnabledPlugins(codexExecutable: string): Promise { + if (isAbsolute(codexExecutable)) { + try { + await access(codexExecutable) + } catch { + fail( + `Codex host CLI not found at ${codexExecutable}. Set CODEX_APP_PATH or pass --app-path for the desktop host under evaluation.` + ) + } + } + const { stdout } = await execFileAsync(codexExecutable, ['plugin', 'list'], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024 + }) + return parseEnabledPlugins(stdout) +} + +function processEvidence(processes: RuntimeProcess[]): Array<{ pid: number; startedAt: string }> { + return processes.map(({ pid, startedAtMs }) => ({ + pid, + startedAt: new Date(startedAtMs).toISOString() + })) +} + +export async function runPreflight( + args: AuthoringPreflightArguments +): Promise { + const runtime = await resolveRuntimeConfiguration(args.checkout) + const codexExecutable = resolveCodexExecutable(args.appPath) + const [cliStat, hubStat, processes, plugins, checkoutExtensionFingerprint, hubRuntimeIdentity] = + await Promise.all([ + stat(runtime.paths.cli), + stat(runtime.paths.hub), + listProcesses(), + listEnabledPlugins(codexExecutable), + resolveCheckoutExtensionFingerprint(args.checkout), + readOptionalJson(runtime.hubRuntimeIdentityPath) + ]) + const freshness = evaluateRuntimeFreshness( + runtime.paths, + { cli: cliStat.mtimeMs, hub: hubStat.mtimeMs }, + processes + ) + const pluginIdentity = evaluateTempadPluginIdentity(runtime.generatedVersion, plugins) + const activeExtension = evaluateActiveExtensionRuntime( + checkoutExtensionFingerprint, + freshness.hub, + hubRuntimeIdentity + ) + const issues = [...freshness.issues, ...pluginIdentity.issues, ...activeExtension.issues] + + return { + valid: issues.length === 0, + checkedAt: new Date().toISOString(), + checkout: args.checkout, + runtime: { + cli: { + bundle: runtime.paths.cli, + bundleModifiedAt: cliStat.mtime.toISOString(), + processes: processEvidence(freshness.cli) + }, + hub: { + bundle: runtime.paths.hub, + bundleModifiedAt: hubStat.mtime.toISOString(), + processes: processEvidence(freshness.hub) + }, + extension: { + checkoutFingerprint: checkoutExtensionFingerprint, + identityFile: runtime.hubRuntimeIdentityPath, + hubProcessId: activeExtension.hubProcessId, + active: activeExtension.activeExtension + } + }, + plugin: { + codexExecutable, + generatedVersion: runtime.generatedVersion, + installedVersion: pluginIdentity.installed?.version ?? null, + installedPath: pluginIdentity.installed?.path ?? null + }, + issues + } +} + +async function main(): Promise { + const args = parseArguments(process.argv.slice(2)) + if (!args) { + console.log(usage()) + return + } + if (process.platform === 'win32') { + fail('Authoring runtime process freshness verification is not implemented for Windows.') + } + const result = await runPreflight(args) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + if (result.valid !== true) process.exitCode = 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} diff --git a/packages/extension/scripts/capture-screenshots.ts b/packages/extension/scripts/capture-screenshots.ts index c7603f5e..88e1fd79 100644 --- a/packages/extension/scripts/capture-screenshots.ts +++ b/packages/extension/scripts/capture-screenshots.ts @@ -1,8 +1,17 @@ -import { mkdir, readFile } from 'node:fs/promises' +import { AGENT_INTEGRATIONS } from '@tempad-dev/shared' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Locator, type Page } from 'playwright' +import { captureDialogPng } from '../screenshots/dialog-capture.mjs' +import { + needsFixtureRuntime, + selectScenarios, + selectThemes, + type ScreenshotScenario +} from './screenshot-plan' + type Point = { x: number; y: number } type Rect = Point & { height: number; width: number } type Theme = 'dark' | 'light' @@ -16,7 +25,7 @@ type Assertion = { values?: number[] } -type Scenario = { +type Scenario = ScreenshotScenario & { assertions: Assertion[] clip?: Rect & { anchor?: { @@ -26,7 +35,7 @@ type Scenario = { offset?: Point } } - figma: { + figma?: { captureAnchor?: Point focus: string selection: string[] @@ -43,6 +52,8 @@ type Scenario = { selectText?: boolean } setupTarget?: string + setupScroll?: 'start' | 'end' + visibleActionIds?: string[] mcpEnabled?: boolean measure?: boolean options?: { @@ -79,6 +90,7 @@ type Manifest = { } } capture: { + clipScale: number canvasAnchor: Point clip: Rect hiddenPointer: Point @@ -134,11 +146,13 @@ function usage(): string { '', ' pnpm screenshots capture --cdp-url http://127.0.0.1:9222', ' pnpm screenshots capture --only code,unit,deep', + ' pnpm screenshots capture --group setup', '', 'Options:', ' --cdp-url Chrome DevTools endpoint (default: TEMPAD_SCREENSHOT_CDP_URL or http://127.0.0.1:9222)', ' --output-dir Candidate directory (default: .artifacts/marketing-screenshots)', ' --only Comma-separated scenario ids', + ' --group inspect, setup, or status (exclusive with --only)', ' --themes light,dark or one theme', ' --help Show this help', '', @@ -274,8 +288,21 @@ async function resetPanel(page: Page): Promise { } async function configureScenario(page: Page, scenario: Scenario): Promise { - await resetPanel(page) const panel = scenario.panel ?? {} + if (scenario.group === 'setup') { + await closeSetupDialog(page) + await ensurePreferences(page, true) + await setMcpEnabled(page, panel.mcpEnabled ?? true) + if (!panel.setupTarget) fail(`${scenario.id}: a setup target is required.`) + const tempad = page.locator('tempad') + await tempad.getByRole('button', { name: 'Set up agents', exact: true }).click() + await tempad.getByRole('tab', { name: panel.setupTarget, exact: true }).click() + await page + .locator('#tp-agent-setup-panel [data-overlayscrollbars-viewport]') + .press(panel.setupScroll === 'end' ? 'End' : 'Home') + return + } + await resetPanel(page) if (panel.options?.cssUnit) await setSelect(page, 'CSS unit', panel.options.cssUnit) if (panel.options?.rootFontSize !== undefined) { @@ -302,6 +329,7 @@ async function stageCanvas( scenario: Scenario, theme: Theme ): Promise<{ selection: unknown[] }> { + if (!scenario.figma) fail(`${scenario.id}: missing Figma fixture contract.`) const anchor = scenario.figma.captureAnchor ?? manifest.capture.canvasAnchor return page.evaluate( ({ input }) => { @@ -469,7 +497,8 @@ async function assertMcpStatus( async function assertScenario( page: Page, scenario: Scenario, - stage: { selection: unknown[] } | null + stage: { selection: unknown[] } | null, + clip: Rect ): Promise { const panelText = await page.locator('article main').innerText() let dialogText: string | undefined @@ -477,6 +506,60 @@ async function assertScenario( for (const assertion of scenario.assertions) { switch (assertion.kind) { + case 'setup-contract': { + const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel?.setupTarget) + if (!target) fail(`${scenario.id}: unknown agent setup target.`) + const selected = page.locator('tempad').getByRole('tab', { name: target.name, exact: true }) + if ((await selected.getAttribute('aria-selected')) !== 'true') { + fail(`${scenario.id}: ${target.name} is not the selected setup target.`) + } + const content = await page.locator('#tp-agent-setup-panel').innerText() + const pluginActions = target.actions.filter(({ id }) => id.startsWith('plugin-')) + const actions = pluginActions.length ? pluginActions : target.actions + for (const action of actions) { + if (action.kind !== 'deep-link' && !content.includes(action.value)) { + fail( + `${scenario.id}: displayed ${action.id} does not match the current shared setup configuration.` + ) + } + } + break + } + case 'setup-visible-actions': { + const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel?.setupTarget) + if (!target) fail(`${scenario.id}: unknown setup target.`) + const viewport = await expectRect( + page.locator('#tp-agent-setup-panel [data-overlayscrollbars-viewport]'), + 'Setup content' + ) + for (const id of scenario.panel?.visibleActionIds ?? []) { + const action = target.actions.find((action) => action.id === id) + if (!action) fail(`${scenario.id}: unknown action ${id}.`) + const code = await expectRect( + page.locator('#tp-agent-setup-panel code').filter({ hasText: action.value }), + id + ) + if (code.y < viewport.y - 1 || code.y + code.height > viewport.y + viewport.height + 1) { + fail(`${scenario.id}: ${id} is clipped at the declared scroll position.`) + } + } + break + } + case 'dialog-fits-clip': { + const dialog = await expectRect( + page.locator('tempad').getByRole('dialog', { name: 'Set up agents', exact: true }), + 'Agent setup dialog' + ) + if ( + dialog.x < clip.x - 1 || + dialog.y < clip.y - 1 || + dialog.x + dialog.width > clip.x + clip.width + 1 || + dialog.y + dialog.height > clip.y + clip.height + 1 + ) { + fail(`${scenario.id}: the capture would crop the agent setup dialog.`) + } + break + } case 'figma-selection-count': if (stage && stage.selection.length !== assertion.value) { fail( @@ -565,7 +648,10 @@ async function captureScenario( fail(`${scenario.id}: leave TemPad Dev preferences open before capturing this MCP state.`) } - const stage = isStateGated ? null : await stageCanvas(page, manifest, scenario, theme) + const stage = + isStateGated || scenario.group === 'setup' + ? null + : await stageCanvas(page, manifest, scenario, theme) if (isStateGated) { await page.evaluate((value) => { const runtime = ( @@ -583,11 +669,23 @@ async function captureScenario( await renderPointer(page, point, scenario.pointer.visible) await stabilizeCompositor(page, manifest.capture.hiddenPointer) await page.waitForTimeout(scenario.pointer.tooltip ? 700 : manifest.capture.settleMs) - await assertScenario(page, scenario, stage) - const clip = await resolveClip(page, manifest, scenario) + await assertScenario(page, scenario, stage, clip) const outputPath = resolve(outputDir, `${scenario.id}-${theme}.png`) - await page.screenshot({ animations: 'disabled', clip, path: outputPath, scale: 'device' }) + if (scenario.group === 'setup') { + const cdp = await page.context().newCDPSession(page) + try { + const capture = await captureDialogPng((method, params) => cdp.send(method, params), { + ...clip, + scale: manifest.capture.clipScale + }) + await writeFile(outputPath, Buffer.from(capture.data, 'base64')) + } finally { + await cdp.detach() + } + } else { + await page.screenshot({ animations: 'disabled', clip, path: outputPath, scale: 'device' }) + } const size = readPngSize(await readFile(outputPath)) if (!size || size.width !== scenario.width || size.height !== scenario.height) { fail( @@ -611,35 +709,19 @@ async function main(): Promise { const outputDir = outputDirArgument ? resolve(repoRoot, outputDirArgument) : `${repoRoot}.artifacts/marketing-screenshots` - const defaultScenarioIds = manifest.scenarios - .filter((scenario) => !['inactive', 'unavailable'].includes(scenario.mcpStatus ?? '')) - .map((scenario) => scenario.id) - const selectedIds = new Set( - (readArgument('--only') ?? defaultScenarioIds.join(',')).split(',').filter(Boolean) - ) - const selectedThemes = new Set( - (readArgument('--themes') ?? manifest.capture.themes.join(',')) - .split(',') - .filter(Boolean) as Theme[] - ) - const scenarios = manifest.scenarios.filter((scenario) => selectedIds.has(scenario.id)) + const scenarios = selectScenarios(manifest.scenarios, { + only: readArgument('--only'), + group: readArgument('--group'), + capture: true + }) + const selectedThemes = new Set(selectThemes(manifest.capture.themes, readArgument('--themes'))) const orderedScenarios = [...scenarios].sort( (a, b) => Number(a.id === 'plugins') - Number(b.id === 'plugins') ) - const unknown = [...selectedIds].filter( - (id) => !manifest.scenarios.some((scenario) => scenario.id === id) - ) - if (unknown.length) fail(`Unknown scenarios: ${unknown.join(', ')}`) - if (!scenarios.length) fail('No scenarios selected.') - const hasStateGatedScenario = scenarios.some((scenario) => - ['inactive', 'unavailable'].includes(scenario.mcpStatus ?? '') + const useFixtures = needsFixtureRuntime(scenarios) + const hasStateGatedScenario = scenarios.some(({ mcpStatus }) => + ['inactive', 'unavailable'].includes(mcpStatus ?? '') ) - if (hasStateGatedScenario && scenarios.length !== 1) { - fail('Capture MCP unavailable/inactive as a single --only scenario after preparing its state.') - } - if ([...selectedThemes].some((theme) => !manifest.capture.themes.includes(theme))) { - fail(`Themes must be one of: ${manifest.capture.themes.join(', ')}.`) - } await mkdir(outputDir, { recursive: true }) const browser = await chromium.connectOverCDP(cdpUrl) @@ -659,32 +741,42 @@ async function main(): Promise { ) } - const cdp = await page.context().newCDPSession(page) - await cdp.send('Runtime.evaluate', { - awaitPromise: true, - expression: fixtureRuntime, - returnByValue: true - }) - await page.waitForFunction(() => '__TEMPAD_README_SCREENSHOTS__' in globalThis) - const markers = await page.evaluate(() => { - const runtime = ( - globalThis as typeof globalThis & { - __TEMPAD_README_SCREENSHOTS__: FixtureRuntime - } - ).__TEMPAD_README_SCREENSHOTS__ - return runtime.list().map((node) => node.marker) - }) - if ( - !['code', 'deep_outer', 'deep_inner', 'measure_outer', 'measure_inner', 'plugins'].every( - (marker) => markers.includes(marker) + if (useFixtures) { + const cdp = await page.context().newCDPSession(page) + const editable = await cdp.send('Runtime.evaluate', { + expression: + "typeof figma !== 'undefined' && figma.editorType === 'figma' && figma.mode === 'default'", + returnByValue: true + }) + if (!editable.result.value) { + fail( + 'Fixture capture requires an editable Figma Design file. Setup-only captures work with --group setup without changing canvas content.' + ) + } + const result = await cdp.send('Runtime.evaluate', { + awaitPromise: true, + expression: fixtureRuntime, + returnByValue: true + }) + if (result.exceptionDetails) + fail('Could not prepare the canonical Figma fixtures. No screenshots were captured.') + await page.waitForFunction(() => '__TEMPAD_README_SCREENSHOTS__' in globalThis) + const markers = await page.evaluate(() => { + const runtime = ( + globalThis as typeof globalThis & { + __TEMPAD_README_SCREENSHOTS__: FixtureRuntime + } + ).__TEMPAD_README_SCREENSHOTS__ + return runtime.list().map((node) => node.marker) + }) + const requiredMarkers = new Set( + scenarios.flatMap(({ figma }) => (figma ? [figma.focus, ...figma.selection] : [])) ) - ) { - fail('The canonical Figma fixture page is incomplete.') - } - - await minimizeFigmaUi(page) - if (!hasStateGatedScenario) { - await placePanel(page, manifest.capture.panel) + if ([...requiredMarkers].some((marker) => !markers.includes(marker))) { + fail('The canonical Figma fixture page is incomplete.') + } + await minimizeFigmaUi(page) + if (!hasStateGatedScenario) await placePanel(page, manifest.capture.panel) } try { @@ -698,21 +790,22 @@ async function main(): Promise { } } } finally { + await closeSetupDialog(page).catch(() => undefined) await setFigmaTheme(page, 'light').catch(() => undefined) await page - .evaluate(() => { + .evaluate((restoreFixtures) => { const runtime = ( globalThis as typeof globalThis & { __TEMPAD_README_SCREENSHOTS__: FixtureRuntime } ).__TEMPAD_README_SCREENSHOTS__ - runtime.setCanvasTheme('light') + if (restoreFixtures) runtime?.setCanvasTheme('light') document.querySelector('#tempad-readme-cursor')?.remove() document.querySelector('#tempad-readme-compositor')?.remove() - }) + }, useFixtures) .catch(() => undefined) - if (!hasStateGatedScenario) { + if (useFixtures && !hasStateGatedScenario) { await resetPanel(page).catch(() => undefined) await ensurePreferences(page, false).catch(() => undefined) const code = manifest.scenarios.find((scenario) => scenario.id === 'code') 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/scripts/compare-screenshots.ts b/packages/extension/scripts/compare-screenshots.ts index fc5e0f2b..b19d74a0 100644 --- a/packages/extension/scripts/compare-screenshots.ts +++ b/packages/extension/scripts/compare-screenshots.ts @@ -2,7 +2,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -type Scenario = { height: number; id: string; width: number } +import { selectScenarios, selectThemes, type ScreenshotScenario } from './screenshot-plan' + +type Scenario = ScreenshotScenario & { height: number; width: number } type Manifest = { capture: { sourceScale: number; themes: string[] } scenarios: Scenario[] @@ -42,36 +44,32 @@ async function main(): Promise { const outputPath = outputArgument ? resolve(repoRoot, outputArgument) : `${candidateDir}/comparison.html` - const selected = new Set( - (readArgument('--only') ?? manifest.scenarios.map((scenario) => scenario.id).join(',')) - .split(',') - .filter(Boolean) - ) + const selected = selectScenarios(manifest.scenarios, { + only: readArgument('--only'), + group: readArgument('--group') + }) + const themes = selectThemes(manifest.capture.themes, readArgument('--themes')) const rows: string[] = [] - for (const scenario of manifest.scenarios) { - if (!selected.has(scenario.id)) continue + for (const scenario of selected) { const displayWidth = scenario.width / manifest.capture.sourceScale - for (const theme of manifest.capture.themes) { + for (const theme of themes) { const filename = `${scenario.id}-${theme}.png` const baselinePath = resolve(baselineDir, filename) const candidatePath = resolve(candidateDir, filename) - let baseline: string - let candidate: string + const candidate = await imageData(candidatePath) + let baseline: string | null = null try { - ;[baseline, candidate] = await Promise.all([ - imageData(baselinePath), - imageData(candidatePath) - ]) - } catch { - throw new Error(`Missing baseline or candidate for ${filename}.`) + baseline = await imageData(baselinePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } rows.push(`

${escapeHtml(scenario.id)} ${escapeHtml(theme)}

-
Committed baseline
+
Committed baseline
${baseline ? `` : '

New scenario — no committed baseline.

'}
New candidate
`) diff --git a/packages/extension/scripts/inspect-agent-authoring-rollout.ts b/packages/extension/scripts/inspect-agent-authoring-rollout.ts new file mode 100644 index 00000000..ce24b92e --- /dev/null +++ b/packages/extension/scripts/inspect-agent-authoring-rollout.ts @@ -0,0 +1,755 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +import { inspectAgentRunIdentity } from './inspect-agent-run-identity' + +interface NodeLimitAttempt { + limit: number + dataKeyCount: number + markupCharacters: number +} + +interface AuthoringRolloutInspection { + execution: ReturnType + prompt: { + text: string | null + sha256: string | null + wordCount: number + characterCount: number + } + tools: { + completedCalls: number + failures: number + byName: Record + } + skillContext: { + readCalls: number + uniqueResources: string[] + } + applyCanvas: { + calls: number + failures: number + failureCodes: Record + nodeLimitAttempts: NodeLimitAttempt[] + maxMarkupCharacters: number + maxDataKeyCount: number + } + research: { + webCalls: number + imageQueryCalls: number + openedSourceCalls: number + browserScreenshotCalls: number + } + imageViews: { + total: number + references: number + tempadScreenshots: number + other: number + } + assets: { + imageGenerationCalls: number + appliedRemoteImageDomains: string[] + iconLibraries: string[] + } + components: { + authoredComponentCalls: number + instanceBindingCalls: number + } + timing: { + rolloutStartedAt: string | null + finalResponseAt: string | null + totalWallClockMs: number | null + firstToolCallMs: number | null + firstApplyAttemptMs: number | null + firstSuccessfulApplyMs: number | null + firstResearchCallMs: number | null + lastResearchCallMs: number | null + firstOpenedTempadScreenshotMs: number | null + lastOpenedTempadScreenshotMs: number | null + firstApplyToOpenedScreenshotMs: number | null + lastSuccessfulApplyMs: number | null + lastApplyToOpenedScreenshotMs: number | null + finalizationAfterLastApplyMs: number | null + observedToolBusyMs: number | null + nonToolWallClockMs: number | null + } + runtime: { + observations: number + locked: boolean + valid: boolean + hubFingerprints: string[] + extensionFingerprints: string[] + issues: string[] + } + limitations: string[] +} + +interface ApplyEvent { + arguments: unknown + result: unknown + status: unknown + timestampMs: number | null +} + +interface TimedInterval { + startMs: number + endMs: number +} + +interface CustomCallEvent { + input: string + name: string + timestampMs: number | null +} + +interface CompletedToolEvent { + failed: boolean + name: string + timestampMs: number | null +} + +interface RuntimeObservation { + locked: boolean + valid: boolean + hubFingerprint: string | null + extensionFingerprint: string | null + issues: string[] +} + +function rows(rolloutJsonl: string): unknown[] { + return rolloutJsonl + .split('\n') + .filter((line) => line.trim()) + .flatMap((line) => { + try { + return [JSON.parse(line) as unknown] + } catch { + return [] + } + }) +} + +function get(value: unknown, key: string): unknown { + return value && typeof value === 'object' ? Reflect.get(value, key) : undefined +} + +function stringify(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value) ?? '' + } catch { + return '' + } +} + +function countMatches(value: string, pattern: RegExp): number { + return [...value.matchAll(pattern)].length +} + +function increment(counts: Record, key: string): void { + counts[key] = (counts[key] ?? 0) + 1 +} + +function timestampMs(value: unknown): number | null { + if (typeof value !== 'string') return null + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function elapsedMs(startMs: number | null, endMs: number | null): number | null { + if (startMs === null || endMs === null || endMs < startMs) return null + return endMs - startMs +} + +function toolBusyMs(intervals: TimedInterval[], startMs: number, endMs: number): number { + const clipped = intervals + .map((interval) => ({ + startMs: Math.max(startMs, interval.startMs), + endMs: Math.min(endMs, interval.endMs) + })) + .filter((interval) => interval.endMs >= interval.startMs) + .sort((a, b) => a.startMs - b.startMs) + + let total = 0 + let activeStart: number | null = null + let activeEnd: number | null = null + for (const interval of clipped) { + if (activeStart === null || activeEnd === null) { + activeStart = interval.startMs + activeEnd = interval.endMs + continue + } + if (interval.startMs <= activeEnd) { + activeEnd = Math.max(activeEnd, interval.endMs) + continue + } + total += activeEnd - activeStart + activeStart = interval.startMs + activeEnd = interval.endMs + } + if (activeStart !== null && activeEnd !== null) total += activeEnd - activeStart + return total +} + +function applyEvents(parsedRows: unknown[]): ApplyEvent[] { + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'event_msg') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'item_completed') return [] + const item = get(payload, 'item') + if (get(item, 'type') !== 'McpToolCall' || get(item, 'tool') !== 'apply_canvas') return [] + return [ + { + arguments: get(item, 'arguments'), + result: get(item, 'result'), + status: get(item, 'status'), + timestampMs: timestampMs(get(row, 'timestamp')) + } + ] + }) +} + +function customCallEvents(parsedRows: unknown[]): CustomCallEvent[] { + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'response_item') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'custom_tool_call') return [] + return [ + { + input: stringify(get(payload, 'input')), + name: typeof get(payload, 'name') === 'string' ? String(get(payload, 'name')) : '', + timestampMs: timestampMs(get(row, 'timestamp')) + } + ] + }) +} + +function functionCallEvents(parsedRows: unknown[]): CustomCallEvent[] { + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'response_item') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'function_call') return [] + return [ + { + input: stringify(get(payload, 'arguments')), + name: typeof get(payload, 'name') === 'string' ? String(get(payload, 'name')) : '', + timestampMs: timestampMs(get(row, 'timestamp')) + } + ] + }) +} + +function commandExecutionInputs(parsedRows: unknown[]): string[] { + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'event_msg') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'item_completed') return [] + const item = get(payload, 'item') + if (get(item, 'type') !== 'CommandExecution' || get(item, 'status') === 'failed') return [] + const command = get(item, 'command') + if (typeof command === 'string') return [command] + if (!Array.isArray(command)) return [] + return [command.map(stringify).join(' ')] + }) +} + +function itemToolName(item: unknown): string | null { + const type = get(item, 'type') + if (type === 'McpToolCall') { + const server = get(item, 'server') + const tool = get(item, 'tool') + return typeof tool === 'string' + ? `${typeof server === 'string' ? `${server}.` : ''}${tool}` + : null + } + if (type === 'ImageView') return 'view_image' + if (type === 'CommandExecution') return 'exec_command' + if (type === 'Extension') { + const name = get(item, 'name') ?? get(item, 'tool') + return typeof name === 'string' ? name : 'extension' + } + return null +} + +function completedToolEvents(parsedRows: unknown[]): CompletedToolEvent[] { + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'event_msg') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'item_completed') return [] + const item = get(payload, 'item') + const name = itemToolName(item) + if (!name) return [] + return [ + { + name, + failed: get(item, 'status') === 'failed' || get(get(item, 'result'), 'isError') === true, + timestampMs: timestampMs(get(row, 'timestamp')) + } + ] + }) +} + +function messageText(row: unknown): { role: string | null; text: string } | null { + if (get(row, 'type') !== 'response_item') return null + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'message') return null + const content = get(payload, 'content') + if (!Array.isArray(content)) return null + return { + role: typeof get(payload, 'role') === 'string' ? String(get(payload, 'role')) : null, + text: content + .map((item) => (typeof get(item, 'text') === 'string' ? String(get(item, 'text')) : '')) + .join('\n') + } +} + +function createThreadOutputText(row: unknown): string | null { + if (get(row, 'type') !== 'response_item') return null + const payload = get(row, 'payload') + if ( + get(payload, 'type') !== 'function_call_output' || + get(payload, 'namespace') !== 'codex_app' || + get(payload, 'name') !== 'create_thread' + ) { + return null + } + const output = get(payload, 'output') + return typeof output === 'string' ? output : null +} + +function normalizePrompt(value: string): string { + return value.trim().replaceAll(/\s+/g, ' ') +} + +function decodeXmlText(value: string): string { + return value + .replaceAll(/&#(x[0-9a-f]+|\d+);/gi, (entity, code: string) => { + const point = code.toLowerCase().startsWith('x') + ? Number.parseInt(code.slice(1), 16) + : Number.parseInt(code, 10) + try { + return String.fromCodePoint(point) + } catch { + return entity + } + }) + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&') +} + +function extractPrompt(parsedRows: unknown[]): string | null { + const messages = parsedRows.flatMap((row) => { + const message = messageText(row) + return message ? [message] : [] + }) + const delegatedTexts = [ + ...messages.map(({ text }) => text), + ...parsedRows.flatMap((row) => { + const output = createThreadOutputText(row) + return output ? [output] : [] + }) + ] + for (const text of delegatedTexts) { + const delegated = text.match(/[\s\S]*?([\s\S]*?)<\/input>/) + if (delegated?.[1]) return normalizePrompt(decodeXmlText(delegated[1])) + } + const userMessages = messages + .filter( + ({ role, text }) => + role === 'user' && !/|/.test(text) + ) + .map(({ text }) => normalizePrompt(text)) + .filter(Boolean) + return userMessages.at(-1) ?? null +} + +function skillResources(input: string): string[] { + const normalized = input.replaceAll('\\/', '/').replaceAll('\\\\', '/') + const expanded = normalized.replace( + /(\/skills\/[^/"'\s]+\/references\/)\{([^{}]+)\}/g, + (_match, prefix: string, resources: string) => + resources + .split(',') + .map((resource) => `${prefix}${resource}`) + .join(' ') + ) + return [ + ...expanded.matchAll(/\/skills\/([^/"'\s]+)\/(SKILL\.md|references\/[^"'\s),{}]+\.md)/g) + ].flatMap((match) => (match[1] && match[2] ? [`${match[1]}/${match[2]}`] : [])) +} + +function runtimeObservation(value: unknown, seen = new Set()): RuntimeObservation | null { + const trimmedValue = typeof value === 'string' ? value.trimStart() : '' + if (trimmedValue.startsWith('{') || trimmedValue.startsWith('[')) { + try { + return runtimeObservation(JSON.parse(trimmedValue), seen) + } catch { + return null + } + } + if (!value || typeof value !== 'object' || seen.has(value)) return null + seen.add(value) + const locked = get(value, 'locked') + const valid = get(value, 'valid') + const hub = get(value, 'hub') + const extension = get(value, 'extension') + if (typeof locked === 'boolean' && typeof valid === 'boolean' && hub && extension) { + const hubFingerprint = get(hub, 'runtimeFingerprint') + const extensionFingerprint = get(extension, 'runtimeFingerprint') + const issues = get(value, 'issues') + return { + locked, + valid, + hubFingerprint: typeof hubFingerprint === 'string' ? hubFingerprint : null, + extensionFingerprint: typeof extensionFingerprint === 'string' ? extensionFingerprint : null, + issues: Array.isArray(issues) + ? issues.filter((issue): issue is string => typeof issue === 'string') + : [] + } + } + for (const nested of Object.values(value)) { + const observation = runtimeObservation(nested, seen) + if (observation) return observation + } + return null +} + +function imageViewPaths(parsedRows: unknown[]): string[] { + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'event_msg') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'item_completed') return [] + const item = get(payload, 'item') + if (get(item, 'type') !== 'ImageView') return [] + const path = get(item, 'path') + return typeof path === 'string' ? [path] : [] + }) +} + +function itemTimestampMs(row: unknown, itemType: string): number | null { + if (get(row, 'type') !== 'event_msg') return null + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'item_completed') return null + const item = get(payload, 'item') + if (get(item, 'type') !== itemType) return null + return timestampMs(get(row, 'timestamp')) +} + +function completedToolIntervals(parsedRows: unknown[]): TimedInterval[] { + const toolItemTypes = new Set(['CommandExecution', 'Extension', 'ImageView', 'McpToolCall']) + return parsedRows.flatMap((row) => { + if (get(row, 'type') !== 'event_msg') return [] + const payload = get(row, 'payload') + if (get(payload, 'type') !== 'item_completed') return [] + const item = get(payload, 'item') + if (!toolItemTypes.has(String(get(item, 'type')))) return [] + const startMs = get(payload, 'started_at_ms') + const endMs = get(payload, 'completed_at_ms') + if ( + typeof startMs !== 'number' || + typeof endMs !== 'number' || + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + endMs < startMs + ) { + return [] + } + return [{ startMs, endMs }] + }) +} + +function resultText(event: ApplyEvent): string { + return stringify(event.result) +} + +function markup(event: ApplyEvent): string { + const value = get(event.arguments, 'markup') + return typeof value === 'string' ? value : '' +} + +export function inspectAuthoringRollout(rolloutJsonl: string): AuthoringRolloutInspection { + const parsedRows = rows(rolloutJsonl) + const applies = applyEvents(parsedRows) + const customCalls = customCallEvents(parsedRows) + const callEvents = [...customCalls, ...functionCallEvents(parsedRows)] + const callInputs = callEvents.map(({ input }) => input) + const commandInputs = commandExecutionInputs(parsedRows) + const completedTools = completedToolEvents(parsedRows) + const viewedImages = imageViewPaths(parsedRows) + const applyPayloads = applies.map((event) => stringify(event.arguments)) + const failureCodes: Record = {} + const nodeLimitAttempts: NodeLimitAttempt[] = [] + const domains = new Set() + const iconLibraries = new Set() + const rowTimestamps = parsedRows + .map((row) => timestampMs(get(row, 'timestamp'))) + .filter((value): value is number => value !== null) + const rolloutStartedMs = rowTimestamps.length ? Math.min(...rowTimestamps) : null + const prompt = extractPrompt(parsedRows) + const finalResponseMs = parsedRows.reduce((latest, row) => { + const value = itemTimestampMs(row, 'AgentMessage') + return value === null || (latest !== null && value <= latest) ? latest : value + }, null) + const successfulApplyTimestamps = applies + .filter((event) => event.status === 'completed' && get(event.result, 'isError') !== true) + .map((event) => event.timestampMs) + .filter((value): value is number => value !== null) + const firstSuccessfulApplyAt = successfulApplyTimestamps.length + ? Math.min(...successfulApplyTimestamps) + : null + const applyAttemptTimestamps = applies + .map((event) => event.timestampMs) + .filter((value): value is number => value !== null) + const firstApplyAttemptAt = applyAttemptTimestamps.length + ? Math.min(...applyAttemptTimestamps) + : null + const completedToolTimestamps = completedTools + .map(({ timestampMs: value }) => value) + .filter((value): value is number => value !== null) + const firstToolTimestamps = [ + ...completedToolTimestamps, + ...callEvents + .map(({ timestampMs: value }) => value) + .filter((value): value is number => value !== null) + ] + const researchTimestamps = callEvents + .filter(({ input, name }) => + /web__run|image_query|createBrowserTab|\.goto\s*\(|\.(?:getScreenshot|getAXStateAndScreenshot|screenshot)\s*\(|\bopen\s*:/i.test( + `${name}\n${input}` + ) + ) + .map(({ timestampMs: value }) => value) + .filter((value): value is number => value !== null) + const lastSuccessfulApplyAt = successfulApplyTimestamps.length + ? Math.max(...successfulApplyTimestamps) + : null + const openedTempadScreenshotTimestamps = parsedRows.flatMap((row) => { + const value = itemTimestampMs(row, 'ImageView') + if (value === null) return [] + const payload = get(row, 'payload') + const path = get(get(payload, 'item'), 'path') + return typeof path === 'string' && /\/tempad-dev\/assets\//.test(path) ? [value] : [] + }) + const firstOpenedTempadScreenshotAt = openedTempadScreenshotTimestamps.length + ? Math.min(...openedTempadScreenshotTimestamps) + : null + const lastOpenedTempadScreenshotAt = openedTempadScreenshotTimestamps.length + ? Math.max(...openedTempadScreenshotTimestamps) + : null + const firstOpenedTempadScreenshotAfterLastApplyAt = + lastSuccessfulApplyAt === null + ? null + : (openedTempadScreenshotTimestamps.find((value) => value >= lastSuccessfulApplyAt) ?? null) + const totalWallClockMs = elapsedMs(rolloutStartedMs, finalResponseMs) + const observedToolBusyMs = + rolloutStartedMs !== null && finalResponseMs !== null + ? toolBusyMs(completedToolIntervals(parsedRows), rolloutStartedMs, finalResponseMs) + : null + + let failures = 0 + let authoredComponentCalls = 0 + let instanceBindingCalls = 0 + + for (const event of applies) { + const output = resultText(event) + const failed = event.status === 'failed' || get(event.result, 'isError') === true + if (failed) failures += 1 + const failureCode = output.match(/failed \[([A-Z][A-Z0-9_]*)\]/)?.[1] + if (failureCode) increment(failureCodes, failureCode) + + const limit = output.match(/more than (\d+) elements/i)?.[1] + if (limit) { + const value = markup(event) + nodeLimitAttempts.push({ + limit: Number(limit), + dataKeyCount: countMatches(value, /\bdata-key\s*=/g), + markupCharacters: value.length + }) + } + + const payload = stringify(event.arguments) + for (const match of payload.matchAll(/"imageUrl"\s*:\s*"https?:\\?\/\\?\/([^/\\?"\s]+)/g)) { + if (match[1]) domains.add(match[1].replaceAll('\\', '').toLowerCase()) + } + + if (/"type"\s*:\s*"COMPONENT"/.test(payload)) authoredComponentCalls += 1 + if (/"component"\s*:\s*\{\s*"(?:id|key)"\s*:/.test(payload)) { + instanceBindingCalls += 1 + } + } + + const allCalls = callInputs.join('\n') + const iconEvidence = [...applyPayloads, ...callInputs].join('\n') + const referenceImageViews = viewedImages.filter((path) => + /\/work\/(?:references|research)\//.test(path) + ).length + const tempadScreenshotViews = viewedImages.filter((path) => + /\/tempad-dev\/assets\//.test(path) + ).length + if (/lucide-icons|lucide-static/i.test(iconEvidence)) iconLibraries.add('Lucide') + if (/primer\\?\/octicons|@primer\\?\/octicons/i.test(iconEvidence)) iconLibraries.add('Octicons') + if (/material-design-icons|material-symbols/i.test(iconEvidence)) iconLibraries.add('Material') + + const byName: Record = {} + for (const tool of completedTools) increment(byName, tool.name) + const executedSkillReads = commandInputs + .map(skillResources) + .filter((resources) => resources.length) + const requestedSkillReads = callInputs.map(skillResources).filter((resources) => resources.length) + const skillReads = executedSkillReads.length ? executedSkillReads : requestedSkillReads + const uniqueResources = new Set(skillReads.flat()) + let missingRuntimeEvidence = false + const runtimeObservations = applies.flatMap((event) => { + const observation = runtimeObservation(event.result) + if (!observation && event.status === 'completed' && get(event.result, 'isError') !== true) { + missingRuntimeEvidence = true + } + return observation ? [observation] : [] + }) + const runtimeIssues = new Set(runtimeObservations.flatMap(({ issues }) => issues)) + if (applies.length > 0 && runtimeObservations.length === 0) { + runtimeIssues.add('No runtime identity evidence was returned by apply_canvas.') + } else if (missingRuntimeEvidence) { + runtimeIssues.add('A successful apply_canvas call has no runtime identity evidence.') + } + + return { + execution: inspectAgentRunIdentity(rolloutJsonl), + prompt: { + text: prompt, + sha256: prompt ? createHash('sha256').update(prompt).digest('hex') : null, + wordCount: prompt ? prompt.split(/\s+/).filter(Boolean).length : 0, + characterCount: prompt?.length ?? 0 + }, + tools: { + completedCalls: completedTools.length, + failures: completedTools.filter(({ failed }) => failed).length, + byName + }, + skillContext: { + readCalls: skillReads.length, + uniqueResources: [...uniqueResources].sort() + }, + applyCanvas: { + calls: applies.length, + failures, + failureCodes, + nodeLimitAttempts, + maxMarkupCharacters: Math.max(0, ...applies.map((event) => markup(event).length)), + maxDataKeyCount: Math.max( + 0, + ...applies.map((event) => countMatches(markup(event), /\bdata-key\s*=/g)) + ) + }, + research: { + webCalls: callInputs.filter((input) => input.includes('web__run')).length, + imageQueryCalls: callInputs.filter((input) => /\bimage_query\s*:/.test(input)).length, + openedSourceCalls: callInputs.filter( + (input) => + /\.goto\s*\(/.test(input) || + /\bcreateBrowserTab\s*\(/.test(input) || + /\bopen\s*:/.test(input) + ).length, + browserScreenshotCalls: callInputs.filter((input) => + /\.(?:getScreenshot|getAXStateAndScreenshot|screenshot)\s*\(/.test(input) + ).length + }, + imageViews: { + total: viewedImages.length, + references: referenceImageViews, + tempadScreenshots: tempadScreenshotViews, + other: viewedImages.length - referenceImageViews - tempadScreenshotViews + }, + assets: { + imageGenerationCalls: countMatches(allCalls, /image_gen__imagegen/g), + appliedRemoteImageDomains: [...domains].sort(), + iconLibraries: [...iconLibraries].sort() + }, + components: { + authoredComponentCalls, + instanceBindingCalls + }, + timing: { + rolloutStartedAt: rolloutStartedMs === null ? null : new Date(rolloutStartedMs).toISOString(), + finalResponseAt: finalResponseMs === null ? null : new Date(finalResponseMs).toISOString(), + totalWallClockMs, + firstToolCallMs: elapsedMs( + rolloutStartedMs, + firstToolTimestamps.length ? Math.min(...firstToolTimestamps) : null + ), + firstApplyAttemptMs: elapsedMs(rolloutStartedMs, firstApplyAttemptAt), + firstSuccessfulApplyMs: elapsedMs(rolloutStartedMs, firstSuccessfulApplyAt), + firstResearchCallMs: elapsedMs( + rolloutStartedMs, + researchTimestamps.length ? Math.min(...researchTimestamps) : null + ), + lastResearchCallMs: elapsedMs( + rolloutStartedMs, + researchTimestamps.length ? Math.max(...researchTimestamps) : null + ), + firstOpenedTempadScreenshotMs: elapsedMs(rolloutStartedMs, firstOpenedTempadScreenshotAt), + lastOpenedTempadScreenshotMs: elapsedMs(rolloutStartedMs, lastOpenedTempadScreenshotAt), + firstApplyToOpenedScreenshotMs: elapsedMs( + firstSuccessfulApplyAt, + firstOpenedTempadScreenshotAt + ), + lastSuccessfulApplyMs: elapsedMs(rolloutStartedMs, lastSuccessfulApplyAt), + lastApplyToOpenedScreenshotMs: elapsedMs( + lastSuccessfulApplyAt, + firstOpenedTempadScreenshotAfterLastApplyAt + ), + finalizationAfterLastApplyMs: elapsedMs(lastSuccessfulApplyAt, finalResponseMs), + observedToolBusyMs, + nonToolWallClockMs: + totalWallClockMs === null || observedToolBusyMs === null + ? null + : Math.max(0, totalWallClockMs - observedToolBusyMs) + }, + runtime: { + observations: runtimeObservations.length, + locked: + runtimeObservations.length > 0 && + runtimeObservations.every((observation) => observation.locked), + valid: + runtimeObservations.length > 0 && + !missingRuntimeEvidence && + runtimeObservations.every((observation) => observation.valid), + hubFingerprints: [ + ...new Set(runtimeObservations.flatMap(({ hubFingerprint }) => hubFingerprint ?? [])) + ].sort(), + extensionFingerprints: [ + ...new Set( + runtimeObservations.flatMap(({ extensionFingerprint }) => extensionFingerprint ?? []) + ) + ].sort(), + issues: [...runtimeIssues].sort() + }, + limitations: [ + 'Image-view categories use path heuristics; final-write timing does not prove screenshot capture freshness, target identity, or full-screen coverage.', + 'Trace signals do not prove that researched evidence or acquired assets were retained in the final artifact.', + 'Component counters identify authoring mechanics, not whether the chosen component boundary was semantically correct.', + 'Timing milestones identify trace events, not the first usable design: an apply may be scaffolding and a screenshot may show a component or partial screen. Inspect the opened pixels and record usability separately.', + 'Trace counts do not substitute for evaluator inspection of screenshot pixels and live native structure.' + ] + } +} + +function main(): void { + const rolloutPaths = process.argv.slice(2) + if (!rolloutPaths.length) { + throw new Error('Usage: inspect-agent-authoring-rollout [...]') + } + const results = rolloutPaths.map((rolloutPath) => ({ + rolloutPath, + ...inspectAuthoringRollout(readFileSync(rolloutPath, 'utf8')) + })) + process.stdout.write(`${JSON.stringify(results.length === 1 ? results[0] : results, null, 2)}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/packages/extension/scripts/inspect-agent-run-identity.ts b/packages/extension/scripts/inspect-agent-run-identity.ts new file mode 100644 index 00000000..4efe4577 --- /dev/null +++ b/packages/extension/scripts/inspect-agent-run-identity.ts @@ -0,0 +1,145 @@ +import { createHash } from 'node:crypto' + +export interface AgentRunIdentity { + taskId: string | null + model: string | null + reasoningEffort: string | null + promptCount: number + promptSha256: string | null + issues: string[] +} + +function field(value: unknown, key: string): unknown { + return value && typeof value === 'object' ? Reflect.get(value, key) : undefined +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function decodeXml(value: string): string { + return value + .replaceAll(/&#(x[0-9a-f]+|\d+);/gi, (entity, code: string) => { + const point = code.toLowerCase().startsWith('x') + ? Number.parseInt(code.slice(1), 16) + : Number.parseInt(code, 10) + try { + return String.fromCodePoint(point) + } catch { + return entity + } + }) + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&') +} + +// Inspect source records, not model prose or tool output claiming an identity. +export function inspectAgentRunIdentity(source: string): AgentRunIdentity { + const taskIds = new Set() + const models = new Set() + const efforts = new Set() + const prompts: string[] = [] + const delegatedPrompts = new Set() + const issues = new Set() + + for (const [index, line] of source.split('\n').entries()) { + if (!line.trim()) continue + let row: unknown + try { + row = JSON.parse(line) as unknown + } catch { + issues.add(`Invalid rollout JSON on line ${String(index + 1)}.`) + continue + } + const payload = field(row, 'payload') + if (field(row, 'type') === 'session_meta') { + for (const key of ['id', 'session_id']) { + const id = text(field(payload, key)) + if (id) taskIds.add(id) + } + } + if (field(row, 'type') === 'turn_context') { + const settings = field(field(payload, 'collaboration_mode'), 'settings') + const model = text(field(payload, 'model')) + const effort = text(field(payload, 'effort')) ?? text(field(settings, 'reasoning_effort')) + if (model) models.add(model) + else issues.add('A turn context has no model identity.') + if (effort) efforts.add(effort) + else issues.add('A turn context has no reasoning effort.') + const settingsModel = text(field(settings, 'model')) + const settingsEffort = text(field(settings, 'reasoning_effort')) + if (settingsModel && model && settingsModel !== model) { + issues.add('Turn model disagrees with collaboration settings.') + } + if (settingsEffort && effort && settingsEffort !== effort) { + issues.add('Turn reasoning effort disagrees with collaboration settings.') + } + } + if (field(row, 'type') !== 'response_item') continue + if ( + field(payload, 'type') === 'function_call_output' && + field(payload, 'name') === 'create_thread' && + field(payload, 'namespace') === 'codex_app' + ) { + const output = text(field(payload, 'output')) ?? '' + for (const match of output.matchAll( + /[\s\S]*?([\s\S]*?)<\/input>[\s\S]*?<\/codex_delegation>/g + )) { + delegatedPrompts.add(decodeXml(match[1]!)) + } + continue + } + if (field(payload, 'type') !== 'message') continue + const content = field(payload, 'content') + if (!Array.isArray(content)) continue + const message = content.map((item) => text(field(item, 'text')) ?? '').join('\n') + if (field(payload, 'role') === 'developer') { + // Some native host versions carry the original request in this envelope. + for (const match of message.matchAll( + /[\s\S]*?([\s\S]*?)<\/input>[\s\S]*?<\/codex_delegation>/g + )) { + delegatedPrompts.add(decodeXml(match[1]!)) + } + } else if (field(payload, 'role') === 'user') { + let remainder = message + .replaceAll(/[\s\S]*?<\/recommended_plugins>/g, '') + .replaceAll(/[\s\S]*?<\/environment_context>/g, '') + .trim() + if (/^# AGENTS\.md instructions(?: for .*)?\n/.test(remainder)) { + // Preserve any task text following the host-supplied repository block. + const end = remainder.indexOf('') + if (end < 0) issues.add('Unrecognized repository instruction envelope.') + remainder = end < 0 ? '' : remainder.slice(end + ''.length).trim() + } + if (remainder) prompts.push(remainder) + else if (content.some((item) => field(item, 'type') === 'input_image')) { + issues.add('An additional user image requires intervention review.') + } + } + } + + function single(values: Set, label: string): string | null { + if (values.size === 1) return [...values][0]! + issues.add( + values.size ? `${label} changed within the rollout.` : `No ${label.toLowerCase()} evidence.` + ) + return null + } + const taskId = single(taskIds, 'Task identity') + const model = single(models, 'Model') + const reasoningEffort = single(efforts, 'Reasoning effort') + prompts.unshift(...delegatedPrompts) + if (prompts.length !== 1) issues.add('A clean run requires exactly one original task prompt.') + const prompt = prompts[0]?.trim().replaceAll(/\s+/g, ' ') + return { + taskId, + model, + reasoningEffort, + promptCount: prompts.length, + promptSha256: prompt ? createHash('sha256').update(prompt).digest('hex') : null, + issues: [...issues] + } +} diff --git a/packages/extension/scripts/inspect-agent-skill-catalog.ts b/packages/extension/scripts/inspect-agent-skill-catalog.ts new file mode 100644 index 00000000..ba229b49 --- /dev/null +++ b/packages/extension/scripts/inspect-agent-skill-catalog.ts @@ -0,0 +1,139 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +interface SkillCatalogEntry { + name: string + description: string + locatorKind: 'file' | 'environment resource' | 'orchestrator package' | 'custom resource' + locator: string +} + +interface SkillCatalogFingerprint { + catalogFingerprint: string + runtimeFingerprint: string + count: number + skills: SkillCatalogEntry[] + tempadSkillPaths: string[] +} + +const SKILL_ENTRY_PATTERN = + /^- ([^\n]+?): (.*) \((file|environment resource|orchestrator package|custom resource): (.+)\)$/gm +const SKILL_ROOT_PATTERN = /^- `([^`]+)` = `([^`]+)`$/gm + +function hash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +function messageText(value: unknown): string { + if (!value || typeof value !== 'object') return '' + const payload = Reflect.get(value, 'payload') + if (!payload || typeof payload !== 'object' || Reflect.get(payload, 'type') !== 'message') { + return '' + } + const content = Reflect.get(payload, 'content') + if (!Array.isArray(content)) return '' + return content + .map((item) => + item && typeof item === 'object' && typeof Reflect.get(item, 'text') === 'string' + ? String(Reflect.get(item, 'text')) + : '' + ) + .join('\n') +} + +export function extractSkillCatalog(rolloutJsonl: string): SkillCatalogEntry[] { + for (const line of rolloutJsonl.split('\n')) { + if (!line.trim()) continue + let row: unknown + try { + row = JSON.parse(line) + } catch { + continue + } + const text = messageText(row) + const start = text.indexOf('') + const end = text.indexOf('') + if (start === -1 || end === -1 || end <= start) continue + + const block = text.slice(start, end) + const roots = new Map() + for (const match of block.matchAll(SKILL_ROOT_PATTERN)) { + const [, alias, root] = match + if (alias && root) roots.set(alias, root) + } + const entries: SkillCatalogEntry[] = [] + for (const match of block.matchAll(SKILL_ENTRY_PATTERN)) { + const [, name, description, locatorKind, locator] = match + if (!name || !description || !locatorKind || !locator) continue + const [alias, ...relativeSegments] = locator.split('/') + const root = alias ? roots.get(alias) : undefined + entries.push({ + name, + description, + locatorKind: locatorKind as SkillCatalogEntry['locatorKind'], + locator: root && relativeSegments.length ? join(root, ...relativeSegments) : locator + }) + } + if (entries.length) return entries + } + throw new Error('No complete catalog found in rollout.') +} + +export function fingerprintSkillCatalog(skills: SkillCatalogEntry[]): SkillCatalogFingerprint { + const portable = skills.map(({ name, description }) => ({ name, description })) + const runtime = skills.map(({ name, description, locatorKind, locator }) => ({ + name, + description, + locatorKind, + locator + })) + return { + catalogFingerprint: hash(portable), + runtimeFingerprint: hash(runtime), + count: skills.length, + skills, + tempadSkillPaths: skills + .filter((skill) => skill.name.endsWith(':figma-canvas-authoring')) + .map((skill) => skill.locator) + } +} + +function parseExpected(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag) + if (index === -1) return undefined + const value = args[index + 1] + if (!value) throw new Error(`${flag} requires a SHA-256 fingerprint.`) + return value +} + +function main(): void { + const args = process.argv.slice(2) + const rolloutPath = args.find((arg) => !arg.startsWith('--')) + if (!rolloutPath) { + throw new Error( + 'Usage: inspect-agent-skill-catalog [--expect-catalog ] [--expect-runtime ]' + ) + } + const result = fingerprintSkillCatalog(extractSkillCatalog(readFileSync(rolloutPath, 'utf8'))) + const expectedCatalog = parseExpected(args, '--expect-catalog') + const expectedRuntime = parseExpected(args, '--expect-runtime') + + if (expectedCatalog && result.catalogFingerprint !== expectedCatalog) { + throw new Error( + `Skill catalog fingerprint mismatch: expected ${expectedCatalog}, received ${result.catalogFingerprint}.` + ) + } + if (expectedRuntime && result.runtimeFingerprint !== expectedRuntime) { + throw new Error( + `Skill runtime fingerprint mismatch: expected ${expectedRuntime}, received ${result.runtimeFingerprint}.` + ) + } + + process.stdout.write(`${JSON.stringify({ rolloutPath, ...result }, null, 2)}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/packages/extension/scripts/promote-screenshots.ts b/packages/extension/scripts/promote-screenshots.ts index 31e2af7f..c92e9a6b 100644 --- a/packages/extension/scripts/promote-screenshots.ts +++ b/packages/extension/scripts/promote-screenshots.ts @@ -2,7 +2,9 @@ import { copyFile, readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' -type Scenario = { height: number; id: string; width: number } +import { selectScenarios, selectThemes, type ScreenshotScenario } from './screenshot-plan' + +type Scenario = ScreenshotScenario & { height: number; width: number } type Manifest = { capture: { themes: string[] }; scenarios: Scenario[] } const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) @@ -31,16 +33,15 @@ async function main(): Promise { const candidateDir = candidateDirArgument ? resolve(repoRoot, candidateDirArgument) : `${repoRoot}.artifacts/marketing-screenshots` - const selected = new Set( - (readArgument('--only') ?? manifest.scenarios.map((scenario) => scenario.id).join(',')) - .split(',') - .filter(Boolean) - ) + const selected = selectScenarios(manifest.scenarios, { + only: readArgument('--only'), + group: readArgument('--group') + }) + const themes = selectThemes(manifest.capture.themes, readArgument('--themes')) const candidates: Array<{ source: string; target: string }> = [] - for (const scenario of manifest.scenarios) { - if (!selected.has(scenario.id)) continue - for (const theme of manifest.capture.themes) { + for (const scenario of selected) { + for (const theme of themes) { const filename = `${scenario.id}-${theme}.png` const source = resolve(candidateDir, filename) const size = readPngSize(await readFile(source)) diff --git a/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts b/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts new file mode 100644 index 00000000..8ec7cdc8 --- /dev/null +++ b/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts @@ -0,0 +1,150 @@ +type RuntimeProcess = { + pid: number +} + +type RuntimeProcessState = { + cli: RuntimeProcess[] + hub: RuntimeProcess[] +} + +type RuntimeState = 'installed' | 'uninstalled' + +export type CdpTarget = { + title: string + type: string + url: string + webSocketDebuggerUrl: string +} + +export function formatCodexConnectionError( + cdpUrl: string, + message: string, + cdpReady: boolean +): string { + const recovery = cdpReady + ? 'The CDP endpoint is reachable; inspect the connection error and exposed targets without restarting Codex.' + : 'Start Codex with remote debugging, or pass --restart-codex when the CDP endpoint is unavailable.' + return `Could not connect to Codex CDP at ${cdpUrl}: ${message}\n${recovery}` +} + +export async function selectCodexTarget( + listTargets: () => Promise, + pageUrl: string | undefined, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs + let targets: CdpTarget[] = [] + let lastFailure: { error: unknown } | undefined + while (Date.now() <= deadline) { + try { + targets = await listTargets() + lastFailure = undefined + } catch (error) { + targets = [] + lastFailure = { error } + } + const codexPages = targets.filter((target) => { + if (target.type !== 'page' || target.url.includes('initialRoute=%2Favatar-overlay')) { + return false + } + try { + return new URL(target.url).protocol === 'app:' + } catch { + return false + } + }) + const exactCandidates = pageUrl ? codexPages.filter((target) => target.url === pageUrl) : [] + if (exactCandidates.length === 1 && exactCandidates[0]) return exactCandidates[0] + const candidates = pageUrl + ? codexPages.filter((target) => target.url.includes(pageUrl)) + : codexPages + const candidate = candidates[0] + if (candidates.length === 1 && candidate) return candidate + if (candidates.length > 1) { + throw new Error( + `Multiple Codex pages are available. Pass --page-url with a unique substring:\n${candidates + .map(({ title, url }) => `- ${title}: ${url}`) + .join('\n')}` + ) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + if (lastFailure) { + const { error } = lastFailure + throw new Error( + `Could not list Codex CDP targets: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ) + } + throw new Error( + `No Codex app page found at the CDP endpoint. Exposed pages:\n${targets + .map(({ title, url }) => `- ${title}: ${url}`) + .join('\n')}` + ) +} + +export const detachedReinstallJobPrefix = 'com.tempad-dev.codex-plugin-reinstall.' + +type DetachedReinstallIdentity = { + jobLabel: string + logFileName: string +} + +export function resolveDevPluginVersion(input: unknown, requested?: string): string { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Generated development plugin manifest must be an object.') + } + const manifest = input as Record + if (manifest.name !== 'tempad-dev-dev' || typeof manifest.version !== 'string') { + throw new Error('Generated development plugin manifest has an unexpected identity.') + } + if (requested && requested !== manifest.version) { + throw new Error( + `Generated plugin version is ${manifest.version}, not ${requested}. ` + + 'Run pnpm agent-plugin:dev, then omit the version or pass the generated value.' + ) + } + return manifest.version +} + +export function detachedReinstallIdentity( + pid: number, + timestamp: number +): DetachedReinstallIdentity { + const suffix = `${String(pid)}.${String(timestamp)}` + return { + jobLabel: `${detachedReinstallJobPrefix}${suffix}`, + logFileName: `codex-plugin-reinstall.${suffix}.log` + } +} + +export function assertNoDetachedReinstallJobs(labels: string[]): void { + if (labels.length === 0) return + throw new Error(`A detached Codex plugin reinstall is already running: ${labels.join(', ')}`) +} + +export function assertRestartCodexNeeded(cdpReady: boolean): void { + if (!cdpReady) return + throw new Error( + 'Refusing to restart Codex because its CDP endpoint is already available. ' + + 'Run pnpm agent-plugin:reinstall without --restart-codex; if target selection fails, ' + + 'fix --page-url instead.' + ) +} + +export function runtimeStateMatches( + processes: RuntimeProcessState, + expectedState: RuntimeState, + baseline?: RuntimeProcessState +): boolean { + if (expectedState === 'uninstalled') { + return processes.cli.length === 0 && processes.hub.length === 0 + } + + return ( + processes.hub.length > 0 && + (baseline + ? processes.cli.some(({ pid }) => !baseline.cli.some((process) => process.pid === pid)) + : processes.cli.length > 0) + ) +} diff --git a/packages/extension/scripts/reinstall-codex-dev-plugin.ts b/packages/extension/scripts/reinstall-codex-dev-plugin.ts new file mode 100644 index 00000000..ceffe57f --- /dev/null +++ b/packages/extension/scripts/reinstall-codex-dev-plugin.ts @@ -0,0 +1,920 @@ +import { execFile } from 'node:child_process' +import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, normalize, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +import { + assertNoDetachedReinstallJobs, + assertRestartCodexNeeded, + detachedReinstallIdentity, + detachedReinstallJobPrefix, + formatCodexConnectionError, + resolveDevPluginVersion, + runtimeStateMatches, + selectCodexTarget, + type CdpTarget +} from './reinstall-codex-dev-plugin-runtime' +import { parseLaunchctlLabels } from './switch-codex-host-runtime' + +const execFileAsync = promisify(execFile) +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const pluginRoot = join(repoRoot, '.dev/plugins/tempad-dev-dev') +const scriptPath = fileURLToPath(import.meta.url) +const pluginDisplayName = 'TemPad Dev (Dev)' +const pluginName = 'tempad-dev-dev' +const pollIntervalMs = 500 +const pageFunctionDeclaration = `function (request) { + const isVisible = (element) => + Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length) + const findElement = (query) => { + const match = [...document.querySelectorAll(query.selector)].find( + (element) => + (query.visible === false || isVisible(element)) && + (query.leafOnly !== true || element.children.length === 0) && + (query.text === undefined || element.textContent.trim() === query.text) && + (query.ariaLabel === undefined || + element.getAttribute('aria-label') === query.ariaLabel) + ) + return match && query.closest ? match.closest(query.closest) : match || null + } + const pluginState = () => { + if (findElement({ selector: 'button', text: 'Install plugin' })) return 'uninstalled' + if (findElement({ selector: 'button', ariaLabel: 'More actions' })) return 'installed' + return null + } + + if (request.action === 'body-includes') { + return Boolean(document.body && document.body.innerText.includes(request.text)) + } + if (request.action === 'element-exists') { + return Boolean(findElement(request.query)) + } + if (request.action === 'element-center') { + const element = findElement(request.query) + if (!element) return null + const rect = element.getBoundingClientRect() + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + } + if (request.action === 'click-element') { + const element = findElement(request.query) + if (!element || typeof element.click !== 'function') return false + element.click() + return true + } + if (request.action === 'plugin-state') return pluginState() + if (request.action === 'plugin-state-is') return pluginState() === request.state + if (request.action === 'set-input-value') { + const input = document.querySelector(request.selector) + if (!(input instanceof HTMLInputElement)) return false + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (!setter) return false + setter.call(input, request.value) + input.dispatchEvent(new Event('input', { bubbles: true })) + return true + } + throw new Error('Unknown Codex page action.') +}` + +type Arguments = { + appPath: string + cdpUrl: string + pageUrl?: string + restartCodex: boolean + resumeAfterRestart: boolean + timeoutMs: number + version: string +} + +type ParsedArguments = Omit & { version?: string } + +type ProcessInfo = { + command: string + pid: number + ppid: number +} + +type RuntimeProcesses = { + cli: ProcessInfo[] + hub: ProcessInfo[] +} + +type RuntimePaths = { + cli: string + hub: string +} + +type PluginState = 'installed' | 'uninstalled' + +type PageElementQuery = { + ariaLabel?: string + closest?: string + leafOnly?: boolean + selector: string + text?: string + visible?: boolean +} + +type PageRequest = + | { action: 'body-includes'; text: string } + | { + action: 'click-element' | 'element-center' | 'element-exists' + query: PageElementQuery + } + | { action: 'plugin-state' } + | { action: 'plugin-state-is'; state: PluginState } + | { action: 'set-input-value'; selector: string; value: string } + +type PendingCdpRequest = { + reject: (error: Error) => void + resolve: (result: unknown) => void + timeout: ReturnType +} + +class CdpClient { + private nextId = 0 + private pageObjectId: string | undefined + private readonly pending = new Map() + + private constructor( + private readonly socket: WebSocket, + private readonly timeoutMs: number + ) { + socket.addEventListener('message', (event) => this.onMessage(event)) + socket.addEventListener('close', () => this.rejectPending('CDP connection closed.')) + socket.addEventListener('error', () => this.rejectPending('CDP connection failed.')) + } + + static async connect(url: string, timeoutMs: number): Promise { + if (typeof WebSocket === 'undefined') { + fail('This script requires Node.js 22 or newer for its built-in WebSocket client.') + } + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out opening the CDP page.')), + timeoutMs + ) + socket.addEventListener('open', () => { + clearTimeout(timeout) + resolve() + }) + socket.addEventListener('error', () => { + clearTimeout(timeout) + reject(new Error('Could not open the CDP page.')) + }) + }) + return new CdpClient(socket, timeoutMs) + } + + async call(method: string, params: Record = {}): Promise { + const id = ++this.nextId + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Timed out calling CDP method ${method}.`)) + }, this.timeoutMs) + this.pending.set(id, { reject, resolve, timeout }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + async runPageRequest(request: PageRequest): Promise { + const pageObjectId = await this.getPageObjectId() + const response = objectValue( + await this.call('Runtime.callFunctionOn', { + arguments: [{ value: request }], + awaitPromise: true, + functionDeclaration: pageFunctionDeclaration, + objectId: pageObjectId, + returnByValue: true, + userGesture: true + }), + 'Runtime.callFunctionOn response' + ) + if (response.exceptionDetails) { + fail(`Codex page function failed: ${JSON.stringify(response.exceptionDetails)}`) + } + const result = objectValue(response.result, 'Runtime.callFunctionOn result') + return result.value as T + } + + close(): void { + this.socket.close() + } + + private onMessage(event: MessageEvent): void { + if (typeof event.data !== 'string') return + const message = JSON.parse(event.data) as Record + if (typeof message.id !== 'number') return + const pending = this.pending.get(message.id) + if (!pending) return + clearTimeout(pending.timeout) + this.pending.delete(message.id) + if (message.error) { + pending.reject(new Error(`CDP request failed: ${JSON.stringify(message.error)}`)) + } else { + pending.resolve(message.result) + } + } + + private async getPageObjectId(): Promise { + if (this.pageObjectId) return this.pageObjectId + const response = objectValue( + await this.call('Runtime.evaluate', { + expression: 'globalThis' + }), + 'Runtime.evaluate response' + ) + if (response.exceptionDetails) { + fail(`Could not access the Codex page: ${JSON.stringify(response.exceptionDetails)}`) + } + const result = objectValue(response.result, 'Runtime.evaluate result') + if (typeof result.objectId !== 'string') fail('Codex page global object is unavailable.') + this.pageObjectId = result.objectId + return this.pageObjectId + } + + private rejectPending(message: string): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout) + pending.reject(new Error(message)) + } + this.pending.clear() + } +} + +function fail(message: string): never { + throw new Error(message) +} + +function usage(): string { + return [ + 'Reinstall TemPad Dev (Dev) through a running Codex Desktop CDP endpoint:', + '', + ' pnpm agent-plugin:reinstall [version]', + ' pnpm agent-plugin:reinstall [version] --cdp-url http://127.0.0.1:9222', + '', + 'Arguments:', + ' [version] Exact generated plugin version; defaults to the generated manifest', + '', + 'Options:', + ' --app-path Codex app to launch (default: CODEX_APP_PATH or /Applications/ChatGPT.app)', + ' --cdp-url Codex CDP endpoint (default: CODEX_CDP_URL or http://127.0.0.1:9222)', + ' --page-url Select a Codex page; an exact URL wins over substring matching', + ' --restart-codex Recovery only: restart Codex after the plain command reports that CDP is unavailable', + ' --timeout-ms Timeout for each UI/runtime transition (default: 60000)', + ' --help Show this help', + '', + 'Codex Desktop must already be running with remote debugging enabled, for example on macOS:', + ' open -a ChatGPT --args --remote-debugging-port=9222', + '', + 'Recovery when the plain command reports that the CDP endpoint is unavailable:', + ' pnpm agent-plugin:reinstall [version] --restart-codex', + 'Do not use --restart-codex preemptively or to repair page-target selection.' + ].join('\n') +} + +function parseArguments(argv: string[]): ParsedArguments | null { + if (argv.includes('--help')) return null + + let appPath = process.env.CODEX_APP_PATH ?? '/Applications/ChatGPT.app' + let cdpUrl = process.env.CODEX_CDP_URL ?? 'http://127.0.0.1:9222' + let pageUrl: string | undefined + let restartCodex = false + let resumeAfterRestart = false + let timeoutMs = 60_000 + let version: string | undefined + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (!argument) continue + if (argument === '--restart-codex') { + restartCodex = true + continue + } + if (argument === '--resume-after-restart') { + resumeAfterRestart = true + continue + } + if ( + argument === '--app-path' || + argument === '--cdp-url' || + argument === '--page-url' || + argument === '--timeout-ms' + ) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) fail(`Missing value for ${argument}.`) + index += 1 + if (argument === '--app-path') appPath = value + if (argument === '--cdp-url') cdpUrl = value + if (argument === '--page-url') pageUrl = value + if (argument === '--timeout-ms') { + timeoutMs = Number(value) + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + fail(`Invalid --timeout-ms value: ${value}`) + } + } + continue + } + if (argument.startsWith('--')) fail(`Unknown option: ${argument}`) + if (version) fail(`Unexpected positional argument: ${argument}`) + version = argument + } + + return { appPath, cdpUrl, pageUrl, restartCodex, resumeAfterRestart, timeoutMs, version } +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) +} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be an object.`) + } + return value as Record +} + +async function resolveRequestedVersion(requested?: string): Promise { + const manifestPath = join(pluginRoot, '.codex-plugin/plugin.json') + return resolveDevPluginVersion(await readJson(manifestPath), requested) +} + +async function resolveRuntimePaths(): Promise { + const mcpPath = join(pluginRoot, '.mcp.json') + const mcp = objectValue(await readJson(mcpPath), mcpPath) + const servers = objectValue(mcp.mcpServers, `${mcpPath}#mcpServers`) + const server = objectValue(servers[pluginName], `${mcpPath}#mcpServers.${pluginName}`) + if (!Array.isArray(server.args) || typeof server.args[0] !== 'string') { + fail(`Missing CLI entry in ${mcpPath}.`) + } + + const cliArgument = server.args[0] + const cli = normalize(isAbsolute(cliArgument) ? cliArgument : resolve(pluginRoot, cliArgument)) + const hub = join(dirname(cli), 'hub.mjs') + await Promise.all([access(cli), access(hub)]) + return { cli, hub } +} + +async function listProcesses(): Promise { + const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,command='], { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024 + }) + return stdout + .split('\n') + .map((line): ProcessInfo | null => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/) + if (!match) return null + const [, pid, ppid, command] = match + if (!pid || !ppid || !command) return null + return { command, pid: Number(pid), ppid: Number(ppid) } + }) + .filter((process): process is ProcessInfo => process !== null) +} + +async function listRuntimeProcesses(paths: RuntimePaths): Promise { + const processes = await listProcesses() + + return { + cli: processes.filter((process) => process.command.includes(paths.cli)), + hub: processes.filter((process) => process.command.includes(paths.hub)) + } +} + +function processSummary(processes: RuntimeProcesses): string { + const cliPids = processes.cli.map(({ pid }) => pid).join(', ') || 'none' + const hubPids = processes.hub.map(({ pid }) => pid).join(', ') || 'none' + return `CLI=${processes.cli.length} [${cliPids}], Hub=${processes.hub.length} [${hubPids}]` +} + +async function waitForRuntimeState( + paths: RuntimePaths, + expectedState: PluginState, + timeoutMs: number, + baseline?: RuntimeProcesses +): Promise { + const deadline = Date.now() + timeoutMs + const requiredStableSamples = expectedState === 'installed' ? 2 : 3 + let stableSamples = 0 + let lastSummary = '' + let latest = await listRuntimeProcesses(paths) + + while (Date.now() <= deadline) { + latest = await listRuntimeProcesses(paths) + const matches = runtimeStateMatches(latest, expectedState, baseline) + stableSamples = matches ? stableSamples + 1 : 0 + + const summary = processSummary(latest) + if (summary !== lastSummary) { + console.log(`Runtime: ${summary}`) + lastSummary = summary + } + if (stableSamples >= requiredStableSamples) return latest + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + + const expected = + expectedState === 'installed' + ? baseline + ? 'a new TemPad Dev CLI and an available Hub' + : 'the TemPad Dev CLI and Hub to be running' + : 'all TemPad Dev CLI and Hub processes to stop before reinstalling' + fail( + `Timed out waiting for ${expected}. Last observed state: ${processSummary(latest)}` + + (baseline ? `; baseline: ${processSummary(baseline)}` : '') + ) +} + +function stopRemainingRuntimeProcesses(processes: RuntimeProcesses): void { + const remaining = [...processes.cli, ...processes.hub] + if (remaining.length === 0) return + + console.log( + `Stopping runtime processes left behind after uninstall: ${processSummary(processes)}` + ) + for (const { pid } of remaining) { + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function listCodexMainProcesses(): Promise { + return (await listProcesses()).filter( + ({ command, ppid }) => ppid === 1 && command.includes('.app/Contents/MacOS/ChatGPT') + ) +} + +async function waitUntil( + predicate: () => Promise, + description: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() <= deadline) { + if (await predicate()) return + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + fail(`Timed out waiting for ${description}.`) +} + +async function isCdpReady(cdpUrl: string): Promise { + try { + const versionUrl = new URL('/json/version', cdpUrl) + const response = await fetch(versionUrl, { signal: AbortSignal.timeout(1_000) }) + return response.ok + } catch { + return false + } +} + +function cdpPort(cdpUrl: string): number { + const url = new URL(cdpUrl) + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) { + fail('--restart-codex requires a local HTTP CDP URL.') + } + const port = Number(url.port || 80) + if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) { + fail(`Invalid CDP port in ${cdpUrl}.`) + } + return port +} + +async function waitForCodexExit(timeoutMs: number): Promise { + await waitUntil( + async () => (await listCodexMainProcesses()).length === 0, + 'Codex Desktop to exit', + timeoutMs + ) +} + +async function terminateSingleCodexMainProcess(): Promise { + const processes = await listCodexMainProcesses() + if (processes.length === 0) return + if (processes.length !== 1 || !processes[0]) { + fail( + `Refusing to terminate Codex because ${String(processes.length)} main processes were found:\n${processes + .map(({ command, pid }) => `- ${String(pid)}: ${command}`) + .join('\n')}` + ) + } + const [{ pid }] = processes + console.log(`Terminating the verified Codex main process ${String(pid)}...`) + process.kill(pid, 'SIGTERM') +} + +async function restartCodexForCdp( + appPath: string, + cdpUrl: string, + timeoutMs: number +): Promise { + if (process.platform !== 'darwin') fail('--restart-codex is currently supported only on macOS.') + const port = cdpPort(cdpUrl) + await access(appPath) + + await new Promise((resolve) => setTimeout(resolve, 1_000)) + console.log('Requesting Codex Desktop to quit...') + try { + await execFileAsync('osascript', ['-e', 'tell application id "com.openai.codex" to quit'], { + timeout: timeoutMs + }) + await waitForCodexExit(timeoutMs) + } catch (error) { + console.warn(`Normal Codex quit did not complete: ${errorMessage(error)}`) + await terminateSingleCodexMainProcess() + await waitForCodexExit(timeoutMs) + } + + console.log(`Starting ${appPath} with --remote-debugging-port=${port}...`) + await execFileAsync( + 'open', + ['-n', appPath, '--args', `--remote-debugging-port=${String(port)}`], + { + timeout: timeoutMs + } + ) + await waitUntil(() => isCdpReady(cdpUrl), `Codex CDP endpoint ${cdpUrl}`, timeoutMs) + console.log(`Codex CDP endpoint is ready at ${cdpUrl}.`) +} + +async function startDetachedRestart(args: Arguments, reason: string): Promise { + if (process.platform !== 'darwin') fail('--restart-codex is currently supported only on macOS.') + cdpPort(args.cdpUrl) + const { stdout: launchctlOutput } = await execFileAsync('/bin/launchctl', ['list'], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }) + assertNoDetachedReinstallJobs(parseLaunchctlLabels(launchctlOutput, detachedReinstallJobPrefix)) + + const childArguments = [ + ...process.execArgv, + scriptPath, + args.version, + '--cdp-url', + args.cdpUrl, + '--app-path', + args.appPath, + '--timeout-ms', + String(args.timeoutMs), + '--resume-after-restart', + ...(args.pageUrl ? ['--page-url', args.pageUrl] : []) + ] + const { jobLabel, logFileName } = detachedReinstallIdentity(process.pid, Date.now()) + const restartLogPath = join(repoRoot, '.dev', logFileName) + await mkdir(dirname(restartLogPath), { recursive: true }) + await writeFile(restartLogPath, '') + await execFileAsync( + 'launchctl', + [ + 'submit', + '-l', + jobLabel, + '-o', + restartLogPath, + '-e', + restartLogPath, + '--', + '/bin/sh', + '-c', + '"$@"\nstatus=$?\n/bin/launchctl remove "$0"\nexit "$status"', + jobLabel, + process.execPath, + ...childArguments + ], + { cwd: repoRoot } + ) + + console.log(reason) + console.log(`Detached reinstall helper submitted as ${jobLabel}.`) + console.log(`Codex will restart; progress is written to ${restartLogPath}.`) +} + +async function listCdpTargets(cdpUrl: string): Promise { + const response = await fetch(new URL('/json/list', cdpUrl), { + signal: AbortSignal.timeout(2_000) + }) + if (!response.ok) fail(`Could not list CDP targets: HTTP ${String(response.status)}.`) + const targets = (await response.json()) as unknown + if (!Array.isArray(targets)) fail('CDP target list is not an array.') + return targets.filter( + (target): target is CdpTarget => + Boolean(target) && + typeof target === 'object' && + typeof target.title === 'string' && + typeof target.type === 'string' && + typeof target.url === 'string' && + typeof target.webSocketDebuggerUrl === 'string' + ) +} + +async function connectToCodex(args: Arguments): Promise { + if (args.resumeAfterRestart) { + await restartCodexForCdp(args.appPath, args.cdpUrl, args.timeoutMs) + } + if (args.restartCodex && !args.resumeAfterRestart) { + assertRestartCodexNeeded(await isCdpReady(args.cdpUrl)) + await startDetachedRestart( + args, + 'A clean Codex restart was requested before replacing the plugin.' + ) + return null + } + try { + const target = await selectCodexTarget( + () => listCdpTargets(args.cdpUrl), + args.pageUrl, + args.timeoutMs + ) + console.log(`Codex page: ${target.title || '(untitled)'} (${target.url})`) + return await CdpClient.connect(target.webSocketDebuggerUrl, args.timeoutMs) + } catch (error) { + fail( + formatCodexConnectionError(args.cdpUrl, errorMessage(error), await isCdpReady(args.cdpUrl)) + ) + } +} + +async function waitForPageCondition( + client: CdpClient, + request: PageRequest, + description: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() <= deadline) { + if (await client.runPageRequest(request)) return + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + fail(`Timed out waiting for ${description}.`) +} + +async function clickElementPhysical(client: CdpClient, query: PageElementQuery): Promise { + const point = await client.runPageRequest<{ x: number; y: number } | null>({ + action: 'element-center', + query + }) + if (!point) return false + await client.call('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: point.x, + y: point.y + }) + await client.call('Input.dispatchMouseEvent', { + button: 'left', + clickCount: 1, + type: 'mousePressed', + x: point.x, + y: point.y + }) + await client.call('Input.dispatchMouseEvent', { + button: 'left', + clickCount: 1, + type: 'mouseReleased', + x: point.x, + y: point.y + }) + return true +} + +async function clickPageElement( + client: CdpClient, + selector: string, + description: string, + timeoutMs: number, + text?: string, + ariaLabel?: string, + physical = false +): Promise { + const query: PageElementQuery = { + selector, + ...(text === undefined ? {} : { text }), + ...(ariaLabel === undefined ? {} : { ariaLabel }) + } + await waitForPageCondition(client, { action: 'element-exists', query }, description, timeoutMs) + const clicked = physical + ? await clickElementPhysical(client, query) + : await client.runPageRequest({ action: 'click-element', query }) + if (!clicked) fail(`Could not click ${description}.`) +} + +async function waitForPluginState( + client: CdpClient, + expected: PluginState, + timeoutMs: number +): Promise { + await waitForPageCondition( + client, + { action: 'plugin-state-is', state: expected }, + `the plugin to be ${expected}`, + timeoutMs + ) +} + +async function openPluginDetail(client: CdpClient, timeoutMs: number): Promise { + await clickPageElement(client, 'button', 'the Plugins navigation button', timeoutMs, 'Plugins') + await waitForPageCondition( + client, + { + action: 'element-exists', + query: { selector: 'input[placeholder="Search plugins"]' } + }, + 'the plugin directory search input', + timeoutMs + ) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const filled = await client.runPageRequest({ + action: 'set-input-value', + selector: 'input[placeholder="Search plugins"]', + value: pluginDisplayName + }) + if (!filled) fail('Could not search for TemPad Dev (Dev).') + + const cardQuery: PageElementQuery = { + closest: '[role="button"]', + leafOnly: true, + selector: 'span, div', + text: pluginDisplayName + } + await waitForPageCondition( + client, + { action: 'element-exists', query: cardQuery }, + 'the plugin card', + timeoutMs + ) + const opened = await client.runPageRequest({ + action: 'click-element', + query: cardQuery + }) + if (!opened) fail('Could not open the TemPad Dev (Dev) plugin card.') + + await waitForPageCondition( + client, + { action: 'plugin-state' }, + 'the plugin detail page', + timeoutMs + ) + const state = await client.runPageRequest({ action: 'plugin-state' }) + return state ?? fail('The plugin detail page has no install state.') +} + +async function assertVisibleVersion( + client: CdpClient, + version: string, + timeoutMs: number +): Promise { + await waitForPageCondition( + client, + { action: 'body-includes', text: version }, + `plugin version ${version}`, + timeoutMs + ) +} + +async function uninstallPlugin(client: CdpClient, timeoutMs: number): Promise { + await clickPageElement( + client, + 'button', + 'the plugin actions menu', + timeoutMs, + undefined, + 'More actions', + true + ) + await clickPageElement( + client, + '[role="menuitem"]', + 'the Uninstall menu item', + timeoutMs, + 'Uninstall', + undefined, + true + ) + + await new Promise((resolve) => setTimeout(resolve, 300)) + const confirmQuery: PageElementQuery = { + selector: '[role="dialog"] button', + text: 'Uninstall' + } + if (await client.runPageRequest({ action: 'element-exists', query: confirmQuery })) { + await clickElementPhysical(client, confirmQuery) + } + + await waitForPluginState(client, 'uninstalled', timeoutMs) + console.log('Codex reports the plugin as uninstalled.') +} + +async function installPlugin(client: CdpClient, version: string, timeoutMs: number): Promise { + await assertVisibleVersion(client, version, timeoutMs) + await clickPageElement(client, 'button', 'the Install plugin button', timeoutMs, 'Install plugin') + await waitForPluginState(client, 'installed', timeoutMs) + await assertVisibleVersion(client, version, timeoutMs) + console.log(`Codex reports TemPad Dev (Dev) ${version} as installed.`) +} + +async function tryRestorePreviousCodexView(client: CdpClient, steps: number): Promise { + try { + const query: PageElementQuery = { selector: 'button', ariaLabel: 'Back' } + let restoredSteps = 0 + for (let index = 0; index < steps; index += 1) { + const exists = await client.runPageRequest({ action: 'element-exists', query }) + if (!exists) break + const clicked = await client.runPageRequest({ action: 'click-element', query }) + if (!clicked) break + restoredSteps += 1 + await new Promise((resolve) => setTimeout(resolve, 250)) + } + console.log(`Restored ${String(restoredSteps)} previous Codex view step(s).`) + } catch (error) { + console.warn(`Could not restore the previous Codex view: ${errorMessage(error)}`) + } +} + +async function main(): Promise { + const parsedArgs = parseArguments(process.argv.slice(2)) + if (!parsedArgs) { + console.log(usage()) + return + } + if (process.platform === 'win32') { + fail('Runtime process verification is not implemented for Windows.') + } + + const args: Arguments = { + ...parsedArgs, + version: await resolveRequestedVersion(parsedArgs.version) + } + const runtimePaths = await resolveRuntimePaths() + console.log(`Expected CLI: ${runtimePaths.cli}`) + console.log(`Expected Hub: ${runtimePaths.hub}`) + + let client: CdpClient | undefined + try { + client = (await connectToCodex(args)) ?? undefined + if (!client) return + + const initialState = await openPluginDetail(client, args.timeoutMs) + const initialRuntime = await listRuntimeProcesses(runtimePaths) + if (initialState === 'installed') { + if (initialRuntime.cli.length > 0 && initialRuntime.hub.length > 0) { + await waitForRuntimeState(runtimePaths, 'installed', args.timeoutMs) + console.log('Confirmed that the currently installed TemPad Dev CLI and Hub are running.') + } else if (initialRuntime.cli.length === 0 && initialRuntime.hub.length === 0) { + console.log( + 'The plugin is installed but its runtime is absent; continuing with repair reinstall.' + ) + } else { + fail(`The installed plugin has a partial runtime: ${processSummary(initialRuntime)}`) + } + + console.log('Uninstalling TemPad Dev (Dev) through Codex...') + await uninstallPlugin(client, args.timeoutMs) + } else { + console.log('The plugin is already uninstalled; continuing the interrupted reinstall.') + } + + stopRemainingRuntimeProcesses(await listRuntimeProcesses(runtimePaths)) + const runtimeAfterUninstall = await waitForRuntimeState( + runtimePaths, + 'uninstalled', + args.timeoutMs + ) + console.log('Confirmed that all TemPad Dev CLI and Hub processes have stopped.') + + const stateAfterUninstall = await openPluginDetail(client, args.timeoutMs) + if (stateAfterUninstall !== 'uninstalled') { + fail(`${pluginDisplayName} still appears installed after uninstalling.`) + } + + console.log(`Installing TemPad Dev (Dev) ${args.version} through Codex...`) + await installPlugin(client, args.version, args.timeoutMs) + await tryRestorePreviousCodexView(client, 4) + const runtimeAfterInstall = await listRuntimeProcesses(runtimePaths) + if (runtimeAfterInstall.cli.length === 0 && runtimeAfterInstall.hub.length === 0) { + console.log( + 'Codex installed the plugin; its task-scoped MCP runtime will be verified by the next fresh task.' + ) + } else { + await waitForRuntimeState(runtimePaths, 'installed', args.timeoutMs, runtimeAfterUninstall) + console.log('Confirmed that a new TemPad Dev CLI and an available Hub are running.') + } + } finally { + client?.close() + } +} + +void main().then( + () => process.exit(0), + (error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +) diff --git a/packages/extension/scripts/screenshot-plan.ts b/packages/extension/scripts/screenshot-plan.ts new file mode 100644 index 00000000..d862705d --- /dev/null +++ b/packages/extension/scripts/screenshot-plan.ts @@ -0,0 +1,49 @@ +export type ScreenshotScenario = { + group: 'inspect' | 'setup' | 'status' + id: string + mcpStatus?: string + view: string +} + +export function needsFixtureRuntime(scenarios: readonly ScreenshotScenario[]): boolean { + return scenarios.some(({ group }) => group !== 'setup') +} + +export function selectScenarios( + scenarios: readonly T[], + options: { group?: string | null; only?: string | null; capture?: boolean } = {} +): T[] { + if (options.group && options.only) throw new Error('Use either --group or --only, not both.') + if (options.group && !scenarios.some(({ group }) => group === options.group)) { + throw new Error(`Unknown screenshot group: ${options.group}`) + } + const ids = options.only?.split(',').filter(Boolean) + if (ids) { + const unknown = ids.filter((id) => !scenarios.some((scenario) => scenario.id === id)) + if (unknown.length) throw new Error(`Unknown scenarios: ${unknown.join(', ')}`) + } + const selected = scenarios.filter((scenario) => { + if (ids) return ids.includes(scenario.id) + if (options.group) return scenario.group === options.group + return !options.capture || !['inactive', 'unavailable'].includes(scenario.mcpStatus ?? '') + }) + if (!selected.length) throw new Error('No scenarios selected.') + if ( + options.capture && + selected.length > 1 && + selected.some(({ mcpStatus }) => ['inactive', 'unavailable'].includes(mcpStatus ?? '')) + ) { + throw new Error( + 'Capture MCP unavailable/inactive as a single --only scenario after preparing its state.' + ) + } + return selected +} + +export function selectThemes(available: readonly string[], value: string | null): string[] { + const selected = value === null ? [...available] : [...new Set(value.split(',').filter(Boolean))] + if (!selected.length || selected.some((theme) => !available.includes(theme))) { + throw new Error(`Themes must be one of: ${available.join(', ')}.`) + } + return selected +} diff --git a/packages/extension/scripts/switch-codex-host-runtime.ts b/packages/extension/scripts/switch-codex-host-runtime.ts new file mode 100644 index 00000000..f3528154 --- /dev/null +++ b/packages/extension/scripts/switch-codex-host-runtime.ts @@ -0,0 +1,86 @@ +import { basename, extname, join, normalize, relative, resolve } from 'node:path' + +export type HostProcess = { + command: string + pid: number + ppid: number +} + +type SwitchPaths = { + appPath: string + retireAppPath: string +} + +export function isPathInside(parent: string, candidate: string): boolean { + const path = relative(resolve(parent), resolve(candidate)) + return ( + path !== '' && + path !== '..' && + !path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) + ) +} + +export function parseProcessList(output: string): HostProcess[] { + return output + .split('\n') + .map((line) => line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map((match) => ({ + command: match[3] ?? '', + pid: Number(match[1]), + ppid: Number(match[2]) + })) +} + +export function parseLaunchctlLabels(output: string, prefix: string): string[] { + return output + .split('\n') + .map((line) => line.trim().split(/\s+/).at(-1) ?? '') + .filter((label) => label.startsWith(prefix)) +} + +export function processBelongsToApp(process: HostProcess, appPath: string): boolean { + return process.command.startsWith(`${normalize(resolve(appPath))}/Contents/`) +} + +export function processRunsRetiredHostHelper( + process: HostProcess, + retireAppPath: string, + reinstallScriptPath: string +): boolean { + return ( + process.command.includes(normalize(resolve(reinstallScriptPath))) && + process.command.includes(normalize(resolve(retireAppPath))) + ) +} + +export function resolveSwitchPaths( + appPath: string, + retireAppPath: string, + userHome: string +): SwitchPaths { + const resolvedAppPath = normalize(resolve(appPath)) + const resolvedRetireAppPath = normalize(resolve(retireAppPath)) + const userApplications = join(resolve(userHome), 'Applications') + + if (extname(resolvedAppPath) !== '.app') { + throw new Error(`The target Codex host must be an app bundle: ${resolvedAppPath}`) + } + if (extname(resolvedRetireAppPath) !== '.app') { + throw new Error(`The retired Codex host must be an app bundle: ${resolvedRetireAppPath}`) + } + if (resolvedAppPath === resolvedRetireAppPath) { + throw new Error('The target and retired Codex hosts must be different app bundles.') + } + if (!isPathInside(userApplications, resolvedRetireAppPath)) { + throw new Error(`The retired Codex host must be inside ${userApplications}.`) + } + + return { appPath: resolvedAppPath, retireAppPath: resolvedRetireAppPath } +} + +export function trashName(appPath: string, timestamp: string, suffix = 0): string { + const stem = basename(appPath, '.app') + const collisionSuffix = suffix > 0 ? `-${String(suffix)}` : '' + return `${stem} - retired-${timestamp}${collisionSuffix}.app` +} diff --git a/packages/extension/scripts/switch-codex-host.ts b/packages/extension/scripts/switch-codex-host.ts new file mode 100644 index 00000000..c363fc3a --- /dev/null +++ b/packages/extension/scripts/switch-codex-host.ts @@ -0,0 +1,439 @@ +import { execFile } from 'node:child_process' +import { access, mkdir, rename, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +import { + type HostProcess, + parseLaunchctlLabels, + parseProcessList, + processBelongsToApp, + processRunsRetiredHostHelper, + resolveSwitchPaths, + trashName +} from './switch-codex-host-runtime' + +const execFileAsync = promisify(execFile) +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const scriptPath = fileURLToPath(import.meta.url) +const logPath = join(repoRoot, '.dev/codex-host-switch.log') +const statusPath = join(repoRoot, '.dev/codex-host-switch.status.json') +const pollIntervalMs = 500 +const detachedHandoffDelayMs = 2_500 +const expectedBundleId = 'com.openai.codex' +const reinstallJobPrefix = 'com.tempad-dev.codex-plugin-reinstall.' +const reinstallScriptPath = join( + repoRoot, + 'packages/extension/scripts/reinstall-codex-dev-plugin.ts' +) + +type Arguments = { + appPath: string + cdpUrl: string + dryRun: boolean + resumeDetached: boolean + retireAppPath: string + timeoutMs: number +} + +type SwitchStatus = { + completedAt?: string + error?: string + retiredAppPath?: string + startedAt: string + targetAppPath: string +} + +function fail(message: string): never { + throw new Error(message) +} + +function usage(): string { + return [ + 'Retire an old Codex host and start the current installed version from a detached helper:', + '', + ' pnpm codex-host:switch', + ' pnpm codex-host:switch --dry-run', + '', + 'Options:', + ' --retire-app-path Old host to move to Trash (default: ~/Applications/ChatGPT Eval.app)', + ' --app-path Current host to start (default: /Applications/ChatGPT.app)', + ' --cdp-url Current host CDP endpoint (default: http://127.0.0.1:9222)', + ' --timeout-ms Timeout for each shutdown/startup transition (default: 60000)', + ' --dry-run Validate and print the detached operation without changing state', + ' --help Show this help' + ].join('\n') +} + +function parseArguments(argv: string[]): Arguments | null { + if (argv.includes('--help')) return null + + let appPath = process.env.CODEX_APP_PATH ?? '/Applications/ChatGPT.app' + let cdpUrl = process.env.CODEX_CDP_URL ?? 'http://127.0.0.1:9222' + let dryRun = false + let resumeDetached = false + let retireAppPath = + process.env.CODEX_RETIRED_APP_PATH ?? join(homedir(), 'Applications/ChatGPT Eval.app') + let timeoutMs = 60_000 + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (!argument) continue + if (argument === '--dry-run') { + dryRun = true + continue + } + if (argument === '--resume-detached') { + resumeDetached = true + continue + } + if ( + argument === '--app-path' || + argument === '--cdp-url' || + argument === '--retire-app-path' || + argument === '--timeout-ms' + ) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) fail(`Missing value for ${argument}.`) + index += 1 + if (argument === '--app-path') appPath = value + if (argument === '--cdp-url') cdpUrl = value + if (argument === '--retire-app-path') retireAppPath = value + if (argument === '--timeout-ms') { + timeoutMs = Number(value) + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + fail(`Invalid --timeout-ms value: ${value}`) + } + } + continue + } + fail(`Unknown option: ${argument}`) + } + + const paths = resolveSwitchPaths(appPath, retireAppPath, homedir()) + return { ...paths, cdpUrl, dryRun, resumeDetached, timeoutMs } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function cdpPort(cdpUrl: string): number { + const url = new URL(cdpUrl) + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) { + fail('The Codex host switch requires a local HTTP CDP URL.') + } + const port = Number(url.port || 80) + if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) { + fail(`Invalid CDP port in ${cdpUrl}.`) + } + return port +} + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +async function assertCodexBundle(appPath: string): Promise { + await access(join(appPath, 'Contents/MacOS/ChatGPT')) + const { stdout } = await execFileAsync( + '/usr/bin/plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', join(appPath, 'Contents/Info.plist')], + { encoding: 'utf8' } + ) + if (stdout.trim() !== expectedBundleId) { + fail(`${appPath} has unexpected bundle identifier ${stdout.trim()}.`) + } +} + +async function listProcesses(): Promise { + const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,command='], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }) + return parseProcessList(stdout) +} + +async function listHostProcesses(appPaths: string[]): Promise { + return (await listProcesses()).filter((hostProcess) => + appPaths.some((appPath) => processBelongsToApp(hostProcess, appPath)) + ) +} + +async function listRetiredHostHelpers(retireAppPath: string): Promise { + return (await listProcesses()).filter((hostProcess) => + processRunsRetiredHostHelper(hostProcess, retireAppPath, reinstallScriptPath) + ) +} + +async function listReinstallJobs(): Promise { + const { stdout } = await execFileAsync('/bin/launchctl', ['list'], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }) + return parseLaunchctlLabels(stdout, reinstallJobPrefix) +} + +async function waitUntil( + predicate: () => Promise, + description: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() <= deadline) { + if (await predicate()) return + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + fail(`Timed out waiting for ${description}.`) +} + +function signalProcesses(processes: HostProcess[], signal: NodeJS.Signals): void { + for (const hostProcess of processes) { + try { + process.kill(hostProcess.pid, signal) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } +} + +async function stopHosts(appPaths: string[], timeoutMs: number): Promise { + const initial = await listHostProcesses(appPaths) + if (initial.length === 0) { + console.log('No processes from either Codex host are running.') + return + } + + console.log(`Stopping ${String(initial.length)} verified Codex host process(es)...`) + signalProcesses(initial, 'SIGTERM') + try { + await waitUntil( + async () => (await listHostProcesses(appPaths)).length === 0, + 'both Codex hosts to stop', + Math.min(timeoutMs, 15_000) + ) + } catch { + const remaining = await listHostProcesses(appPaths) + console.warn( + `Graceful shutdown left ${String(remaining.length)} verified process(es); terminating them.` + ) + signalProcesses(remaining, 'SIGKILL') + await waitUntil( + async () => (await listHostProcesses(appPaths)).length === 0, + 'both Codex hosts to stop after forced termination', + timeoutMs + ) + } +} + +async function stopRetiredHostHelpers(retireAppPath: string, timeoutMs: number): Promise { + const jobs = await listReinstallJobs() + for (const job of jobs) { + console.log(`Removing stale reinstall job ${job}...`) + await execFileAsync('/bin/launchctl', ['remove', job]) + } + + const helpers = await listRetiredHostHelpers(retireAppPath) + if (helpers.length === 0) return + + console.log(`Stopping ${String(helpers.length)} stale retired-host helper process(es)...`) + signalProcesses(helpers, 'SIGTERM') + try { + await waitUntil( + async () => (await listRetiredHostHelpers(retireAppPath)).length === 0, + 'stale retired-host helpers to stop', + Math.min(timeoutMs, 10_000) + ) + } catch { + const remaining = await listRetiredHostHelpers(retireAppPath) + signalProcesses(remaining, 'SIGKILL') + await waitUntil( + async () => (await listRetiredHostHelpers(retireAppPath)).length === 0, + 'stale retired-host helpers to stop after forced termination', + timeoutMs + ) + } +} + +function timestampForPath(date: Date): string { + return date + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/, 'Z') +} + +async function nextTrashPath(appPath: string): Promise { + const trashDirectory = join(homedir(), '.Trash') + await mkdir(trashDirectory, { recursive: true }) + const timestamp = timestampForPath(new Date()) + for (let suffix = 0; suffix < 1_000; suffix += 1) { + const destination = join(trashDirectory, trashName(appPath, timestamp, suffix)) + if (!(await exists(destination))) return destination + } + fail(`Could not choose a unique Trash destination for ${appPath}.`) +} + +async function retireHost(appPath: string): Promise { + if (!(await exists(appPath))) { + console.log(`The retired Codex host is already absent: ${appPath}`) + return undefined + } + const destination = await nextTrashPath(appPath) + await rename(appPath, destination) + console.log(`Moved the retired Codex host to Trash: ${destination}`) + return destination +} + +async function isCdpReady(cdpUrl: string): Promise { + try { + const response = await fetch(new URL('/json/version', cdpUrl), { + signal: AbortSignal.timeout(1_000) + }) + return response.ok + } catch { + return false + } +} + +async function startCurrentHost(args: Arguments): Promise { + const port = cdpPort(args.cdpUrl) + console.log(`Starting ${args.appPath} with --remote-debugging-port=${String(port)}...`) + await execFileAsync('/usr/bin/open', [ + '-n', + args.appPath, + '--args', + `--remote-debugging-port=${String(port)}` + ]) + await waitUntil( + async () => + (await listHostProcesses([args.appPath])).some(({ command }) => + command.startsWith(`${args.appPath}/Contents/MacOS/ChatGPT`) + ), + 'the current Codex host process to start', + args.timeoutMs + ) + await waitUntil( + () => isCdpReady(args.cdpUrl), + `Codex CDP endpoint ${args.cdpUrl}`, + args.timeoutMs + ) + console.log(`Current Codex host is ready at ${args.cdpUrl}.`) +} + +async function writeStatus(status: SwitchStatus): Promise { + await mkdir(dirname(statusPath), { recursive: true }) + await writeFile(statusPath, `${JSON.stringify(status, null, 2)}\n`) +} + +async function runDetached(args: Arguments): Promise { + const status: SwitchStatus = { + startedAt: new Date().toISOString(), + targetAppPath: args.appPath + } + await writeStatus(status) + try { + console.log( + 'Detached helper owns the host switch; shutdown begins after a short handoff delay.' + ) + await new Promise((resolve) => setTimeout(resolve, detachedHandoffDelayMs)) + console.log(`Retiring ${args.retireAppPath} and switching to ${args.appPath}.`) + await stopRetiredHostHelpers(args.retireAppPath, args.timeoutMs) + await stopHosts([args.retireAppPath, args.appPath], args.timeoutMs) + status.retiredAppPath = await retireHost(args.retireAppPath) + await startCurrentHost(args) + status.completedAt = new Date().toISOString() + await writeStatus(status) + } catch (error) { + status.completedAt = new Date().toISOString() + status.error = errorMessage(error) + await writeStatus(status) + throw error + } +} + +async function startDetached(args: Arguments): Promise { + if ( + process.execPath.startsWith(`${args.appPath}/Contents/`) || + process.execPath.startsWith(`${args.retireAppPath}/Contents/`) + ) { + fail(`Refusing to use a Node runtime inside a Codex app bundle: ${process.execPath}`) + } + await mkdir(dirname(logPath), { recursive: true }) + await writeFile(logPath, '') + + const jobLabel = `com.tempad-dev.codex-host-switch.${String(process.pid)}.${String(Date.now())}` + const childArguments = [ + ...process.execArgv, + scriptPath, + '--resume-detached', + '--retire-app-path', + args.retireAppPath, + '--app-path', + args.appPath, + '--cdp-url', + args.cdpUrl, + '--timeout-ms', + String(args.timeoutMs) + ] + await execFileAsync( + '/bin/launchctl', + [ + 'submit', + '-l', + jobLabel, + '-o', + logPath, + '-e', + logPath, + '--', + '/bin/sh', + '-c', + '"$@"\nstatus=$?\n/bin/launchctl remove "$0"\nexit "$status"', + jobLabel, + process.execPath, + ...childArguments + ], + { cwd: repoRoot } + ) + + console.log(`Detached Codex host switch submitted as ${jobLabel}.`) + console.log(`Progress: ${logPath}`) + console.log(`Status: ${statusPath}`) +} + +async function main(): Promise { + if (process.platform !== 'darwin') fail('The Codex host switch is supported only on macOS.') + const args = parseArguments(process.argv.slice(2)) + if (!args) { + console.log(usage()) + return + } + + cdpPort(args.cdpUrl) + await assertCodexBundle(args.appPath) + if (await exists(args.retireAppPath)) await assertCodexBundle(args.retireAppPath) + + if (args.dryRun) { + console.log(`Would move ${args.retireAppPath} to Trash.`) + console.log(`Would start ${args.appPath} with CDP at ${args.cdpUrl}.`) + console.log(`Detached runtime: ${process.execPath}`) + return + } + if (args.resumeDetached) { + await runDetached(args) + return + } + await startDetached(args) +} + +main().catch((error) => { + console.error(errorMessage(error)) + process.exitCode = 1 +}) diff --git a/packages/extension/scripts/verify-screenshots.ts b/packages/extension/scripts/verify-screenshots.ts index 3b0a7d18..ec3f0013 100644 --- a/packages/extension/scripts/verify-screenshots.ts +++ b/packages/extension/scripts/verify-screenshots.ts @@ -1,15 +1,19 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' +import { AGENT_SETUP_SHOT, INSPECTION_SLIDES } from '../../site/src/content/landing' + type Scenario = { assertions: unknown[] + group: string + consumers: string[] clip?: { height: number width: number x: number y: number } - figma: { + figma?: { captureAnchor?: { x: number y: number @@ -86,13 +90,14 @@ const readmes = await Promise.all( ) const errors: string[] = [] +const siteImages = [AGENT_SETUP_SHOT, ...INSPECTION_SLIDES.map(({ image }) => image)] -if (manifest.version !== 2) { - errors.push(`scenarios.json: expected version 2, got ${manifest.version}`) +if (manifest.version !== 3) { + errors.push(`scenarios.json: expected version 3, got ${manifest.version}`) } if ( - manifest.fixture.file.key !== '4HPsWWxVESGJ9ka4CDdVMx' || + manifest.fixture.file.key !== 'vJBML2e6g7btKGytwiiyvn' || manifest.fixture.file.title !== 'TemPad Dev fixtures' || !manifest.fixture.file.url.includes(manifest.fixture.file.key) ) { @@ -140,10 +145,13 @@ for (const scenario of manifest.scenarios) { if (!scenario.intent.trim()) { errors.push(`scenarios.json: ${scenario.id} is missing its display intent`) } - if (!scenario.figma.focus || !Array.isArray(scenario.figma.selection)) { + if ( + scenario.group !== 'setup' && + (!scenario.figma?.focus || !Array.isArray(scenario.figma.selection)) + ) { errors.push(`scenarios.json: ${scenario.id} is missing deterministic Figma focus/selection`) } - if (scenario.figma.zoom !== undefined && scenario.figma.zoom <= 0) { + if (scenario.figma?.zoom !== undefined && scenario.figma.zoom <= 0) { errors.push(`scenarios.json: ${scenario.id} must use a positive Figma zoom`) } if (scenario.pointer.shape !== 'default' || !scenario.pointer.target.kind) { @@ -153,6 +161,16 @@ for (const scenario of manifest.scenarios) { errors.push(`scenarios.json: ${scenario.id} must declare pre-capture assertions`) } + if (!['inspect', 'setup', 'status'].includes(scenario.group)) { + errors.push(`scenarios.json: ${scenario.id} has an unknown capture group`) + } + if ( + !scenario.consumers?.length || + scenario.consumers.some((consumer) => !['readme', 'site'].includes(consumer)) + ) { + errors.push(`scenarios.json: ${scenario.id} must declare its consumers`) + } + const plugins = scenario.panel?.plugins ?? [] if (scenario.id === 'plugins') { if (plugins.length !== 1 || plugins[0] !== 'Kong UI') { @@ -209,12 +227,23 @@ for (const scenario of manifest.scenarios) { `${relativePath}: expected ${scenario.width}x${scenario.height}, got ${size.width}x${size.height}` ) } + if (scenario.group === 'setup' && buffer[25] !== 6) { + errors.push(`${relativePath}: setup captures must retain the transparent PNG alpha channel`) + } - for (const readme of readmes) { + for (const readme of scenario.consumers.includes('readme') ? readmes : []) { if (!readme.content.includes(relativePath)) { errors.push(`${readme.path}: missing reference to ${relativePath}`) } } + if (scenario.consumers.includes('site')) { + const image = siteImages.find((image) => + [image.light, image.dark].includes(`/marketing/${scenario.id}-${theme}.png`) + ) + if (!image || image.width !== scenario.width || image.height !== scenario.height) { + errors.push(`Site: missing reference or incorrect dimensions for ${relativePath}`) + } + } } } diff --git a/packages/extension/tests/components/agent-setup-dialog.browser.test.ts b/packages/extension/tests/components/agent-setup-dialog.browser.test.ts index be4f7b06..32cdc3d0 100644 --- a/packages/extension/tests/components/agent-setup-dialog.browser.test.ts +++ b/packages/extension/tests/components/agent-setup-dialog.browser.test.ts @@ -29,6 +29,13 @@ const tokens = { '--text-mono-medium-line-height': '16px' } as const +const SKILLS_SOURCE_URL = + 'https://github.com/ecomfe/tempad-dev/tree/main/agent-plugins/tempad-dev/skills' +const DESIGN_TO_CODE_SKILL_URL = `${SKILLS_SOURCE_URL}/figma-design-to-code` +const CANVAS_AUTHORING_SKILL_URL = `${SKILLS_SOURCE_URL}/figma-canvas-authoring` +const SKILLS_INSTALL_COMMAND = `npx skills add ${SKILLS_SOURCE_URL} --skill figma-design-to-code figma-canvas-authoring` +const PLUGIN_INSTALL_COMMAND = 'npx plugins add ecomfe/tempad-dev' + function mountDialog(): HTMLElement { return mount( defineComponent( @@ -55,16 +62,20 @@ describe('AgentSetupDialog', () => { expect(host.querySelector('[role="dialog"]')).not.toBeNull() expect(host.querySelector('[role="tablist"] svg')).toBeNull() + expect(host.querySelector('.tp-agent-dialog-nav')?.hasAttribute('data-overlayscrollbars')).toBe( + true + ) + expect( + host.querySelector('.tp-agent-dialog-content')?.hasAttribute('data-overlayscrollbars') + ).toBe(true) expect(host.querySelector('.tp-agent-dialog-brand svg title')?.textContent).toBe('Codex') expect(host.querySelector('.tp-agent-dialog-brand')?.getBoundingClientRect()).toMatchObject({ width: 32, height: 32 }) - expect(host.textContent).toContain('TemPad Dev plugin') + expect(host.textContent).toContain('Portable Agent Plugin') expect(host.textContent).toContain('Continue in Codex') - expect(getCode(host)).toContain( - 'codex plugin marketplace add ecomfe/tempad-dev --ref main && codex plugin add tempad-dev@tempad-dev' - ) + expect(getCode(host)).toContain(`${PLUGIN_INSTALL_COMMAND} --target codex`) expect(host.querySelector('[aria-label="Copy command"]')).not.toBeNull() const codeWell = host.querySelector('.tp-agent-dialog-code-well') @@ -105,21 +116,27 @@ describe('AgentSetupDialog', () => { expect(getComputedStyle(manualNote!).paddingTop).toBe('16px') }) - it('shows Cursor one-click setup with explicit manual fallbacks', async () => { + it('uses the portable plugin for Cursor', async () => { const host = mountDialog() await page.getByRole('tab', { name: 'Cursor' }).click() expect(host.querySelector('.tp-agent-dialog-brand svg title')?.textContent).toBe('Cursor') - expect(host.textContent).toContain('Install in Cursor') - expect(getCode(host)).toEqual([ - expect.stringContaining('"mcpServers"'), - 'npx skills add https://github.com/ecomfe/tempad-dev/tree/main/skill --global --agent cursor' - ]) - expect(host.querySelectorAll('[aria-label="Copy configuration"]')).toHaveLength(1) + expect(host.textContent).toContain('Run in your terminal:') + expect(host.textContent).toContain('Connects your agent to Figma') + expect(getCode(host)).toEqual([`${PLUGIN_INSTALL_COMMAND} --target cursor`]) + expect(host.querySelectorAll('[aria-label="Copy configuration"]')).toHaveLength(0) expect(host.querySelectorAll('[aria-label="Copy command"]')).toHaveLength(1) }) + it('uses the portable plugin for VS Code', async () => { + const host = mountDialog() + + await page.getByRole('tab', { name: 'VS Code' }).click() + + expect(getCode(host)).toEqual([`${PLUGIN_INSTALL_COMMAND} --target vscode`]) + }) + it('uses Gemini native commands for both setup steps', async () => { const host = mountDialog() @@ -127,8 +144,10 @@ describe('AgentSetupDialog', () => { expect(getCode(host)).toEqual([ 'gemini mcp add --scope user "tempad-dev" npx -y @tempad-dev/mcp@latest', - 'gemini skills install https://github.com/ecomfe/tempad-dev/tree/main/skill' + `gemini skills install ${DESIGN_TO_CODE_SKILL_URL}`, + `gemini skills install ${CANVAS_AUTHORING_SKILL_URL}` ]) + expect(host.textContent).toContain('Then run in your terminal:') }) it('uses OpenCode-specific MCP config and skill install targets', async () => { @@ -145,9 +164,7 @@ describe('AgentSetupDialog', () => { } } }) - expect(getCode(host)[1]).toBe( - 'npx skills add https://github.com/ecomfe/tempad-dev/tree/main/skill --global --agent opencode' - ) + expect(getCode(host)[1]).toBe(`${SKILLS_INSTALL_COMMAND} --global --agent opencode`) }) it('presents manual setup as the fallback for other agents', async () => { @@ -157,5 +174,6 @@ describe('AgentSetupDialog', () => { await expect.element(page.getByRole('heading', { name: 'Manual setup' })).toBeVisible() expect(host.querySelector('.tp-agent-dialog-brand')).toBeNull() + expect(getCode(host)[1]).toBe(SKILLS_INSTALL_COMMAND) }) }) 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 d2b99010..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' @@ -23,8 +27,9 @@ const mocks = vi.hoisted(() => { layoutReady, listeners, options, - resetUploadedAssets: vi.fn(), + resetAssetCache: vi.fn(), runtimeMode, + setAssetDownloader: vi.fn(), setAssetServerUrl: vi.fn(), setAssetUploader: vi.fn(), window @@ -64,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 })) @@ -137,14 +143,15 @@ describe('composables/mcp', () => { 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 and retries permission from user action', () => { + it('keeps MCP enabled while retrying local-host permission', () => { const mcp = useMcp() const sessionId = getPostedMessage('mcp.enable').sessionId @@ -181,4 +188,96 @@ describe('composables/mcp', () => { ) ).toBe(false) }) + + 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 + }) + + await expect(pending).resolves.toBeUndefined() + }) }) diff --git a/packages/extension/tests/composables/plugin.test.ts b/packages/extension/tests/composables/plugin.test.ts index 8cef8a0d..2f130036 100644 --- a/packages/extension/tests/composables/plugin.test.ts +++ b/packages/extension/tests/composables/plugin.test.ts @@ -20,6 +20,7 @@ import { } from '@/composables/plugin' type MockResponse = { + arrayBuffer?: () => Promise body?: ReadableStream | null headers?: { get: (name: string) => string | null } status?: number @@ -29,11 +30,13 @@ type MockResponse = { } function response(input: MockResponse = {}): MockResponse { + const text = input.text ?? (async () => '') return { + arrayBuffer: async () => new TextEncoder().encode(await text()).buffer as ArrayBuffer, headers: { get: () => null }, status: 200, statusText: '', - text: async () => '', + text, url: '', ...input } 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..388e7fe4 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, type RuntimeHelloMessage } from '@tempad-dev/shared' import { afterEach, describe, expect, it, vi } from 'vitest' import { McpHubClient } from '@/mcp/broker/hub-client' @@ -68,19 +69,28 @@ 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)) } function createClient( sockets: FakeWebSocket[], - events: ConstructorParameters[0] = {} + events: ConstructorParameters[0] = {}, + runtimeIdentity: RuntimeHelloMessage | null = null ): McpHubClient { - return new McpHubClient(events, (url) => { - const socket = new FakeWebSocket(url) - sockets.push(socket) - return socket as unknown as WebSocket - }) + return new McpHubClient( + events, + (url) => { + const socket = new FakeWebSocket(url) + sockets.push(socket) + return socket as unknown as WebSocket + }, + runtimeIdentity + ) } function installHubProbe(isReachable: (port: number) => boolean = () => true): void { @@ -101,6 +111,26 @@ afterEach(() => { }) describe('mcp/broker/hub-client', () => { + it('publishes extension runtime identity before declaring the handshake connected', async () => { + vi.stubGlobal('WebSocket', { OPEN: 1 }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const runtimeIdentity = { + type: 'runtimeHello' as const, + extensionVersion: '0.21.0', + extensionRuntimeFingerprint: 'a'.repeat(64) + } + const client = createClient(sockets, {}, runtimeIdentity) + + client.start() + await flushMicrotasks() + completeHandshake(sockets[0]!) + await flushMicrotasks() + + expect(sockets[0]?.sent.map((message) => JSON.parse(message))).toContainEqual(runtimeIdentity) + expect(client.getSnapshot().status).toBe('connected') + }) + it('tries candidate ports in order and reuses the last successful port first', async () => { const sockets: FakeWebSocket[] = [] const snapshots: Array> = [] @@ -150,6 +180,26 @@ describe('mcp/broker/hub-client', () => { expect(sockets[0]?.sent).toContain(JSON.stringify({ type: 'ping' })) }) + it('does not install keepalive after a reentrant stop during handshake completion', async () => { + vi.useFakeTimers() + vi.stubGlobal('WebSocket', { OPEN: 1 }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const client = createClient(sockets, { + onSnapshot: (snapshot) => { + if (snapshot.status === 'connected') client.stop() + } + }) + + client.start() + await flushMicrotasks() + completeHandshake(sockets[0]!) + await flushMicrotasks() + + expect(client.getSnapshot().status).toBe('idle') + expect(vi.getTimerCount()).toBe(0) + }) + it('ignores stale socket probes after a stop/start cycle', async () => { vi.stubGlobal('WebSocket', { OPEN: 1 }) installHubProbe() @@ -198,7 +248,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 +345,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 +365,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 +395,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..6f03c517 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,8 +19,11 @@ import { MCP_LOCAL_HOST_ORIGIN } from '@/mcp/permissions' +const ASSET_HASH = '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81' + type Listener = (payload: T) => void type BrokerInternals = { + handleHubSnapshot: (snapshot: ReturnType) => void handlePermissionMessage: (type: McpPermissionMessageType) => Promise<{ granted: boolean }> routeToolCall: (message: ToolCallMessage) => void } @@ -93,7 +97,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 +109,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({ @@ -269,6 +284,43 @@ describe('mcp/broker/service-worker', () => { expect(hubClient.sendActivate).toHaveBeenCalledTimes(1) }) + it('requires a new explicit tab choice when a hub reconnects with multiple sessions', () => { + const snapshot: Partial> = { + activeId: 'gateway-1', + registeredId: 'gateway-1', + status: 'connected' + } + const hubClient = createHubClient(snapshot) + const broker = new McpServiceWorkerBroker(hubClient) + const internals = broker as unknown as BrokerInternals + const first = createPort('https://www.figma.com/design/abc/File') + const second = createPort('https://www.figma.com/design/def/File') + + broker.handlePort(first.port) + first.message(pageMessage('mcp.enable', 'session-a')) + broker.handlePort(second.port) + second.message(pageMessage('mcp.enable', 'session-b')) + second.message(pageMessage('mcp.activateSession', 'session-b')) + + snapshot.activeId = 'gateway-2' + snapshot.registeredId = 'gateway-2' + internals.handleHubSnapshot(hubClient.getSnapshot()) + internals.routeToolCall({ + id: 'call-after-reconnect', + payload: { args: undefined, name: 'get_code' }, + type: 'toolCall' + }) + + expect(hubClient.sendToolResult).toHaveBeenLastCalledWith({ + error: { + code: TEMPAD_MCP_ERROR_CODES.NO_ACTIVE_EXTENSION, + message: 'No active TemPad Dev Figma session available.' + }, + id: 'call-after-reconnect', + type: 'toolResult' + }) + }) + it('ignores session control messages from ports that do not own the session', () => { const hubClient = createHubClient() const broker = new McpServiceWorkerBroker(hubClient) @@ -389,7 +441,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 +464,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/broker/sessions.test.ts b/packages/extension/tests/mcp/broker/sessions.test.ts index 21083493..f679d68c 100644 --- a/packages/extension/tests/mcp/broker/sessions.test.ts +++ b/packages/extension/tests/mcp/broker/sessions.test.ts @@ -21,23 +21,46 @@ describe('mcp/broker/sessions', () => { expect(registry.getActive()?.sessionId).toBe('session-a') }) - it('keeps explicit active session across multiple sessions', () => { + it('requires explicit activation when another session introduces ambiguity', () => { const registry = new McpSessionRegistry() registry.register({ port: createPort(), sessionId: 'session-a' }) registry.register({ port: createPort(), sessionId: 'session-b' }) - expect(registry.getActiveId()).toBe('session-a') + expect(registry.getActiveId()).toBeNull() expect(registry.activate('session-b')).toBe(true) expect(registry.getActiveId()).toBe('session-b') expect(registry.activate('missing')).toBe(false) }) + it('preserves activation when the same session replaces its port', () => { + const registry = new McpSessionRegistry() + + registry.register({ port: createPort(), sessionId: 'session-a' }) + registry.register({ port: createPort(), sessionId: 'session-a' }) + + expect(registry.getActiveId()).toBe('session-a') + }) + + it('resets ambiguous routing while retaining a sole session', () => { + const registry = new McpSessionRegistry() + + registry.register({ port: createPort(), sessionId: 'session-a' }) + registry.resetActive() + expect(registry.getActiveId()).toBe('session-a') + + registry.register({ port: createPort(), sessionId: 'session-b' }) + registry.activate('session-b') + registry.resetActive() + expect(registry.getActiveId()).toBeNull() + }) + it('recomputes active session after unregister', () => { const registry = new McpSessionRegistry() registry.register({ port: createPort(), sessionId: 'session-a' }) registry.register({ port: createPort(), sessionId: 'session-b' }) + registry.activate('session-a') registry.unregister('session-a') expect(registry.getActiveId()).toBe('session-b') diff --git a/packages/extension/tests/mcp/config.test.ts b/packages/extension/tests/mcp/config.test.ts index 6ef125f8..86ebc3e0 100644 --- a/packages/extension/tests/mcp/config.test.ts +++ b/packages/extension/tests/mcp/config.test.ts @@ -6,7 +6,7 @@ import * as config from '../../mcp/config' const exports = [ 'AGENT_INTEGRATIONS', 'AGENT_INTEGRATIONS_BY_ID', - 'AGENT_SKILL_INSTALL_COMMAND', + 'AGENT_SKILLS_INSTALL_COMMAND', 'getMcpClientCopyPayload', 'getNextMcpClientCopyVariant', 'MCP_CLIENTS', diff --git a/packages/extension/tests/mcp/local-resources.test.ts b/packages/extension/tests/mcp/local-resources.test.ts new file mode 100644 index 00000000..cbfa3f26 --- /dev/null +++ b/packages/extension/tests/mcp/local-resources.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + getCurrentContextNodeById, + getLocalStyles, + getLocalVariableCollections, + getLocalVariables, + getMainComponent, + getNodeById, + getStyleById, + getVariableById, + getVariableCollectionById +} from '@/mcp/local-resources' + +afterEach(() => vi.unstubAllGlobals()) + +describe('local Figma resource reads', () => { + it('reads an attached node from the current context without using the async backend', () => { + const node = { id: '1:1', removed: false } as BaseNode + const getNodeByIdAsync = vi.fn() + vi.stubGlobal('figma', { + getNodeById: vi.fn(() => node), + getNodeByIdAsync + } as unknown as PluginAPI) + + expect(getCurrentContextNodeById(node.id)).toBe(node) + expect(getNodeByIdAsync).not.toHaveBeenCalled() + }) + + it('returns no current-context node when the synchronous API is unavailable', () => { + vi.stubGlobal('figma', { + getNodeById: vi.fn(() => { + throw new Error('current context unavailable') + }) + } as unknown as PluginAPI) + + expect(getCurrentContextNodeById('1:1')).toBeNull() + }) + + it('uses an attached instance relationship without waiting for the async backend', async () => { + const component = { id: '1:1', removed: false } as ComponentNode + const getMainComponentAsync = vi.fn() + const instance = { componentProperties: {}, getMainComponentAsync, mainComponent: component } + + await expect(getMainComponent(instance as unknown as InstanceNode)).resolves.toBe(component) + expect(getMainComponentAsync).not.toHaveBeenCalled() + }) + + it('uses the asynchronous instance relationship when the current context cannot read it', async () => { + const component = { id: '1:1', removed: false } as ComponentNode + const getMainComponentAsync = vi + .fn() + .mockResolvedValue(component) + const instance = { componentProperties: {}, getMainComponentAsync, mainComponent: null } + + await expect(getMainComponent(instance as unknown as InstanceNode)).resolves.toBe(component) + expect(getMainComponentAsync).toHaveBeenCalledOnce() + }) + + it('prefers the asynchronous Plugin API', async () => { + const node = { id: '1:1' } as BaseNode + const getNodeByIdAsync = vi.fn().mockResolvedValue(node) + const getNodeByIdSync = vi.fn() + vi.stubGlobal('figma', { + getNodeById: getNodeByIdSync, + getNodeByIdAsync + } as unknown as PluginAPI) + + await expect(getNodeById(node.id)).resolves.toBe(node) + expect(getNodeByIdSync).not.toHaveBeenCalled() + }) + + it('uses current-context reads while the rewritten async backend is unavailable', async () => { + const node = { id: '1:1' } as BaseNode + const style = { id: 'S:1', type: 'PAINT' } as PaintStyle + const textStyle = { id: 'S:2', type: 'TEXT' } as TextStyle + const effectStyle = { id: 'S:3', type: 'EFFECT' } as EffectStyle + const gridStyle = { id: 'S:4', type: 'GRID' } as GridStyle + const variable = { id: 'V:1' } as Variable + const collection = { id: 'VC:1' } as VariableCollection + const unavailable = () => Promise.reject(new Error('async backend unavailable')) + + vi.stubGlobal('figma', { + getLocalEffectStyles: vi.fn(() => [effectStyle]), + getLocalEffectStylesAsync: vi.fn(unavailable), + getLocalGridStyles: vi.fn(() => [gridStyle]), + getLocalGridStylesAsync: vi.fn(unavailable), + getLocalPaintStyles: vi.fn(() => [style]), + getLocalPaintStylesAsync: vi.fn(unavailable), + getLocalTextStyles: vi.fn(() => [textStyle]), + getLocalTextStylesAsync: vi.fn(unavailable), + getNodeById: vi.fn(() => node), + getNodeByIdAsync: vi.fn(unavailable), + getStyleById: vi.fn(() => style), + getStyleByIdAsync: vi.fn(unavailable), + variables: { + getLocalVariableCollections: vi.fn(() => [collection]), + getLocalVariableCollectionsAsync: vi.fn(unavailable), + getLocalVariables: vi.fn(() => [variable]), + getLocalVariablesAsync: vi.fn(unavailable), + getVariableById: vi.fn(() => variable), + getVariableByIdAsync: vi.fn(unavailable), + getVariableCollectionById: vi.fn(() => collection), + getVariableCollectionByIdAsync: vi.fn(unavailable) + } + } as unknown as PluginAPI) + + await expect( + Promise.all([ + getNodeById(node.id), + getStyleById(style.id), + getVariableById(variable.id), + getVariableCollectionById(collection.id), + getLocalVariables(), + getLocalVariableCollections(), + getLocalStyles() + ]) + ).resolves.toEqual([ + node, + style, + variable, + collection, + [variable], + [collection], + [style, textStyle, effectStyle, gridStyle] + ]) + }) + + it('loads the current page and retries a transient connection timeout once', async () => { + const node = { id: '1:1' } as BaseNode + const timeout = new Error('Unable to establish connection to Figma after 10 seconds') + const getNodeByIdAsync = vi.fn().mockRejectedValueOnce(timeout).mockResolvedValue(node) + const getNodeByIdSync = vi.fn(() => { + throw timeout + }) + const loadAsync = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('figma', { + currentPage: { loadAsync }, + getNodeById: getNodeByIdSync, + getNodeByIdAsync + } as unknown as PluginAPI) + + await expect(getNodeById(node.id)).resolves.toBe(node) + expect(loadAsync).toHaveBeenCalledOnce() + expect(getNodeByIdAsync).toHaveBeenCalledTimes(2) + expect(getNodeByIdSync).toHaveBeenCalledOnce() + }) + + it('preserves the asynchronous read error when the sync fallback also fails', async () => { + const asyncError = new Error('asynchronous read failed') + const syncError = new Error('synchronous fallback failed') + vi.stubGlobal('figma', { + getNodeById: vi.fn(() => { + throw syncError + }), + getNodeByIdAsync: vi.fn().mockRejectedValue(asyncError) + } as unknown as PluginAPI) + + await expect(getNodeById('1:1')).rejects.toBe(asyncError) + }) +}) diff --git a/packages/extension/tests/mcp/runtime.test.ts b/packages/extension/tests/mcp/runtime.test.ts index 3a1e2779..b6a1b084 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 })) @@ -40,9 +50,10 @@ function createSceneNode(id: string, visible = true): SceneNode { } as unknown as SceneNode } -function setFigmaGetNodeById(returnValue: BaseNode | null) { +function setFigmaGetNodeById(returnValue: BaseNode | null, currentSelection: SceneNode[] = []) { vi.stubGlobal('figma', { - getNodeById: vi.fn().mockReturnValue(returnValue) + getNodeById: vi.fn().mockReturnValue(returnValue), + currentPage: { selection: currentSelection } } as unknown as PluginAPI) } @@ -62,12 +73,16 @@ describe('mcp/runtime', () => { setFigmaGetNodeById(null) const runtime = await importRuntime() - expect(Object.keys(runtime.MCP_TOOL_HANDLERS)).toEqual([ - 'get_code', - '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) @@ -79,11 +94,7 @@ describe('mcp/runtime', () => { const runtime = await importRuntime() const tools = (window as Window & { tempadTools: Record }).tempadTools - expect(tools.existing).toBe(existing) - expect(tools.get_code).toBe(runtime.WINDOW_TEMPAD_TOOL_HANDLERS.get_code) - 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 () => { @@ -93,10 +104,7 @@ describe('mcp/runtime', () => { const runtime = await importRuntime() const tools = (window as Window & { tempadTools: Record }).tempadTools - expect(tools.get_code).toBe(runtime.WINDOW_TEMPAD_TOOL_HANDLERS.get_code) - 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 () => { @@ -131,14 +139,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() + 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('missing', {})).rejects.toThrow( - 'No handler registered for tool "missing".' - ) - }) + 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') @@ -165,12 +176,25 @@ describe('mcp/runtime', () => { }) }) - it('throws coded error when provided nodeId does not resolve to a visible scene node', async () => { + it('distinguishes missing, unsupported, and hidden node ids', async () => { setFigmaGetNodeById(null) const runtime = await importRuntime() await expect(runtime.MCP_TOOL_HANDLERS.get_code({ nodeId: 'missing' })).rejects.toMatchObject({ - code: TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE + code: TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + message: expect.stringContaining('does not exist') + }) + + setFigmaGetNodeById({ id: 'document', type: 'DOCUMENT' } as unknown as BaseNode) + await expect(runtime.MCP_TOOL_HANDLERS.get_code({ nodeId: 'document' })).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + message: expect.stringContaining('not a supported scene node') + }) + + setFigmaGetNodeById(createSceneNode('hidden', false)) + await expect(runtime.MCP_TOOL_HANDLERS.get_code({ nodeId: 'hidden' })).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.NODE_NOT_VISIBLE, + message: expect.stringContaining('is hidden') }) }) @@ -178,12 +202,11 @@ describe('mcp/runtime', () => { setFigmaGetNodeById(null) const runtime = await importRuntime() - mocks.selection.value = [] await expect(runtime.MCP_TOOL_HANDLERS.get_code()).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION }) - mocks.selection.value = [createSceneNode('hidden', false)] + setFigmaGetNodeById(null, [createSceneNode('hidden', false)]) await expect(runtime.MCP_TOOL_HANDLERS.get_code()).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION }) @@ -191,8 +214,7 @@ describe('mcp/runtime', () => { it('uses current visible selection when nodeId is omitted', async () => { const selected = createSceneNode('selected') - mocks.selection.value = [selected] - setFigmaGetNodeById(null) + setFigmaGetNodeById(null, [selected]) mocks.runGetCode.mockResolvedValue({ blocks: [{ lang: 'jsx', code: '
' }] }) const runtime = await importRuntime() @@ -215,6 +237,30 @@ describe('mcp/runtime', () => { ) }) + it('reads the live current-page selection instead of stale UI selection state', async () => { + const stale = createSceneNode('stale-from-previous-page') + const current = createSceneNode('current-page-node') + mocks.selection.value = [stale] + setFigmaGetNodeById(null, [current]) + mocks.runGetCode.mockResolvedValue({ blocks: [] }) + + const runtime = await importRuntime() + await runtime.MCP_TOOL_HANDLERS.get_code() + + expect(mocks.runGetCode).toHaveBeenCalledWith( + [current], + undefined, + undefined, + undefined, + undefined + ) + + setFigmaGetNodeById(null) + await expect(runtime.MCP_TOOL_HANDLERS.get_structure()).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION + }) + }) + it('validates get_token_defs input and forwards includeAllModes', async () => { setFigmaGetNodeById(null) mocks.runGetTokenDefs.mockResolvedValue({ defs: [] }) @@ -233,8 +279,7 @@ describe('mcp/runtime', () => { it('routes screenshot and structure calls with node resolution and depth options', async () => { const node = createSceneNode('node-2') - setFigmaGetNodeById(node) - mocks.selection.value = [node] + setFigmaGetNodeById(node, [node]) mocks.runGetScreenshot.mockResolvedValue({ imageData: 'data:image/png;base64,AA==' }) mocks.runGetStructure.mockResolvedValue({ nodes: [] }) @@ -243,10 +288,62 @@ describe('mcp/runtime', () => { await runtime.MCP_TOOL_HANDLERS.get_screenshot({ nodeId: 'node-2' }) expect(mocks.runGetScreenshot).toHaveBeenCalledWith(node) - await runtime.MCP_TOOL_HANDLERS.get_structure({ nodeId: 'node-2', options: { depth: 3 } }) - expect(mocks.runGetStructure).toHaveBeenCalledWith([node], 3) + await runtime.MCP_TOOL_HANDLERS.get_structure({ + nodeId: 'node-2', + options: { depth: 3, native: true } + }) + expect(mocks.runGetStructure).toHaveBeenCalledWith([node], 3, true) await runtime.MCP_TOOL_HANDLERS.get_structure() - expect(mocks.runGetStructure).toHaveBeenLastCalledWith([node], undefined) + expect(mocks.runGetStructure).toHaveBeenLastCalledWith([node], undefined, undefined) + }) + + it('reads an exact page by managed key without changing the active page', async () => { + const root = createSceneNode('page-root') + const page = { + id: '0:2', + name: 'Evaluation', + type: 'PAGE', + children: [root], + selection: [], + loadAsync: vi.fn().mockResolvedValue(undefined), + getSharedPluginData: vi.fn((_namespace: string, key: string) => + key === 'page-key' ? 'eval/fresh' : '' + ) + } as unknown as PageNode + const currentPage = { + id: '0:1', + name: 'Current', + type: 'PAGE', + children: [], + selection: [], + getSharedPluginData: vi.fn(() => '') + } as unknown as PageNode + vi.stubGlobal('figma', { + root: { children: [currentPage, page] }, + currentPage, + getNodeById: vi.fn() + } as unknown as PluginAPI) + mocks.runGetStructure.mockReturnValue({ roots: [{ id: root.id }] }) + + const runtime = await importRuntime() + const result = await runtime.MCP_TOOL_HANDLERS.get_structure({ + pageKey: 'eval/fresh', + options: { depth: 2 } + }) + + expect(page.loadAsync).toHaveBeenCalledOnce() + expect(mocks.runGetStructure).toHaveBeenCalledWith([root], 2, undefined) + expect(result).toMatchObject({ + page: { + id: page.id, + pageKey: 'eval/fresh', + name: 'Evaluation', + active: false, + childCount: 1, + selectionCount: 0 + } + }) + expect(figma.currentPage).toBe(currentPage) }) }) 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..d7ec3c06 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-assets.test.ts @@ -0,0 +1,110 @@ +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('leaves currentColor substrings in identifiers and references unchanged', async () => { + const assets = await resolveCanvasAssets( + svgAssets( + '' + ), + colors() + ) + const resolved = resolvedSvgAsset(assets, 'icon', undefined) + + expect(resolved?.svg).toContain('id="currentColor-gradient"') + expect(resolved?.svg).toContain('id="currentColor-path"') + expect(resolved?.svg).toContain('aria-label="currentColor icon"') + expect(resolved?.svg).toContain('fill="url(#currentColor-gradient)"') + }) + + it('validates every prefix bound to the XLink namespace', async () => { + await expect( + resolveCanvasAssets( + svgAssets( + '' + ), + colors() + ) + ).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.SVG_EXTERNAL_REFERENCE }) + + await expect( + resolveCanvasAssets( + svgAssets( + '' + ), + colors() + ) + ).resolves.toBeInstanceOf(Map) + }) + + 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..ac671023 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-markup.test.ts @@ -0,0 +1,2841 @@ +import type { + CanvasResolvedApplyParameters, + CanvasBinding, + CanvasFigmaProperties +} from '@tempad-dev/shared' + +import { MAX_CANVAS_NODES } 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('validates image asset declarations referenced by Paint styles', () => { + const markup = '
' + const styles = { + hero: { + type: 'PAINT' as const, + name: 'Hero', + paints: [{ type: 'IMAGE' as const, assetKey: 'photo', scaleMode: 'FILL' as const }] + } + } + + expect(() => parse(markup, { styles })).toThrow('is not declared') + expect(() => + parse(markup, { + styles, + assets: { photo: { type: 'SVG', svg: '' } } + }) + ).toThrow('expected IMAGE') + }) + + 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', + strokesIncluded: true + }, + 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('maps generic Tailwind font families to portable Figma web fonts', () => { + const result = parse(` +
+ Sans + Serif + Mono +
+ `) + + expect(result.root.children).toMatchObject([ + { text: { fontFamily: 'Inter' } }, + { text: { fontFamily: 'Noto Serif', fontStyle: 'SemiBold' } }, + { text: { fontFamily: 'Noto Sans Mono', fontStyle: 'ExtraBold' } } + ]) + }) + + 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('preserves ampersands that do not start an entity', () => { + const result = parse( + '
Artists & labels · R&B
' + ) + + expect(result.root.children?.[0]?.text?.characters).toBe('Artists & labels · R&B') + }) + + 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: { minWidth: null, minHeight: null, 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('resolves right and bottom absolute offsets against fixed parent and child bounds', () => { + const result = parse(` +
+
+
+
+ `) + + expect(result.root.children?.[0]?.position).toEqual({ x: 322, y: 192 }) + expect(result.root.children?.[1]?.position).toEqual({ x: 388, y: 224 }) + }) + + it('rejects ambiguous or unresolved absolute edge offsets', () => { + expect(() => + parse( + '
' + ) + ).toThrow(/exactly one of left-\* or right-\*/) + expect(() => + parse( + '
Copy
' + ) + ).toThrow(/require fixed parent and child bounds/) + }) + + 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: true + }) + 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('explains how to separate a label background from its text fill', () => { + expect(() => + parse( + 'Label' + ) + ).toThrow(/parent div and a child span/) + }) + + it('identifies conflicting stroke colors and explains how to represent distinct edges', () => { + expect(() => + parse( + '
' + ) + ).toThrow( + 'Class "border-[#2B2F36]" conflicts with "border-[#4DA3FF]" for stroke. Figma supports one stroke paint per node across its enabled sides; use one border color or separate edge layers for different side colors.' + ) + }) + + it('compiles bounded linear gradient utilities to native fill paints', () => { + const result = parse( + '
' + ) + + expect(result.root.figma?.fills).toEqual([ + { + type: 'GRADIENT_LINEAR', + gradientTransform: [ + [0.5, 0.5, 0], + [-0.5, 0.5, 0.5] + ], + gradientStops: [ + { position: 0, color: { r: 248 / 255, g: 252 / 255, b: 1, a: 1 } }, + { position: 0.5, color: { r: 122 / 255, g: 184 / 255, b: 224 / 255, a: 0.8 } }, + { position: 1, color: { r: 26 / 255, g: 92 / 255, b: 154 / 255, a: 1 } } + ] + } + ]) + expect(result.root.appearance).not.toHaveProperty('fill') + + const alias = parse( + '
' + ) + expect(alias.root.figma?.fills?.[0]).toMatchObject({ + type: 'GRADIENT_LINEAR', + gradientTransform: [ + [-1, 0, 1], + [0, -1, 1] + ] + }) + }) + + it.each([ + [ + 't', + [ + [0, -1, 1], + [1, 0, 0] + ] + ], + [ + 'tr', + [ + [0.5, -0.5, 0.5], + [0.5, 0.5, 0] + ] + ], + [ + 'r', + [ + [1, 0, 0], + [0, 1, 0] + ] + ], + [ + 'br', + [ + [0.5, 0.5, 0], + [-0.5, 0.5, 0.5] + ] + ], + [ + 'b', + [ + [0, 1, 0], + [-1, 0, 1] + ] + ], + [ + 'bl', + [ + [-0.5, 0.5, 0.5], + [-0.5, -0.5, 1] + ] + ], + [ + 'l', + [ + [-1, 0, 1], + [0, -1, 1] + ] + ], + [ + 'tl', + [ + [-0.5, -0.5, 1], + [0.5, -0.5, 0.5] + ] + ] + ] as const)( + 'maps bg-linear-to-%s to its normalized Figma gradient handles', + (direction, transform) => { + const result = parse( + `
` + ) + + expect(result.root.figma?.fills?.[0]).toMatchObject({ + type: 'GRADIENT_LINEAR', + gradientTransform: transform + }) + } + ) + + it('rejects incomplete or conflicting linear gradient utilities', () => { + const markup = + '
' + + expect(() => + parse('
') + ).toThrow(/requires one bg-linear-to-\* direction plus exact from-\* and to-\* colors/) + expect(() => + parse( + '
' + ) + ).toThrow(/conflicts with gradient stop class/) + expect(() => + parse( + '
' + ) + ).toThrow(/cannot be combined with a solid background/) + expect(() => + parse(markup, { + bindings: { root: { figma: { fills: [] } } } + }) + ).toThrow(/Gradient classes and direct fill paints/) + expect(() => + parse(markup, { + bindings: { root: { styles: { fill: { id: 'style:fill' } } } } + }) + ).toThrow(/Gradient classes and a fill style/) + expect(() => + parse(markup, { + bindings: { root: { variables: { fill: { id: 'variable:fill' } } } } + }) + ).toThrow(/Gradient classes and a fill variable/) + }) + + it('compiles exact box and inset shadow utilities to native effects', () => { + const result = parse( + '
' + ) + + expect(result.root.figma?.effects).toEqual([ + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.1 }, + offset: { x: 0, y: 4 }, + radius: 6, + spread: -1 + }, + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.1 }, + offset: { x: 0, y: 2 }, + radius: 4, + spread: -2 + }, + { + type: 'INNER_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.05 }, + offset: { x: 0, y: 1 }, + radius: 1 + } + ]) + }) + + it('compiles arbitrary text shadows and clears them with text-shadow-none', () => { + const result = parse( + '
Copy
' + ) + + expect(result.root.children?.[0]?.figma?.effects).toEqual([ + { + type: 'DROP_SHADOW', + color: { r: 17 / 255, g: 34 / 255, b: 51 / 255, a: 0.25 }, + offset: { x: 0, y: 2 }, + radius: 4, + showShadowBehindNode: true + } + ]) + + const cleared = parse( + 'Copy', + { mode: 'update', targetNodeId: '1:2' } + ) + expect(cleared.root.figma?.effects).toEqual([]) + }) + + it('rejects shadow classes on the wrong node kind or alongside another effect source', () => { + expect(() => + parse( + '
Copy
' + ) + ).toThrow(/Box shadow classes are not supported on TEXT/) + expect(() => + parse( + '
' + ) + ).toThrow(/Text shadow classes are not supported on FRAME/) + expect(() => + parse('
', { + bindings: { root: { styles: { effect: { id: 'style:effect' } } } } + }) + ).toThrow(/Shadow classes and an effect style cannot be combined/) + expect(() => + parse('
') + ).toThrow(/requires a color and two to four px lengths/) + expect(() => + parse('
') + ).toThrow(/Invalid shadow class/) + expect(() => + parse('
') + ).toThrow(/Invalid shadow value/) + expect(() => + parse( + '
' + ) + ).toThrow(/Invalid shadow value/) + }) + + it.each(['shadow-md', 'inset-shadow-sm', 'text-shadow-lg'])( + 'rejects unresolved theme shadow class %s', + (className) => { + expect(() => + parse(`
`) + ).toThrow(/needs an exact bracketed value or "none"/) + } + ) + + 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.each(['
', '
', '
'])('normalizes %s inside span text', (lineBreak) => { + const result = parse( + `
Line 1 ${lineBreak} Line 2
` + ) + + expect(result.root.children?.[0]?.text?.characters).toBe('Line 1\nLine 2') + }) + + it('rejects a line break outside span text', () => { + expect(() => + parse('

') + ).toThrow('Canvas HTML supports
only inside span text.') + }) + + 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('reports distinct unsupported classes across the markup tree in one pass', () => { + const markup = + '
Copy
' + + let message = '' + try { + parse(markup) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain('Unsupported Canvas classes:') + expect(message).toContain('"p-[0]"') + expect(message).toContain('"shrink-[0]"') + expect(message).toContain('"self-stretch"') + expect(message).toContain('"border-t-[#152A46]"') + expect(message).toContain('"font-[Inter]"') + expect(message).toContain( + 'Canvas does not support shrink utilities; remove the class instead of trying another spelling.' + ) + expect(message).toContain('Fix all listed classes before retrying.') + }) + + it('reports independent static markup issues across the tree in one pass', () => { + const markup = ` +
+
+ Copy +
+
Copy
+ Copy +
+ Copy +
+
+ ` + + let message = '' + try { + parse(markup) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain( + 'Canvas typography does not inherit; put text utilities on each span/TEXT node.' + ) + expect(message).toContain('Canvas markup has multiple repairable issues:') + expect(message).toContain( + 'Element "missing-width" requires exactly one width and one height class.' + ) + expect(message).toContain('Class "py-[4px]" is not supported on span "padded-copy".') + expect(message).toContain('Class "text-[#112233]" is not supported on div "text-on-frame".') + expect(message).toContain('div "direct-copy" cannot contain direct text.') + expect(message).toContain( + 'Element "filled-copy": Class "text-[#112233]" conflicts with "bg-[#FFFFFF]" for fill.' + ) + expect(message).toContain( + 'w-full on "row-copy" requires a flex-col parent; use grow on a row main axis.' + ) + expect(message).toContain('Fix all listed issues before retrying.') + }) + + it.each(['shrink-0', 'shrink-[0]'])( + 'explains the family-level remedy for unsupported %s', + (className) => { + const markup = `
` + + expect(() => parse(markup)).toThrow( + `Unsupported class "${className}". Canvas does not support shrink utilities; remove the class instead of trying another spelling.` + ) + } + ) + + it.each([ + [ + 'non-fixed root', + '
', + /root requires fixed/ + ], + [ + 'unpositioned freeform child', + '
Copy
', + /freeform container requires/ + ], + [ + 'full-width freeform child', + '
', + /w-full.*freeform parent.*add flex-col or grid/ + ], + [ + 'full-height freeform child', + '
', + /h-full.*freeform parent.*add flex-row or grid/ + ], + [ + 'direction without flex', + '
', + /requires flex/ + ], + [ + '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 exactly one of top-\* or bottom-\*/ + ], + [ + 'offset without absolute positioning', + '
', + /require absolute/ + ], + [ + 'fill sizing on absolute child', + '
', + /cannot use grow/ + ], + [ + 'inverted size bounds', + '
', + /cannot exceed/ + ], + [ + 'new Auto Layout narrower than its padding', + '
', + /must be at least 60px/ + ], + [ + '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('uses the CSS horizontal default for flex without an explicit direction', () => { + const result = parse('
') + + expect(result.root.layout).toMatchObject({ mode: 'HORIZONTAL' }) + }) + + it('includes default and explicit inside strokes in the new Auto Layout minimum', () => { + const markup = + '
' + const bindings = { + root: { figma: { stroke: { align: 'INSIDE' as const } } } + } + + expect(() => parse(markup)).toThrow(/must be at least 64px/) + expect(() => parse(markup, { bindings })).toThrow(/must be at least 64px/) + expect(() => + parse(markup.replace('border-[#000000]', 'border-[#000000] box-content'), { bindings }) + ).not.toThrow() + }) + + it('preserves an omitted stroke-layout setting during update', () => { + const result = parse('
', { + mode: 'update', + targetNodeId: '1:2' + }) + + expect(result.root.layout).not.toHaveProperty('strokesIncluded') + }) + + it('allows one side of an existing border to change during update', () => { + const markup = '
' + + expect(() => parse(markup)).toThrow(/requires both stroke weight and paint sources/) + expect(() => + parse(markup, { + mode: 'update', + targetNodeId: '1:2' + }) + ).not.toThrow() + }) + + 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: MAX_CANVAS_NODES - 1 }, + (_, index) => `${index}` + ).join('') + expect(() => + parse(`
${children}
`) + ).not.toThrow() + expect(() => + parse( + `
${children}overflow
` + ) + ).toThrow( + new RegExp(`more than ${MAX_CANVAS_NODES}.*Keep one root.*omitted siblings are preserved`) + ) + expect(() => + parse( + `
invalid${children}
` + ) + ).toThrow( + new RegExp(`more than ${MAX_CANVAS_NODES}.*Keep one root.*omitted siblings are preserved`) + ) + + expect(() => + parse( + '
' + ) + ).toThrow(/exactly one root.*partial update.*omitted siblings are preserved/) + + 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..9ad0db89 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-resolve.test.ts @@ -0,0 +1,470 @@ +import type { ApplyCanvasParameters } from '@tempad-dev/shared' + +import { ApplyCanvasParametersSchema } from '@tempad-dev/shared' +import { readFileSync, readdirSync } 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 +) + +type ReferenceExample = { + file: string + number: number + value: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function referenceExamples(): ReferenceExample[] { + const examples: ReferenceExample[] = [] + for (const file of readdirSync(AUTHORING_REFERENCE_DIR).filter((name) => name.endsWith('.md'))) { + const markdown = readFileSync(new URL(file, AUTHORING_REFERENCE_DIR), 'utf8') + let number = 0 + for (const match of markdown.matchAll(/```json\n([\s\S]*?)\n```/g)) { + number += 1 + const value: unknown = JSON.parse(match[1]!) + if (!isRecord(value) || (!('mode' in value) && !('markup' in value))) continue + examples.push({ file, number, value }) + } + } + return examples.sort((left, right) => + left.file === right.file ? left.number - right.number : left.file.localeCompare(right.file) + ) +} + +const REFERENCE_EXAMPLES = referenceExamples() + +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 authoring references and catalog resolution', () => { + it('discovers every documented complete recipe without a file allowlist', () => { + expect(REFERENCE_EXAMPLES.map(({ file, number }) => `${file}#${number}`)).toEqual( + expect.arrayContaining([ + 'canvas-html.md#1', + 'component-authoring.md#1', + 'component-authoring.md#2', + 'component-authoring.md#3', + 'design-system-reuse.md#1', + 'local-styles.md#1', + 'variables.md#1', + 'icons.md#1' + ]) + ) + }) + + it.each(REFERENCE_EXAMPLES)('keeps $file recipe #$number executable', ({ value }) => { + const designSystem = value.catalogId === undefined ? undefined : catalog() + const input = ApplyCanvasParametersSchema.parse({ + ...value, + ...(designSystem ? { 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('names unrecognized fields when the runtime supplies a generic issue message', () => { + const error = new z.ZodError([ + { + code: 'unrecognized_keys', + keys: ['opacity'], + path: ['bindings', 'card', 'figma'], + message: 'Invalid input' + } + ]) + + expect(formatSchemaError(error)).toBe('bindings.card.figma: Unrecognized key: "opacity"') + }) + + it('names the expected type when the runtime supplies a generic issue message', () => { + const error = new z.ZodError([ + { + code: 'invalid_type', + expected: 'object', + path: ['bindings', 'branch', 'figma', 'shape', 'paths', 0], + message: 'Invalid input' + } + ]) + + expect(formatSchemaError(error)).toBe('bindings.branch.figma.shape.paths[0]: Expected object.') + }) + + it('surfaces the closest union branch and collapses repeated equivalent issues', () => { + const solid = z + .object({ + type: z.literal('SOLID'), + color: z.object({ r: z.number(), g: z.number(), b: z.number() }).strict(), + opacity: z.number().optional() + }) + .strict() + const gradient = z + .object({ + type: z.literal('GRADIENT_LINEAR'), + gradientStops: z.array(z.unknown()) + }) + .strict() + const parsed = z.array(z.union([solid, gradient])).safeParse([ + { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 0.5 } }, + { type: 'SOLID', color: { r: 0, g: 1, b: 0, a: 0.5 } } + ]) + if (parsed.success) throw new Error('Expected validation to fail.') + + const message = formatSchemaError(parsed.error) + + expect(message).toContain('[0].color: Unrecognized key: "a"') + expect(message).toContain('(1 similar validation issues)') + expect(message).not.toContain('Invalid input') + }) + + it('lists every legal variable scope for an invalid scope', () => { + const input = ApplyCanvasParametersSchema.parse({ + mode: 'create', + markup: '
', + variableCollections: { + theme: { + name: 'Theme', + modes: { light: { name: 'Light' } }, + variables: { + border: { + name: 'Color/Border', + type: 'COLOR', + scopes: ['ALL_STROKES'], + values: { light: { r: 0, g: 0, b: 0 } } + } + } + } + } + }) + + expect(() => resolveCanvasInput(input)).toThrow(/STROKE_COLOR.*PARAGRAPH_INDENT/) + }) + + it('rejects mutually exclusive variable scopes during input resolution', () => { + const input = ApplyCanvasParametersSchema.parse({ + mode: 'create', + markup: '
', + variableCollections: { + theme: { + name: 'Theme', + modes: { light: { name: 'Light' } }, + variables: { + surface: { + name: 'Color/Surface', + type: 'COLOR', + scopes: ['ALL_FILLS', 'TEXT_FILL'], + values: { light: { r: 1, g: 1, b: 1 } } + } + } + } + } + }) + + expect(() => resolveCanvasInput(input)).toThrow( + /ALL_FILLS cannot be combined with FRAME_FILL, SHAPE_FILL, or TEXT_FILL/ + ) + }) + + 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('grounds create-only instance state through a catalog component tag', () => { + const designSystem = catalog() + const input = ApplyCanvasParametersSchema.parse({ + mode: 'create', + catalogId: designSystem.id, + markup: + '
', + native: { + save: { + componentProperties: { 'Label#1:2': 'Save' }, + figma: { instance: { scaleFactor: 1.25 } } + } + } + }) + + const resolved = resolveCanvasInput(input) + expect(resolved.input.bindings?.save).toEqual({ + componentProperties: { 'Label#1:2': 'Save' }, + figma: { instance: { scaleFactor: 1.25 } } + }) + + const parsed = parseCanvasMarkup(resolved.input, resolved.catalog) + expect(parsed.root?.children?.[0]).toMatchObject({ + key: 'save', + type: 'INSTANCE', + component: { id: 'component:button', key: 'component-key' }, + componentProperties: { 'Label#1:2': 'Save' }, + figma: { instance: { scaleFactor: 1.25 } } + }) + }) + + 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: '