* perf(canvas): find guide owners from an index instead of walking the page
Drawing and hit-testing guides visited every layer of the page on each frame to find the few that carry guides; on a large page that took about 1.2s of a load's rendering. The graph now keeps the set of layers with guides current from its node events, and both read it.
* perf(layout): write only changed geometry and request one render per layout pass
Layout wrote every laid-out layer's geometry whether or not it changed, so each pass over a large page notified every graph listener for every layer; a page load spent about 1s handling those updates. Layout now writes only the fields that changed, and the updates a layout pass makes ask for a single render, queued for before the next frame, instead of bumping the editor's reactive versions once per layer.
* test(canvas): drop the graph double the guide tests no longer use
* perf(fig): copy an opened file once on the main thread instead of three times
Opening a .fig in the browser copied the file for a main-thread fallback the caller's buffer already served, then copied it twice more for the worker, which parses from one and keeps the other as the original archive. A 109MB file briefly took over 400MB on the main thread. The fallback now reuses the caller's buffer, and the worker receives one copy and makes its archive copy itself.
* docs(changelog): note faster large-page loads
* fix(app): run recovery and autosave after edits, not redraws
Recovery and autosave watched sceneVersion, a render counter that every
requestRender bumps: opening a file, layout passes, fonts, and each layer a
page loads. Safari has no File System Access API, so its opened files have
no writable source and always get recovery snapshots. Loading a page also
invalidated the original archive, so a snapshot encoded the whole document
again; on the Preline UI kit's CMS page that ran about 100 s on the main
thread in WebKit before failing.
Recovery, autosave, and saved versions now follow the content revision of
createDocumentChanges. A page's layers loading from the opened file, which
runs inside applyImportedStateDuring on both the worker and main-thread
paths, no longer counts as an edit for that revision or for the original
archive, and asks for one render rather than one per layer.
In WebKit the CMS page switch drops from 105-133 s to 21 s, and its longest
main-thread stall from 90-117 s to 1.4 s.
* refactor(app): stop storing a version with recovery snapshots
Adopting a snapshot now protects the document's current content revision,
so nothing reads the version a snapshot was taken at. Snapshots saved by
earlier versions carry a sceneVersion field instead; dropping the field
keeps the metadata type true for both. Tests tell snapshots apart by the
bytes they were built with.
* fix(fig): keep the sizes Figma derived for instance layers when a page loads
Loading a page resynced every component its new instances used, which copied component sizes over the sizes Figma derived for instance layers on earlier pages, and over those of the page's own new instances. A text field filling a 312px instance came back at its component's 210px.
The page now syncs only the instances it placed, through a new SceneGraph.syncInstance, and then puts back the sizes Figma derived for them unless that component layer's size was edited live. The gold input now matches the captured Figma Content width (374.86 instead of 633.69), and its placeholder matches the 97.51 x 39.38 Figma derived in the file.
* fix(fig): keep live edits to mapped component layers over derived sizes
restoreDerivedSizes checked only a layer's own component for live edits. A
nested instance clone syncs from the layer its owning instance maps it to
through sourceComponentId, so a live width edit to that layer was carried
over by the sync and then replaced by the size Figma derived. The restore
now reads the same source layers as reconcileLiveComponentEdits, through a
shared syncSourceLayers helper.
* fix(figma-api): size script text to its content, as Figma does
figma.createText() made fixed 100px text at 14px, and the API never re-measured text, so scripts saw sizes Figma does not. New text is now empty 12px WIDTH_AND_HEIGHT text, one line tall; API updates re-measure auto-sizing text through the editor's textAutoResizeChanges; and resize() sets textAutoResize to NONE. Values were recorded in live Figma.
* test(figma-api): hold recorded text sizes to the stated 1 px
* fix(canvas): bound image decoding for large Figma documents
* test(canvas): update lifecycle fixture for image caches
* test(canvas): align image regressions with contribution guides
Use native wheel zoom in the browser regression instead of inline store access. Keep export state restoration in the existing raster suite and explicitly format the new image tests omitted by the root format script.
* fix(harness): pin patched proxy address dependency
Override the transitive proxy-addr dependency with 2.0.8 to fix IPv4-mapped IPv6 trust subnet checks and pass the critical dependency audit (GHSA-jqcg-44mw-7w3h).
* build: drop the proxy-addr override master already resolves
Master's lockfile has proxy-addr 2.0.8 since the advisory was fixed
upstream, so the override this branch added is redundant.
* perf(canvas): encode image previews as WebP
PNG previews of photos ran to several megabytes each, so a page of them
filled the 64 MiB preview cache and evicted itself: page 2 of the #924
reproduction held 48 MB of PNG previews against 7.3 MB as WebP. Browsers
that cannot encode WebP hand back PNG.
Co-authored-by: sableangle <sableangle@gmail.com>
* perf(canvas): decode four image previews at once
The browser decodes previews off the main thread, so one at a time left
a page of photos filling in for seconds: page 2 of the #924 reproduction
took 4.6 s to finish and now takes 2.0 s.
Co-authored-by: sableangle <sableangle@gmail.com>
* refactor(canvas): decide on image previews once per document size
needsImagePreviews replaces useViewportImageRendering, which was not a
Vue composable, names its two thresholds, and keeps its answer per
document and image count instead of summing every image on each frame.
Choosing a preview by zoom carries a lint exception: preview mode draws
the scene uncached.
Co-authored-by: sableangle <sableangle@gmail.com>
* docs: describe the image memory fix in the changelog
Co-authored-by: sableangle <sableangle@gmail.com>
* fix(canvas): let go of a document's image previews when the document changes
Switching to a document that needs no previews left the preview cache
holding the previous graph and its encoded images, often hundreds of
megabytes, until previews were used again. The renderer now releases
them when its document changes, keeping the decoder for the next one.
---------
Co-authored-by: sableangle <sableangle@gmail.com>
* refactor: share behaviour control roles through dom-css
Code export needs the same control model and layer roles preview islands use. They move from packages/vue to packages/dom-css/src/behaviours as behaviourControls and controlRoles, and the layerPath, findLayerByPath and hasBehaviour helpers they need move from Core down to scene-graph, since dom-css cannot depend on Core. Preview behaves the same.
* fix(scene-graph): escape layer path names and stop on parent cycles
A layer named Wifi#1 or Row/1 did not round-trip through its path, so preview skipped that control or reveal target, and a parent cycle in imported data hung the walk. Names escape % / and #, and the walk goes through closest(), which stops on a cycle.
* perf(text): keep glyph coverage when layout resizes a text box
Every layout write invalidated a text node's coverage record by id, and the record compared its width and height, so each measurement shaped the text twice. Coverage now ignores the box size unless the text is truncated, and an update that changes only fields coverage compares keeps the record. A demo load went from about 38,000 coverage checks with 228 hits to one shaping per text node.
* perf(editor): skip component sync for layout's own writes
Layout lays out instances itself, and the edit that made it run has already scheduled its sync. Its size and position writes scheduled about 56,000 more, so each yield of an async build re-synchronised components and re-laid out whole pages.
* fix: keep bound text and visibility in step with their variables
Text bound to a string variable was resolved only when a .fig file was read, and visibility bound to a boolean never, so editing the value or switching a mode left them showing the old one. The binding reconcile, renamed reconcileVariableBindings, now resolves text and visibility with numbers, scoped the same way, and .fig export writes them resolved in each collection's default mode. A bound font family is left as stored, since changing it needs the font loaded.
* fix: keep text and visibility an instance overrides over their bindings
Typing in a bound layer inside an instance or toggling its eye records an override but keeps the binding, so the next value edit or mode switch replaced what was typed or hidden. An overridden text or visibility now keeps its value, as an overridden width or height does.
* feat: show controls in the demo and tidy the component panels
The demo gains a Controls page: a switch, checkbox, slider, tabs, text
field, and a button with interaction states, written as Reka-named design
JSX, and a settings card of their instances to try in preview.
Go to main component and Detach instance move into the instance's panel
header as icon buttons. The Behaviour section's "Still needed" line is a
status rather than a button that only moved focus, and the rows it names
are marked instead.
The DOM projection writes flex-start for auto layout's start alignment:
CSS stretches children by default, so a hugging button in a column filled
its card in preview and HTML export. The paint page snapshot catches up
with the frame names #930 draws.
* perf: open the demo as a prebuilt .fig instead of generating it
/demo built every page in the browser and laid them all out in one task,
which froze the page for about six seconds. The demo's content moves to
tools/generate/demo, which builds public/demo.fig headlessly, before Vite
serves or bundles the app and only when its inputs changed; /demo opens
that file like any other, off the main thread and behind the loader. The
document is now lint-clean: small captions are 12 px, and the badge and
disabled button text meet contrast.
Opening the demo from .fig showed three bugs in saving and reading files,
fixed here:
- A layer's blend mode was never written, so Multiply and Screen reopened
as pass-through.
- Underlines drawn from saved glyphs ran to the text box edge; glyphs now
keep their advance, read from and written to .fig, and the underline
ends where the text does.
- A frame laid out on its own because of a saved hug size ignored the
width its parent stretched it to, so centred content moved to the start.
* feat(design-jsx): write Reka elements in TSX
The Reka element names were only part of the JSX string grammar, so code
that builds trees in TypeScript had to assemble strings by hand, unchecked.
@open-pencil/design-jsx now exports each Reka namespace as typed element
functions (Switch.Root, Slider.Thumb, Tabs.Trigger, …), derived from the
same table the string renderer reads, with props typed as the behaviour
props a root binds. The demo's controls are TSX built with them.
* fix(canvas): draw a component set's editing border at the live zoom
A component set without a stroke got its dashed border in the scene, sized
by dividing by the zoom. The scene is cached as retained pictures and
scaled while navigating, so the border was recorded at one zoom and grew
into thick dashes when zooming in, until the page was redrawn. It is
editor chrome, so it now comes from the overlay pass, which draws every
frame at the live zoom, as slot outlines do. The label cache tracks every
component set on the page for it, and image export no longer includes the
border.
* test: fit the P3 paint page to the canvas size it is captured at
The P3 test zoomed to the paint page while the wide-gamut notice still
took space, then dismissed it, so the first capture fit a shorter canvas
than the second and the two only matched by timing. focusPaintEffects
waits for the canvas to take a window resize, the test focuses again after
the notice goes, and the snapshot shows the demo as it now opens.
* refactor(canvas): fill sections and sets through one helper
Without its own fallback border, a component set fills and strokes its rounded bounds the way a section does; fillRoundedBounds fills them for both.
* test: check the demo controls on their own page and type pixel reads
The demo test built and round-tripped the whole four-page document to
check the controls, which took six seconds on CI and timed out; it now
builds the Controls page alone through the same measured-text setup the
build uses. The underline test reads CanvasKit pixels as any number array,
since readPixels may return floats.
* fix(canvas): measure long text extents without spreading glyphs
Spreading every glyph into Math.min/max overflows the argument limit on long texts.
* fix(demo): keep a tab touched while the demo loads and report a failed load
A file opened or a layer drawn during the fetch keeps the tab; a failed fetch shows the open-file error instead of an unhandled rejection.
* test: check a component set border stays one pixel wide when zoomed
* fix(fig): keep filled frames fixed along the axis they fill
Figma stores fill on the child and keeps that axis fixed in the frame's
own sizing; a hugging value there wins over the stretch or grow. A row
filled across a vertical card, or an instance stretched to the card's
width, was written with its own sizing still hugging, so in Figma the
row shrank and pushed its badge against the title, and the button
shrank to its label.
The .fig and clipboard writer now writes a filled axis as FIXED, both on
the frame's own record and in an instance's sizing override, which Figma
applies over the record. Unedited imported layers keep their stored
sizing, since Figma's own files also hold hugging axes on stretched
children.
* fix(clipboard): keep how text resizes when copying to Figma
The Figma clipboard writer forced every text record to a fixed size and
layout version 5. Figma then kept the width OpenPencil measured instead
of laying the text out in its own font, so labels it draws wider
overflowed or wrapped: a pricing card's title ran under its badge and
its button label broke onto two lines. Text now keeps the auto-resize
and layout version the .fig writer gives it, as Figma's own files store
them, and a paste into Figma 126 grows each label to fit.
Follow-ups from review of #940. A document handed to the editor with a theme keeps its first page's stored background; only a page without one takes the theme default. Canvas layers are numbered from the page they go on rather than the page on screen. Script strokes are copied with copyStrokes so the node owns its nested paint values. CanvasHelper switches only a fresh document (one empty page on the exact dark default) to the light page, and the round-trip test imports SceneGraph from its own package.
* test: resolve repository files without climbing directories
Twelve tests and helpers reached shared fixtures, package assets, and workers with ../.. paths from import.meta, which the import rule does not see. They now go through repoPath and testPath, a workspaceRoot() that finds the root by its lockfile, the core package's own root, or the #core alias through import.meta.resolve. The root finder derives its folder from import.meta.url, so Playwright specs running under Node can use the helpers too. open-pencil/no-deep-parent-relative-paths rejects climbing two levels in new URL(…, import.meta.url) and in path calls that start from import.meta.
* fix(lint): catch Windows separators and wrapped import.meta paths, and stop at template expressions
The path rule missed '..\..' and a base such as dirname(fileURLToPath(import.meta.url)), and read `../${folder}` followed by '..' as climbing two levels.
* 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.
* perf: resolve only the layers a variable change can reach
Every value edit, mode change, and binding change re-resolved every bound layer in the document. A value or mode change now reaches the layers bound to its variables or to variables aliasing them, and a binding or variable-mode change reaches the layer's own subtree, so layers elsewhere whose saved values differ from their bindings stay as saved and are not laid out again.
* perf: keep the canvas as drawn when a variable change shows nothing on it
Every variable change bumped sceneVersion, which also keys the canvas's recorded pictures, so renaming or reordering a variable re-recorded every visible layer. The canvas now redraws on its own canvasVersion, which requestRender bumps; requestRefresh bumps only sceneVersion, so views, saving, and recovery still follow. Adding, renaming, reordering, and duplicating variables and collections, renaming modes, and token fields, conditions, and the switch attribute use it.
* test: give the text undo editor double requestRefresh
The double builds EditorContext by hand and missed the new requestRefresh and canvasVersion, which failed check:test-types in the merge queue.
* fix(core): start new layers with Figma's defaults in the editor and plugin API
The plugin API created bare nodes: frames, components, and shapes without fills, and lines and vectors without strokes, so scripts written for Figma drew nothing. Drawn lines also had a black fill instead of a stroke and were invisible. Both paths now share newLayerDefaults, recorded from Figma desktop 126: frames and components white with frames clipping their content, shapes #D9D9D9, lines and vectors a black 1 px stroke, text black. A stroke a script adds gets the 1 px default weight, and an empty vector has no render bounds.
* fix(core): combine variants as Figma does from the canvas and from scripts
The plugin API and the editor command each built component sets their own way, both with 40 px of padding and a grey fill. Figma's command pads the variants by 20 and outlines the set with a 1 px dashed #8A38F5 stroke; its plugin API wraps them exactly with no fill or stroke. One variantSetProps now places and styles the set for both, with a canvas or script style, and applyVariantProperties derives variant properties for both.
* fix(core): report group children in their container's space in the plugin API
Figma's plugin API places children of groups and booleans relative to the nearest real container and refits a group whenever a script changes one of its children. Ours reported group-relative positions and never refit, so scripts placing layers inside groups landed them in the wrong place. x, y, and relativeTransform now map through the groups around a node, and geometry changes, appendChild, insertChild, and remove refit the surrounding groups. The refit moves to Scene Graph as fitEnclosingGroups, shared by the canvas (with undo) and the plugin API.
* test(core): pass script-style strokes and typed components in parity tests
* test(e2e): expect Figma's default shape grey in the scene freshness spec
* fix(vue): draw lines by length and angle as Figma does
The Line tool sized a line as the box spanned by the drag. With the stroke a new line now gets, that box drew as a rectangle outline. A line now starts at the press point with the drag length as its width, no height, and the drag angle as its rotation, as Figma's Line tool makes it; Shift snaps the angle to 45° steps, as the docs already described, and a click makes a 100 px horizontal line.
* fix(core): give each new layer its own copy of the default paints
The defaults spread each paint shallowly, so every layer shared the colour object of the module-level default and editing one layer's colour in place changed the next new layer. Copy the paints with the Scene Graph copy helpers.
* fix(core): group, ungroup, and combine layers through shared code in the plugin API
The plugin API wrapped layers, ungrouped, made booleans, and made components from layers with its own code. Ungroup moved the children to the top of the stack, booleans were named "Boolean union", and a component made from a frame cloned its children under new ids. These now run through the editor's shared wrap, ungroup, and boolean functions, with the placement and defaults recorded in Figma desktop 126: a group or boolean without an index goes on top, ungrouped children take the group's place, booleans are named after the operation and filled with the default grey, a frame becomes a component in its place with its children, and any other layer is wrapped in a white component named after it. Undoing a wrap in the editor now returns each layer to its own place in the stack.
* fix(core): group, frame, combine, and make components from the canvas as Figma does
Recorded in Figma desktop 126: a container made from the canvas takes the topmost selected layer's place, Frame selection adds no fill and does not clip, a component wrapped around layers is white and takes a single layer's name, and a boolean is filled like its topmost operand, or its base for Subtract, without strokes. The canvas commands and the plugin API now share the wrap parent check, stack ordering, component rules, and boolean paints, and the plugin API's createComponentFromNode converts groups in place as Figma does. Undoing a boolean returns each operand to its own place in the stack.
* refactor(core): reuse translate when centering pasted layers
* fix: match Figma's transforms, strokes, booleans, sections, and names
Each behaviour was recorded in Figma desktop 126 with the same script, or
from its canvas, and both the editor and the plugin API now share it.
- Scripts turn a layer counterclockwise about its top-left corner, read x
and y as that corner, keep it in place on resize, can set
relativeTransform, and get absoluteBoundingBox around the turned layer.
appendChild and insertChild keep x, y, and rotation in the new parent
instead of the canvas position, which the create and reparent tools
inherit.
- A layer keeps its stroke weight and alignment without strokes, in the
model and through .fig export and import. strokeWeight and strokeAlign
apply to every stroke, and a stroke added from the panel or a script
takes the layer's.
- Booleans size to their result when a renderer can measure it, from the
canvas, from scripts, and when an operand moves.
- New sections take Figma's fill for the interface theme, a faint white
stroke, and 2 px corners; sections and component sets draw with their
own radius.
- Layers drawn on the canvas and the containers it wraps layers in are
numbered past the highest number on the page; scripts keep plain names.
* test(core): dispose the editor in the canvas boolean bounds test
Attaching CanvasKit installs a global text measurer, and the test left it installed, so later text measurement tests in the same process found it.
* fix(core): undo a boolean's group refit and clamp dashed set corners
Sizing a boolean to its result also refits the groups around it, and undo left them refitted, so the operands came back displaced. createBooleanOperation returns the fit, and undo reverses it first. A component set's dashed outline now clamps its radius to its bounds as the fill does, and a script's resize refits the enclosing groups once, after the corner is restored.
* fix: show and edit X, Y, and rotation as Figma's properties panel does
Recorded in Figma desktop 126: the panel's X and Y are the top-left of a turned layer's box on the canvas, measured from its frame or page through any groups, rotation is counterclockwise with a flip reading as 180, a typed rotation turns the layer about its center, and a typed X or Y moves its box. The panel showed our clockwise angle and the unturned box. The conversions live in @open-pencil/core/geometry beside figmaRotation, which the plugin API now shares, and the position controls preview a change worked out for each selected node.
* fix: start new pages on Figma's background for the interface theme
Recorded in Figma desktop 126: a page made in the dark theme is #1E1E1E and in the light theme #F5F5F5, from the canvas and from scripts. New documents, pages the editor adds, and figma.createPage now store that background; existing pages keep theirs. The unused OS-scheme helper getDefaultCanvasBgColor goes. Playwright runs the app in its dark theme, so CanvasHelper keeps fresh documents on the light page the canvas snapshots were drawn on, and a dedicated spec covers both themes.
* test(e2e): switch the theme through the app module in the page background spec
Writing localStorage directly breaks the no-direct-storage-access rule. The spec now sets the light theme the way the chat harness does, reloads, and checks the theme took effect before reading the page color.
* fix(core): start new layers with Figma's defaults in the editor and plugin API
The plugin API created bare nodes: frames, components, and shapes without fills, and lines and vectors without strokes, so scripts written for Figma drew nothing. Drawn lines also had a black fill instead of a stroke and were invisible. Both paths now share newLayerDefaults, recorded from Figma desktop 126: frames and components white with frames clipping their content, shapes #D9D9D9, lines and vectors a black 1 px stroke, text black. A stroke a script adds gets the 1 px default weight, and an empty vector has no render bounds.
* fix(core): combine variants as Figma does from the canvas and from scripts
The plugin API and the editor command each built component sets their own way, both with 40 px of padding and a grey fill. Figma's command pads the variants by 20 and outlines the set with a 1 px dashed #8A38F5 stroke; its plugin API wraps them exactly with no fill or stroke. One variantSetProps now places and styles the set for both, with a canvas or script style, and applyVariantProperties derives variant properties for both.
* fix(core): report group children in their container's space in the plugin API
Figma's plugin API places children of groups and booleans relative to the nearest real container and refits a group whenever a script changes one of its children. Ours reported group-relative positions and never refit, so scripts placing layers inside groups landed them in the wrong place. x, y, and relativeTransform now map through the groups around a node, and geometry changes, appendChild, insertChild, and remove refit the surrounding groups. The refit moves to Scene Graph as fitEnclosingGroups, shared by the canvas (with undo) and the plugin API.
* test(core): pass script-style strokes and typed components in parity tests
* test(e2e): expect Figma's default shape grey in the scene freshness spec
* fix(vue): draw lines by length and angle as Figma does
The Line tool sized a line as the box spanned by the drag. With the stroke a new line now gets, that box drew as a rectangle outline. A line now starts at the press point with the drag length as its width, no height, and the drag angle as its rotation, as Figma's Line tool makes it; Shift snaps the angle to 45° steps, as the docs already described, and a click makes a 100 px horizontal line.
* fix(core): give each new layer its own copy of the default paints
The defaults spread each paint shallowly, so every layer shared the colour object of the module-level default and editing one layer's colour in place changed the next new layer. Copy the paints with the Scene Graph copy helpers.
* fix(core): group, ungroup, and combine layers through shared code in the plugin API
The plugin API wrapped layers, ungrouped, made booleans, and made components from layers with its own code. Ungroup moved the children to the top of the stack, booleans were named "Boolean union", and a component made from a frame cloned its children under new ids. These now run through the editor's shared wrap, ungroup, and boolean functions, with the placement and defaults recorded in Figma desktop 126: a group or boolean without an index goes on top, ungrouped children take the group's place, booleans are named after the operation and filled with the default grey, a frame becomes a component in its place with its children, and any other layer is wrapped in a white component named after it. Undoing a wrap in the editor now returns each layer to its own place in the stack.
* fix(core): group, frame, combine, and make components from the canvas as Figma does
Recorded in Figma desktop 126: a container made from the canvas takes the topmost selected layer's place, Frame selection adds no fill and does not clip, a component wrapped around layers is white and takes a single layer's name, and a boolean is filled like its topmost operand, or its base for Subtract, without strokes. The canvas commands and the plugin API now share the wrap parent check, stack ordering, component rules, and boolean paints, and the plugin API's createComponentFromNode converts groups in place as Figma does. Undoing a boolean returns each operand to its own place in the stack.
* refactor(core): reuse translate when centering pasted layers
* fix: match Figma's transforms, strokes, booleans, sections, and names
Each behaviour was recorded in Figma desktop 126 with the same script, or
from its canvas, and both the editor and the plugin API now share it.
- Scripts turn a layer counterclockwise about its top-left corner, read x
and y as that corner, keep it in place on resize, can set
relativeTransform, and get absoluteBoundingBox around the turned layer.
appendChild and insertChild keep x, y, and rotation in the new parent
instead of the canvas position, which the create and reparent tools
inherit.
- A layer keeps its stroke weight and alignment without strokes, in the
model and through .fig export and import. strokeWeight and strokeAlign
apply to every stroke, and a stroke added from the panel or a script
takes the layer's.
- Booleans size to their result when a renderer can measure it, from the
canvas, from scripts, and when an operand moves.
- New sections take Figma's fill for the interface theme, a faint white
stroke, and 2 px corners; sections and component sets draw with their
own radius.
- Layers drawn on the canvas and the containers it wraps layers in are
numbered past the highest number on the page; scripts keep plain names.
* test(core): dispose the editor in the canvas boolean bounds test
Attaching CanvasKit installs a global text measurer, and the test left it installed, so later text measurement tests in the same process found it.
* fix(core): undo a boolean's group refit and clamp dashed set corners
Sizing a boolean to its result also refits the groups around it, and undo left them refitted, so the operands came back displaced. createBooleanOperation returns the fit, and undo reverses it first. A component set's dashed outline now clamps its radius to its bounds as the fill does, and a script's resize refits the enclosing groups once, after the corner is restored.
The behaviour tests used property IDs that may be null as references and read a parsed .fig buffer and the node iterable as arrays, so bun run check failed on master. CI does not run the test typecheck.
* feat: author behaviours on main components
A main component or component set can behave as a Switch, Checkbox,
Slider, or Tabs, after Reka UI's primitives. The behaviour lives in
OpenPencil plugin data: boolean values bind to variant or boolean
properties with the values meaning on and off, a number keeps its own
range since Figma has no number property, and the control's
subcomponents bind to the component's slots. A Behaviour section in the
properties panel adds, binds, and removes it, each as one undo step,
and flags required bindings that are missing. The canvas-only layout's
pill becomes a component that preview will reuse.
* feat: preview instances with behaviours on the canvas
View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas
switches to the canvas-only layout with a Previewing pill, and a split
canvas previews on its own side. Clicking a Switch or Checkbox flips it,
dragging a Slider moves its thumb and range, and clicking a Tabs trigger
shows its panel. Preview keeps its state on copies of the instances it
touched, in a private graph with the document's ids, and the canvas
draws those copies in place of the originals, so the document, undo,
autosave, and collaborators never see it. Escape or the pill leaves
preview, Reset restores every control, and editing shortcuts, labels,
and outlines stay off while previewing.
* feat: translate behaviour and preview strings; cover preview with an e2e flow
* refactor(vue): reuse VariantDefinitionControl for behaviour property options
* refactor: split variant actions and preview interactions by domain
Variant authoring was one 706-line closure; it is now graph queries
(model), undo snapshots (history), property definition edits
(definitions), and the editor facade (index). Preview interactions move
into play/kinds, one module per control, registered by behaviour kind so
a new kind cannot ship without its contract and interaction. Behaviour
contracts are keyed by kind. In the Vue SDK, slot and variant authoring
controls get their own folders beside component-props and behaviour,
and the app's variant section joins slot/ and behaviour/.
* refactor: keep the behaviour model in scene-graph's plugin-data registry
Master now defines every OpenPencil plugin-data key in one typed registry
in scene-graph. The behaviour schema registers there as a field, and the
model and contracts move beside slots, exported from the package root;
the @open-pencil/core/behaviours subpath is gone.
* feat: interaction states and keyboard focus in preview
A behaviour can bind a variant property to the default, hover, pressed,
focus, and disabled states; binding it maps values named like those
states. Preview switches the instance's copy to the matching variant as
the pointer hovers, presses, and releases, keeps other values when the
set draws the combination and falls back to rest otherwise, and skips
disabled instances. Tab moves visible keyboard focus between controls,
Space, Enter, arrows, Home, and End use the focused one, and Escape
takes visible focus off before leaving preview. A Button kind covers
controls that only have states.
* feat: toggle, radio, group, progress, collapsible, and accordion behaviours
Radio group, toggle group, and accordion hold their items in a slot;
each item is an instance with its own behaviour, so a press inside the
slot goes to the group, which turns the pressed item on and the others
off through the item's own interaction. Progress shares the slider's
number handling through rangeControl, and a collapsible shows and hides
its content slot from its trigger, remembering its open state even
when no property draws it. Tabs and groups share arrow-key navigation.
* feat: text field, textarea, and number field behaviours
A behaviour value can now be text, bound to a text property, so
preview types into a copy of the field through the same property path
the editor uses. A bound Filled value switches to the placeholder
variant when the field empties. A number field keeps its own range,
shows its value through a text property, and steps from its increment
and decrement slots and the arrow keys. Text fields show focus from a
click, and the focused control receives every key; Option still types,
and only Cmd or Ctrl combinations stay shortcuts.
* fix: keep behaviour bindings when saving as .fig
Saving as .fig gives component properties new GUIDs, but behaviours
kept the old ids in their plugin data, so every binding read as missing
after reopening. The export now renames the ids behaviours bind with
the same GUIDs, on its own copy of the document.
* fix: let previewed controls resize layout imported from .fig
Layers from a .fig keep the sizes Figma computed, and auto layout
prefers them, so an opened collapsible or accordion item kept its
closed height in preview. When preview shows, hides, or retypes a
layer in a copy, it drops those sizes from the layer's copied ancestors
so auto layout sizes them again; untouched layers keep Figma's sizes.
* fix: publish behaviours and other plugin content with library assets
Every OpenPencil plugin-data field now declares its role: content that
exists only as plugin data (behaviours, OkHCL picks), format copies of
node fields written for files, or bookkeeping about where a document
or node came from. Library snapshots keep a node's content plugin data,
including other plugins' entries, and drop the rest; the asset hash
counts the same entries, so a behaviour-only change is offered as an
update while a .fig round trip still changes nothing.
* feat: name behaviour rows by meaning and create what they need
The Behaviour section named every main value "Value" under a "Values"
heading, and a component without matching properties left an empty
picker with no way forward. Rows are now named for the control (On,
Checked, Pressed, Text), rows the control needs or already uses come
first, and the optional rest folds under More options; a button keeps
its states in view. An empty row creates what it needs in one undo
step: a text layer and text property, Off and On variants on a set, or
a slot frame for a part. The missing chip names the row it means and
takes you there.
* fix(dom-css): position free layers, hug content, and round ellipses
HTML and Tailwind export stacked the layers of frames without auto
layout in block flow, wrote fixed pixel sizes for auto layout frames
set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers
a parent does not lay out are now absolutely positioned at their
coordinates inside a relative frame, hugging axes are left to the
content, and ellipses get a 50% radius.
* feat: run preview as live Reka UI islands over the canvas
Preview simulated controls on the canvas: copies of instances, a
handler per kind, its own key routing, and append-only text. It now
runs them as real components. Each top-level layer that holds an
instance with a behaviour becomes an island: its layers are projected
to DOM through dom-css into a shadow root laid over the pane at its pan
and zoom, and each behaviour mounts its Reka UI primitives on its
layers, so text fields are real inputs and focus, keys, and layout are
the browser's. Core's resolvePlayState shows instances in a state on a
private graph, so the component's variants draw it, and controls are
keyed by layer path so a variant switch keeps their DOM. The canvas
leaves island layers to the islands, and the canvas play runtime and
its key routing are gone.
* fix: derive variant properties from Property=Value component names
figma.combineAsVariants and Combine as variants only derived variant
properties from slash-separated names, so components named as Figma
names variants, such as State=On, Size=Large, became a set with no
properties. Both now derive each named property and its values, after
the slash form.
* feat: script and tool access to behaviours by name
Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers
in the list slot and their content panels in a panels slot, and a slot
of repeated parts names the Reka part of its children. A behaviour
spec names component properties and slots instead of ids and resolves
to the stored behaviour and back, with errors that list what the
component has.
Scripts get an `openpencil` global next to `figma`, in the Figma API's
style: setBehaviour, getBehaviour with bindValue, bindPart, states,
and missing, behaviourKinds, and createSlot. The eval tool, the CLI,
and app automation compile scripts through one compileScript, so the
CLI now returns the last expression as the others do. MCP and AI chat
get set_behaviour, get_behaviour, and create_slot.
* feat: write controls in design JSX with Reka UI's element names
`<Switch.Root modelValue="State">` renders a main component, or a set
when its children are variants, that behaves as a switch, and
`<Switch.Thumb>` the slot that draws its thumb, one slot across the
set's variants. Inputs become the text property of a field, tab
triggers and panels go in their List and Panels slots, and a group's
items are `<RadioGroup.Item of={…} />` instances in its Items slot.
JSX export writes components with behaviours the same way, so they
render back unchanged. The authoring reference documents controls, and
the codegen and chat prompts now include it verbatim instead of
dedenting its code examples.
* chore: format the CLI export test
* docs: document slots, behaviours, preview, and the openpencil API
The components guide covers slots, behaviours, and preview with its
shortcut; scripting covers the openpencil global and eval's last-
expression result; the MCP and AI chat pages list the new tools; the
features overview, README, and roadmap mention working controls. The
chat prompt says how to build a control, and the codegen prompt builds
components with behaviours on their Reka UI primitives.
* chore: format the eval CLI test
* docs: explain behaviours and preview islands, and guide the openpencil API
A development page explains the behaviour model, the four authoring
surfaces, how preview islands turn a control's state into live Reka UI
components, and how to add a kind; the architecture page links it. The
Core guide sets the rules for OpenPencilAPI: Figma-only `figma`,
OpenPencil features on `openpencil` in the same style, one
compileScript, names over ids, and docs with every member. Package
READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and
the behaviour model. Design JSX's behaviour modules move into a
behaviours folder instead of a suffixed sibling.
* refactor: center pasted layers through translate
centerNodesAt repeated translate's loop, which test:dupes reports on
master too.
* fix: validate behaviour ranges and guess on and off by name
A number value now needs max above min and a positive step: the schema,
specs, and the panel reject a range a slider cannot step through. Binding
a variant property guesses on and off by value name, as specs do, and a
boolean property gets no on/off pair. Part bindings are read through
partBinding, a replaced document restarts preview from its designed
state, and the e2e preview shortcut uses ControlOrMeta.
* feat: make the Behaviour section say what to do next
A slider's range fields now carry inline Min, Max, Step, and Start labels.
States offers Add state variants, which adds a Default, Hover, Pressed,
Focus, and Disabled variant and binds them; Add Off and On variants and
Add state variants turn a lone main component into a component set first,
and a part's slot can be added to a set, in every variant under one slot
id. Rows that could do nothing are gone: no empty pickers and no hints to
combine variants by hand, and an unbound Disabled is left to the states.
A warning line names what is still needed and replaces the missing chip,
and the Switch's main value is called Checked.
* fix: keep each slot to one part and keep creating slots at hand
A slot draws one part, so the Behaviour section no longer offers a slot
another part uses, and specs (the openpencil API, tools, and JSX) reject
binding one slot to two parts. A part's picker keeps an action to add a
new slot in its footer, so adding the first slot no longer hides it for
the other parts.
* feat: let a collection name the attribute its modes switch by
Manually switched modes were always selected by data-<collection name>, so a collection called New wrote data-new and a codebase already using data-color-scheme could not be matched. A collection can now name its attribute in the inspector, validated so a stylesheet can select it as is. The stylesheet and exported code use it, setting it is undoable, and .fig files keep it in the collection's plugin data.
* fix: show the switch attribute only where there are modes to switch
* fix: return to the list after committing the switch attribute
* fix: keep a collection's switch attribute when a new one is refused
setModeAttribute cleared the attribute when given a name a stylesheet cannot select on; it now leaves the current one, and only an empty name restores the default. In the inspector, Enter on a refused name keeps the focus so it can be corrected.
* docs: describe the switch attribute in the mode conditions
The Switched manually row still said the attribute is named after the collection. The changelog had collected four copies of the variables dialog entry through rebases; one remains, with the switch attribute in it.
* 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.
* fix(core): start new layers with Figma's defaults in the editor and plugin API
The plugin API created bare nodes: frames, components, and shapes without fills, and lines and vectors without strokes, so scripts written for Figma drew nothing. Drawn lines also had a black fill instead of a stroke and were invisible. Both paths now share newLayerDefaults, recorded from Figma desktop 126: frames and components white with frames clipping their content, shapes #D9D9D9, lines and vectors a black 1 px stroke, text black. A stroke a script adds gets the 1 px default weight, and an empty vector has no render bounds.
* fix(core): combine variants as Figma does from the canvas and from scripts
The plugin API and the editor command each built component sets their own way, both with 40 px of padding and a grey fill. Figma's command pads the variants by 20 and outlines the set with a 1 px dashed #8A38F5 stroke; its plugin API wraps them exactly with no fill or stroke. One variantSetProps now places and styles the set for both, with a canvas or script style, and applyVariantProperties derives variant properties for both.
* fix(core): report group children in their container's space in the plugin API
Figma's plugin API places children of groups and booleans relative to the nearest real container and refits a group whenever a script changes one of its children. Ours reported group-relative positions and never refit, so scripts placing layers inside groups landed them in the wrong place. x, y, and relativeTransform now map through the groups around a node, and geometry changes, appendChild, insertChild, and remove refit the surrounding groups. The refit moves to Scene Graph as fitEnclosingGroups, shared by the canvas (with undo) and the plugin API.
* test(core): pass script-style strokes and typed components in parity tests
* test(e2e): expect Figma's default shape grey in the scene freshness spec
* fix(vue): draw lines by length and angle as Figma does
The Line tool sized a line as the box spanned by the drag. With the stroke a new line now gets, that box drew as a rectangle outline. A line now starts at the press point with the drag length as its width, no height, and the drag angle as its rotation, as Figma's Line tool makes it; Shift snaps the angle to 45° steps, as the docs already described, and a click makes a 100 px horizontal line.
* fix(core): give each new layer its own copy of the default paints
The defaults spread each paint shallowly, so every layer shared the colour object of the module-level default and editing one layer's colour in place changed the next new layer. Copy the paints with the Scene Graph copy helpers.
* fix(core): group, ungroup, and combine layers through shared code in the plugin API
The plugin API wrapped layers, ungrouped, made booleans, and made components from layers with its own code. Ungroup moved the children to the top of the stack, booleans were named "Boolean union", and a component made from a frame cloned its children under new ids. These now run through the editor's shared wrap, ungroup, and boolean functions, with the placement and defaults recorded in Figma desktop 126: a group or boolean without an index goes on top, ungrouped children take the group's place, booleans are named after the operation and filled with the default grey, a frame becomes a component in its place with its children, and any other layer is wrapped in a white component named after it. Undoing a wrap in the editor now returns each layer to its own place in the stack.
* fix(core): group, frame, combine, and make components from the canvas as Figma does
Recorded in Figma desktop 126: a container made from the canvas takes the topmost selected layer's place, Frame selection adds no fill and does not clip, a component wrapped around layers is white and takes a single layer's name, and a boolean is filled like its topmost operand, or its base for Subtract, without strokes. The canvas commands and the plugin API now share the wrap parent check, stack ordering, component rules, and boolean paints, and the plugin API's createComponentFromNode converts groups in place as Figma does. Undoing a boolean returns each operand to its own place in the stack.
* refactor(core): reuse translate when centering pasted layers
* fix(fig): write text glyphs from the layout the renderer draws
Text without saved glyphs was written to .fig with outlines from a
character-by-character fallback: one unwrapped line at y = lineHeight,
no alignment, advances in pixels. Figma lays saved text out from that
data, so every wrapped OpenPencil label opened in Figma on one line, and
since saved glyphs now draw before the paragraph, OpenPencil did the same
after a reopen (#914). The Figma clipboard had a second writer with its
own shaping that matched outlines to characters by index.
One builder in @open-pencil/fig now writes derivedTextData for both. It
keeps glyphs a layer already has and otherwise asks the export runtime to
shape the text. Core shapes with the paragraph the renderer draws, so
lines, alignment, and baselines match, and takes each outline from the
glyph ID CanvasKit chose, so ligatures and contextual forms keep their
shapes. CanvasKit does not say which font drew a run, so outlines are
written only when the run's own font covers it; otherwise the layout is
written without outlines and readers lay the text out themselves.
Advances are in em units and the character offset map has one entry per
character, as in Figma's files.
The reader drops glyphs the earlier fallback wrote, recognised by its
offset map one entry longer than the text with every glyph on the one
written baseline, and any glyph set with a missing outline.
* fix(scene-graph): reflow resized text instead of stretching its glyphs
Resizing scaled a text layer's saved glyphs into the new box. That suits
path text, whose glyphs follow its path, but flat text reflows, and with
saved glyphs drawn before the paragraph a resized Figma text layer was
drawn stretched. Scale glyphs only for path text, including baked path
text that kept only rotated glyphs, and let the width change drop the
rest.
* docs(fig): describe how derived text is written
Figma draws saved text from derivedTextData even when it has the font, so the export docs record which glyphs the shared writer keeps, shapes, or leaves without outlines, and the units it writes. The README names the runtime service by what it now does.
* fix(fig): write text without glyphs when shaping fails
Glyphs are derived data, so a shaper error must not fail the .fig save or Figma clipboard copy that writes them. The builder now writes the layer without glyphs and logs a warning; the clipboard used to swallow the error silently, which hid a font-provider mismatch.
The Kiwi codec types only symbolID, so every test reading symbolOverrides
or uniformScaleFactor repeated the same cast against a type the package
exported without the readers that go with it. symbolDataOf and
symbolOverridesOf are now exported and five call sites use them.
tests/engine/library exercises @/app/libraries, so it belongs under
tests/app by the placement rules; the shard list and the engine baseline
follow it.
Both factories know what they built, but returned the bare proxy, so a
caller reading componentProperties, setProperties or isExposedInstance
had to narrow first — the instance surface is only spelled out on the
node types. Two test suites had each grown their own cast for it.
FigmaInstanceNode joins the other node types and is exported, and the
compatibility check names it instead of respelling the intersection.
Narrowing a return type is not a breaking change: a caller that held
the result as a FigmaNodeProxy still compiles.
* fix(vue): release CanvasKit WebGL contexts when canvases go away
GetWebGLContext registers a canvas's context in CanvasKit's global
table, and the surface manager only called deleteContext on a failed
setup. Every destroyed canvas, and every surface rebuilt after a resize
or color-space change, stayed registered, and through the canvas
element CanvasKit kept the closed editor's component tree, store, and
graph alive.
The manager now keeps the handle and releases it on rebuild and
destroy. deleteContext also leaves CanvasKit holding the last current
context, so when the released context was current, a parking context
on a 1x1 offscreen canvas becomes current instead.
* fix(core): uninstall an editor's text measurer when it closes
setCanvasKit installed a global text measurer that closed over the
editor and its renderer, and nothing uninstalled it, so the last editor
to set up a canvas stayed alive after its tab closed and layout kept
measuring with its destroyed renderer. installTextMeasurer returns an
uninstall function; an editor uninstalls its measurer when disposed or
when its last renderer goes, and the most recent measurer still
installed takes over.
* fix(app): give editor stores their own effect scope
The first store is created during WorkspaceView's setup, so effects
created while building it joined the view's scope. Their cleanups
stayed registered there after the store was disposed and kept the
startup document alive for the life of the app. Stores now build inside
a detached effect scope that dispose stops.
* fix(app): follow the active tab in app-level editor subscriptions
App.vue provided a proxy that resolved to whichever store was active
when a property was read, so app-level composables such as the menu
and keyboard commands subscribed once to the startup store. They missed
events from later documents and kept that store alive, and Undo and
Redo availability came from the first document's history.
The app-level editor now moves event subscriptions to the active store,
and each tab's editor UI gets its own store through EditorTabScope, so
per-tab components stay bound to their document when it closes.
Selection capabilities read the undo history lazily instead of
capturing the first store's manager.
* fix(app): stop keeping closed documents in chat history and startup
The chat history kept the last editor it served only to compare it with
the next one; it now holds it weakly. WorkspaceView's first tab was a
top-level setup binding, which Vue keeps on the instance; it is now
block-scoped.
* test(app): check that documents closed in tabs are released
Opens a document in a new tab three times, closes each with discard,
forces garbage collection, and checks that weak references to the
closed graphs clear. On master all three graphs stay alive.
* refactor(app): provide the tab's store from a tab-keyed editor view
EditorTabScope existed only to provide a tab's store to its editor UI.
WorkspaceView now keys EditorWorkspace by tab, whose contents were
already remounted per tab, so the view provides the tab's store in its
own setup and the wrapper component goes away.
* fix(app): stop a store's effect scope when building the store throws
Effects created before the throw would otherwise stay alive with the partial store, which no caller can dispose.
* test(app): avoid empty callbacks in the store scope tests
* test: run each heavy unit test file in its own process
`test:unit` and `test:unit:heavy` ran every file in one `bun test`
process. A heavy fixture file alone takes 2-6 GB, and what one file
keeps stays alive for the rest of the run. Quick files still share one
process; heavy files now run one per process, so the peak is the
heaviest single file. Coverage runs keep one process, since coverage is
collected per process.
stale-overrides parses material3.fig and joins the heavy list, and a
guard fails when a quick test reads a large fixture corpus.
* test(core): keep one material3 graph alive at a time in stale-overrides
The edited-document test held the exported reader graph, the reopened
graph, and the parsed node changes at once, and the first test's graph
was still alive when the second started. Each step now runs in its own
function and returns only what the next check needs, and the file
collects garbage between steps and tests. The checks are unchanged;
peak memory drops from 5.6 GB to 3.6 GB.
* feat: name the call and list every problem in validation errors
Tool arguments were checked with v.parse, whose error names only the first problem and neither the tool nor the argument, so a model that sent a wrong create_shape call read 'Expected string but received 42'. Tool arguments in Core, which AI chat, MCP, the CLI, and WebMCP all reach, the automation bridge's file commands, design JSX component properties, and gradient stops now throw a heading that names the call followed by v.summarize, which lists each issue with its path. parseToolArgs is exported for the app's own parses of tool arguments.
* test(design-jsx): pass a deliberately wrong property value through the types
The test checks what a script sees for a non-string instance property, which the Instance types reject at compile time.
* docs: say that MCP clients get the MCP SDK's validation report
The MCP SDK validates tool arguments against the registered schema before OpenPencil's handler runs, so an MCP client already received every problem with its path and does not see parseToolArgs' message.
* fix(core): let fill text share an auto-layout row
Fill frames grow and shrink from a zero flex basis, so fill siblings split a row's free space. Fill text only got flexGrow, and its measure function capped it at its stored width, 100px for new text, so seven fill labels in a 280px row each kept 100px and overflowed. Without a measurer, the fallback pinned that width and set no grow at all. Fill text now uses the same zero basis as fill frames on both paths.
* fix(design-jsx): read repeat() and minmax() in grid tracks
Track lists were split on whitespace, so columns="repeat(7, 1fr)" became the tracks repeat(7, and 1fr), read as fixed 0px and 1px columns that collapsed the grid. Tokens inside parentheses now stay together, repeat() expands its tracks, minmax() grows like its maximum, and a track the grid cannot express sizes to its content instead of to 0.
* refactor(scene-graph): parse CSS grid tracks with postcss-value-parser
design-jsx read repeat() and minmax() with a hand-written tokenizer and regexes, while dom-css already parses CSS values with postcss-value-parser. Track lists are now parsed in @open-pencil/scene-graph/css on that library, where both packages can use it, and design-jsx calls it. dom-css's hand-written declaration of the library's types is replaced by the types the library ships, which the shared module needs. The Scene Graph guide records that CSS values are parsed there.
* build: typecheck the test suites
Tests were in no TypeScript program: no tsconfig included tests/** or
packages/*/tests/**, and bun strips types without checking them, so a
fixture could drop a required field and keep passing until something
read it.
@types/bun moves to the root because it was installed per package only,
and #cli-tests/* joins the paths the root config already carries.
* test: fix the type errors the test suites were hiding
Typechecking the tests turned up 1123 errors. Most were ordinary
strictness, but some were real: `NodeChange` bound to Figma's plugin
typings rather than the Kiwi codec in thirteen .fig tests,
materializeInstance was called with six arguments against five so the
blobs and source children were dropped, CanvasKit pixels were written
to a plain object that never reached WASM, and assertions were made
through accessors that do not exist, so they asserted nothing.
Fixtures that had quietly lost a required field now carry it, nullable
results are narrowed through the existing expectDefined helper rather
than assumed, and stand-ins for CanvasKit and the editor go through one
named helper instead of an unexplained cast at each site.
No test was deleted, skipped, or weakened, and no `any`, non-null
assertion, or ts-expect-error was introduced.
* docs: record what typechecking the tests established
Pins the app program's global types with an assertion rather than a
note, since an unpinned types list lets any root @types package decide
which platform src/** is judged against.
The two environment faults that look like code regressions — Vite's
dependency pre-bundle outliving a package rebuild, and heavy .fig
suites failing under load — go to the development docs, where an
explanation belongs.
* fix: align @types/bun and keep node types resolvable when extended
The root manifest declared a newer @types/bun than every package, which
check:monorepo rejects, and pinning the app program's types left them
unresolvable from a config that extends this one out of tree.
* fix: fail the test typecheck when the compiler itself fails
The gate matched diagnostics by substring, so a compiler or config
failure that named no test file printed a pass while having checked
nothing. Diagnostics are now split by whether they name a file: an
unscoped one is the run failing and stops the gate, a test file's is a
finding, and a source file's stays out by design.
Also drops the parameter planComponentConstruction never read, and
makes the inner-shadow verification script exit non-zero when it
renders no image instead of logging and succeeding.
* chore: merge master into tests-typecheck
* fix: match Figma when dragging, drawing, duplicating, and pasting
Checked against Figma desktop 126 with real pointer input. A dragged layer
lands in the topmost unlocked frame, section, component, or instance under
the cursor, following rotation and clipping, and leaves its frame as soon as
the cursor does; groups, boolean operations, component sets, and locked
frames never take a drop, and a layer stays in its group unless it lands on
another frame. Space keeps parents, Shift locks an axis, and Control drops
into auto layout as an absolute-positioned layer. Locked layers stay put.
New shapes go into the frame under the start point, frames and sections take
in the unlocked siblings they fully cover, duplicates land in place (top-level
frames to the right), and paste keeps the copied position, centering an axis
that does not fit the selected frame.
* fix: match Figma for drag edge cases with components, groups, and auto layout
A second round of checks against Figma desktop 126, replaying the same
pointer input in both editors. A component set takes back only its own
variants, and components never go into other components. Groups and
booleans refit their children after a move or nudge, and a group whose
last layer leaves is removed. Pressing inside a selected frame, group, or
component set drags it instead of the layer under the cursor.
Auto layout children dragged out land where they are dropped, drawing
inside auto layout adds to the end of the flow, and wrapped frames insert
on the line under the cursor. Duplicates keep their names, and a main
component duplicates as an instance with Cmd+D or Alt-drag; a multi-layer
duplicate stays in place, and a lone frame in a section counts as
top-level.
* fix(core): refuse new shapes in the locked part of an instance
createShape redirected a refused parent through acceptingParent while keeping coordinates in the original parent's space, and still inserted the layer when the slot claim failed. It now takes the given parent, claims a slot when needed, and throws when the parent refuses children; drawing skips such parents before creating anything.
* feat(scene-graph): let a SceneGraph take its ID generator
The constructor accepts an ID generator, used for the root node and for
nodes, variables, and collections created later; generated IDs skip any
node, variable, collection, or mode ID already in the graph.
Refs #770
Signed-off-by: Marc Went <marc@went.io>
* perf(scene-graph): check mode IDs without allocating per created entity
Generating an ID spread every collection into a new array and scanned it
for each candidate, which runs on every createNode. Check the node,
variable, and collection maps first and walk the modes with an early exit.
An index of mode IDs is not kept because history snapshots, transfer, and
undo replace collection maps and edit modes in place, which would leave it
stale.
* test(scene-graph): cover injected ID generators and collision skipping
* docs: note the SceneGraph ID generator in the changelog
* test(core): keep reopened .fig GUIDs when a sibling is inserted before them
Refs #770
* test(collab): cover concurrent additions, same-property writes, and delete versus edit
Move the synced-store harness to tests/helpers/collab so the new cases
live in tests/app/collab, and let it bind graph events as a collab session
does. The cases edit both peers while disconnected and check the
converged graphs.
Refs #770
* fix(scene-graph): keep a collection's IDs apart and stop on an exhausted generator
createCollection asks for the collection and default mode IDs before
registering either, so a generator repeating a candidate gave both the
same ID. A generator that only returned taken IDs looped forever, even in
the constructor; it now throws after a bounded number of attempts.
* test: require the inserted GUID and a real disconnect in new tests
The GUID test passed even if export dropped the inserted layer, and the
concurrent-edit helper would have tested sequential sync had its peers
not been disconnectable.
* feat(scene-graph): create variable modes through the graph's ID generator
The editor minted added and duplicated modes as mode:<random hex>, so an
injected generator governed every graph entity except modes added after
a collection's first. SceneGraph.createMode mints the ID and adds the
mode; undo and redo replay it with addMode.
* test(core): type the reopened .fig buffer in the GUID test
* refactor(scene-graph)!: share random helpers and one component property ID
randomHex, randomInt, and randomIndex move from @open-pencil/core/random
to @open-pencil/scene-graph/random, so format packages can use them:
design-jsx and the MCP test server dropped private copies. Component
property IDs, written as prop:<random hex> in six places across Core and
design-jsx, come from createComponentPropertyId.
* docs: point the randomness rule at the shared ID and random helpers
---------
Signed-off-by: Marc Went <marc@went.io>
Co-authored-by: Marc Went <marc@went.io>
* feat(app): record runtime errors in diagnostics with their stack
Uncaught errors and unhandled rejections only showed a toast, Vue component errors after boot only reached the console, and a failed chat kept just its error name, so a failure like WebKit's 'Attempting to define property on object that is not extensible.' left nothing to diagnose. They now record a runtime.error, and chat.failed its code, message, and stack. Messages and stacks are scrubbed of URL queries, key- and token-like strings, and home folder names and bounded; AI SDK and provider errors keep no message, since it can quote prompts or responses. Copied diagnostics start with the app version, shell, browser, and language.
* feat(app): label, filter, and page diagnostics events
Every row in Settings → Diagnostics read 'Technical event': the summary looked labels up under diagnostics-prefixed keys the messages do not have, and only a few event kinds had labels at all. Each event now has a specific label and a short detail, such as 'Tool: render · 162 ms', 'Model step · <model>', or an error's message, expands to its recorded fields and stack, and the list filters by level and category and grows a page at a time. The copy action passes the environment header, which moves out of the recorder so tooling that compiles it needs no build-time globals.
* test(app): stream a reasoning reply in WebKit without page errors
Errors such as WebKit's "not extensible" TypeError appear only in that engine, so run a streamed reasoning reply there and fail on any page or console error.
* fix(app): scrub queries on bare paths in diagnostic errors
Only URLs had their query removed, so a message like 'Failed to load /Designs/app.fig?token=…' kept the token.
* fix(app): count diagnostics recorded before Settings opens
The event count and size updated only on new events, so the panel showed 0 events beside a full list.
* feat(app): record failed AI tool calls as problems, with the stack of engine errors
A tool catches what it throws and returns only the message to the model, so diagnostics saw a failed tool as an info event without details. The adapter now passes the thrown value to the tool log. A failed call is a warning; a TypeError, ReferenceError, or RangeError, which comes from a bug in OpenPencil rather than a wrong call, is an error with its message and stack. Other tool errors keep only their name, since their messages quote layer names and arguments.
* fix(core): log tool calls that return an error as failed
Most tools report a failure by returning { error } rather than throwing, such as describe with an unknown node, so the tool log and diagnostics counted them as successful calls while the chat showed them failed.
* feat(app): scrub cloud keys, JWTs, private keys, URL credentials, and emails from diagnostics
The scrubber caught keys by shape only, so 20-character AWS access key IDs, user:pass@ in URLs, and emails reached the log, and a JWT's payload survived because its dots split it into short runs. It moves into its own module with rules grouped by what they protect. The added credential formats follow gitleaks; keys the shape rules already catch, such as GitHub, OpenAI, Anthropic, and Stripe ones, get no separate rule. No maintained browser library fits: secretlint needs Node built-ins and adds at least 23 KB gzipped, and the PII redactors miss tokens. The scrubber is 0.8 KB gzipped.
* refactor(app): name how a tool call is recorded and import diagnostics from its index
* fix(app): record demo document loads in diagnostics
The preparation event's schema listed its kinds, phases, cancel reasons, and failure codes by hand and lacked demo-load, so every demo load failed validation and was dropped. The schema now validates against the same lists the preparation types derive from.
* feat(app): label document preparation events in Settings diagnostics
Preparation events showed their raw name, editor.preparation.finished, because the summary had no label for them. They now read as their kind, such as Switch page, with the outcome and duration below. Event names are a typed union and the labels a map keyed by it, so recording a new event without a label fails type-checking; names stored by older versions still fall back to the raw name.
* fix(app): keep source paths and scrub provider stacks, auth headers, and spaced home folders
Review follow-up. A provider error's message was dropped but repeated on its stack's first line, so it is now removed there too. The long-run rule redacted source paths of 40 or more characters, losing the failing file; a run with slashes now loses only its key-like segments. The bare-path query rule cut optional chaining such as a.b?.c and now needs name= after the question mark. Authorization header values in any scheme, credential assignments such as api_key= or password:, and home folder names with spaces are now scrubbed.
* fix(app): suppress repeats of alternating runtime errors
Repeat suppression compared each error only with the previous one, so a loop alternating between two errors recorded every occurrence. Recent errors are now kept in a small bounded map.
* test(app): validate copied diagnostics and wait for the copy to finish
Master now rejects JSON.parse with a type assertion, so the copied report is read through a Valibot schema. The uncaught-error test read the clipboard before its copy finished and could see the previous test's report; it now waits for the confirmation, as the export test does.
* 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.
* feat(app): add slot property controls and a shared picker
AppPicker is a searchable, grouped list that opens beside the properties
panel, built on Reka's popover and listbox so search keeps arrow-key
navigation. It has comfortable rows with a thumbnail and description and
compact rows for plain names, plus an optional footer action.
The slot property row shows whether an instance's slot is Default or
Modified, its item count, its limits with a checklist popover, Add
instances on AppPicker, and Reset slot and Delete contents. These are
presentational; wiring them to the editor follows. Strings are English
only until the locale files catch up.
* feat(app): open variable, style, and instance-swap choices in the shared picker
The variable binding picker, the shared style fields, and instance-swap
properties now open AppPicker: a titled panel beside the properties panel
with search that keeps arrow-key navigation, a check on the current
choice, and footer actions. Variable binding keeps its detach and
create-variable actions. Instance-swap choices list the property's
preferred components first; instanceSwapOptions keeps the preferred flag
it already computed. AppPickerField gives select-shaped fields the same
picker with a combobox trigger.
* feat(core): edit instance slot content
Only an instance's slots take layers now. slotScope classifies a parent as
free, a slot of an instance, or the locked rest of an instance. Moves,
reorders, layer-panel drops, paste, duplicate, and instance creation
claim an untouched slot first, as Figma does on the first edit, and
refuse the locked part; drops over it land in the instance's parent.
Claiming keeps the layers but unlinks them from the component and moves
the instance's overrides on nested instances onto those instances.
Reset slot, Delete contents, and Add instance are editor actions, each
one undo step that restores the instance's subtree. A canvas drop or
reorder that claims a slot undoes together with the move.
* feat(app): show and edit slot properties of the selected instance
The component properties section lists each slot of the selected instance
with its state, item count and limits, and adds instances, resets or clears
its content through the editor's slot actions. The slot model lives in the
Vue SDK as useSlotProperties.
* feat(app): outline slots on the canvas and mark them in the layers panel
Hovering or selecting a component, an instance, or a layer inside a slot
draws its slots with a dashed pink outline and tints empty ones. Slot
frames are selected and hovered in pink and show a dashed-square icon in
the layers panel.
* test(app): cover slot outlines with canvas snapshots
* feat(app): translate slot and picker strings
* docs: note slot editing and the shared picker
* refactor: share slot test and story setup
* refactor(app): name the picker's header prop heading
* feat(core): create, configure, and remove slots on main components
A frame of a main component becomes a slot through a SLOT property
named after it; other sibling layers are first wrapped in an auto
layout frame. Slot settings and removal are single undo steps.
Instances now follow the component's property bindings on sync, so a
slot created or removed on the component reaches existing instances.
Slot helpers move to a slots domain folder in Scene Graph and Core.
* feat(app): create and configure slots from the menu and properties panel
Create slot joins the canvas context menu for layers of a main
component. A Slots section on main components and their frames
renames slots, sets their description, layer limits, and preferred
components, and removes them.
* fix: keep slot claims in the same undo step and refuse wraps inside instances
Creating an instance and pasting HTML now batch the slot claim with the
edit. Undoing an added slot instance restores the previous selection.
Grouping or wrapping layers in the locked part of an instance is
refused, and a section that cannot move no longer claims a slot. The
picker clears its search however it closes, and its close and slot
actions labels are translated.
* feat(core): create and inspect slots through the plugin API
component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so
on, bound to a new SLOT property, as live Figma does. Slot frames read
type SLOT, resetSlot() restores an instance slot's component content,
and limitViolations reports BELOW_MIN, ABOVE_MAX, and
HAS_NON_PREFERRED for instance slots. addComponentProperty and
editComponentProperty take a description and slotSettings, a cloned
slot is a plain frame, and componentPropertyReferences uses property
keys in both directions. The limit checks move to Scene Graph so the
Vue SDK and the plugin API share them.
* fix: keep nested slot content across swaps and read nested instance properties
A nested instance points at the instance it was cloned from, so its
component properties resolved to nothing. Properties now resolve through
those links to the main component, and a swap or variant switch parks
the slot content nested instances own and restores it into nested
instances of the same names, as live Figma does. Plugin appendChild and
insertChild claim the slot they add to and refuse the locked part of an
instance with Figma's error.
* test(core): record resetSlot on a main component slot; fix the Slots roadmap row
* fix(core): refuse deleting layers outside an instance's slots
As in Figma, delete and plugin remove() leave an instance's own layers
and its slot frames alone, while removing slot content claims the slot
in the same undo step as the delete.
* test(app): wait for the bulk rename dialog to close before the next shortcut
* feat(app): unify add, remove, and settings controls in the properties panel
A section's + adds an item and a row's - removes it everywhere: grid
tracks and variant properties now follow the fill and effect lists, and
the header + of a component set adds Property 1 ready to rename instead
of an inline form. Removing a variant is Delete, as for any layer.
Row settings share the sliders icon, the variables button opens with
its own icon, every icon button requires a label, and the instance
header buttons use sentence case. Create Slot joins the app menu.
* docs: note the unified properties panel controls
* feat(app): animate floating panels alike and share the severity icon
Popovers, menus, selects, comboboxes, and pickers now fade and grow in
from the side they open on and fade out on close, from one motion
preset that respects reduced motion; tooltips keep the tooltip preset.
SeverityIcon and its colours move from the design check to shared
feedback UI, and slot limits use them, so a broken limit reads like a
design-check warning.
* docs: note consistent popover and menu animation
* feat(app): give every floating panel one surface and close it at once
Popovers, menus, selects, comboboxes, pickers, presence cards, chat
history, and the issue tooltip share one rounded surface with a 1px
ring that outlines it in both themes; one-line tooltips keep a compact
shape with the same edge. The select theme's radius and elevation
options and local shadow overrides are gone. Panels still grow in from
their side but now close immediately: a fading modal menu kept blocking
the canvas and shortcuts until it unmounted.
* fix(app): keep a reopened context menu open and name option-drag undo Duplicate
Closing the canvas menu hands focus back to the canvas; when that
landed just after a quick reopen, the new menu closed as focus moved
outside it. Shortcuts no longer wait for a panel that is already
closing, and an option-drag duplicate that claims a slot is undone as
Duplicate rather than Move.
* test(app): wait for menus and popovers to close before the next key
* fix: validate parsed JSON at untrusted boundaries with Valibot
Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.
The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.
open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.
* refactor: validate parsed JSON in tests and tooling
Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.
* fix: validate clipboard geometry bytes, library images and model catalogs
Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging.
* refactor: extend the JSON validation lint to .json() results
no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas.
* test: validate the RPC request body in the CLI app export test
* test: validate CLI JSON output in the tool and app command tests
* test: compare the malformed models.dev fallback with the curated list
* feat(core): export resolvePasteTarget from the editor entry
* docs(changelog): limit the resolvePasteTarget note to ordinary paste and drop
* docs(changelog): word the paste target export like other entries
* fix(core): let resolvePasteTarget take the editor createEditor returns
It required the internal EditorContext, which the public Editor does not
satisfy, so embedders could not pass their editor. It only reads graph and
state.
* docs(changelog): limit the paste target entry to ordinary paste
---------
Co-authored-by: mrhard9090 <moltrax88@gmail.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
* 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>
* fix(core): save an unedited worker-opened .fig instead of hanging
Saving a .fig that was opened through the session worker and not edited
yet never finished. The export asks the worker for the original archive,
but the reply listener lived in port1.onmessage, which
registerFigPopulationWorker replaces once the graph arrives. The worker
answered and the reply was dropped, so the save promise never settled.
Listen for archive results with addEventListener so the population
client can own onmessage.
* test(core): drive the session worker open directly in the archive save test
IS_BROWSER is read once at import and Bun shares the module cache, so when another session test loads the reader first, parseFigFile took the main-thread path and the test passed without the fix. Export the worker open from read.ts and call it directly, release the worker afterwards, and drop the global window patch and the uncleared race timer.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
* fix(core): draw gradient and image strokes as the paint they are
A stroke carried the paint vocabulary already, but nothing read it: the
.fig reader sent every stroke paint through resolvedPaintColor, which
returns black for a gradient or image, the renderer set a flat color on
strokePaint, and the writer emitted a SOLID paint.
Strokes now go through the same conversion fills do in both directions,
and applyGradientFill and applyImageFill take the target Paint so a
stroke reuses the fill shader path instead of growing a second one.
forVisibleStrokes is the single place every stroke draw passes through,
so the shader is set and cleared there rather than threaded through each
draw helper.
Closes#797 for rendering and .fig; authoring a gradient stroke from the
stroke panel is still to come.
* feat(app): author gradient and image strokes from the stroke panel
StrokeSection opened a solid-only colour picker and synthesised a fake
fill for the swatch, so a stroke could never be anything but one flat
colour. It now opens FillPicker like the fill panel does, and
applyStrokePaint keeps the stroke's weight, align, cap, join and dashes
across a paint change.
Completes #797.
* fix(core): let a gradient stroke reach vector outlines and arrowheads
A vector stroke draws its outline as a filled shape with fillPaint, a
dashed one strokes the path, and arrowheads are filled shapes of their
own; each cleared the shader first, so a gradient or image stroke on a
vector drew black. The stroke pass now configures both paints and owns
clearing them, and those helpers keep what it set.
Resolve each gradient stop against the stroke's own colour binding
rather than the stop's position, which looked up another stroke's.
Reported in review of #868.
* fix(core): release the shaders a paint no longer owns
Every gradient and image shader was handed to a paint and then leaked:
the paint takes its own reference, so the caller's handle has to go or
WASM memory grows with each redraw. Only the diamond branch did this.
A gradient stroke now configures two paints, which doubled the leak.
Reported in review of #868.
* test(render): model a shader handle the caller deletes
The pattern shader double returned a plain string, so deleting the
handle the paint no longer owns threw instead of passing.
* fix(core): keep SVG icon stroke caps and joins after saving
Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.
* fix(core): fill open subpaths of filled SVG paths
SVG fills every subpath as if it were closed, but parseSVGPath put only
closed subpaths in the fill region. The renderer filled the open ones as
a separate path, so a hole formed by an open subpath and a closed one
under the path's fill rule was filled in, as in some Font Awesome icons.
Add an includeOpenRegions option to parseSVGPath, off by default, and set
it for filled paths without a stroke from Iconify icons and import_svg,
and for SVG clip paths. Stroked paths keep open subpaths out of the
region so their closing edges are not stroked. A filled polyline now
flattens with adjacent filled shapes like a polygon does.
* docs(changelog): state where open SVG subpaths are now filled
The app has no icon picker. The fix reaches Design JSX <Icon> and
inline <svg> through scalePathInfos, dropped and pasted SVG files and
clip paths through svgToVectorPaths, and makes filled polylines render
filled. Name the unstroked-path limit instead of an unqualified claim.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it for
compatibility. importVectors also returns the graph, matching the same
helper in #832 so the shared test files reconcile cleanly.
* docs(changelog): name every path that keeps SVG stroke caps
The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.
* refactor(core): read the first SVG path stroke with at(0)
Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
* test(fig): cover reopened instance overrides through component sync
Instance overrides read from a saved .fig must survive the live editor's
main-component sync, a second save and reopen, and lazy population of a
later page through the session worker. Regression coverage for #750, which
the occurrence-scoped reader (#646) fixes.
* test(core): move reopened override coverage to its Core home
check:test-homes rejects new tests under tests/engine; the test drives createEditor and Core fig internals, so it belongs beside component-sync.test.ts. The second-save case passed on v0.15.1 and now runs as the tail of the first case.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
* feat(lint): suggest converting groups to frames and deleting hidden layers
no-groups and no-hidden-layers now carry suggestions the Lint panel, the
lint_fix tool and editor.applyLintFixes can apply. A group becomes a frame
in place, keeping its id, children, bounds and look; a hidden layer is
deleted with its children. Neither is offered for locked layers or inside
components and instances, and both are checked again when applied.
The editor gains convertGroupToFrame and deleteNodes, which deleteSelected
now uses, and applies lint fixes through a bridge so structure changes and
property updates share one undo step. lint_fix becomes a document mutation
because atomic tools cannot remove layers.
* fix(code): keep canvas edits made while replaced code waits to render
Replacing all the code drops its layer links until the preview links it
again, so a canvas edit in the preview delay could not be patched into the
code and the preview drew over it. Edits made while code waits to render
are now applied again once the preview has linked the code, in the same
undo step, and reach the code like any other canvas change.
* fix(figma-api): default paint opacity and visibility like Figma
Plugin scripts may leave out a paint's opacity and visible; Figma reads
them back as 1 and true. The fills and strokes setters stored the paint
as given, so the Design panel received an undefined opacity.
* build(dev): forward only errors from the browser console
Vite forwards browser logs when an agent starts the dev server. Serializing
a Vue warning's component props walks the editor state and freezes the
tab, so warnings stay in the browser console.
* fix(code): follow values while they are dragged or scrubbed
Live previews change layers without a new scene version, so the code kept
the old value until the gesture ended. The Code tab now follows preview
updates once per frame, as the Design panel does, and goes back when the
gesture is cancelled.
* fix(code): keep canvas edits when the replaced code fails to preview
A failed preview took the canvas edits waiting for it and dropped them,
and stopped recording new ones, so correcting the code drew over them.
The edits now wait for the next preview.
* fix(core): keep SVG icon stroke caps and joins after saving
Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.
* docs(changelog): name every path that keeps SVG stroke caps
The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.
* refactor(core): read the first SVG path stroke with at(0)
Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
* feat(scene-graph): model variables as CSS tokens
A variable now has a CSS custom property name, a unit, raw CSS expressions
per mode, and each mode a CSS condition (a selector or @media prelude), so
code export can treat variables as design tokens rather than resolved
literals.
Names are derived when not set: Tailwind v4 theme namespaces from the type,
the scopes or the leading name segment, so Gray/50 is --color-gray-50. The
first token to claim an explicit name keeps it; Figma files contain
duplicates, and later claimants fall back to a derived name. FLOAT tokens
infer px except for opacity and font weights. Lengths stay in canvas pixels
and only convert when written, so rem does not change what the canvas or
Figma sees.
In .fig, the name goes to codeSyntax.WEB in the form the snippet already
uses, or to plugin data when WEB holds something else such as a Tailwind
class. Unit, expressions and conditions are OpenPencil plugin data,
validated with Valibot. Conditions and expressions reject braces and
semicolons because they are written into stylesheets, and an expression
whose mode value was edited elsewhere is dropped so the number stays
authoritative.
* refactor: move token naming to dom-css and keep fig to persistence
CSS naming, namespaces and units are CSS projection, which dom-css owns;
scene-graph keeps only the token data and the px/rem storage conversion,
and fig only persists plugin data, validated for shape.
Drop Variable.cssName: codeSyntax.WEB is the single place a token's name
lives, read with postcss-value-parser when it is --x or var(--x), so no
second copy has to stay in sync with Figma's field. Derived names use
es-toolkit kebabCase and twirlwind's Tailwind namespace table, which
excludes opacity since Tailwind v4 has no such namespace.
Whether a condition or expression is valid CSS is no longer guessed with
a regex in fig; the stylesheet generator will check it with cssom where
the string enters a stylesheet.
* refactor(fig): parse token plugin data with Valibot's parseJson
Invalid JSON becomes a validation issue like any wrong shape instead of
a caught exception, and the plugin data lookup reuses
getOpenPencilPluginValue rather than repeating it.
* refactor: use es-toolkit for token expression keys and name segments
mapKeys re-keys expressions by file mode id instead of a manual loop, and
compact drops empty name segments. Reading expressions keeps the plain
filter: pickBy returns Partial<T>, which would need a cast.
* fix: keep token expressions on float32 values and rem precision
.fig stores numbers as float32 while plugin data keeps the resolved value
as a double, so a value such as 1234.567 differed by more than the 1e-6
tolerance and its expression was dropped as stale on reopen. Compare both
at float32 precision.
Token numbers were written with four decimals, which turned 0.5px into
0.0313rem; six keep every pixel step down to 1/1024px exact.
Also note that derived names can collide, so stylesheets take them from
variableCSSNames.
* feat(code): link code to canvas layers and underline design issues
Code in the Code tab and layers on the canvas were unrelated: finding the
element behind a layer, or the layer behind an element, meant reading names.
Generated Design JSX and Tailwind JSX report the layer behind each element
in the order elements open, and edited Design JSX keeps the source line of
every element through the sandbox and renderer, so hovering an element
highlights its layer, Cmd/Ctrl-click brings it into view without changing
the selection the code shows, and errors and warnings from the design check
are underlined on the property that causes them.
* feat(code): explain the Code tab when nothing is selected
With no selection the editor showed a starter frame that read like a real
layer. The tab now says it shows the selected layers' code and offers Write
JSX, which opens the editor focused on the starter template.
* refactor(code): group code-to-layer linking into its own domain
Layer link types, issue mapping, and the hover and reveal behavior move
from the Code panel and a component file into src/app/code/layers, with
useCodeLayers as the panel's entry point, so app code no longer imports
types from components.
* fix(code): underline off-scale gaps after the spacing rule renamed its property
* feat(code): mark the layer of the element around the cursor
Hover highlighting and ⌘-click reveal replaced by one model: the element
around the cursor marks its opening and closing tag names and outlines its
layer on the canvas while the editor has focus. ⌘-click also collided
with CodeMirror's add-a-cursor gesture. Read-only Tailwind JSX now takes a
cursor so it links the same way.
Leaving the editor now ends a live Design JSX edit as one undo step.
Before, canvas edits made after typing never reached the code until the
tab was reopened, and their undo entries landed before the edit's.
* feat(code): sync the Code tab and the canvas both ways by patching
Canvas edits now patch the Design JSX a person wrote instead of waiting
for them to leave the editor: each linked element remembers the layer as
Design JSX last wrote it, and a canvas change rewrites only the attributes,
text and child elements that differ from that base, as CodeMirror changes
that keep the cursor, comments, formatting and history. Attributes written
as expressions are never overwritten; the code marks them when the canvas
now differs. Untouched code is regenerated with a minimal text change.
Code edits update layers in place: the new render is reconciled into the
existing layers (reconcileRenderedLayers), which keep their ids, so links,
selection and canvas edits survive typing. Each edit is one coalesced undo
step, replacing the restore-and-rerender preview and the commit on blur.
* feat(code): patch reordered layers and aliased properties in edited code
Reordering layers on the canvas now moves their elements in code a person
wrote: each child element and the blank lines and comments above it form a
block kept as written, and the children are written again in the new
order, staying linked. Children that cannot move safely, such as a loop
between them, keep their order and are marked.
Properties accepted under several names now come from one alias table in
the Design JSX schema, which the renderer resolves through and the patcher
and issue underlines use, so a canvas change to `w` patches `width` where
the person wrote that, instead of adding a second attribute.
* feat(code): keep the cursor in moved code and patch values written in style
A reorder rewrites the children span in one change, which collapsed a
cursor or out-of-sync marker inside a moved element to the span's edge.
The patch now carries where each block moved and places selections and
markers inside it at their new position.
Properties the renderer also reads from style={{ … }} come from a table in
the Design JSX schema instead of a hand-written list, keeping the rule
that an attribute under any of its names wins. The patcher uses it to
update a value written in style where it is, as a number or a px string
as written; values the renderer cannot read, such as '50%', are marked.
The layer patcher is split by concern: syntax helpers, attribute and
style patches, child patches, out-of-sync state and transaction assembly.
* feat(code): show the code's layer on the canvas as a tinted box
The layer of the element around the cursor used the canvas hover slot, so
moving the pointer over the canvas replaced it and the two read the same.
It now has its own shared editor state, codeFocusNodeId, drawn as the hover
outline over a light tint in every pane: hover stays an outline and the
selection keeps its handles, without borrowing the dashed outlines that
already mean component sets, drag parents and ghosts.
* fix(code): write added and removed layers when a reorder cannot move the code
When children could not be moved, such as two written on one line, the
patch marked the order and returned before adding or removing elements,
so a layer created in the same change never reached the code. It now
marks the order and still writes additions and removals.
* refactor(design-jsx): format the rebased layer description and stroke aliases
* feat(code): mount the layer-linked code editor through useCodeMirror
Master moved the code editor onto the shared useCodeMirror composable.
Its layer links, issue underlines, canvas patches, minimal text updates,
autofocus and read-only cursor now sit on that composable instead of a
hand-mounted view.
* feat: check designs live with a Check panel and canvas issue markers
Design lint only ran from the CLI and AI tools, and its rules were too noisy
to show continuously: on a real imported page 786 of 888 layers had a
warning. The rules now report where a finding is actionable (a hardcoded
color only when a variable matches it, nesting only where the limit is
crossed, instance sublayers through their main component) and carry
structured data, and Recommended keeps warnings for likely problems.
The app checks the current page after edits settle. The Check tab groups
issues by rule with hover highlighting, reveal on click, and one-step
variable binding. Errors and warnings are marked on the canvas with
clustered markers that roll up to visible ancestors when zoomed out; markers
explain themselves on hover, open Check on click, and toggle with
View > Design issues.
* fix: keep the right panel and markers stable
The Check tab made the right-panel tab row overflow at common window widths,
so focusing the zoom menu scrolled the row and shifted the panel. Code and
AI tabs now drop their labels to screen readers when the row is narrow.
Touch target names are matched as whole words: "Rectangle" contained "cta"
and marked every rectangle. Markers also stay drawn during interactive edits
instead of blinking while a value is scrubbed.
* fix(ui): show right panel tab labels whenever they fit
* fix(ui): name the design check tab Lint and keep panel tabs consistent
The tab was an unlabelled icon between labelled Code and AI tabs. It is now
Lint, with the same icon and label anatomy as its neighbours, and its icon
takes the severity color instead of a count badge. All labelled tabs show
their labels when the row fits and drop them together when it does not.
* refactor(ui): build the Lint panel from shared components
Issue groups use AppCollapsible, actions use AppButton, and the severity
filters are a Reka toggle group with keyboard navigation. Issue rows no
longer nest a button inside a button. Panel state, visibility and the
focused-issue scroll live in useDesignCheckPanel, the rules menu is its own
component, and rule preferences change through preference actions.
Severity ordering reuses Core's ranking, detail numbers follow the app
language, and the check debounce uses useTimeoutFn.
* fix(lint): check the WCAG AA touch target size in the Recommended preset
Recommended flagged a 394 × 39 input because it required the 44 × 44 AAA size. It now checks the 24 × 24 AA minimum through a minSize option; Strict and Accessibility keep 44 × 44.
* feat(lint): fix design issues from rules, the Lint panel, the CLI, and agents
Rules attach fixes as data: a safe fix keeps the design as it looks (bind a
color to the variable it matches, round subpixel geometry that layout does
not own), a suggestion changes values (snap radius and spacing to the
scale, raise small text to the minimum). One Core applier re-validates
each fix against the current graph and merges changes per layer.
The Lint panel offers a fix per row and Fix all for safe fixes as one undo
step; openpencil lint --fix writes the fixed document; the lint and
lint_fix tools expose the same to MCP and AI chat.
The design-check spec's Close button is now 24 x 20: at 24 x 24 it passes
the WCAG AA touch target size that Recommended checks.
* feat(lint): pin issues outside the view to the canvas edge
Errors and warnings on layers outside the viewport had no marker, so a
check could report issues nobody could see. They are now pinned to the
canvas edge where a ray from the viewport center toward them leaves it,
with a chevron pointing their way; pins in one direction merge like
markers. Hovering lists them under the direction they lie in, and
clicking reveals and opens the most severe, nearest one.
Pins keep clear of UI floating over the canvas: the toolbar marks itself
with data-canvas-obstacle, and canvases report such rectangles to the
renderer through getOverlayObstacles each frame.
* feat(lint): mark layers with design issues in the Layers panel
Like an IDE marks files with problems and the folders holding them, a
layer with errors or warnings shows the most severe as an icon, and a
collapsed layer with issues inside it shows a dot in that color.
Suggestions stay in the Lint panel, as on the canvas, and the marks
follow the View → Design issues toggle.
* feat(lint): show issues per page and across the document
Loaded pages beyond the current one are now checked in the background,
one page at a time while the editor is idle, and checked again only when
an edit touches them; pages a large .fig file has not loaded are left
alone until opened rather than forced in. The page list shows each page's
errors and warnings like an IDE's problem count, and the Lint panel gains
a Document scope that lists every page's issues, tags the ones on other
pages, and switches to a row's page when it is opened.
* test(lint): use the core-tests alias and no comma operator in lint tests
Master now rejects ../../ imports and the comma operator in tests.
* refactor(app): create the Lint session with the editor store modules
The composition root passed its line budget once master added recent
pages; the Lint session belongs with the other per-editor services that
the modules factory creates and disposes.
* docs(changelog): keep master's latest Unreleased entries
* 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.
Figma draws the value a bound paint or number stores until something makes
it resolve the variable again. OpenPencil resolves bound colours only when
drawing, so exports wrote the colour from before the binding, and a graph
bound without the editor kept stale numbers too: a file opened in Figma
showed bound fills in their old colour.
The exporter now writes each node with its bindings resolved, without
changing the document: colours as the variable's RGB at full alpha with
its alpha as the paint's opacity, which is how Figma stores them in every
fixture, and numbers in scene units. Nodes without an explicit mode take
the collection's default, because the mode the editor shows is not saved.
The resolution moves into scene-graph as resolvedPaintBindings and
resolvedNumericBindings, which the reader and reconcileNumericVariableBindings
now share, and the node resolvers take a fallback of 'active' or 'default'.
* fix(fig): keep variable metadata, plugin data and default mode on save
Saving a .fig rebuilt every variable record from the model, which held none
of Figma's variable metadata, so each save wrote variables as published to
all scopes with no description or code names, dropped other plugins' data,
and made the first mode the default.
Variables now carry scopes, codeSyntax and pluginData, collections carry
pluginData, and the reader and writer translate description,
symbolDescription, isPublishable, variableScopes and codeSyntax. Figma has
no default-mode field, so setting a default moves that mode first (undo
restores the order) and the writer orders the default first.
This is the base for design tokens: the CSS name will live in
codeSyntax.WEB and unit and mode conditions in OpenPencil plugin data.
* refactor(scene-graph): share the default-first mode order
setDefaultMode and the .fig writer both moved the default mode first by
hand; modesDefaultFirst does it once, with es-toolkit's partition.
* fix(core): undo a default mode change on the collection currently in the graph
Undoing a collection's removal restores a copy of it, so the inverse of
setDefaultMode wrote the previous order and default to an object the
graph no longer held when both were undone. Look the collection up by id
when undoing, as the forward step already does.
* fix(scene-graph): leave a field a component property drives to its instance
A component states the default for a field a property reference drives,
but the enclosing instance's assignment decides the value. Synchronising
copied the default over it, so loading a page into an open document —
which synchronises every component it places — reset those layers. A
label a navigation item assigned came back as the component's default
when its page loaded on its own, while reading the whole archive gave
the assigned text.
Synchronising now skips a field whose reference names a property an
enclosing instance assigns, and carries everything else as before.
* fix(scene-graph): read an own assignment, not an inherited key
A document-authored component property id can collide with an Object
prototype key, and `in` would then report an assignment the instance
never made, silently leaving the referenced field unsynchronised.
* test(core): cover visibility and swap on an independent page load
The fix skips any field a component property reference drives, so the
page-scoped test should exercise VISIBLE and INSTANCE_SWAP alongside
TEXT rather than leaving the other two to full-document reads.
* fix(scene-graph): leave slot content to its own guard
#850 added a SLOT_CONTENT reference field, which drives a slot frame's
children rather than a scalar field, so the property-driven field map
no longer claims to cover every reference field.
AI SDK 7 has no 'media' tool output part; images are { type: 'file', mediaType, data: { type: 'data', data } }. After diff_visual, export_image, or any other image result, the next step's prompt failed validation with AI_InvalidPromptError and the chat run stopped. toModelOutput is now typed against the SDK's ToolResultPart output, and a test validates the resulting tool message with the SDK's own schema.