* fix(figma-api): lay out pending edits before geometry reads
Scripts read x, y, width, height, transforms, and bounds as they were before the script ran until the tool finished and laid out its changes. Figma lays out on read, so a hugging parent reports its new size right after a child is added.
The graph now records the scope of edits outside layout application, and the geometry getters lay that scope out first through the same runner tools use after a call. One recorder exists per graph; a new FigmaAPI starts it afresh because the editor lays out its own edits.
* fix(layout): size each axis on its own, as Figma does
Fill is stored on the child, as layoutGrow along the parent's primary axis and STRETCH across it, with a grid laid out like a row; primaryAxisSizing and counterAxisSizing only fix or hug. A shared layoutSizing helper in scene-graph reads and writes per-axis sizing, and layout, the Figma API, the properties panel, design JSX, DOM/CSS export, and .pen import use it.
This fixes grid children filling both axes when set to fill one, auto-layout children that stretch but kept a fixed size, the Figma API writing Fill to a frame's own sizing (which .fig export dropped), and JSX and HTML exports losing grid and cross-axis fill. Behavior was checked against live Figma, and imported layouts were compared with the geometry stored in material3.fig and nuxtui.fig.
* fix(layout): keep pending edits until laid out and opt out of inherited stretch
A new FigmaAPI cleared the edits recorded on its graph, so after a script failed before its tool laid out its changes, the next script read stale geometry; edits now stay recorded until a read lays them out, and return to the record if that layout throws. Setting a child to Fixed or Hug across a parent that stretches every child left it filling; it now opts out with MIN, as frame presets do.
* feat(scene-graph)!: make a stroke a paint
Stroke extends Fill, so a stroke carries the same paint vocabulary a
fill does instead of a lone color. Every construction site now states
a solid paint type, which keeps today's behaviour exactly; rendering,
.fig conversion, and the stroke panel still read solid strokes only.
copyFill is generic over the paint shape so copyStroke reuses it
rather than repeating the deep copy of gradient stops, transforms and
pattern fields.
Groundwork for the gradient and image strokes in #797.
* test(scene-graph): cover a stroke's nested paint data in copyStroke
A stroke is a paint now, so its gradient stops and transform must copy
as deeply as a fill's; the fixture was solid and proved only the color
and dash pattern.
* build(core): import Markdown with unplugin-raw
Core inlined ?raw imports with a hand-written Rolldown plugin that also turned plain .md imports into strings, which nothing in Core uses. unplugin-raw already does this for the Vue SDK and design-jsx; use it here too and drop the unused *.md module declaration.
* refactor(pen): use the scene-graph color parser
pen/src/color.ts duplicated parseColor from @open-pencil/scene-graph/color line for line. Import it instead, which also drops pen's direct culori dependency.
* fix(figma-api): validate effects like Figma
The effects setter stored whatever a script passed, so scripts that Figma rejects ran here and malformed effects reached rendering and .fig export. Validate against Figma's effect shapes with Valibot, recorded from live Figma: strict objects, required shadow fields, radius >= 0, RGBA channels within 0..1, and no shadow fields on blurs. The getter now returns Figma's shape so node.effects = node.effects keeps working.
Closes#786
* fix(figma-api): reject infinite numbers in effects
Figma rejects Infinity in every effect number ("Number must be finite"), but v.number() accepts it, so infinite radii, offsets, and spreads reached the scene graph.
* fix(figma-api): store PASS_THROUGH shadow blend as NORMAL
PASS_THROUGH is a layer blend mode. Live Figma accepts it on drop and inner shadows but reads NORMAL back, so do the same instead of storing 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>
* fix: explain unsupported browsers instead of a blank window
The desktop app on macOS 13 with WebKit older than Safari 17.4 opened an
empty window because startup called Promise.withResolvers, which Vite lowers
nothing for: build.target only rewrites syntax and never polyfills APIs, and
the target itself was an implicit Vite default (#744).
Make the supported baseline explicit in src/app/shell/support/baseline.ts and
feed it to build.target, a lint rule that rejects newer static built-ins in
browser-shipped sources, and the documented system requirements. Replace
Promise.withResolvers with a createDeferred() helper.
Turn src/main.ts into a small gate that checks sentinel features before
dynamically importing the app, so an old engine still evaluates enough code
to render platform-specific update guidance: macOS/Safari via Software
Update, WebKitGTK and WebView2 on Linux and Windows, and each browser's
own update path on the web, with a prefilled bug report link. Render-blocking
errors during the first route are captured through app.config.errorHandler
and shown the same way instead of leaving the window blank.
Desktop facts come from tauri-plugin-os and a webview_version command; the
bundle now declares macOS 13 as its minimum system version.
* build: enforce the browser baseline from compatibility data
Replace the hand-maintained list of built-ins newer than the baseline with
two data-driven checks. The app and browser-shipped packages pin their
TypeScript lib to ES2023, the last edition Chrome 111, Firefox 128 and
Safari 16.4 implement in full, so a newer built-in such as
Promise.withResolvers fails type-checking. Web APIs, which lib.dom does not
version, go through eslint-plugin-compat under oxlint with the same browsers
in settings.browsers, scoped to sources that ship to a browser.
A unit test keeps the oxlint browser list and the tsconfig libs derived from
src/app/shell/support/baseline.ts, so the three cannot drift apart.
* fix: recognise production error codes in the boot observer
Vue passes the error reference URL as the errorHandler info argument in
production builds instead of the development string, so the observer never
classified a setup or render failure as fatal in the shipped app and the
boot-failure notice only appeared on the dev server. Match Vue's exported
ErrorCodes in both forms, and cover the component-setup path in the E2E
spec; the scenario was also verified against a production build.
* 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.
- Move component properties, symbol overrides, derived symbol data, and clone synchronization into @open-pencil/fig\n- Add the public instance-overrides subpath with package tests and packed smoke coverage\n- Remove cross-package forwarding modules and enforce direct owner imports in architecture checks