openpencil/packages/docs/programmable/mcp-server.md

359 lines
17 KiB
Markdown
Raw Normal View History

---
title: MCP Server
description: Connect Claude Code, Cursor, Windsurf, and other MCP clients to OpenPencil for AI-assisted design inspection and editing.
---
# MCP Server
OpenPencil includes an MCP (Model Context Protocol) server that lets AI coding tools — Claude Code, Cursor, Windsurf, etc. — read and modify designs through the running app.
Two transports: **stdio** for MCP clients, and **Streamable HTTP** for browser extensions and scripts. On macOS and Linux, local clients prefer a private Unix domain socket; Windows and unavailable sockets fall back to localhost TCP.
Tool definitions own native Valibot input schemas, execution/mutation metadata, capabilities, and optional interface exposure exclusions. Tools are included by default; `exposure: { mcp: false, ai: false, webmcp: false }` can exclude them independently from each adapter. Exposure does not bypass execution support or user permissions: WebMCP still requires supported execution and explicit Off, Inspect, or Edit access. AI and MCP consume the same schema through Standard Schema; WebMCP derives its JSON Schema from that input. Numeric strings are accepted consistently across adapters, while non-finite values are rejected. Programmatic integrations use MCP SDK v2; custom tools replace the former `params`/`ParamDef` contract with `input` and execution metadata.
feat(settings): configure tool access, MCP failures, and step limits * feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
2026-09-17 20:55:58 +00:00
## Tool access settings
Use **Settings → Tool access** (select **Local MCP**) to search and toggle the local server's tools, individually or by read-only/side-effect group. Group switches affect all group members, even during search. **Restore defaults** enables the configurable MCP tools again. Existing MCP preferences are preserved separately from the **Built-in AI** settings.
Restart the MCP server, then reconnect stdio clients, to apply changes. For an externally managed server, restart its owning process. The list reflects the tools discovered from the server; disabling a dedicated tool does not prevent an enabled script tool from performing the same operation. These switches are not a sandbox and do not configure remote MCP servers or WebMCP.
## Browser-native WebMCP (experimental) {#webmcp}
feat(settings): configure tool access, MCP failures, and step limits * feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
2026-09-17 20:55:58 +00:00
WebMCP is **off by default**. Open **Settings → MCP → WebMCP** and choose **Inspect** for read-only access or **Edit** to also allow scoped, undoable changes. **Off** unregisters all browser tools; changing modes revokes the previous registrations immediately. This preference is independent of local MCP authentication, tool switches, and outbound connections.
For local testing, use a Chrome version exposing `document.modelContext`, enable `chrome://flags/#enable-webmcp-testing`, and relaunch the browser. Open a document, enable access in Settings, and connect a WebMCP-capable browser agent or the [Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp). Settings shows browser support and registration status. See the [Chrome WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) for current availability.
In supported browsers, OpenPencil registers the selected reviewed set of tools directly in the workspace. Browser agents can inspect nodes, JSX, variables, components, and design patterns, and edit existing layer properties and variable values without installing or connecting an MCP server.
Tools target the document and page active when the call starts. Switching tabs does not redirect an in-flight call. Closing the workspace unregisters the tools. Tool inputs are validated and large inspection results require a narrower query. Oversized editing results are omitted with a committed-edit notice rather than reporting a successful edit as failed.
Edits to geometry, paints, layout, text, and variable bindings/values commit synchronously as individual undoable operations. Failed edits roll back, and undo targets the original document/page even after a page switch. Cancellation prevents an edit from starting; cancellation after commit does not reverse it. Font loading finishes separately without holding a mutation transaction open. Atomic editing currently requires a document with at most 10,000 nodes and variables combined; this shared limit also applies when the same editing tools run through app AI/MCP.
This surface does **not** expose structural creation/deletion, arbitrary JavaScript/JSX execution, image loading, filesystem operations, or credentials. Those tools retain their existing AI/MCP paths. WebMCP is an evolving browser proposal, not universally available; unsupported browsers continue to use OpenPencil normally. The stdio and HTTP integrations below remain independent.
## Install
```sh
npm install -g @open-pencil/mcp
```
## Stdio (Claude Code, Cursor, etc.)
The stdio server discovers the running OpenPencil app automatically. It prefers the app's Unix domain socket on macOS and Linux and falls back to localhost TCP when needed. Make sure the desktop app is open with a document loaded.
### Claude Code
Install the MCP package and register it with Claude Code:
```sh
npm install -g @open-pencil/mcp
claude mcp add --scope user open-pencil -- openpencil-mcp
```
Check the connection:
```sh
claude mcp list
```
Claude Code asks before using each MCP tool unless you allow the server's tools. To auto-approve OpenPencil tools only, add this to `~/.claude/settings.json`:
```json
{
"permissions": {
"allow": ["mcp__open-pencil__*"]
}
}
```
This is narrower than `--permission-mode bypassPermissions`, which skips prompts for every tool. You can also approve tools interactively from Claude's prompt by choosing “Yes, and don't ask again”.
Example prompt:
```text
Use the open-pencil MCP server to inspect the current page and create a small hero section on the canvas.
```
### Other MCP clients
Add to your MCP config (for example `.cursor/mcp.json`):
```json
{
"mcpServers": {
"open-pencil": {
"command": "openpencil-mcp"
}
}
}
```
Or run from source without installing:
::: code-group
```json [Bun]
{
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
```
```json [Node.js]
{
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
```
:::
## HTTP
For browser extensions, scripts, CI, or any HTTP client:
```sh
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults:
- Unix socket and discovery files are created with owner-only permissions on macOS and Linux.
- TCP binds to `127.0.0.1` and uses port 7600 by default.
- Authentication is enabled by default with a generated token stored in the private discovery file.
- `eval` is disabled.
- File operations are limited to `OPENPENCIL_MCP_ROOT` (defaults to the current working directory) and reject symlink escapes.
feat(settings): configure tool access, MCP failures, and step limits * feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
2026-09-17 20:55:58 +00:00
- Only the desktop app's own origin (`tauri://localhost` and its `http(s)://tauri.localhost` variants) is allowed by default, so a server you start yourself works from the app without extra configuration. Set `OPENPENCIL_MCP_CORS_ORIGIN` to a comma-separated list to allow other origins, such as a worktree dev server.
Set `PORT=0` to disable TCP on macOS and Linux. Windows requires TCP. Set `OPENPENCIL_MCP_SOCKET` to override the Unix socket path, or `OPENPENCIL_MCP_DISCOVERY_PATH` to override the discovery file location. To provide a stable token, set `OPENPENCIL_MCP_AUTH_TOKEN`; an explicitly empty value disables authentication and should only be used with a trusted local socket.
Endpoints are available over both active transports:
- `GET /health` — server and app connection status; never returns the auth token.
- `POST /rpc` — authenticated live-app automation.
- `POST /mcp` — MCP Streamable HTTP. Sessions use the `mcp-session-id` header.
## Workflow
1. **Discover targets** — call `list_documents` first when more than one document or page may be open. It returns stable `document_id` and page IDs.
2. **Open** — `open_file` to load an existing `.fig`, or `new_document` for a blank canvas. These return target metadata for the opened or created document.
3. **Read** — `get_page_tree`, `find_nodes`, `get_node`, `list_pages`
4. **Create** — `create_shape`, `render` (JSX)
5. **Modify** — `set_fill`, `set_stroke`, `set_layout`, `update_node`, `set_effects`
6. **Structure** — `reparent_node`, `group_nodes`, `clone_node`, `delete_node`
7. **Save** — `save_file` to write back to `.fig`
feat: control documents, history, settings, and tools from the CLI and MCP (#871) * fix(app): record MCP and CLI structural edits as undo steps The automation bridge ran non-atomic tools, render, and eval without an undo entry, so Edit > Undo could not revert layers an MCP client or the CLI created, deleted, or rearranged. Snapshot the page around these edits as the AI chat does, and skip the entry when nothing changed so read-only scripts leave the history alone. * feat(app): activate documents, undo, redo, and change settings over automation Add activate_document, undo, redo, get_settings, and update_settings to the app's automation bridge. Settings cover appearance, snapping, canvas rendering, recovery, and chat preferences, validated with Valibot and applied through their owning stores; credentials, models, MCP connections, storage, and tool access stay out of reach. * feat(mcp): expose document activation, history, and settings tools * feat(cli): manage documents, history, settings, and tools in the running app Turn documents into a command group (list, open, new, save, close, activate), add undo, redo, and settings get/set, and add tool list/describe/call so every MCP tool runs from the shell, against the running app or headlessly on a file. * docs: document app control from the CLI and MCP * fix: never prompt in the app from automation closes and saves close_file opened the app's Save changes dialog, which an agent cannot answer: the call timed out and the dialog stayed open. It now fails on unsaved changes unless the caller passes unsaved "save" or "discard" (CLI --save or --discard). save_file and new_document no longer open a Save dialog for a document that was never saved, report a failed save as an error, and leave the document untouched when the path is refused. * docs: describe non-interactive close and save * fix: address review findings in app automation Keep a document's source when a save to a new path fails, report vector-edit undo and redo no-ops as unapplied, echo only the applied patch from update_settings so writing cannot read settings, reject tool call --write/--output without a file, and stop settings get from following inherited keys. * fix(app): record render undo on the page that receives the layers A render into a parent on another page was snapshotted against the target page, so undo left the new layers in place. Snapshot the page that contains the parent instead, and document that eval edits made after switching pages stay outside the undo step. * feat(app): limit automation undo to its own steps and expose design check settings The undo history is shared with the person in the editor, so an agent's undo could revert the user's last edit. Automation undo and redo now act only on steps made through the bridge, and only while they are newest; otherwise they fail and leave the history alone. Vector edit mode's session history is off limits entirely. Settings automation also covers the design check preferences that landed on master.
2026-10-04 16:02:36 +00:00
8. **Close** — `close_file` to close an open document tab. With unsaved changes it fails unless `unsaved` is `"save"` or `"discard"`; it never prompts in the app.
`undo` and `redo` step back through the agent's own changes, and `activate_document` brings a tab to the front when the user should see it.
Most tools accept optional `document_id` and `page_id` fields. Pass them explicitly for agent workflows instead of relying on the visible active tab/page. `create_page` only creates a page; call `switch_page` separately when the workflow should change the active page.
## AI Agent Skill
Teach your AI coding agent to use OpenPencil tools:
```sh
npx skills add open-pencil/open-pencil
```
Works with Claude Code, Cursor, Windsurf, Codex, and any agent that supports [skills](https://skills.sh). The skill covers the CLI, MCP tools, JSX rendering, eval, and the running app's automation bridge.
## Tools
OpenPencil currently registers 100+ shared design tools, plus MCP-only document and prompt operations when applicable.
### Document
| Tool | Description |
|------|-------------|
| `open_file` | Open a `.fig` file for editing |
feat: control documents, history, settings, and tools from the CLI and MCP (#871) * fix(app): record MCP and CLI structural edits as undo steps The automation bridge ran non-atomic tools, render, and eval without an undo entry, so Edit > Undo could not revert layers an MCP client or the CLI created, deleted, or rearranged. Snapshot the page around these edits as the AI chat does, and skip the entry when nothing changed so read-only scripts leave the history alone. * feat(app): activate documents, undo, redo, and change settings over automation Add activate_document, undo, redo, get_settings, and update_settings to the app's automation bridge. Settings cover appearance, snapping, canvas rendering, recovery, and chat preferences, validated with Valibot and applied through their owning stores; credentials, models, MCP connections, storage, and tool access stay out of reach. * feat(mcp): expose document activation, history, and settings tools * feat(cli): manage documents, history, settings, and tools in the running app Turn documents into a command group (list, open, new, save, close, activate), add undo, redo, and settings get/set, and add tool list/describe/call so every MCP tool runs from the shell, against the running app or headlessly on a file. * docs: document app control from the CLI and MCP * fix: never prompt in the app from automation closes and saves close_file opened the app's Save changes dialog, which an agent cannot answer: the call timed out and the dialog stayed open. It now fails on unsaved changes unless the caller passes unsaved "save" or "discard" (CLI --save or --discard). save_file and new_document no longer open a Save dialog for a document that was never saved, report a failed save as an error, and leave the document untouched when the path is refused. * docs: describe non-interactive close and save * fix: address review findings in app automation Keep a document's source when a save to a new path fails, report vector-edit undo and redo no-ops as unapplied, echo only the applied patch from update_settings so writing cannot read settings, reject tool call --write/--output without a file, and stop settings get from following inherited keys. * fix(app): record render undo on the page that receives the layers A render into a parent on another page was snapshotted against the target page, so undo left the new layers in place. Snapshot the page that contains the parent instead, and document that eval edits made after switching pages stay outside the undo step. * feat(app): limit automation undo to its own steps and expose design check settings The undo history is shared with the person in the editor, so an agent's undo could revert the user's last edit. Automation undo and redo now act only on steps made through the bridge, and only while they are newest; otherwise they fail and leave the history alone. Vector edit mode's session history is off limits entirely. Settings automation also covers the design check preferences that landed on master.
2026-10-04 16:02:36 +00:00
| `close_file` | Close an open document tab; `unsaved: "save"` or `"discard"` decides what happens to unsaved changes |
| `save_file` | Save the current document to a `.fig` file |
| `new_document` | Create a new empty document |
| `list_documents` | List open app documents/tabs and their pages |
feat: control documents, history, settings, and tools from the CLI and MCP (#871) * fix(app): record MCP and CLI structural edits as undo steps The automation bridge ran non-atomic tools, render, and eval without an undo entry, so Edit > Undo could not revert layers an MCP client or the CLI created, deleted, or rearranged. Snapshot the page around these edits as the AI chat does, and skip the entry when nothing changed so read-only scripts leave the history alone. * feat(app): activate documents, undo, redo, and change settings over automation Add activate_document, undo, redo, get_settings, and update_settings to the app's automation bridge. Settings cover appearance, snapping, canvas rendering, recovery, and chat preferences, validated with Valibot and applied through their owning stores; credentials, models, MCP connections, storage, and tool access stay out of reach. * feat(mcp): expose document activation, history, and settings tools * feat(cli): manage documents, history, settings, and tools in the running app Turn documents into a command group (list, open, new, save, close, activate), add undo, redo, and settings get/set, and add tool list/describe/call so every MCP tool runs from the shell, against the running app or headlessly on a file. * docs: document app control from the CLI and MCP * fix: never prompt in the app from automation closes and saves close_file opened the app's Save changes dialog, which an agent cannot answer: the call timed out and the dialog stayed open. It now fails on unsaved changes unless the caller passes unsaved "save" or "discard" (CLI --save or --discard). save_file and new_document no longer open a Save dialog for a document that was never saved, report a failed save as an error, and leave the document untouched when the path is refused. * docs: describe non-interactive close and save * fix: address review findings in app automation Keep a document's source when a save to a new path fails, report vector-edit undo and redo no-ops as unapplied, echo only the applied patch from update_settings so writing cannot read settings, reject tool call --write/--output without a file, and stop settings get from following inherited keys. * fix(app): record render undo on the page that receives the layers A render into a parent on another page was snapshotted against the target page, so undo left the new layers in place. Snapshot the page that contains the parent instead, and document that eval edits made after switching pages stay outside the undo step. * feat(app): limit automation undo to its own steps and expose design check settings The undo history is shared with the person in the editor, so an agent's undo could revert the user's last edit. Automation undo and redo now act only on steps made through the bridge, and only while they are newest; otherwise they fail and leave the history alone. Vector edit mode's session history is off limits entirely. Settings automation also covers the design check preferences that landed on master.
2026-10-04 16:02:36 +00:00
| `activate_document` | Bring a document tab to the front, optionally on a given page |
### History
| Tool | Description |
|------|-------------|
| `undo` | Undo the newest change made through MCP or the CLI |
| `redo` | Redo the newest change undone through MCP or the CLI |
The history is shared with the person in the editor. `undo` and `redo` refuse when the newest step was made in the editor, so an agent never reverts the user's work. Each editing tool call is one undo step. An `eval` script is recorded against its target page, so edits it makes after switching `figma.currentPage` are not undoable.
### Settings
| Tool | Description |
|------|-------------|
| `get_settings` | Read editor settings: appearance, snapping, canvas rendering, recovery, AI chat, and design check preferences |
| `update_settings` | Change settings with a partial object shaped like `get_settings` output; invalid keys and values are rejected |
Settings tools never expose credentials, AI models, MCP connections, storage, or tool access. The available keys are listed in [Controlling the App](/programmable/cli/app-control#settings).
### Read
| Tool | Description |
|------|-------------|
| `get_selection` | Get currently selected nodes |
| `get_page_tree` | Get the full node tree of the current page |
| `get_current_page` | Get the current page name and ID |
| `get_node` | Get detailed properties of a node by ID |
| `find_nodes` | Find nodes by name pattern and/or type |
| `get_components` | List all components in the document |
| `list_pages` | List all pages |
| `list_variables` | List design variables |
| `list_collections` | List variable collections |
| `list_fonts` | List fonts used in the current page |
| `list_available_fonts` | List font families the current host can render |
| `get_font_status` | Report requested faces, loaded sources, active substitutions, why an installed face could not be loaded, and affected nodes |
| `page_bounds` | Get bounding box of all objects on the current page |
| `node_bounds` | Get bounding box of a node |
| `node_ancestors` | Get ancestor chain of a node |
| `node_children` | Get direct children of a node |
| `node_tree` | Get the subtree rooted at a node |
| `node_bindings` | Get variable bindings on a node |
### Create
| Tool | Description |
|------|-------------|
| `create_shape` | Create a shape (`FRAME`, `RECTANGLE`, `ELLIPSE`, `TEXT`, `LINE`, `STAR`, `POLYGON`, `SECTION`) |
| `create_vector` | Create a vector node from a path string |
| `create_slice` | Create an export slice |
| `create_page` | Create a new page |
| `render` | Render JSX to design nodes — create entire component trees in one call |
| `create_component` | Convert a frame/group into a component |
| `create_instance` | Create an instance of a component |
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
| `create_slot` | Make a frame inside a main component a slot |
| `set_behaviour` | Make a component behave as a Reka UI control, by its property and slot names; `null` removes it |
| `get_behaviour` | Read a component's behaviour and what it still misses; without an ID, list every kind |
| `node_to_component` | Convert an existing node into a component in-place |
### Modify
| Tool | Description |
|------|-------------|
| `set_fill` | Set fill color (hex) |
| `set_stroke` | Set stroke color, weight, alignment |
| `set_effects` | Add shadow or blur effects |
| `update_node` | Update position, size, opacity, corner radius, text, font |
| `set_layout` | Set auto-layout (flexbox) — direction, spacing, padding, alignment |
| `set_constraints` | Set resize constraints |
| `set_rotation` | Set rotation angle in degrees |
| `set_opacity` | Set opacity (0–1) |
| `set_radius` | Set corner radius (uniform or per-corner) |
| `set_minmax` | Set min/max width and height constraints |
| `set_text` | Set text content of a `TEXT` node |
| `set_font` | Set font family and weight |
| `set_font_range` | Set font properties on a character range |
| `set_text_resize` | Set text auto-resize mode (fixed/auto-width/auto-height) |
| `set_visible` | Show or hide a node |
| `set_blend` | Set blend mode |
| `set_locked` | Lock or unlock a node |
| `set_stroke_align` | Set stroke alignment (inside/center/outside) |
| `set_text_properties` | Set text layout: alignment, auto-resize, text case, decoration, truncation |
| `set_layout_child` | Configure auto-layout child: sizing, grow, alignment, absolute positioning |
| `node_move` | Move a node to a new position |
| `node_resize` | Resize a node |
| `node_replace_with` | Replace a node with another node |
| `arrange` | Align or distribute selected nodes |
### Structure
| Tool | Description |
|------|-------------|
| `delete_node` | Delete a node |
| `clone_node` | Duplicate a node |
| `rename_node` | Rename a node |
| `reparent_node` | Move a node into a different parent |
| `select_nodes` | Select nodes by ID |
| `group_nodes` | Group nodes |
| `ungroup_node` | Ungroup a group |
| `flatten_nodes` | Flatten nodes into a single vector |
| `boolean_union` | Boolean union of two or more nodes |
| `boolean_subtract` | Boolean subtraction |
| `boolean_intersect` | Boolean intersection |
| `boolean_exclude` | Boolean exclusion |
### Vector Path
| Tool | Description |
|------|-------------|
| `path_get` | Get the path data of a vector node |
| `path_set` | Set the path data of a vector node |
| `path_scale` | Scale a vector path |
| `path_flip` | Flip a vector path horizontally or vertically |
| `path_move` | Translate a vector path |
### Export
| Tool | Description |
|------|-------------|
| `export_image` | Export nodes as PNG, JPG, or WEBP. Returns base64-encoded image data |
| `export_svg` | Export nodes as SVG markup |
### Viewport
| Tool | Description |
|------|-------------|
| `viewport_get` | Get current viewport position and zoom level |
| `viewport_set` | Set viewport position and zoom |
| `viewport_zoom_to_fit` | Zoom viewport to fit specified nodes |
### Variables
| Tool | Description |
|------|-------------|
| `get_variable` | Get a variable by ID or name |
| `find_variables` | Find variables by name pattern or type |
| `create_variable` | Create a new variable in a collection |
| `set_variable` | Set a variable value in a mode |
| `delete_variable` | Delete a variable |
| `bind_variable` | Bind a variable to a node property |
| `get_collection` | Get a variable collection by ID or name |
| `create_collection` | Create a new variable collection |
| `delete_collection` | Delete a variable collection |
### Analyze
| Tool | Description |
|------|-------------|
| `analyze_colors` | Analyze color palette usage across the document |
| `analyze_typography` | Analyze font/size/weight distribution |
| `analyze_spacing` | Analyze gap and padding values |
| `analyze_clusters` | Detect repeated patterns (potential components) |
feat: check designs live with a Lint panel, canvas markers, and fixes (#804) * feat: check designs live with a Check panel and canvas issue markers Design lint only ran from the CLI and AI tools, and its rules were too noisy to show continuously: on a real imported page 786 of 888 layers had a warning. The rules now report where a finding is actionable (a hardcoded color only when a variable matches it, nesting only where the limit is crossed, instance sublayers through their main component) and carry structured data, and Recommended keeps warnings for likely problems. The app checks the current page after edits settle. The Check tab groups issues by rule with hover highlighting, reveal on click, and one-step variable binding. Errors and warnings are marked on the canvas with clustered markers that roll up to visible ancestors when zoomed out; markers explain themselves on hover, open Check on click, and toggle with View > Design issues. * fix: keep the right panel and markers stable The Check tab made the right-panel tab row overflow at common window widths, so focusing the zoom menu scrolled the row and shifted the panel. Code and AI tabs now drop their labels to screen readers when the row is narrow. Touch target names are matched as whole words: "Rectangle" contained "cta" and marked every rectangle. Markers also stay drawn during interactive edits instead of blinking while a value is scrubbed. * fix(ui): show right panel tab labels whenever they fit * fix(ui): name the design check tab Lint and keep panel tabs consistent The tab was an unlabelled icon between labelled Code and AI tabs. It is now Lint, with the same icon and label anatomy as its neighbours, and its icon takes the severity color instead of a count badge. All labelled tabs show their labels when the row fits and drop them together when it does not. * refactor(ui): build the Lint panel from shared components Issue groups use AppCollapsible, actions use AppButton, and the severity filters are a Reka toggle group with keyboard navigation. Issue rows no longer nest a button inside a button. Panel state, visibility and the focused-issue scroll live in useDesignCheckPanel, the rules menu is its own component, and rule preferences change through preference actions. Severity ordering reuses Core's ranking, detail numbers follow the app language, and the check debounce uses useTimeoutFn. * fix(lint): check the WCAG AA touch target size in the Recommended preset Recommended flagged a 394 × 39 input because it required the 44 × 44 AAA size. It now checks the 24 × 24 AA minimum through a minSize option; Strict and Accessibility keep 44 × 44. * feat(lint): fix design issues from rules, the Lint panel, the CLI, and agents Rules attach fixes as data: a safe fix keeps the design as it looks (bind a color to the variable it matches, round subpixel geometry that layout does not own), a suggestion changes values (snap radius and spacing to the scale, raise small text to the minimum). One Core applier re-validates each fix against the current graph and merges changes per layer. The Lint panel offers a fix per row and Fix all for safe fixes as one undo step; openpencil lint --fix writes the fixed document; the lint and lint_fix tools expose the same to MCP and AI chat. The design-check spec's Close button is now 24 x 20: at 24 x 24 it passes the WCAG AA touch target size that Recommended checks. * feat(lint): pin issues outside the view to the canvas edge Errors and warnings on layers outside the viewport had no marker, so a check could report issues nobody could see. They are now pinned to the canvas edge where a ray from the viewport center toward them leaves it, with a chevron pointing their way; pins in one direction merge like markers. Hovering lists them under the direction they lie in, and clicking reveals and opens the most severe, nearest one. Pins keep clear of UI floating over the canvas: the toolbar marks itself with data-canvas-obstacle, and canvases report such rectangles to the renderer through getOverlayObstacles each frame. * feat(lint): mark layers with design issues in the Layers panel Like an IDE marks files with problems and the folders holding them, a layer with errors or warnings shows the most severe as an icon, and a collapsed layer with issues inside it shows a dot in that color. Suggestions stay in the Lint panel, as on the canvas, and the marks follow the View → Design issues toggle. * feat(lint): show issues per page and across the document Loaded pages beyond the current one are now checked in the background, one page at a time while the editor is idle, and checked again only when an edit touches them; pages a large .fig file has not loaded are left alone until opened rather than forced in. The page list shows each page's errors and warnings like an IDE's problem count, and the Lint panel gains a Document scope that lists every page's issues, tags the ones on other pages, and switches to a row's page when it is opened. * test(lint): use the core-tests alias and no comma operator in lint tests Master now rejects ../../ imports and the comma operator in tests. * refactor(app): create the Lint session with the editor store modules The composition root passed its line budget once master added recent pages; the Lint session belongs with the other per-editor services that the modules factory creates and disposes. * docs(changelog): keep master's latest Unreleased entries
2026-10-04 10:08:39 +00:00
| `lint` | Check accessibility and consistency issues, with fixes and suggestions |
| `lint_fix` | Apply safe lint fixes, and optionally the first suggestion of each finding |
### Diff
| Tool | Description |
|------|-------------|
feat: add visual diff and patch apply tools and openpencil diff (#810) * feat(core): add visual diff and patch apply tools diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page. diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path. Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP. * feat(cli): add diff commands and agent diff guidance openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ. The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools. * feat(core): diff and patch node trees as JSX attributes diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled `key: value` property format that covered about fifteen properties, matched children by name path, and could not see moves. Nodes are now projected to the attributes the JSX export prints, and jsondiffpatch matches children (by ID or by name path) and detects moves. Patches list `-`/`+` attribute lines per node plus moved, added, and removed children. diff_apply checks every hunk first, applies attribute changes through the renderer's prop handling, and changes only the fields an attribute moves, so IDs, instance links, and other state survive. diff_show takes JSX attributes instead of a JSON props object. design-jsx gains sceneNodeAttributes, parseJSXAttributes, and jsxNodeFields for this, and the export round-trip property table is shared so every case is also diffed and applied. `diff files` loads its documents in order so node IDs, and so its patches, are deterministic. * fix(core): keep diff_apply atomic and diff files honest about differences - Added nodes render before anything else changes; if one fails, for example on a missing component, the rendered ones are deleted and nothing else is committed. - A hunk with an attribute the renderer ignores fails instead of reporting "unchanged". - diffDocuments reports `changed` from page statuses, and a page only one document has gets its status but no patch, since patches do not add or remove pages. diff files uses it, so an added empty page no longer reads as a match. - diff files rejects a --page neither document has and a --depth that is not a non-negative integer, exiting 2; diff_create's depth is validated the same way.
2026-10-03 17:00:13 +00:00
| `diff_create` | Patch that turns one node tree into another, as JSX attribute changes |
| `diff_jsx` | Structural diff between two nodes as design JSX |
| `diff_show` | Preview the patch that setting JSX attributes on a node would produce |
| `diff_apply` | Apply a patch after checking the nodes still match its old values |
| `diff_visual` | Pixel diff between two rendered nodes, returned as an image |
### Navigation
| Tool | Description |
|------|-------------|
| `switch_page` | Switch to a page by name or ID |
### Escape Hatch
| Tool | Description |
|------|-------------|
| `eval` | Execute JavaScript with full Figma Plugin API access |
Note: `eval` is available over stdio, but disabled in HTTP mode for security.