Commit graph

16 commits

Author SHA1 Message Date
Danila Poyarkov fb0cb9ee5f
feat: write variable-bound properties as tokens in exported code (#895)
* feat: write variable-bound properties as tokens in exported code

HTML and Tailwind JSX export reference the design tokens a layer is bound to, var(--color-primary) or bg-primary, instead of baking in their values. A binding becomes a reference only where the stylesheet resolves it as the canvas draws it: the layer still draws the value, the token's unit fits the property, and an attribute such as data-theme="dark" can put the element in the layer's mode. Standalone HTML includes the stylesheet for the tokens it uses, compiled into Tailwind's @theme for Tailwind pages.

* refactor: share the collection variable list and tighten token helpers

Read every variable in collection order through one collectionVariables helper built on es-toolkit's compact, used by the stylesheet and the token references, and map padding sides and color channels instead of repeating them.
2026-10-05 09:15:44 +00:00
Danila Poyarkov 01e58a3ad0
feat: write variables as a CSS token stylesheet (#856)
* feat: write variables as a CSS token stylesheet

Copy a collection as CSS custom properties or a Tailwind v4 theme from the variables dialog, print it with openpencil tokens, and rebuild design_to_tokens on the same generator. Default modes go in :root or @theme, other modes override under their condition, and aliases are declared again in each mode scope so they follow it.

* fix: give modes that slug alike their own selector and variant

Two modes in one collection whose names reduce to the same slug, such as Dark and dark!, shared one default selector and Tailwind variant, so the later mode silently overrode the earlier one. Slugs are now numbered in mode order, as variable names already are.
2026-10-04 18:15:52 +00:00
Danila Poyarkov b9753aa016
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
Danila Poyarkov 8d10b9ca27
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
Marc Went 8c72b62da0
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 22:05:51 +04:00
Marc Went 802091b051
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-30 03:16:47 +04:00
Danila Poyarkov 7a37f14327
refactor!: register HTML and Tailwind JSX as IO formats (#774)
* refactor!: register HTML and Tailwind JSX as IO formats

HTML and Tailwind JSX went around the IO registry: the CLI appended
`html` to its format list and had its own HTML and Tailwind export paths,
so the app's export options offered neither.

Core now registers `html` and `tailwind-jsx` adapters built on a new
browser-safe `@open-pencil/dom-css/export` entry. Export results can
carry assets written next to the main file, which covers standalone HTML
with external images and fonts, and the CLI writes every format the same
way. The CSS object model and Node file access load only when an export
needs them, so the app bundle stays free of the headless CSS runtime.

BREAKING CHANGE: `sceneNodesToTailwindJSX` and `designDocumentToTailwindJSX`
moved from `@open-pencil/dom-css/browser` to `@open-pencil/dom-css/export`.

* refactor(core): share export support and fixed-size options across IO formats

Five adapters export every target and six have no scale or quality
options; the new HTML and Tailwind JSX adapters repeated those blocks
again. Both are now named once and shared.

* fix(core): keep HTML asset paths relative for Windows output paths

The CLI passed the absolute output path as the export file name, and the
HTML adapter only split it on `/`, so on Windows the page referenced
absolute `C:\...\card.assets` paths and assets were written to a doubled
location. The CLI now passes the file name, and the adapter accepts
either separator.
2026-09-26 11:54:16 +04:00
Danila Poyarkov d354c990ee docs: refresh SDK and workflow guides
Document current public contracts and implemented workflows, correct invalid editor and slot examples, and distinguish supported font, recovery, and library behavior from remaining gaps.
2026-09-15 22:52:41 +03:00
Danila Poyarkov 7ac3ee035d fix(dom-css): compile standalone HTML exports 2026-07-04 19:37:58 +03:00
Danila Poyarkov b22a751f27 Revert "feat(cli): bundle standalone HTML exports"
This reverts commit 8330dda410.
2026-07-04 18:18:32 +03:00
Danila Poyarkov 8330dda410 feat(cli): bundle standalone HTML exports 2026-07-04 18:08:42 +03:00
Danila Poyarkov 3df6cc9bcd feat(cli): add standalone HTML export mode 2026-07-04 14:54:58 +03:00
Danila Poyarkov 8b1c452cc3 refactor(cli): replace DOM command with import and HTML export 2026-07-04 13:34:24 +03:00
Danila Poyarkov 883782846f docs(cli): use released openpencil command 2026-05-29 15:56:52 +03:00
Danila Poyarkov 890fb4f6d5 Update README and docs for new formats and SDK 2026-03-28 15:42:21 +03:00
Danila Poyarkov a4b810620f 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 13:22:36 +03:00