* feat(desktop): register openpencil:// deep link scheme
Signed-off-by: Marc Went <marc@went.io>
* feat(desktop): parse openpencil://open?file&node links
Signed-off-by: Marc Went <marc@went.io>
* feat(desktop): queue openpencil:// links as pending opens
Signed-off-by: Marc Went <marc@went.io>
* fix(desktop): read cold-start deep links on windows/linux
Signed-off-by: Marc Went <marc@went.io>
* feat(app): resolve openpencil:// links and select the target node
A link's file is repo-relative, so it is resolved against the paths of the
open tabs and otherwise located once by the user through the dialog picker.
Nothing else is read from disk and no fs scope is widened. The node is matched
by exact name on the current page, selected and zoomed to; a missing node
raises a notice instead of failing silently.
Signed-off-by: Marc Went <marc@went.io>
* docs: document the openpencil:// URL scheme
Describe the link format, the relative-path rule, how the file is resolved
against open tabs or a one-time picker, and that the scheme can only open a
document and select a layer.
Signed-off-by: Marc Went <marc@went.io>
* test(app): cover cancelled deep-link picks
Inject the file picker and open entry points into openDeepLink so the test can
drive the branch where the picked file is not the requested one. The repository
lint forbids module registry mocking, and the existing file batch helper takes
its opener the same way.
Reword the module comment: the opened file joins the recent-files list like any
other opened file, and a one-segment file matches the first open tab whose path
ends with it.
Signed-off-by: Marc Went <marc@went.io>
* docs: sharpen the URL scheme notes
Selecting by name selects every layer with that name on the current page and
zooms to the whole selection. Record that the first matching open tab wins,
that path separators may stay literal in the query, and how the scheme reaches
the app on each platform.
Signed-off-by: Marc Went <marc@went.io>
* refactor(app): keep the deep-link io type internal
Nothing outside the module names the injected io type, so contextual typing at
the call site is enough. Drop the redundant recording array from the cancelled
pick test.
Signed-off-by: Marc Went <marc@went.io>
* fix(desktop): tag pending opens by producer
The frontend classified a pending entry by the shape of its path, which
called a canonicalized Windows path (`\\?\C:\…`) relative and sent a
double-clicked document into the deep-link resolver. Rust now says which
producer queued the entry, and the tail both producers shared moves into
`queue_pending`.
Signed-off-by: Marc Went <marc@went.io>
* test(app): assert the opener receives the resolved path
Signed-off-by: Marc Went <marc@went.io>
* test(desktop): refuse a percent-encoded parent segment
Signed-off-by: Marc Went <marc@went.io>
* chore(desktop): relax the deep-link plugin pin
Signed-off-by: Marc Went <marc@went.io>
* chore(desktop): drop the unused deep-link capability
Draining links is Rust-side, so the webview never calls
`deep-link:allow-get-current`.
Signed-off-by: Marc Went <marc@went.io>
* fix(app): clamp link values in notices
Signed-off-by: Marc Went <marc@went.io>
* fix(desktop): pass deep links through the linux desktop entry
The bundler's default desktop template writes `Exec={{exec}}` with no
field code, so a Linux cold start from a deb, rpm or AppImage never
receives the `openpencil://` link as an argument and `get_current()`
has nothing to recover. Ship a custom template that is the bundler
default plus `%U`, wired to both the deb and rpm bundlers (AppImage
reuses the deb data dir). MIME types still come from `{{mime_type}}`,
so the file associations are unchanged.
Signed-off-by: Marc Went <marc@went.io>
* feat(app): open documents from ?file= links in the browser
The desktop build takes openpencil:// links; the web app had no equivalent.
It now reads file and node off its own address bar on boot, fetches the
document from an absolute https URL without credentials and without
following redirects, selects the named layer through the same path the
deep link uses, and strips both params so a reload does not re-open.
Signed-off-by: Marc Went <marc@went.io>
* fix(app): keep router state coherent when stripping web link params
Rewriting history directly left the router's own record of the current URL
pointing at the un-stripped one, so the next router.push wrote file and node
back into the history entry. The strip is now an injected action that goes
through router.replace, preserving the route, hash and every other query key.
Also clamp the failure detail, take the last value of a repeated key like the
desktop parser does, share deep-link's clamp instead of copying it, and report
a failed fetch through toast.error.
Signed-off-by: Marc Went <marc@went.io>
* fix(app): resolve deep links by filesystem case and bound remote fetches
Deep links resolved their file by comparing path segments in JavaScript,
which is case-sensitive: on macOS and Windows `Web/Design/hikyo.pen` and
`web/design/hikyo.pen` name the same file, yet both the open-tab lookup and
the picker check refused it and the link was cancelled. The comparison now
goes through a `path_matches_suffix` Tauri command that canonicalizes the
candidate and folds ASCII case on macOS and Windows while staying exact on
Linux. `resolveDeepLinkFile` takes the comparator as an argument, so it
stays testable without Tauri, and the rule is tested in `deep_link.rs`.
An already open document is focused through `activateTabForPath` instead of
`openFileFromPath`, which re-read the file from disk first and rejected the
whole link when it had moved or lost its permissions since the tab opened
it. The picker branch still opens the file, and a tab that closed between
the snapshot and the activate falls back to opening it.
A web link's `file` URL drops its fragment. The tab identity compares source
URLs exactly, so two links to one document differing only in fragment opened
two tabs.
A document fetched from a URL is capped at 64 MiB, counted off the streamed
body rather than the sender's `Content-Length`, with the request aborted the
moment it goes over instead of buffering whatever the host decides to send.
Draining the pending-open queue goes through `openDesignFileBatch`, the
per-item catch every other open path already uses, so one failing entry no
longer skips the rest of the batch.
Signed-off-by: Marc Went <marc@went.io>
* fix(desktop): match a deep-link suffix against the literal path too
Canonicalizing the candidate resolves a symlink that sits inside the trailing
segments, so a monorepo checkout where `packages/web` links to `../apps/web`
would stop matching a link that spells the path the way the tab does. Compare
both spellings: the canonical path keeps `..` and prefix symlinks working, the
literal one keeps the path the user actually sees. Both inputs are already-open
or user-picked paths, so trying the literal one grants nothing new.
Signed-off-by: Marc Went <marc@went.io>
* fix(app): cap the automation fetch and chain a caller's abort signal
`openBrowserFileFromURL` replaced a caller-supplied `signal` with the one the
size cap needs, so a caller could no longer cancel its own request. The two
are chained instead: the caller's abort aborts the cap's controller, and an
already-aborted signal is honoured before the fetch goes out.
`handleOpenFile` in the automation bridge was the last fetch buffering an
unbounded body. It reads a document the same way, so it gets the same 64 MiB
ceiling, counted off the stream and aborted on overflow. Its relative-path
resolution and its lack of a format assert are unchanged.
`path_matches_suffix` runs `async`, so `canonicalize` cannot block the main
thread on a stale network mount, and it now refuses an absolute or
`..`-bearing suffix: `parse_open_url` already does, but this is the comparison
every caller funnels through and an absolute suffix would otherwise match on
its segments alone. The command itself gained tests over a real temp tree —
exact match, the platform case rule, the symlinked trailing directory that
motivated the literal fallback, a missing file, and the refusals.
The docs and the module header claimed an opened file always joins the recent
files list, in the same breath as saying an already open tab is focused
without re-reading it. Only the former opens anything, so only the former
touches the list.
Signed-off-by: Marc Went <marc@went.io>
* docs(changelog): note the 64 MiB ceiling on the automation bridge openFile
Signed-off-by: Marc Went <marc@went.io>
* fix(app): deliver cold-start deep links through the deep-link path
macOS hands a launch `openpencil://` link to the app as `RunEvent::Opened`
before the app's `setup` closure runs. Traced on a cold `open`:
`RunEvent::Opened` at T+0.085 s, `setup` at T+0.342 s, and `on_open_url` never
fired. The plugin's `deep-link://new-url` emit therefore reached no listener
and the URL survived only in the plugin's `current`, which was drained under
`#[cfg(any(windows, target_os = "linux"))]` on the assumption that macOS was
unaffected. It is not: a cold link launched the app to an empty tab with no
picker, no toast and no log line, while the same link fired at a running app
worked. The drain now runs on every desktop platform; `register_all` stays
gated, macOS does not support it.
Nothing is queued twice. `RunEvent::Opened` is dispatched on the thread that
runs `setup`, so a link cannot arrive between registering `on_open_url` and
reading `current`, and anything later is no longer in `current`. A cold
double-clicked document is unaffected: `current` now also yields its `file://`
URL, and the `scheme == "openpencil"` filter in `queue_deep_links` drops it,
leaving `queue_open_paths` the only producer for that path.
The pending-open routing moves out of `WorkspaceView.vue` into
`app/document/io/pending-open.ts`, so which entry reaches the deep-link
resolver and which reaches the plain opener is unit-testable without mounting
the view. A drain that fails wholesale — the `take_pending_open` invoke, the
event binding — now raises a toast instead of only a console line; per-entry
failures were already toasted.
Signed-off-by: Marc Went <marc@went.io>
* refactor(app): share one bounded body reader
readBodyWithLimit reimplemented the chunked cap that vectorize's
readBoundedResponse already applied, and it lived in the menu module while
the automation bridge imported it from there.
Move the reader to the browser document-io owner as readBoundedBody,
returning bytes with an optional overflow hook and error message, and have
both the document fetch and the vectorize providers use it. The automation
bridge now opens a browser file through openBrowserFileFromURL instead of
re-inlining fetch, cap and tab creation, so it also gets the same format
check as the Tauri path, and the caller's abort signal is combined with the
cap's controller through AbortSignal.any.
* fix(app): report a failed tab activation
activateTabForPath returned true after calling switchTab, but switchTab
silently does nothing when the tab is gone. A tab that closed while the
identity lookup awaited therefore looked focused, and the caller skipped
opening the file, so the link did nothing at all.
Return whether a tab was actually activated.
* fix(app): translate the document link notices
The four notices added for document links existed only in the English
defaults, so a localized build showed English toasts. check:i18n does not
cover the app-level notification catalog, which is why nothing caught it.
Also correct the docs: a `.` segment is refused along with `..`, matching
the matcher.
* fix(desktop): refuse a dot segment in deep links
The parser accepted `web/./design.pen` while path_ends_with_segments
refuses `.`, so such a link was queued and could then never match an open
tab or a picked file — it failed silently after asking the user to locate
the file.
Refuse `.` alongside `..` in the parser and drop the whitespace-only line
left in the capability file.
* refactor(app): tidy the document link plumbing
Four smaller things from review:
- A dismissed file picker is not a wrong file, so it no longer reports
"expected a file ending in …", which named a file the user never chose.
- Reuse es-toolkit's omit for stripping the link params, as the MCP
settings form already does.
- Drop the openDesignFileBatch re-export from menu/use.ts; nothing
imports it from there.
- Move the exact-name lookup out of the view: selectNodesByName lives with
the other selection helpers and walks the graph directly, instead of
building a whole FigmaAPI facade from the automation bridge to answer
one query.
* refactor(app): centralize focusing nodes
The name lookup was a link-shaped helper in the selection domain, and it
baked one strategy into the action. Split it into the two things a caller
actually needs: focusNodes(ids) is the select-and-zoom primitive that
share and collaboration references want, and focusNodesByName resolves an
exact name on the current page first.
The store dependency is a narrow interface, as with the viewport actions,
so the action is unit-testable and stale ids can be ignored instead of
selected.
---------
Signed-off-by: Marc Went <marc@went.io>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
92 lines
7.7 KiB
Markdown
92 lines
7.7 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. 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.
|