Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
---
title: Exporting
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
description: Export document content to images, PDF, PowerPoint, `.fig` , JSX, HTML, or Storybook stories, with font-substitution policies for raster and PDF output.
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
---
# Exporting
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
Export designs from the terminal — raster images, vectors, PDF, editable PowerPoint, `.fig` subsets, JSX code, HTML, or Storybook stories.
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
## Image Export
```sh
2026-05-29 12:56:52 +00:00
openpencil export design.fig # PNG (default)
openpencil export design.fig -f jpg -s 2 -q 90 # JPG at 2× , quality 90
openpencil export design.fig -f webp -s 3 # WEBP at 3×
openpencil export design.fig -f svg # SVG vector
2026-09-15 19:52:41 +00:00
openpencil export design.fig -f pdf # PDF
openpencil export design.fig -f pptx # editable PowerPoint
2026-05-29 12:56:52 +00:00
openpencil export design.fig -f fig --page "Page 1" # export one page as .fig
openpencil export design.fig -f fig --node 1:23 # export one node as .fig
2026-07-04 11:54:58 +00:00
openpencil export design.fig -f html --css tailwind # export an HTML fragment with Tailwind classes
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
```
Options:
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
- `-f` — format: `png` , `jpg` , `webp` , `svg` , `pdf` , `pptx` , `jsx` , `html` , `fig` , `storybook`
2026-09-15 19:52:41 +00:00
- `-s` — export scale (default: `1` )
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
- `-q` — quality: `0` – `100` (JPG/WEBP only)
- `-o` — output path
- `--page` — page name
- `--node` — specific node ID
2026-09-15 19:52:41 +00:00
## Font Substitution Policy
For file-backed PNG, JPG, WEBP, and PDF exports, choose how missing or substituted fonts are handled:
```sh
openpencil export design.fig -f pdf --font-policy strict
openpencil export design.fig -f png --font-policy warn
openpencil export design.fig -f webp --font-policy allow
```
- `warn` (default) — report substitutions and continue exporting.
- `strict` — stop with a nonzero exit status if font preparation cannot faithfully resolve the requested faces.
- `allow` — export without the additional font-fidelity check.
Run [`openpencil fonts` ](./inspecting#font-diagnostics ) first to inspect affected faces. Export preparation may use configured font providers, so its result can differ from the offline file diagnostic.
This policy does not apply to exports from the running app, or to SVG, PowerPoint, JSX, HTML, and `.fig` output. It checks font resolution, not complete visual equivalence with Figma.
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
## JSX Export
Export as JSX with Tailwind utility classes:
```sh
2026-09-26 07:54:16 +00:00
openpencil export design.fig -f tailwind-jsx # or: -f jsx --style tailwind
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
```
Output:
```html
< div className = "flex flex-col gap-4 p-6 bg-white rounded-xl" >
< p className = "text-2xl font-bold text-[#1D1B20]" > Card Title< / p >
< p className = "text-sm text-[#49454F]" > Description text< / p >
< / div >
```
Also supports `--style openpencil` for the native JSX format (see [JSX Renderer ](../jsx-renderer )).
2026-07-04 10:34:24 +00:00
## HTML Export
2026-07-04 11:54:58 +00:00
Export as an HTML fragment with inline styles by default, or Tailwind utility classes:
2026-07-04 10:34:24 +00:00
```sh
openpencil export design.fig -f html
2026-07-04 11:54:58 +00:00
openpencil export design.fig -f html --css tailwind
2026-07-04 10:34:24 +00:00
```
2026-07-04 11:54:58 +00:00
Use `--html standalone` for a browser-openable HTML document with reset styles and a page wrapper. Standalone HTML is intended as a useful visual/code handoff, not a pixel-perfect renderer replacement:
```sh
openpencil export design.fig -f html --html standalone --css inline
openpencil export design.fig -f html --html standalone --css tailwind
2026-07-04 16:37:58 +00:00
openpencil export design.fig -f html --html standalone --css tailwind --assets external
2026-07-04 11:54:58 +00:00
```
2026-07-04 16:37:58 +00:00
Standalone Tailwind output is compiled during export; it does not depend on the Tailwind browser runtime. Use `--assets external` to write CSS and extracted image assets next to the HTML file. Use `--fonts assets` with external assets to resolve detected SceneGraph text fonts through OpenPencil's configured web-font providers and emit local `@font-face` files.
2026-07-04 11:54:58 +00:00
2026-07-04 10:34:24 +00:00
HTML export is available in file mode.
2026-10-05 09:15:44 +00:00
### Tokens in exported code
HTML and Tailwind JSX write variable-bound properties as the design tokens they come from: a fill bound to `Primary` is `background-color: var(--color-primary)` , and in Tailwind `bg-primary` , or `bg-(--name)` for a token outside Tailwind's namespaces. A layer set to another mode gets that mode's attribute, such as `data-theme="dark"` , so the tokens resolve as the canvas draws them.
A value stays literal where CSS would not reproduce it: the layer no longer draws the variable's value, the token is unitless where a length is needed, or the layer sits in a mode that only its own condition, such as a `@media` query, can select. Standalone HTML includes the stylesheet for the tokens it uses; for fragments and JSX, generate it with `openpencil tokens` (below).
2026-10-04 18:15:52 +00:00
## Design Tokens
Write the document's variables as CSS custom properties:
```sh
openpencil tokens design.fig > tokens.css
openpencil tokens design.fig --format tailwind > theme.css
openpencil tokens design.fig --collection Theme --type COLOR
```
Each collection's default mode goes in `:root` . Every other mode overrides the values that differ under its condition: `[data-theme="dark"]` for a Theme collection's Dark mode, or the selector or `@media` query the mode names. Aliases stay `var()` references and are declared again in each mode that changes what they point to, so switching `data-theme` on any element restyles everything built on it.
```css
:root {
--color-blue-500: #3B82F5 ;
--color-primary: var(--color-blue-500);
}
/* Theme: Dark */
[data-theme="dark"] {
--color-primary: var(--color-blue-300);
}
```
`--format tailwind` puts tokens with a Tailwind v4 namespace (`--color-*`, `--spacing-*` , `--radius-*` , `--text-*` , …) in `@theme` , so `bg-primary` and `rounded-card` work, and adds a `@custom-variant` per mode, so `dark:bg-surface` follows the same condition. Import the file after `@import "tailwindcss";` . Names come from the variable's code syntax when it names a custom property (`--x` or `var(--x)` ), otherwise from its name and type: `Blue/500` as a color is `--color-blue-500` .
Tokens that cannot be written, such as boolean variables or a condition that is not a selector or query, are listed on stderr and left out. The command works in file mode and against the running app.
feat: import and export W3C design tokens (#964)
* feat(core): read and write W3C design tokens
Variables export as DTCG 2025.10 token files, one per collection mode in the shape Figma imports, with text and effect styles as typography and shadow tokens and a resolver document that combines them. OpenPencil's own token fields ride in a dev.openpencil extension, so an export reads back as it was.
Import reads plain mode files, resolver documents, and Tokens Studio themes, plans what it would add, update, or skip against the document without changing it, and applies the plan as one undo step: values merge into variables by name, nothing is deleted, and an updated style updates the layers that use it.
* feat: import and export design tokens from the variables dialog
The stylesheet menu exports the document's variables and styles as a zip of W3C design tokens, and Import design tokens reads token files, folders, or a zip into a dialog that shows where each collection and mode goes, what would be added, updated, or skipped and why, before applying it as one undo step.
* feat: import and export design tokens from tools and the CLI
openpencil tokens --format dtcg --out <folder> writes the token files, design_to_tokens takes the same format, and import_design_tokens imports token files into a document for AI, MCP, and openpencil tool call. The editor and these paths share one apply step behind a target interface: the editor makes each change undoable, a headless document takes them directly.
Re-importing an export changes nothing it does not need to: values that match are left alone, layers that use an updated style are changed only outside instances, where they follow their component, and collections that share a name keep their own files apart.
* perf(core): index token lookups when planning an import
Resolving each reference scanned every token and variable, which made importing a large design system quadratic. A resolver now also finds files picked without their folders by name.
* docs: document design token import and export
* test: type the design token result in the tokens command test
* fix: lay out the design token import dialog as a mapping list
Each collection and mode sits beside the picker for where it goes, in one bordered list, so the pickers no longer read as headings. Skipped tokens use AppCollapsible, file problems AppAlert, and the counts move to the footer.
* fix: keep durations, variable metadata, and style edits through design token import
A duration Figma's format writes in seconds goes back into a variable that holds milliseconds. Tokens without OpenPencil's extension leave an updated variable's unit, scopes, and code syntax as they are. A style token whose value was edited in another tool takes that edit instead of the stale exact fields its extension still carries. Aliases to a repeated collection name resolve within one collection, a collection named Styles no longer collides with the resolver's styles set, and the dialog plans again when the document changes and right before applying. The editor imports the apply step directly rather than the whole format barrel.
* fix: keep effect order and explicit alias targets in design token import
Edited shadows take the places of the shadows they replace, so blurs keep their positions. An alias whose Figma data names a collection that is left out or absent is missing rather than bound to the same path in another collection. The resolver reserves the Styles set name only when there is a styles file.
* perf(cli): print token stylesheets without loading every page
A CSS or Tailwind stylesheet reads only variables, which load with the document, so tokens skips populating and laying out every page as variables already does. On an 85 MB design kit this takes the command from about seven minutes to seconds.
2026-10-08 16:53:46 +00:00
### W3C design tokens
`--format dtcg` writes the variables and styles as [W3C design tokens ](https://www.designtokens.org/tr/2025.10/format/ ) into a folder:
```sh
openpencil tokens design.fig --format dtcg --out tokens
```
Each collection mode is one file, `Theme/Light.tokens.json` and `Theme/Dark.tokens.json` , in the shape Figma imports as a mode: lengths in `px` , times in `s` , booleans as numbers marked `com.figma.type` , and aliases to another collection naming it in `com.figma.aliasData` . Text and effect styles become typography and shadow tokens in `styles.tokens.json` , kept apart because Figma's import refuses composite tokens. `tokens.resolver.json` is a DTCG resolver that switches each collection's modes and layers the rest, for tools such as Style Dictionary. OpenPencil's own fields, such as a token's `rem` unit, CSS name, scopes, or `clamp()` expression, and each mode's condition, ride in `$extensions["dev.openpencil"]` , so the export reads back exactly.
Import token files into a document with the `import_design_tokens` tool, passing each file's path and text:
```sh
openpencil tool call import_design_tokens design.fig --args-file tokens.json --write
```
It reads a file per mode grouped by folder, a resolver with the files it refers to, or a Tokens Studio file with `$themes` . Collections and modes with existing names, and variables and styles by name, are updated; the rest is added, and nothing is deleted. The result lists what was added, updated, and skipped, with the reason for each skip.
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
## Storybook Export
Generate one CSF3 `.stories.ts` file per component set or component:
```sh
openpencil export design.fig -f storybook # React stories in ./design-stories/
openpencil export design.fig -f storybook --framework vue -o src/stories
openpencil export design.fig -f storybook --framework html --page "Components"
openpencil export design.pen -f storybook -o src/stories --watch # re-export on every save
feat(cli): export Storybook stories beside many documents (#761)
* feat(cli): export Storybook stories beside many documents
Accept several documents, or a quoted glob such as 'src/**/*.pen', and add --beside to write each document's stories, design images, and manifest into the document's own folder, next to the component's code. Documents export one after another, since documents in one folder share its manifest; a failed document is reported and the rest still export. --watch covers every matched document through one queue. Several documents need --beside or --output, and --page takes a single document.
Refs #727
* fix(cli): resolve Storybook export documents by existence, not glob syntax
Deciding between a path and a pattern by looking for glob characters missed
extglobs, so 'src/+(a|b).pen' was opened as a literal filename, and it flagged
an escaped star, so a file genuinely named that way went to the matcher. The
character list also could not agree with Node's matcher: is-glob rejects a
bare '?', picomatch accepts a parenthesised directory name.
An existing path is now that file, and everything else goes to glob(), which
matches a plain path to itself and expands every pattern it supports.
---------
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-30 18:05:51 +00:00
openpencil export 'src/**/*.pen' -f storybook --beside --watch # stories next to each design
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
```
feat(cli): export Storybook stories beside many documents (#761)
* feat(cli): export Storybook stories beside many documents
Accept several documents, or a quoted glob such as 'src/**/*.pen', and add --beside to write each document's stories, design images, and manifest into the document's own folder, next to the component's code. Documents export one after another, since documents in one folder share its manifest; a failed document is reported and the rest still export. --watch covers every matched document through one queue. Several documents need --beside or --output, and --page takes a single document.
Refs #727
* fix(cli): resolve Storybook export documents by existence, not glob syntax
Deciding between a path and a pattern by looking for glob characters missed
extglobs, so 'src/+(a|b).pen' was opened as a literal filename, and it flagged
an escaped star, so a file genuinely named that way went to the matcher. The
character list also could not agree with Node's matcher: is-glob rejects a
bare '?', picomatch accepts a parenthesised directory name.
An existing path is now that file, and everything else goes to glob(), which
matches a plain path to itself and expands every pattern it supports.
---------
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-30 18:05:51 +00:00
### Designs next to their stories
Keep each component's design file in the component's folder and export with `--beside` : each document's stories, design images, and `.openpencil-stories.json` manifest go into that document's own folder, next to the component's code. A Storybook `stories` glob such as `../src/**/*.stories.ts` in `.storybook/main.ts` then picks them up without further configuration.
Pass several documents, or a quoted glob such as `'src/**/*.pen'` that OpenPencil expands itself (Node.js 22 or later). Several documents need `--beside` or `--output` ; `--page` works with one document only. Documents are exported one after another, and when one fails the rest are still exported before the command exits with an error. `--watch` watches every matched document; a document created after the watch started needs another run.
2026-10-07 20:06:17 +00:00
Each variant of a component set becomes a story, and its variant properties become `select` controls, so switching a control shows the matching variant. A set with a behaviour gets the control's own props instead: a Switch or Checkbox has a `checked` boolean, a Toggle `pressed` , a Collapsible `open` , and `disabled` when the set draws a disabled state; hover, pressed, and focus stay stories of their own. Standalone components named with slashes, such as `Button/Primary` and `Button/Secondary` , are grouped into one `Button` file with a `Variant` control. A combination the design has no variant for throws a named error in Storybook rather than showing a different variant.
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
Stories render the component as HTML with inline styles, like `-f html` , so they need no OpenPencil runtime; `--framework` (`react`, `vue` , or `html` ) only changes the wrapper and the `Meta` /`StoryObj` import from `@storybook/react-vite` , `@storybook/vue3-vite` , or `@storybook/html-vite` . Text uses the document's font families, which Storybook has to load itself. Text, boolean, and instance-swap properties are not exported yet.
feat(storybook): generate Radix UI components for React stories (#971)
* refactor(dom-css): share the generated component's markup and bindings
The component model now holds the element tree every framework writes: each layer's behaviour part, design tag, state class, and attributes, and the root's typed bindings (the two-way value, disabled, and each variant prop as a data attribute). The Vue generator only maps parts to Reka components and writes each binding its way, so another framework is a mapping table and a printer.
* feat(emit): print TSX modules with namespaced tags and spreads
jsx.printModule prints an es template filled with JSX as one TSX module, quoting every string the same way. Dotted tags such as Switch.Root print as JSX member expressions, and jsx.spread passes an object on as attributes. fill now fills holes inside the annotation it keeps on a filled identifier, which left $Props unfilled in a parameter's type.
* feat(storybook): generate Radix UI components for React stories
With --framework react, a component set whose behaviour is a Button, Switch, Checkbox, Toggle, or Collapsible exports as a component on the radix-ui package with its state styles in a CSS module. Its props extend the Radix root's, so checked, defaultChecked, onCheckedChange, and disabled come from Radix, and each other variant property is a prop set as a data attribute. Its stories use Radix's uncontrolled value so a play function can operate it. The browser spec now runs the same Switch and Collapsible checks for Vue and React.
* refactor(storybook): let generators say how stories import their component
The stories printer named each framework to know its Storybook package, how to import the component, and which prop sets its value. A generator now returns its files with the import path, whether the export is named, and the value's arg, and the Storybook packages come from the one table static stories use, so the printer is framework-neutral and exportStorybook looks generators up instead of branching.
* fix(storybook): keep a caller's class on React roots and run stories' play functions
The generated class came after the spread props, so a className passed to a generated React component was dropped. The root now joins its classes with the caller's. The browser spec mounts each framework's Default story and runs its exported play function with Storybook's own within and userEvent, checking the switch ends up on, and checks a caller's class stays on the root. emit shares one isNode guard.
2026-10-08 10:13:52 +00:00
With `--framework vue` or `--framework react` , a component set whose behaviour is a Button, Switch, Checkbox, Toggle, or Collapsible becomes a real component instead: `<Name>.vue` built on Reka UI, or `<Name>.tsx` with a `<Name>.module.css` built on Radix UI's `radix-ui` package. Its variants are compiled into a stylesheet keyed on the states Reka and Radix set (`data-state`, `data-disabled` , hover, and focus), and every other variant property is a prop the component sets as a `data-*` attribute. In Vue its value is a `v-model` such as `checked` or `open` ; in React the component takes the Radix root's props, such as `checked` , `defaultChecked` , and `onCheckedChange` . Its stories render the component, and a play function clicks the control and checks its state, so the Storybook project needs `reka-ui` or `radix-ui` installed.
feat(storybook): generate Reka UI components for Vue stories (#969)
* feat(emit): print Vue templates and single-file components
Generated Vue components need a template printer alongside the TypeScript and JSX ones. vue builds elements, text, static and bound attributes, and v-model, and prints a single-file component with script setup, template, and scoped style. Text escapes &, <, and { and attribute values escape & and ", which Vue decodes only after it parses interpolations and attributes; tests render printed templates with Vue's own compiler and server renderer. objectType takes optional members for defineProps.
* feat(storybook): generate Reka UI components for Vue stories
A component set whose behaviour is a Button, Switch, Checkbox, Toggle, or Collapsible now exports with --framework vue as a single-file component on Reka UI: its parts become Reka components, its variants become a scoped stylesheet from its state styles, its value a defineModel, and every other variant property a prop set as a data attribute. Its stories render the component, with a play function that clicks the control and checks its state. The native button reset moves to dom-css so preview and generated components share it.
* test: run generated Vue components in a browser
Exports a Switch and a Collapsible to Vue stories, serves each generated component with Vite and Vue's plugin, and checks in Chromium that clicking flips Reka's state, the state styles move the thumb and change the fill, a disabled switch ignores clicks, and a collapsible shows its content when opened.
* refactor(emit): escape Vue templates with entities
Text and attribute values are escaped as HTML by entities' escapeText and escapeAttribute, which follow the HTML spec, instead of hand-written tables. Text still writes { as an entity, since Vue reads {{ as an interpolation before it decodes entities.
* fix(emit): keep Vue text adjacent and raw blocks closed where they end
An element with text content printed each child on its own line, which Vue renders as a space between text and an element. It now prints its children inline. A closing tag inside a script string or a style value, which can come from an imported design, ended the block early; it is written <\/, which JavaScript strings and CSS read back as </.
2026-10-08 10:13:52 +00:00
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
Stories carry `parameters.design` entries for [`@storybook/addon-designs` ](https://github.com/storybookjs/addon-designs ):
- **OpenPencil** — when the document path is inside the current directory, an [`openpencil://` link ](../index#url-scheme ) that opens the document in the desktop app and selects the variant, or its component set when another layer shares the variant's name. The scheme addresses layers by name, so a story whose variant and component names are both shared by other layers gets no link.
- **Design** — a 2× PNG of the variant, written to `<Name>.design/` next to the story and referenced with `new URL(…, import.meta.url)` , so Vite bundles it. Copy the parameter onto the story of your own component to compare the implementation with the design. `--no-design-images` skips rendering; font substitution follows `--font-policy` as in raster export.
`-o` names the output directory. A `.openpencil-stories.json` manifest there records which document, as a path relative to the output directory, and which page generated each story and design image; commit it with the stories. An export replaces the files that a previous export of the same document generated there, including those of components since deleted or renamed; with `--page` , only that page's. It refuses, before changing anything, to overwrite any other file — a hand-written story, another document's, or a stray image. A one-page export whose file names shifted onto another page's stories asks for a full export instead. Without the manifest, existing stories count as someone else's, so remove them before exporting again. `--watch` keeps the command running and re-exports whenever the document is saved, so Storybook's hot reload follows the design; a save that cannot be read is reported and the watch continues. A missing `--page` or a `--font-policy strict` substitution still ends the command.
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
## Live App Mode
Omit the file to export from the running app:
```sh
fix: export layers and pages that are not on screen in app mode (#877)
* fix(automation): export layers from a page that is not on screen
The app's raster export rendered against the page on screen unless the
caller passed a page, so MCP export_image with ids on any other page, or
with page_id naming another page, failed with "Raster export selection
must stay on a single page". Automation shares one app between clients,
so the page on screen says nothing about what a request means.
Render on the page that holds the requested layers instead. The user's
view and selection stay where they were.
* fix(cli): export the requested page from the running app
`openpencil export --page` never reached the app: `exportViaApp` only
forwarded `--document-id` and `--page-id`, and the app's `export` RPC
exported the given nodes or the selection on screen, ignoring the target
page. `--page` and `--page-id` therefore exported whatever was selected.
The CLI now resolves `--page` to a page ID through `list_documents` and
asks for a page-scoped export. The app answers a page-scoped export with
the layers of the target page, loading a `.fig` page that has not been
shown yet without switching to it.
CLI tests address the package source by `#cli/`, as Core and fig tests
already do, so the alias owner widens to the whole package.
* fix(automation): prepare fonts and layout for a page exported off screen
A page export loaded the layers of a page that had not been shown, but not
its fonts or layout, so text and auto layout could render differently from
the screen. preparePageNodes runs the same font and layout pass as a page
switch, once per page, without switching or superseding a switch.
The CLI export test now writes its own discovery file, so it no longer
replaces or removes the record of an app that is running.
* fix(automation): prepare a .fig page before running a tool on it
A `.fig` opens with only its first page populated; the others get their
layers, fonts and layout when first shown. The automation tool handler built
its FigmaAPI on the target page without loading it, so MCP tools aimed at a
page nobody had opened (`page_id`) saw an empty page: find_nodes found
nothing, export_image reported "No visible nodes to export", and create_shape
added a shape to a page that then held only that shape.
Prepare the target page first with preparePageNodes, as page exports do:
layers, fonts and layout, once per page. The page on screen does not change.
* fix(automation): render explicit export IDs on the page that holds them
Since the visual diff tools, the automation FigmaAPI passes its target page
with every raster export, so export_image with IDs from another page asked to
render them on the target page and failed with "Raster export selection must
stay on a single page". The page now names which layers to export only when no
IDs are given; an ID list is rendered on its own page.
* fix(core): share one off-screen page preparation between concurrent callers
Two concurrent preparePageNodes calls for the same page both populated it
and resolved its fonts, and the font manager's blocked-node set has no
reference count, so the first to finish unblocked text the second was still
resolving. Callers now share the in-flight preparation, which is kept once
it succeeds and retried after a failure.
preparePageNodes also reports whether the page is ready, so a caller can
refuse to run on a page whose document was closed or replaced mid-way
instead of acting on a page with no layers. Its unused options are gone:
one caller's signal cannot cancel a shared preparation.
* fix(automation): prepare the target page once for every command
Preparing an unshown .fig page lived in the page export handler, so explicit
export IDs, export_jsx, eval, tools, and the RPC fallback still saw such a
page as empty. The request dispatcher now prepares the resolved target page
before any page-targeted command, and stops with an error when the page's
document closed while it loaded.
* docs(changelog): fold the off-screen page fixes into one entry
* refactor(automation): rely on the dispatcher to prepare a tool's target page
The request dispatcher now prepares the target page before every
page-targeted command, so the tool handler no longer does it itself. The
tests run tools through the dispatcher, which is where that guarantee lives.
* docs(changelog): drop the tool entry now covered by the off-screen page fix
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:55:23 +00:00
openpencil export -f png # export the selection in the active document
openpencil export --page "Components" -f png # export every layer of a page
openpencil export --node 1:23 -f png # export one layer, on any page
Add Programmable docs section with CLI, JSX, MCP, AI, Collab
New top-level nav section documenting OpenPencil's programmability:
- CLI: inspecting, exporting, analyzing, scripting (eval)
- JSX Renderer: elements, style props, visual diffing
- MCP Server: moved from Reference (setup + tool list)
- AI Chat: setup, 87 tools, example prompts
- Collaboration: room sharing, cursors, follow mode
Restructured sidebar:
- Programmable added to nav bar (7 locales)
- Context Menu moved from User Guide to Reference
- MCP and eval-command removed from Reference
2026-03-08 10:22:36 +00:00
```
2026-09-15 19:52:41 +00:00
docs: sync translations with documentation changes since v0.15.1 (#886)
Eleven translated pages changed in English since the last release without
matching updates: system requirements, the Lint tab, AI chat diff tools,
collaboration with agents, design JSX imports and export fidelity,
Storybook and live-app export, lint fixes, page navigation, and Checking
Designs. Bring de, es, fr, it, pl, and ru up to date, add the sections
the abridged translations lacked where a change landed, use each
language's UI labels, and point the export page at documents list.
2026-10-04 16:38:35 +00:00
`--page` takes a page name and `--page-id` a page ID from `openpencil documents list` ; either exports that page without switching the app to it. Add `--document-id` to export from a document other than the active one.
fix: export layers and pages that are not on screen in app mode (#877)
* fix(automation): export layers from a page that is not on screen
The app's raster export rendered against the page on screen unless the
caller passed a page, so MCP export_image with ids on any other page, or
with page_id naming another page, failed with "Raster export selection
must stay on a single page". Automation shares one app between clients,
so the page on screen says nothing about what a request means.
Render on the page that holds the requested layers instead. The user's
view and selection stay where they were.
* fix(cli): export the requested page from the running app
`openpencil export --page` never reached the app: `exportViaApp` only
forwarded `--document-id` and `--page-id`, and the app's `export` RPC
exported the given nodes or the selection on screen, ignoring the target
page. `--page` and `--page-id` therefore exported whatever was selected.
The CLI now resolves `--page` to a page ID through `list_documents` and
asks for a page-scoped export. The app answers a page-scoped export with
the layers of the target page, loading a `.fig` page that has not been
shown yet without switching to it.
CLI tests address the package source by `#cli/`, as Core and fig tests
already do, so the alias owner widens to the whole package.
* fix(automation): prepare fonts and layout for a page exported off screen
A page export loaded the layers of a page that had not been shown, but not
its fonts or layout, so text and auto layout could render differently from
the screen. preparePageNodes runs the same font and layout pass as a page
switch, once per page, without switching or superseding a switch.
The CLI export test now writes its own discovery file, so it no longer
replaces or removes the record of an app that is running.
* fix(automation): prepare a .fig page before running a tool on it
A `.fig` opens with only its first page populated; the others get their
layers, fonts and layout when first shown. The automation tool handler built
its FigmaAPI on the target page without loading it, so MCP tools aimed at a
page nobody had opened (`page_id`) saw an empty page: find_nodes found
nothing, export_image reported "No visible nodes to export", and create_shape
added a shape to a page that then held only that shape.
Prepare the target page first with preparePageNodes, as page exports do:
layers, fonts and layout, once per page. The page on screen does not change.
* fix(automation): render explicit export IDs on the page that holds them
Since the visual diff tools, the automation FigmaAPI passes its target page
with every raster export, so export_image with IDs from another page asked to
render them on the target page and failed with "Raster export selection must
stay on a single page". The page now names which layers to export only when no
IDs are given; an ID list is rendered on its own page.
* fix(core): share one off-screen page preparation between concurrent callers
Two concurrent preparePageNodes calls for the same page both populated it
and resolved its fonts, and the font manager's blocked-node set has no
reference count, so the first to finish unblocked text the second was still
resolving. Callers now share the in-flight preparation, which is kept once
it succeeds and retried after a failure.
preparePageNodes also reports whether the page is ready, so a caller can
refuse to run on a page whose document was closed or replaced mid-way
instead of acting on a page with no layers. Its unused options are gone:
one caller's signal cannot cancel a shared preparation.
* fix(automation): prepare the target page once for every command
Preparing an unshown .fig page lived in the page export handler, so explicit
export IDs, export_jsx, eval, tools, and the RPC fallback still saw such a
page as empty. The request dispatcher now prepares the resolved target page
before any page-targeted command, and stops with an error when the page's
document closed while it loaded.
* docs(changelog): fold the off-screen page fixes into one entry
* refactor(automation): rely on the dispatcher to prepare a tool's target page
The request dispatcher now prepares the target page before every
page-targeted command, so the tool handler no longer does it itself. The
tests run tools through the dispatcher, which is where that guarantee lives.
* docs(changelog): drop the tool entry now covered by the off-screen page fix
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:55:23 +00:00
feat: export components as Storybook stories (#751)
* 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>
2026-09-29 23:16:47 +00:00
Live app mode supports PNG, JPG, WEBP, SVG, and PDF. PowerPoint, JSX, HTML, Storybook, and `.fig` exports require a file argument. File-mode thumbnail export is not currently supported.