* feat: prototype the design tokens panel
Undoable editor actions for token fields and mode conditions, a token view model, and table, inspector, collection and stylesheet panels shown in a Storybook story with a real editor.
* feat: lay the tokens panel out for mobile
Below the mobile breakpoint the collection tabs become a select, the list shows one chosen mode with the CSS name under each token, and the token, the modes, and the stylesheet each open over the list behind Back. CodeViewer can fill its container for the full-screen stylesheet.
* feat: share drill-in navigation and put tokens on a listbox
PanelDrillIn gives detail views one back control that names where it returns, a slide preset from theme/motion, and focus that moves into the detail and back to what opened it. The Settings model editor and the tokens panel's mobile views use it. The token list is a Reka Listbox with arrow-key navigation and labelled groups, and the inspector and stylesheet swap with a short fade under the motion policy.
* feat: lay the tokens panel out by container width
The panel switches to the compact drill-in by its own measured width, and the list's columns follow the list's width through container queries, so the panel adapts inside dialogs and split views, not only on phones. src/AGENTS.md now says when to use container queries, measured size, and viewport breakpoints.
* test(core): group the variable undo tests in a domain folder
* feat: edit variables as tokens in the variables dialog
The variables dialog now hosts the tokens panel: a grouped token list with CSS names, an inspector for each token and for the collection and its modes, and the live stylesheet. It keeps adding variables by type, search, collection and mode management, and copying the document's stylesheet, and lays out by its own width down to phones.
useVariables().collections returns copies, so components given a collection see modes added or renamed in place, and addVariable returns the new ID so the panel can open it.
* fix: satisfy the type-aware lint in token updates and panel stories
* docs: describe the tokens panel in the translated variables guides
* feat: say when each mode applies in plain words
A mode's condition was a raw CSS field that designers could not read. Each non-default mode now picks when it applies (switched manually, system dark or light mode, high contrast, reduced motion, screen or container width, or custom CSS), with the CSS it writes shown underneath and a note on how the mode behaves on the canvas and in exported code. Presets only write the condition string modes already store, so files round-trip unchanged. Column headers name the condition in words, and deleting the collection moves into a menu beside its name.
* feat: bring the variables dialog up to Figma's
The dialog now covers what Figma's variables dialog offers: collections and groups in a sidebar with counts, a group, search and type filter, type icons, names and values edited in place, drag to reorder, multi-select with a context menu, the Delete key and a side panel to duplicate, group and delete, aliases chosen from a variable picker and detached back to the value they showed, hiding from publishing, and an expand toggle. It opens from View → Variables… and the command palette as well as the Design panel.
It also fixes review findings: a single-mode collection labels its value Value, column titles share one font, a CSS name is typed after a fixed -- and checked by the CSS parser before it is saved, and the stylesheet previews CSS with the format chosen when copying. AppInput applies an instance's input classes last so they can override adornment padding.
* feat: undo and redo inside the variables dialog
Canvas shortcuts stop while any dialog is open, so Cmd+Z did nothing in the variables dialog although every edit there is on the editor's history. Undo and redo now take a document scope: they also run when the topmost layer is a dialog that edits the document, which the variables dialog marks, while menus, pickers and Settings still hold them back and a field with uncommitted text keeps its own undo.
Enter commits a field and returns focus to the list. The panel drops selections, group filters and collections that undo removed. A color picker session undoes as one step through a coalesce key on updateVariableValue, and undoing a deletion puts the variable back in its place.
* feat: search variables like the command palette
The variables search matched a substring of the name only, so a CSS name, a hex color or a description found nothing, and the palette, AppPicker and AppCombobox each spelled out the same Fuse.js options. One helper in @open-pencil/vue now owns the matching: fuzzySearch ranks results for lists people pick from, and fuzzyFilter keeps a list's own order for lists people arrange. The panel searches names, CSS names, descriptions and every mode's value, alias names included; useVariables() searches names and descriptions.
* fix: say a token group once when its name repeats it
Kits that mirror Tailwind classes name tokens like Gap/gap-1, which derived --gap-gap-1. A group the next segment repeats is now said once, giving --gap-1.
* fix: keep the add variable menu under its button after a resize
The toolbar swaps the icon button for the labelled one when the dialog widens. Reka keeps the anchor it mounted with, so the menu opened at the window corner; keying the trigger remounts it with the new button.
* fix: leave bound layers alone when variables are added or reordered
Adding, copying, or reordering variables re-resolved every bound layer in the document. In the shadcn kit two Avatar instances are saved at 24 while their binding gives 40, so adding a number resized them and relaid out about 7,800 layers, freezing the browser for seconds. These changes alter no bound value and now only request a render, as renaming already did.
* refactor: let the variables dialog own its undo shortcuts
Undo and redo in the dialog went through a document scope in the global shortcut registry, which decided whether the dialog was on top by querying Reka's dismissable layers in DOM order. The dialog now listens on its own content with a tinykeys handler built from the command keybindings: menus and pickers it opens portal elsewhere, so their keys never reach it, and the registry is back to one scope.
* refactor: read mode conditions with css-tree
Presets were recognized by normalizing the condition with regular expressions and matching another. css-tree, which Core already ships through unifont, now parses the condition into its media or container feature, so spacing, case and comments are handled as CSS does, and a non-breaking space, which CSS does not treat as whitespace, no longer turns a custom condition into a preset.
* refactor: take the mode attribute hint from modeAttribute
The hint for a manually switched mode cut the brackets off its selector with a regular expression. It now reads the attribute name and value from modeAttribute, which the selector is built from.
* fix: keep a refused CSS name in focus and color picker sessions apart
Enter on a CSS name the parser refuses no longer hands the keyboard back to the list, so the name can be corrected. The typed name is read with parseCSSName instead of stripping a leading -- by hand, so a pasted var(--name) works too.
Each color picker session takes a random undo key; a counter restarted when the inspector remounted, so two sessions on the same token could merge into one undo step.
* fix: keep token expressions and cleared fields in step with their variable
Editing a number left its CSS expression recording the old number, so reopening the file dropped the expression as edited elsewhere. A number now updates its expression, and an alias drops it, in the same undo step.
Undoing a token field that had been unset kept the key with an undefined value; it is now removed.
* fix: preview and detach aliases in their own mode
An alias in the Dark column showed, and detached to, what its target gives in the mode the canvas is in. Both now resolve in the column's mode.
* fix: drop tokens a filter hides from the selection
A search, group, or type filter could hide selected tokens that stayed selected, so the inspector, the bulk actions, and the Delete key still acted on rows the list no longer showed.
* fix: keep a drill-in's list out of the tab order while its detail slides
The list stayed focusable until the detail finished sliding in, and became focusable again under a detail sliding out. It is now inert from the moment the detail opens and the departing detail is inert. A detail with no field focuses its back control, and hidden inputs are skipped.
* docs: drop a duplicated Stroke entry from the changelog
Two merges left the Stroke-extends-Fill breaking change twice; the copy that still said strokes render solid only is outdated, since gradient and image strokes now import and render.
* build: update dependencies
Update the AI SDK providers, Vue, Reka UI, Valibot, Zod, es-toolkit,
CodeMirror, Storybook, Playwright, Hono and other dependencies to their
current releases, consistently across workspaces.
The Tauri plugin packages must match their Rust crates, and the new plugin
crates require Tauri 2.12, so Cargo.lock, @tauri-apps/api and the Tauri CLI
move to 2.12 as well.
* build(harness): update the AI SDK harness packages
@ai-sdk/harness 1.0.74 pinned ai 7.0.67, so the workspace carried a second
copy of ai next to the root one; 1.0.138 depends on the same ai release.
The Pi adapter no longer takes a model: HarnessAgent does. The settings
were spread from untyped records, so the compiler could not reject the
stale key and the chosen model would have been dropped; they are plain
literals now.
PiAuthOptions is now PiAuthenticationMode, and auth accepts an environment
record. Pass the gateway key that way instead of writing it into the
process-wide environment while a session is created. Derive the thinking
level from the adapter's settings, which adds 'max'.
* build: hold vue-tsc at 3.3.11
vue-tsc 3.3.12 no longer sees a v-slot binding inside a component that
also has an event listener, so check:vue reports "Cannot find name
'control'" in MCPConnectionEditor and ProfileEditor. 3.3.11 checks them
cleanly.
* fix(ai): keep retryability for provider errors reported mid-stream
From ai 7.0.80 a provider error after the response stream starts is a StreamProviderError rather than an APICallError, so classifyAIChatError lost its isRetryable.
* feat(desktop): accept updates only when signed for their version
Tauri CLI 2.12 records the app version in each updater signature, and
updater 2.13 checks it against the version latest.json announces. With
requireSignedVersion it also rejects signatures that carry no version, so a
tampered manifest cannot pair a newer version number with an older, still
validly signed bundle.
Release assembly now fails when a signature does not name the version
being released, instead of shipping one that installed apps would reject.
* docs: note the dependency update's security fixes in the changelog
* feat(ai): recommend the latest models
The provider packages now know Claude Sonnet 5.5 and Opus 5.5 and the GPT-6
series. Make Sonnet 5.5 and GPT-6.1 Sol the defaults, list Opus 5.5, Fable
5.1, GPT-6 Astra and GPT-6 Luna, and replace the two free OpenRouter models
that OpenRouter no longer serves.
* build: align the fig package's valibot with the workspace
* refactor: rename @open-pencil/codegen to @open-pencil/emit
In design tools codegen means design-to-code, which is dom-css's job and
Core's codegen tools'. The package builds and prints the syntax trees
exporters emit, so name it for that. It has not been released yet.
* fix: alias @open-pencil/emit to its source in Vite
The rename missed the escaped regex, so Vite resolved the package through
its built dist output.
0.4.0 maps var() references to @theme variables onto their utilities,
which design-token export will use, and parses CSS values with
postcss-value-parser instead of regular expressions, fixing lossy
shorthand, filter, transform and media-query conversion.
* refactor(codegen): share syntax-tree code generation between exporters
dom-css printed Tailwind JSX with its own esrap JSX builders and kept TypeScript template helpers under its Storybook export. The OpenPencil JSX exporter needs the same JSX builders, and design-jsx and dom-css may not depend on each other.
@open-pencil/codegen holds both: es for ESTree templates and modules (moved from dom-css) and jsx for JSX elements, attributes, text, and printing, including the literal rules that keep exported strings from being reinterpreted. dom-css no longer depends on acorn and esrap directly.
* refactor(design-jsx): print JSX export as syntax trees
sceneNodeToJSX concatenated strings with hand-written escaping and indentation. It now collects typed props (moved to export/props.ts) and prints them with @open-pencil/codegen's JSX builders. Output is unchanged except for text: special characters print as a string expression instead of entities, and multi-line text keeps its line breaks, which the old line-splitting lost on render.
* docs(codegen): fix package metadata and dependency rules
codegen's repository.directory still named design-jsx, the README mentioned es without showing it, and the design-jsx and dom-css guides still said they depend only on scene-graph.
* test(core): move the JSX export round-trips to the design-jsx test home
Covers tabs in layer names and text, which JSX keeps as written.
* refactor(dom-css): group sources into import, export, and runtime
dom-css kept both conversion directions in one flat folder: HTML and CSS
import next to SceneGraph export, a JSX runtime next to the Tailwind JSX
printer, and CSS parsing and formatting in one file.
Sources now live in `import/`, `export/`, and `runtime/`, with the CSS
value helpers split by direction and `export/index.ts` as the `./export`
entry. Tests mirror the new folders. Public entry points are unchanged.
* docs(dom-css): add a package guide for the import, export, and runtime layout
dom-css had no AGENTS.md. The guide records the source layout, the
scene-graph-only dependency, the browser-safe `./export` entry, printed
code generation, and Base64 validation for imported documents, and the
root map links to it.
* 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>
* 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.
* refactor!: move shared primitives below dom-css and core
dom-css depended on core for color conversion, base64 helpers, text
direction, and web-font assets, so core could not use dom-css and every
caller special-cased HTML and Tailwind output.
Color conversion and management, base64 helpers, and text/layout
direction now live in scene-graph under `color`, `bytes`, and
`text-direction`. dom-css takes web-font resolution as an injected
`fonts` option and owns the font face types, so it depends only on
scene-graph and core can depend on it.
BREAKING CHANGE: `@open-pencil/core/color` and `@open-pencil/core/bytes`
are removed, and the direction helpers are no longer exported from
`@open-pencil/core/text`; import them from `@open-pencil/scene-graph`
subpaths. `exportHTMLBundle` takes a font resolver in `fonts` instead of
`'assets'`.
* fix(tools): import color parsing from scene-graph in visual bisect
* fix(mcp): declare the scene-graph dependency
MCP imports `@open-pencil/scene-graph/bytes` since base64 helpers moved
there, but only reached scene-graph through core, so isolated installs
and package checks depended on transitive resolution.
* refactor!: use js-base64 directly instead of a base64 wrapper
Base64 helpers had moved into scene-graph only to sit below dom-css,
but they are a thin wrapper over js-base64 and unrelated to the graph;
fig already called js-base64 directly.
Callers use js-base64 and check `isValid` where input comes from outside
(clipboard, imported HTML, tool arguments, the plugin API). A new
`open-pencil/no-hand-rolled-base64` lint rule rejects atob, btoa, and
Buffer Base64 conversions, and AGENTS.md records the convention.
BREAKING CHANGE: `@open-pencil/core/bytes` is removed; use `js-base64`.
* fix(dom-css): keep images with invalid Base64 inline in HTML export
`exportHTMLBundle` accepts documents parsed from outside HTML, and
js-base64 drops characters it cannot decode, so extracting an invalid
image data URL wrote different bytes. Such images now stay inline.
* refactor!: generate Tailwind JSX through dom-css
Core kept its own SceneGraph → Tailwind mapper next to the one dom-css
uses for Tailwind HTML, and the two drifted: HTML export turned grid
frames into flex columns and dropped rotation, inner shadows, blur, and
flex grow, while Tailwind JSX had them.
Tailwind JSX is now printed by dom-css from the same CSS projection as
HTML export, with esrap building the JSX and string literals carrying
text or attributes that JSX would otherwise reinterpret. The projection
gains grid layout and placement, rotation, every shadow, layer and
background blur, flex grow, right-to-left direction, and sections, and
writes opaque colors as hex so Tailwind can match its palette.
BREAKING CHANGE: `sceneNodeToJSX` and `selectionToJSX` in
`@open-pencil/core` no longer accept a format, and `JSXFormat` and
`JSXExportOptions` are removed. Use `sceneNodesToTailwindJSX` from
`@open-pencil/dom-css` or `@open-pencil/dom-css/browser`.
* fix(dom-css): keep backslashes and line breaks in Tailwind JSX attributes
JSX attribute strings keep backslashes literally, but the printer
escapes backslashes and line breaks in string literals, so a layer
named `a\b` came back as `a\\b`. Such values are now written as
expression containers, like values containing quotes or `&`.
* fix(packages): make packed exports runtime-safe
Make checked-in package manifests truthful for ordinary npm and Bun packing, and verify installed artifacts under both runtimes. Centralize package discovery, artifact inspection, and bounded process execution so CI and release publication share the same contracts.
* ci: build package dependencies before checks
Keep workspace jobs independent of ignored dist output now that public package exports consistently resolve built artifacts.
* ci: preserve source-first engine tests
Keep the broader dependency build for package and repository validation, but retain Core-only setup for engine shards so workspace tests continue exercising source modules.
* ci: build engine shard dependencies
Build the seven workspace packages imported by engine tests in dependency order. This preserves a single module instance per package and keeps each clean CI shard independent of ignored dist output.
* ci: restore established package build boundaries
Keep the original repository and engine job setup, and add installed artifact verification only after the existing package build. Avoid changing which module copies unrelated tests execute.
* fix(packages): preserve Bun source identity in tarballs
Retain source-first workspace resolution and ship complete source trees for Bun conditions. Verify clean installed consumers without overlaying archives, reuse consumer validation before release publication, and repair Bun 1.3.10 private source alias resolution.
* refactor(tooling): reuse package and process utilities
Use pkg-types for manifest I/O and types, tinyexec for subprocess lifecycle, and npm's pack listing for release staging. Preserve archive verification and project release invariants rather than reimplementing package-manager file selection.
* refactor(tooling): resolve workspace roots at CLI boundaries
Discover and validate the nearest workspace once, support explicit roots, and pass roots to reusable checks. Replace subprocess entrypoint dispatch with direct calls and preserve aggregate package diagnostics without adding arbitrary test timeout increases.
* fix(release): enforce publication boundaries
Use root version alignment and shared validated npm output parsing. Enforce the public npm registry policy and test verification-before-publication, mismatched artifacts, and partial retries without registry writes.
* refactor(tooling): validate package responses with Valibot
Express npm pack and manifest identity contracts as schemas, infer parsed output types, and preserve contextual failures and relative-path safety. Document Valibot as the first-party validation convention while retaining Zod at SDK boundaries.
* refactor(tooling): validate manifests at input boundaries
Share Valibot schemas for consumed manifest fields, recursive exports, supported workspace declarations, and npm registry responses. Infer domain types and reject malformed metadata instead of silently skipping it downstream.
* refactor(tooling): group package helpers by ownership
Colocate manifest and workspace contracts, separate npm response parsing from generic JSON handling, and split smoke packing, installation, and runtime checks. Remove the release tarball forwarding shim and consolidate its coverage in package-artifacts. Preserve public tooling exports and CLI commands.