* feat(cli): export components as Storybook stories Add `openpencil export -f storybook`, which writes one CSF3 `.stories.ts` file per component set or component. Each variant becomes a story and the variant properties become select controls, so the story renders the matching variant; an unknown combination throws instead of showing another variant. Stories embed the existing inline-style HTML projection, so consumers need no OpenPencil runtime. `--framework react|vue|html` only changes the render wrapper and the Meta/StoryObj import. When the document sits under the current directory, stories carry an `openpencil://` design link for @storybook/addon-designs. Refs #727 * fix(pen): size auto-width text from its content on import Text without a width in an auto-layout parent was imported 10000px wide, a placeholder the app's text measurer replaces. Headless layout keeps stored sizes, so CLI HTML and Storybook exports stretched hugging frames to over 10000px. Import the width as 0 so the importer's existing text-length estimate applies, and headless layout estimates the rest. * feat(app): follow layer links to other pages openpencil:// and web ?node= links only searched the current page, so a Storybook story linking to a component on another page reported it missing. When the current page has no match, load the other pages without showing them and switch to the first that carries the name. * feat(cli): add design images and watch mode to Storybook export Each story now links to its own variant when the layer name is unique, and carries a 2x PNG of the variant for @storybook/addon-designs, imported so Vite bundles it. --watch re-exports on every save. Re-exports replace the stories a previous export of the same document generated, including those of deleted components, and refuse to overwrite hand-written stories or another document's. Refs #727 * fix(cli): reference Storybook design images without ambient PNG types Import design images with new URL(..., import.meta.url) instead of an import declaration, so consumers need no vite/client types to typecheck the stories. Document that exports should run from the same directory. * fix(app): search other pages for a link without cancelling page switches The cross-page layer search prepared each page with preparePage, which advances the page-switch generation, so a page switch the user had in progress could be dropped, and every searched page paid for fonts and layout. Add loadPageNodes, which populates a page's layers through the same worker path without touching the switch generation, and report a failed search as an error instead of a missing layer. * fix(pen): never import width-less text zero wide Text without a width now imports at width 0 and relies on the importer's text-length estimate, which skipped single-glyph text. Estimate zero-width text of any length. * fix(cli): harden Storybook export ownership, titles, and links - A --page export replaces only its own stories, and names files as a full export does, so it cannot delete or overwrite other pages' stories. - Same-named components on a page get distinct titles, so Storybook story ids do not collide. - Read the generated header through CRLF line endings, and refuse a source containing a line break, which would end the header comment and start code. - Link a story only to a layer name no other layer carries. - Document the --page default for Storybook export. Refs #727 * fix(app): let a page switch overtake a link's layer search A link search that loads other pages could resume after the user started switching pages and move them to the matching page. Expose pageSwitchCount, which advances whenever a page switch starts, and abandon the search when it changes. An overtaken search reports neither a match nor a missing layer. * fix(pen): estimate only omitted text widths Estimate a width-less text node's width when it is imported, instead of estimating every zero-width text node afterwards, so an explicit width of 0 is kept. * fix(cli): track Storybook story ownership by document path and page - Identify the document by its path relative to the output directory rather than a basename or cwd-relative path, so same-named documents do not share stories and the export no longer depends on the working directory. - Record the page in each story's header; a --page export replaces all of that page's stories and asks for a full export when renumbered file names land on another page's. - Check every target, including design images, before removing anything, and refuse to overwrite files this export does not own. - Quote the header fields as JSON with U+2028/U+2029 escaped, so any path stays inside the comment, instead of refusing line breaks. - Deduplicate titles by Storybook id, which ignores case and punctuation. Refs #727 * fix(app): focus a searched page only after its switch committed A page switch the user starts while the link search's own switch is pending can keep that switch from committing. Check that the search's switch was the only one and landed on its page before focusing; otherwise report the search as superseded. * fix(pen): keep empty text without a width at zero * fix(cli): remove only the design images a Storybook export generated Replacing a story removed its whole .design folder, including files someone else put there. Read the images each owned story references, remove just those, and remove a .design folder only once it is empty. Refs #727 * test(app): cover a page switch still pending during a link search The previous test committed the overtaking switch, so the page check alone caught it. Advance the switch count without committing, so the test fails without the count check. * fix(cli): stage Storybook exports and refuse linked design folders - Write every file to a staging folder inside the output before removing the previous export, then move them into place, so a failed write no longer leaves the export half replaced. - Refuse a .design path that is not a real folder, such as a symbolic link, before removing or writing images through it, so an export cannot reach outside the output directory. Refs #727 * refactor(dom-css): print Storybook stories from a parsed template Story modules were assembled from string fragments, so quoting and layout were an implicit contract: the CLI found design images with a regex that only matched double-quoted `new URL("…")` paths. A story module is now one TypeScript template, parsed once with acorn and its TypeScript plugin. Data is filled into `$placeholder` nodes and the module is printed with esrap, which owns quoting and escaping. The CLI reads referenced design images back through `storyImagePaths()` instead of matching text. Tests import generated modules and assert values rather than formatting. * refactor(storybook): track generated files in a manifest The export recovered which files it owned by parsing its own output: a header regex over JSON-quoted strings, line-separator escaping, CRLF handling, an AST walk for design images, and a path regex in the CLI. A `.openpencil-stories.json` manifest now records the document and page behind each generated file. The CLI validates it with Valibot, including that every listed path stays inside the output folder, and the story header is a plain note. Story ids use a copy of Storybook's `sanitize`, tested against the installed Storybook; the previous rule treated `A§B` and `A-B` as the same story. Export names use es-toolkit's `pascalCase`. The CLI export command moves into `commands/export/`, dom-css splits grouping and naming out of the Storybook exporter, and the CLI takes the framework list from dom-css. * fix(pen): keep explicit narrow text widths A post-import pass widened every multi-character text narrower than two font sizes, including widths the `.pen` file set on purpose, such as `width: 0`. Omitted widths are now estimated when the text node is created, so the pass only overrode explicit widths and is removed. --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
92 lines
7.8 KiB
Markdown
92 lines
7.8 KiB
Markdown
---
|
||
layout: doc
|
||
title: Automation
|
||
description: AI chat, CLI, JSX renderer, MCP server, and other automation surfaces built on the OpenPencil editor engine.
|
||
---
|
||
|
||
# Automation
|
||
|
||
OpenPencil treats design files as data. Every operation available in the editor — creating shapes, setting fills, managing auto-layout, exporting assets — is also available from the terminal, from AI agents, and from code. No plugins to install, no API keys, no waiting list.
|
||
|
||
The editor UI and the automation interfaces use the same engine. If you can do it by clicking, you can do it by scripting.
|
||
|
||
## The bigger idea
|
||
|
||
OpenPencil is not just meant to be a design app.
|
||
|
||
It is also meant to be a toolkit: something you can embed into other products, wrap with your own UI, and use to build editing workflows that fit your own domain.
|
||
|
||
That is why the automation surface matters. The app, the CLI, the AI tools, the JSX renderer, the MCP server, and the SDK all build on the same underlying editor engine.
|
||
|
||
## AI Chat
|
||
|
||
The built-in assistant has access to 90+ tools that cover the full surface of the editor. Describe what you want in natural language — "add a 16px drop shadow to all buttons", "create a card component with dark mode variant", "export every frame on this page at 2×".
|
||
|
||
[AI Chat →](./ai-chat)
|
||
|
||
## Collaboration
|
||
|
||
Real-time multiplayer editing over peer-to-peer WebRTC. No server, no account. Share a room link and edit together with live cursors and follow mode. Document state syncs via CRDT, so edits merge automatically even on flaky connections.
|
||
|
||
[Collaboration →](./collaboration)
|
||
|
||
## Vue SDK
|
||
|
||
Build OpenPencil-powered editors with the same Vue SDK the app uses internally. The SDK exposes editor context, canvas wiring, selection state, command models, property-panel composables, and headless primitives.
|
||
|
||
[Vue SDK →](./sdk/)
|
||
|
||
## JSX Renderer
|
||
|
||
Describe UI as JSX — the same syntax LLMs already know from React. A single call can create an entire component tree with frames, text, auto-layout, fills, and strokes. Compact, declarative, and diffable.
|
||
|
||
Going the other direction, export any selection back to JSX with Tailwind classes — useful for handing off to development or feeding designs back into an LLM.
|
||
|
||
[JSX Renderer →](./jsx-renderer)
|
||
|
||
## CLI
|
||
|
||
Inspect, lint, export, and analyze design documents without opening the editor. List pages, search nodes, extract design tokens, catch layout or accessibility issues, and render to PNG — all from the terminal with machine-readable JSON output.
|
||
|
||
The CLI also connects to the running desktop app via RPC, so you can script the editor while you're using it.
|
||
|
||
[Inspecting Files](./cli/inspecting) · [Exporting](./cli/exporting) · [Analyzing Designs](./cli/analyzing) · [Scripting](./cli/scripting)
|
||
|
||
## MCP Server
|
||
|
||
Connect Claude Code, Cursor, Windsurf, or any MCP-compatible client to OpenPencil. The server exposes 90 tools for reading, creating, and modifying designs — the same tools the built-in AI chat uses. Runs over stdio or HTTP with session support.
|
||
|
||
[MCP Server →](./mcp-server)
|
||
|
||
## URL scheme
|
||
|
||
The desktop app registers `openpencil://`, so a published page — a Storybook story, a design review, a README — can link straight to a layer:
|
||
|
||
```
|
||
openpencil://open?file=web/design/hikyo.pen&node=Button/Large/Default
|
||
```
|
||
|
||
`file` is a repository-relative path ending in `.pen` or `.fig`; absolute paths and `.` or `..` segments are refused. `node` is optional. Both values are URL-encoded — path separators may stay literal, but a literal `+` must be sent as `%2B` — and a repeated key takes its last value.
|
||
|
||
The app matches `file` against the paths of the open tabs as a whole trailing segment sequence, and focuses that tab without re-reading the document, so a file that moved or turned unreadable since it opened still gets its layer selected. The first open tab whose path ends with the requested path wins, which matters when two checkouts have the same file open. Segments are compared the way the platform's filesystem does: ASCII-case-insensitively on macOS and Windows, exactly on Linux, so `Web/Design/hikyo.pen` and `web/design/hikyo.pen` are the same file on a Mac and two different ones on Linux. If no open tab matches, a file picker asks for the file once; the picked file must end with the same relative path, otherwise the link is cancelled. No path is joined onto a root and no filesystem access is granted beyond what the picker returns. A file the link actually opens — the picked one — joins the recent-files list like any other file you open; focusing a tab that was already open does not touch the list, because nothing was opened.
|
||
|
||
With a node name, the app selects every layer carrying that exact name on the current page and zooms the view to the whole selection. When the current page has none, it switches to the first page that does, loading pages as needed. An unknown name shows a notice and leaves the document open. Opening a file and selecting layers is all the scheme can do.
|
||
|
||
The web app takes the same link from its own address bar:
|
||
|
||
```
|
||
https://app.openpencil.dev/?file=https://raw.githubusercontent.com/open-pencil/open-pencil/master/tests/fixtures/pencil_button.pen&node=Button/Large/Default
|
||
```
|
||
|
||
Here `file` is an absolute `https:` URL ending in `.pen` or `.fig` — the web app has no filesystem, so a relative path, an `http:` URL or any other extension is refused with a console warning and nothing else. The extension is read off the URL's path, so a query string on the linked file changes nothing. A fragment is dropped before the fetch: it never reaches the server, so `…/hikyo.pen#a` and `…/hikyo.pen#b` open one tab, not two. `node` behaves exactly as above: the same exact-name selection and zoom, the same notice when no layer carries the name. Both values are URL-encoded, a literal `+` must be sent as `%2B`, and a repeated key takes its last value, as on the desktop. The link is handled on any route, so `/share/<room>?file=…` and `/demo?file=…` work like `/?file=…`.
|
||
|
||
The browser fetches the file cross-origin, so the host must allow it: `raw.githubusercontent.com` sends `Access-Control-Allow-Origin: *` and works. The request carries no credentials and refuses to follow redirects, which keeps an `https:` link from being bounced to a plaintext one — a `https://github.com/<owner>/<repo>/raw/...` URL redirects to `raw.githubusercontent.com` and is therefore refused, so link to the raw host directly. `file` and `node` are stripped from the address bar through the router as soon as they are read — before the fetch, and also when the link was refused — so a reload does not re-open the document, and neither a copied URL nor a later in-app navigation carries the link payload. A linked document is capped at 64 MiB: the body is counted as it streams in, not trusted from `Content-Length`, and the request is aborted the moment it goes over, with the link reporting that the file exceeds 64 MiB. A deployment that serves the app under a Content-Security-Policy must allow the linked host in `connect-src`, otherwise the fetch is blocked and the link reports that it could not open the file.
|
||
|
||
On macOS the scheme belongs to the installed app bundle, so links reach an installed build and not a `tauri dev` process. On Windows and Linux the link arrives through the deep-link plugin, including when the app is not running yet: the link is queued at startup and handled once the editor is ready; on Linux the bundled desktop entry passes the link through `%U`.
|
||
|
||
## Why Open?
|
||
|
||
Figma is a closed platform. Their MCP server is read-only. CDP browser access was killed in version 126. Design files live in a proprietary format on someone else's servers. Plugin development requires a custom runtime with limited APIs.
|
||
|
||
OpenPencil is the alternative: open source, MIT licensed, every operation scriptable, data stored locally. Your design files are yours — inspect them, transform them, pipe them into CI, feed them to an LLM. No permission needed.
|