CanvasKit lays out no line for an empty paragraph, so the text editor found no caret until the first character was typed. A one-space line now gives an empty text's caret its height, and its alignment places it.
* 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(core): load fontoxpath under Node's ESM loader
fontoxpath is CommonJS, so Node exposes its exports only on default and openpencil query / MCP query_nodes failed with 'evaluateXPathToNodes is not a function'. Closes#787.
* test(tools): run an XPath query in the package runtime smoke check
The Bun-run unit tests take fontoxpath's named exports, so they never covered the Node fallback to default.
* test(tools): also run matchByXPath in the XPath smoke scenario
* refactor(core): import fontoxpath types through one namespace import
---------
Co-authored-by: mrhard9090 <moltrax88@gmail.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
* fix(canvas): show every top-level frame's name and select frames by it
Since clicks follow Figma's depth, the empty part of a top-level frame
that holds layers selects nothing, but a frame's name was drawn and
hit-tested only while that frame was already selected. Once a top-level
frame held layers it could not be selected from the canvas, as when one
frame is dragged into another and the outer frame is then out of reach.
Every frame on the page or in a section now shows its name, faded in
the canvas's text color and in the selection color while selected or
hovered, from the same viewport-culled label catalog as section and
component labels. Its name is a hit target whether or not the frame is
selected, so clicking it selects the frame, dragging it moves the frame,
and hovering it highlights the frame; locked frames stay out of reach.
SkiaRenderer.hitTestFrameTitle no longer takes the selected IDs.
* fix(canvas): draw and hit labels whose node is just outside the view
Label catalogs culled nodes by their own bounds, but frame, section, and
component names sit outside the node, so a node just below the view hid
a name that was on screen and could not be clicked. Label lookups now use
the viewport widened by how far labels reach. Presses and hover also test
component labels, then section titles, then frame names, the reverse of
the order they are drawn, so overlapping labels pick the one on top.
* 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.
* fix: stop ancestor walks from hanging on a parent cycle
A collaborator's concurrent reparent can leave two layers as each other's
parent. isDescendant, the design check's pageOf, and component sync walked
parentId without a bound, so applying such a change froze the editor.
Add SceneGraph.closest(), a bounded nearest-ancestor lookup, and use it for
these walks so bad data ends the walk instead of the tab.
* fix(collab): sync layer moves without parent cycles or stale child lists
Remote changes assigned each layer's synced parentId and childIds as plain
properties. Two peers moving layers into each other made them each other's
parent, a moved layer stayed listed under its old parent, reorders never
synced, and concurrent additions ended up in different orders or missing
from the parent's list.
Apply the tree after a change's properties: move layers to their synced
parents, skip a move that would make a layer its own ancestor and write the
layer's current parent and position back so every peer settles on it, and
derive each touched parent's childIds from its synced order followed by
unlisted children by id. Locally, a parent's child list syncs once after
each edit that adds, removes, moves, or reorders its children.
Fixes#888Fixes#889
* refactor(scene-graph)!: move sibling order keys to scene-graph
Collaboration needs the same fractional keys as .fig export to order
siblings, and the app must not depend on @open-pencil/fig for them. Move
fractionalPosition, orderKeyBetween, and siblingOrderKeys to
@open-pencil/scene-graph/order-keys.
orderKeyBetween now always returns a key: when no printable key fits it
returns one above lo, which hasOrderKeyBetween detects, so callers no
longer branch on null. It takes an optional suffix, and siblingOrderKeys
can request one per key, so two peers inserting at one spot get distinct
keys. .fig export keeps its keys.
* fix(scene-graph): report instance child reorders as graph events
Instance sync sorted an instance's children in place, so nothing that
listens to graph events saw the new order; in a shared room the order
never reached other peers. Move each child that changes position with
insertChildAt, which reports the reorder.
* feat(collab): resolve the layer tree from each layer's parent history
Add a pure LayerTree for the shared document: each layer records every
parent it was moved under with a move counter and an order key. A layer
sits under its newest parent, ties broken by parent id; when concurrent
moves close a loop, the latest move in the loop falls back to the
layer's next entry until none is left, and a layer no parent can take
goes to its page (Evan Wallace's mutable tree hierarchy CRDT).
The result depends only on the entries, and resolution revisits only
changed, orphaned, and displaced layers. Seeded random runs check that
peers converge and that the incremental result matches a full one.
* fix(collab): sync layer moves as parent history and order keys
Each layer's shared map now records every parent it was moved under with
a move counter from a document-wide Lamport clock, and its order key
among siblings, instead of parentId and childIds. Every peer derives
parentId and childIds from these with LayerTree, so concurrent moves,
reorders, and additions merge, a loop from concurrent moves undoes its
latest move, and a layer whose new parent was deleted meanwhile returns
to its previous one.
A local edit's graph events are written once after the edit, in one
transaction. It records a parent entry for each layer whose parent
changed, a new entry for displaced layers on the touched paths so a move cannot pull
them back, and keys between the moved layers' neighbours with a random
suffix, so concurrent inserts at one spot get distinct keys. Remote
changes find their layers through each event's path, resolve only what
they touch, and move and sort layers through insertChildAt.
This replaces the childIds merge and the write-back of rejected moves:
a rejected move now resolves the same way on every peer from the shared
history, so nothing needs to be written back.
Fixes#888Fixes#889
* feat(collab)!: convert saved rooms and keep mismatched builds apart
A room saved by an earlier build records parentId and childIds. When a
change brings in such layers, from this browser's storage or a peer,
convert them in one transaction: each layer's synced parent becomes its
only entry with counter 0, its position in the parent's synced childIds
becomes an order key, and the old fields go. The result depends only on
the document, so two peers converting at once write the same values,
and converting again writes nothing. The document's meta map records
treeFormat 2.
Builds that record the tree differently would corrupt each other's
rooms, so the collaboration namespace becomes openpencil/2 for Trystero
and the test relay alike, and each peer publishes its treeFormat in
awareness for future version messages.
* fix(collab): send a layer whose parents were all deleted back to its own page
Each layer's shared map records the page it was last placed on, and the
layers under a frame moved to another page are re-recorded. A layer whose
parent chain was deleted goes back to that page, falling back to the first
page only when the recorded one is gone. Saved rooms record pages when
they are converted.
* fix(collab): keep one root per room when peers edit their own documents
Joining a room keeps the joiner's earlier document in its graph, and an
undo or an edit made before the room arrived could still reach it. That
edit shared the joiner's root, every peer adopted it, and the room's
pages disappeared.
The room now records its root as claims in meta, each with the time it
was made, and every peer follows the earliest. Sharing claims the room,
and so does the first edit in a room nobody has shared, which now shares
the whole document as Share does. A peer shares only layers under the
room's root. Converted rooms claim the root with the most children.
Move counters must also be safe integers, so an oversized counter from
another peer cannot stop the move clock from advancing.
* fix(collab): rank root claims by how they were made, not by clocks
Root claims carried the claiming peer's wall-clock time, so a guest whose
clock ran behind the sharer's could still win the room with an edit made
before the room reached them. A claim now records whether it came from
Share or a converted room, or from the first edit in an unshared room;
a shared root outranks an edited one, and the lower id breaks a tie.
A claim also replaces an invalid value already stored for its root.
* fix(collab): keep every root claim through concurrent writes
A root claim was one key per root holding its kind, so two peers claiming
the same root by Share and by an edit at once kept only one of the two
values, and the shared claim could be lost. Each kind of claim on a root
is now its own key. Converting a saved room also claims its root unless
a shared claim exists, so a guest's earlier edited claim no longer keeps
the converted room from outranking it.
* fix(collab): mint layer IDs under a session of each editor window
Every editor window started its IDs at 0:1 from the same counter, so two
people adding layers to a shared room at once could mint the same IDs,
and one person's layers replaced the other's in the room. A joiner's
starting page also took the sharer's page ID and stayed in their list.
SceneGraph's default IDs now carry a session set with setIdSession, as
in Figma's sessionID:localID GUIDs. The editor picks a random 32-bit
session at startup, as Yjs does for each document's clientID; headless
tools keep session 0, so the CLI and MCP server give a file's layers the
same IDs on every run.
* fix(collab): let only Share set a room's root
A guest's first edit in a room whose contents had not arrived claimed
the room and wrote the guest's whole open document into it, images
included, and adopting the sharer's root later only hid it. Every room
starts with someone sharing a document, so a guest has nothing to claim:
only Share, or converting a saved room, now sets the room's root, as a
single value in meta, and a peer writes nothing until the root is known.
Unbinding a room also writes an edit still waiting to be sent, so a move
or deletion made just before leaving reaches the room.
* feat(collab)!: open each room in a tab of its own
Joining a room bound it to whatever tab was active, so a pasted link
could turn a saved file into the room's document, and a share link first
showed an editable blank document. A room is now a document: joining
always opens it in a new tab, or switches to the tab already showing it,
and only Share puts an existing tab's document into a room.
Every room tab owns its session (src/app/collab/rooms.ts and
session.ts), so several rooms can be live at once and keep syncing in
the background. The collaboration panel, presence, following, and the
/share/<id> address follow the active tab, and a canvas publishes its
cursor and selection only to its own tab's room.
A room tab derives its state: joining while its saved copy loads, then
waiting, with an explanation, while nobody who has the file is online;
live with others, or alone on this device's copy. Until the document
arrives the room's screen replaces the editor. Reloading a share link
rejoins it; leaving a room you joined keeps its file as a local unsaved
copy. "Connected" now means another peer answered. Pasted links and IDs
are normalised and validated, and invalid ones say so.
People join right away under a generated name such as "Teal Fox", with
a hint to set one; the one app-wide name is set in the share panel or
in Settings. On a phone, Share copies the room's link instead of making
a new room, and the presence popover shows the room's state.
* feat(desktop): open rooms from openpencil://join links and Home
The desktop app could not receive a share link: links point at the web
app, and openpencil:// only opened files. openpencil://join?room=<id>
now opens the room in a tab of its own. The native parser refuses
anything but a room ID, queues rooms for the frontend through
take_pending_rooms, and a second launch on Windows and Linux forwards
its link through the single-instance handler.
In a browser on a computer, the room's screen and the share panel offer
Open in desktop app, a link the browser hands to the app on click; it
never opens the app by itself. Home gains Join room…, which takes a
pasted room link or ID and opens the room in a new tab.
* feat(collab): set your name on the room screen
The room screen told someone joining under a generated name to set
their name but offered nowhere to do it before the file arrived. It now
has the same name field as the room panel.
* feat(collab): offer the desktop download beside Open in desktop app
A browser on a computer offers a room's openpencil://join link, which
does nothing where the app is not installed. The room screen and panel
now link to the latest release beside it.
* docs: describe joining rooms in the German, Polish, and Russian guides
Bring the translated collaboration pages up to the English one: Share as
the only way into a room, joining in a tab of its own, the waiting
screen, leaving with a local copy, and how layer moves merge. The
English page now names the panel's Leave room button.
* fix(collab): lay out the room screens like the app's empty states
The joining and waiting screens were a left-aligned card with a stray
spinner, a primary Copy link button beside an outline button and a
bare link, and a name field on a screen that lasts seconds. They now use
AppPlaceholder, centred over the tab: a heading, the explanation, the
two hints, secondary Copy link and Leave, a 'You'll appear as' line
whose Change opens a small rename popover, and the desktop handoff on
one muted line. The room panel lines its status dot up with wrapped
text, no longer selects the room link when it opens, and puts the
desktop links and Leave room on one footer line.
* fix(collab): show what a room tab is doing instead of a timed guess
A joined tab said nobody with the file was online five seconds after it
opened, whether or not it had reached the signaling service or met the
people already in the room. Its state now follows what the tab can
observe: connecting until the service answers, looking for people for as
long as that transport takes to introduce everyone, getting the file
from someone who says they have it, waiting when nobody who has it
showed up (naming other guests waiting too), and a can't-connect screen
when the service cannot be reached. Each peer says in its presence
whether it has the room's file.
* fix(collab): list other waiting guests with the explanation
The line naming other guests who are waiting too is information, not an
action, so it follows the hints above the buttons. Peers' hasFile flag
is optional, as older builds do not send it.
* fix(collab): send a canvas's cursor and selection to its tab's room again
Canvases read the editor through a proxy that follows the active tab,
and the room lookup by store never matched it, so pointer moves and
selections stopped reaching the room: collaborators lost each other's
cursors, selections, and page markers. The canvas now looks up its
room by its tab's own store, and its selection listener ends when the
canvas unmounts instead of piling up across tab switches.
* feat(collab): one avatar stack for the toolbar, the mobile pill, and pages
The toolbar, the page list, and the mobile HUD each drew the people in a
room their own way, and the mobile pill read 'Online: 3' in hard-coded
English beside a status dot too small to render. AvatarStack now draws
people overlapping with their agent counts and '+N', and every place
uses it: the toolbar wraps each avatar in its menu or hover card, the
mobile pill shows the room's state dot and the stack with a translated
name, and hovering a page with people on it opens a card with the stack
and who is there, with their agents, to follow. The mobile list is the
shared presence list, so it is translated and can follow agents too.
* docs: note the page hover card and mobile avatars in the changelog
* fix(collab): stop listening to a room once its tab leaves it
A session left its Yjs observers and its awareness listener attached
after dispose, relying on destroy() and the order of teardown not to
touch the tab again. It now removes them explicitly and clears its peer
list. Also fix a missing comma in the Polish collaboration guide.
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
* 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.
* feat: select layers at Figma's click depth
Checked against Figma desktop 126 with the same pointer input in both
editors. A click walks from the page down to the deepest layer under the
cursor and stops at the first layer that is not open: top-level frames
(on the page or in a section) and sections that hold layers, component
sets, and every ancestor of the selection are open. So a click inside a
top-level frame selects its direct child, the empty part of a top-level
frame or section selects nothing, and a selected layer's siblings and
cousins are one click away. Double-click goes one level deeper and
Cmd/Ctrl-click reaches the deepest layer.
A marquee started inside a top-level frame or section selects that
container's layers; from the page, a frame or section holding layers is
selected only when fully enclosed.
* fix(vue): compare marquee hits by canvas bounds
A marquee scoped to a rotated frame mapped only two corners into its space, and page-level enclosure used a frame's unrotated rectangle. Both now compare each layer's axis-aligned canvas bounds with the marquee, so rotated frames and their children are selected by what is drawn.
* 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.
* chore: prefer es-toolkit helpers and lint the mechanical cases
AGENTS.md now names the es-toolkit helpers to reach for instead of hand-written equivalents, and the exceptions: a single clear native call or a measured hot path. The new open-pencil/prefer-es-toolkit rule rejects filter(Boolean) and Set round trips on arrays, the two cases that need no type information, and the existing 45 sites use compact and uniq. tools/ci/policy runs before dependencies are installed, so the rule is off there.
* refactor: deduplicate diagnostic categories with uniq
* fix: keep es-toolkit out of serialized Playwright callbacks
The codemod rewrote a filter(Boolean) inside a page.evaluate callback, which Playwright runs in the page where the compact import does not exist. The spec filters there again, and the rule now skips callbacks passed to evaluate, $eval, $$eval, evaluateHandle, addInitScript and waitForFunction, and filter calls on iterators from values, keys, entries and matchAll, which compact cannot take.
* test: write the prefer-es-toolkit cases like the other rule tests
Short standalone snippets, as in the base64 and JSON rule tests, instead of a declaration prefix on every case and inline object types.
* 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.
* 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.
* refactor: define OpenPencil plugin data in one typed registry
Every plugin-data key OpenPencil writes is now a field of OPEN_PENCIL_PLUGIN_DATA in scene-graph, with the Valibot schema that reads it; readPluginData and withPluginData replace per-key constants, JSON.parse and hand-matched pluginId/key filters across fig, core, and vue. Moving OkHCL onto it fixes picking a colour rewriting the layer's other plugin data as OkHCL entries.
* 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(app): record MCP and CLI structural edits as undo steps
The automation bridge ran non-atomic tools, render, and eval without an
undo entry, so Edit > Undo could not revert layers an MCP client or the
CLI created, deleted, or rearranged. Snapshot the page around these
edits as the AI chat does, and skip the entry when nothing changed so
read-only scripts leave the history alone.
* feat(app): activate documents, undo, redo, and change settings over automation
Add activate_document, undo, redo, get_settings, and update_settings to
the app's automation bridge. Settings cover appearance, snapping, canvas
rendering, recovery, and chat preferences, validated with Valibot and
applied through their owning stores; credentials, models, MCP
connections, storage, and tool access stay out of reach.
* feat(mcp): expose document activation, history, and settings tools
* feat(cli): manage documents, history, settings, and tools in the running app
Turn documents into a command group (list, open, new, save, close,
activate), add undo, redo, and settings get/set, and add tool
list/describe/call so every MCP tool runs from the shell, against the
running app or headlessly on a file.
* docs: document app control from the CLI and MCP
* fix: never prompt in the app from automation closes and saves
close_file opened the app's Save changes dialog, which an agent cannot
answer: the call timed out and the dialog stayed open. It now fails on
unsaved changes unless the caller passes unsaved "save" or "discard"
(CLI --save or --discard). save_file and new_document no longer open a
Save dialog for a document that was never saved, report a failed save
as an error, and leave the document untouched when the path is refused.
* docs: describe non-interactive close and save
* fix: address review findings in app automation
Keep a document's source when a save to a new path fails, report
vector-edit undo and redo no-ops as unapplied, echo only the applied
patch from update_settings so writing cannot read settings, reject
tool call --write/--output without a file, and stop settings get from
following inherited keys.
* fix(app): record render undo on the page that receives the layers
A render into a parent on another page was snapshotted against the
target page, so undo left the new layers in place. Snapshot the page
that contains the parent instead, and document that eval edits made
after switching pages stay outside the undo step.
* feat(app): limit automation undo to its own steps and expose design check settings
The undo history is shared with the person in the editor, so an agent's
undo could revert the user's last edit. Automation undo and redo now act
only on steps made through the bridge, and only while they are newest;
otherwise they fail and leave the history alone. Vector edit mode's
session history is off limits entirely. Settings automation also covers
the design check preferences that landed on master.
* feat(core): export flatten and outline stroke geometry from canvas
* docs(changelog): word the flatten geometry export like other entries
---------
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>
* build: update dependencies
Update the AI SDK providers, Vue, Reka UI, Valibot, Zod, es-toolkit,
CodeMirror, Storybook, Playwright, Hono and other dependencies to their
current releases, consistently across workspaces.
The Tauri plugin packages must match their Rust crates, and the new plugin
crates require Tauri 2.12, so Cargo.lock, @tauri-apps/api and the Tauri CLI
move to 2.12 as well.
* build(harness): update the AI SDK harness packages
@ai-sdk/harness 1.0.74 pinned ai 7.0.67, so the workspace carried a second
copy of ai next to the root one; 1.0.138 depends on the same ai release.
The Pi adapter no longer takes a model: HarnessAgent does. The settings
were spread from untyped records, so the compiler could not reject the
stale key and the chosen model would have been dropped; they are plain
literals now.
PiAuthOptions is now PiAuthenticationMode, and auth accepts an environment
record. Pass the gateway key that way instead of writing it into the
process-wide environment while a session is created. Derive the thinking
level from the adapter's settings, which adds 'max'.
* build: hold vue-tsc at 3.3.11
vue-tsc 3.3.12 no longer sees a v-slot binding inside a component that
also has an event listener, so check:vue reports "Cannot find name
'control'" in MCPConnectionEditor and ProfileEditor. 3.3.11 checks them
cleanly.
* fix(ai): keep retryability for provider errors reported mid-stream
From ai 7.0.80 a provider error after the response stream starts is a StreamProviderError rather than an APICallError, so classifyAIChatError lost its isRetryable.
* feat(desktop): accept updates only when signed for their version
Tauri CLI 2.12 records the app version in each updater signature, and
updater 2.13 checks it against the version latest.json announces. With
requireSignedVersion it also rejects signatures that carry no version, so a
tampered manifest cannot pair a newer version number with an older, still
validly signed bundle.
Release assembly now fails when a signature does not name the version
being released, instead of shipping one that installed apps would reject.
* docs: note the dependency update's security fixes in the changelog
* feat(ai): recommend the latest models
The provider packages now know Claude Sonnet 5.5 and Opus 5.5 and the GPT-6
series. Make Sonnet 5.5 and GPT-6.1 Sol the defaults, list Opus 5.5, Fable
5.1, GPT-6 Astra and GPT-6 Luna, and replace the two free OpenRouter models
that OpenRouter no longer serves.
* build: align the fig package's valibot with the workspace
* 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.
* 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.
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.
* fix(fig): read, render, and write Figma slots
Instances of a component with a slot showed the component's default
content instead of their own, and saving to .fig dropped slot properties,
their settings, and every instance's content.
Figma stores an instance's slot content as a frame on the internal canvas
and assigns the slot property that frame's GUID. The reader follows the
assignment while expanding the slot frame and pulls content frames into
the dependency closure without making them layers. The scene graph gains
a SLOT property type with its settings, a SLOT_CONTENT binding, and the
rule that an assigned slot's content belongs to the instance, which
component sync now leaves alone. The writer emits content frames on the
internal canvas and binds slot frames through the parameter map, as
Figma does.
The occurrence and diagnostic types move from the interpreter to
instance-overrides/occurrence.ts to keep it under the size limit.
* fix(fig): keep slot content through swaps and missing content frames
A slot assignment whose content frame the archive lacks no longer refuses
the document: the reader reports it through onMissingSlotContent, like a
missing component, and the slot keeps its component's content.
Swapping an instance's component, which variant switches do, now carries
the instance's own slot content to the new component's slot of the same
name instead of dropping it, as Figma does.
Component sync reads which slot a frame is from the component, whose
bindings instance copies do not receive. Clipboard export numbers slot
content after the other records on its dependency canvas, and the reader
and writer share Figma's default slot value.
* docs: note slot content kept across variant switches
* fix(fig): pair clipboard text by record and drop dangling slot assignments
The Figma clipboard paired text records with source text nodes by
traversal order, but instance-owned slot content is written after the
selected layers, so slotted text and the text after it swapped shaping
data. Records are now paired with their nodes through their GUIDs.
An instance assignment whose slot content frame is missing is dropped
along with the reported diagnostic, so the slot keeps following its
component instead of looking instance-owned.
* ci: pull the slots fixture for unit tests
* test: use GUID and Array.from in the slot tests
* refactor(fig): group occurrence types and paths in one folder
occurrence.ts and occurrence-path.ts became sibling prefixes when the
occurrence types moved out of the interpreter; they now live in
instance-overrides/occurrence/ as types.ts and path.ts.
* feat(canvas): draw agents' cursors as outlined sparkles
Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts.
* feat(app): publish AI agent presence to collaborators
The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only.
Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded.
* feat(app): follow agents and list them in the share panel
Following lived in collab and only knew people. It moves into the presence registry with a person-or-agent target, so you can follow anyone's agent, including your own outside a room: the view goes to the agent's page and keeps its cursor centered, stays attached while it idles between replies, and lets go when it leaves. A new editor action, centerOn, replaces reading the canvas size from the DOM. Peer cursors keep their zoom so following a person still matches it.
The share panel lists everyone in the room with their agents, each with its status, page, and a follow toggle, and your own agents can be renamed inline. CollabPanel moves to collab-panel, and the two-browser relay helpers move out of the collab spec into tests/helpers/collab.
* docs(collaboration): list the agent model among shared presence
* test(vue): a canvas story for presence cursors
Storybook now serves CanvasKit, so a story can render the real canvas:
people's arrows and agents' outlined sparkles, with controls for names,
colors, and zoom.
* feat(canvas): mark agents with a sparkle label instead of a sparkle cursor
A sparkle on its own did not read as a pointer. Agents now point with
the same filled arrow as people, in their owner's color, and their
outlined label starts with a sparkle.
* refactor(app): split the collaboration theme by component
One 18-slot theme served five components that each used a few slots,
with variants that applied to one slot. Avatars, the share button, the
presence list, page markers, and the mobile presence popover now have
their own themes, exported as tv() like the rest of src/theme.
* fix(app): truncate an agent's status before its name in the presence list
In a narrow share panel the status kept its width and the callsign
shrank to its first letter.
* feat(app): show presence and following on the toolbar avatars
The share popover held who was online, the Share button turned into a
Connected status, and following gave no feedback. Collaborators' avatars
now count their agents and list them on hover, your avatar holds your
agents and Leave room, and +N collects the rest. A frame and bar in the
followed color show whom you follow; your own input or Escape stops it.
The share popover keeps the room link, and Share keeps its label.
* fix(app): address review of following from the avatars
Escape stops following from the follow frame, once and not while typing
or after another control handled it. The frame shows the agent sparkle
as an icon. A test pins that following survives the target changing
pages mid-switch, and the test relay tolerates frames that are not JSON.
* fix(app): follow until you leave, wait for silent peers, reach agents by keyboard
Following records the page it put you on, so any other page change ends
it, even of a resting agent that would otherwise pull you back later. A
present peer without a cursor yet is waited for instead of dropped. The
room list button is always there, so keyboard users reach agents that
hover cards only show to the mouse. centerOn ignores points too far away
to represent, and the docs describe following from the avatars.
* fix(app): stop following on any zoom of yours, and keep follow switches from restarting
Keyboard and menu zoom changed the view without the pointer or wheel
input the frame listens for, so following kept going and later undid the
zoom; any viewport change following did not make now ends it. Cursor
updates no longer restart a switch already heading to the same page,
which could keep a slow page from committing. The room's connected
store is reactive, and agent rename starts on a single click.
* fix(app): never loop on a followed cursor's unknown page, and stop on zoom mid-switch
A peer's cursor could name a page this document lacks; following then
retried a switch that never moved, forever. Following now waits on such
cursors and only re-syncs after a switch that landed. A page switch
restores its viewport without viewport:changed, so your zoom during a
follow switch now stops following too.
* fix(app): cancel a loading follow switch when following stops
Stopping following, by Escape, your own input, or zoom, left a follow
page switch loading, which then took you to their page anyway. Stopping
now overtakes it with a switch to the page you are on.
* feat(core): add visual diff and patch apply tools
diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.
diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.
Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.
* feat(ai): show what each AI edit changed in its tool call
Reviewing an AI run meant reading tool output or undoing steps to see
what moved. Each document-changing call now rebuilds its page before
and after from snapshots taken around it, and diffs each top-level
layer's JSX with jsdiff, the same patch diff_jsx returns, to find the
layers it changed. After the call returns, the changed region renders
in both states at one size and pixelmatch highlights the difference.
The tool card opens on a Changes view with a before/after slider, the
pixel highlight, and a CodeMirror merge view of the JSX. Records are
saved with the conversation next to attachments.
Calls snapshot their page individually instead of through one shared
variable, so concurrent calls in a step no longer overwrite each
other's undo state. Core gains graphFromPageSnapshot for rebuilding a
past page state, diffPageLayersJSX and jsxPatch (now shared with
diff_jsx), renderRegionToImage for rendering two states of one region
pixel for pixel, and comparePNGs on the raster codec. Settings > Chat >
Change previews sets the stored image size or turns images off.
* feat(cli): add diff commands and agent diff guidance
openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.
The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.
* feat(ai): render tool calls as summarized, highlighted cards
Every tool call showed only a status and its output as a JSON string,
so render calls hid their JSX, export_image dumped base64, and long
runs filled the transcript with identical rows.
A call now shows a one-line summary read from its input and chips that
select and zoom to the layers it touched, switching to the run's page
when needed. Expanded, it shows the JSX or script it wrote and its
JSON input and output in a read-only CodeMirror view, and exported
images inline. Render calls can be expanded while their input streams,
so the JSX appears alongside the canvas preview. Consecutive calls
beyond three fold into one row that keeps the latest call visible.
CodeMirror loads with the first expanded call. The code theme gains a
monospace fallback because the editor font variable is not always
emitted.
* feat(ai): let the chat AI diff its run against the starting state
The diff tools compare two nodes, so checking an edit meant cloning a
reference first, which the agent rarely did. diff_changes compares the
current page, or one node under it, with the page as it was before the
run first edited it, in diff_create's patch format. The app keeps that
page snapshot per run and exposes it through FigmaAPI.changeBaseline;
MCP and WebMCP have no run, so the tool is offered only to the AI chat,
where it is enabled by default and the prompt asks for it before
reporting.
* feat(core): diff and patch node trees as JSX attributes
diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.
Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.
design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.
* feat(ai): report diff_changes as a patch diff_apply can replay
diff_changes printed a unified diff of the JSX, which agents could read
but not apply. It now diffs the run's baseline against the live page
with the patch engine, matching nodes by ID, so a rename is a changed
name and the output replays on the starting state with diff_apply. The
chat's Changes view keeps the JSX line diff, which is for people.
* fix(core): keep diff_apply atomic and diff files honest about differences
- Added nodes render before anything else changes; if one fails, for
example on a missing component, the rendered ones are deleted and
nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
one document has gets its status but no patch, since patches do not
add or remove pages. diff files uses it, so an added empty page no
longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
is not a non-negative integer, exiting 2; diff_create's depth is
validated the same way.
* refactor(ai): drop the unused tool JSON slot and place the JSX summary comment
* refactor(ai): find a tool change's clipping region with jsdiff
clipChangedJSX scanned both JSX sources character by character for their common start and end. diffLines gives the unchanged lines before the first change and after the last; the app now declares the diff dependency Core already uses.
* feat(core): add visual diff and patch apply tools
diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.
diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.
Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.
* feat(cli): add diff commands and agent diff guidance
openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.
The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.
* feat(core): diff and patch node trees as JSX attributes
diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.
Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.
design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.
* fix(core): keep diff_apply atomic and diff files honest about differences
- Added nodes render before anything else changes; if one fails, for
example on a missing component, the rendered ones are deleted and
nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
one document has gets its status but no patch, since patches do not
add or remove pages. diff files uses it, so an added empty page no
longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
is not a non-negative integer, exiting 2; diff_create's depth is
validated the same way.
* feat(canvas): draw agents' cursors as outlined sparkles
Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts.
* feat(app): publish AI agent presence to collaborators
The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only.
Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded.
* docs(collaboration): list the agent model among shared presence
* test(vue): a canvas story for presence cursors
Storybook now serves CanvasKit, so a story can render the real canvas:
people's arrows and agents' outlined sparkles, with controls for names,
colors, and zoom.
* feat(canvas): mark agents with a sparkle label instead of a sparkle cursor
A sparkle on its own did not read as a pointer. Agents now point with
the same filled arrow as people, in their owner's color, and their
outlined label starts with a sparkle.
Three defects a confidential Figma file surfaced, each reproduced by a
test that fails without the change.
A page's background lived only in viewport state, so an imported canvas
colour never reached the canvas and an edit was lost on the next page
switch or save. It is read from and written to the page's paints, which
the Figma API and export already carry.
Fixed-size text in a Hug container reported no intrinsic cross-axis
size, so a stretched label collapsed and clipped beside smaller
siblings. Yoga now measures it.
Populating a page resynchronised an instance whose text the file had
already resolved, replacing it with the component's default.
Co-authored-by: Victor Wads <victor@wads.dev>
`fig.kiwi` never defined the field, so nothing Figma writes carries one
and nothing OpenPencil writes has since `colorVar` replaced it. What
remained were readers kept so that files written by earlier OpenPencil
builds still restored their bindings — a compatibility path the project
does not keep before 1.0.
The field, the `VariableBinding` type and both readers are gone.
Reopening one of those files leaves the paint's colour unbound; binding
it again records it the way Figma does.
* fix(editor): keep the centered point when zooming to a fixed level
zoomToLevel found the world point at the viewport center, changed the zoom, then set the pan from that point without scaling it by the new zoom, so zooming to 100% from any other level moved the view away.
* fix(canvas): shape collaborator cursor names
Cursor names were drawn with Canvas.drawText, which neither kerns nor falls back to other fonts, so names like Orbit showed visible gaps. Draw them through the label paragraph cache like section and component titles, which also ellipsizes long names.
* fix(ui): drop icons from three canvas context menu items
Flatten, Outline text, and Outline stroke were the only context menu items with icons, so the menu looked uneven. The icon map also listed boolean operations that the context menu never shows; remove it.
* fix(app): cap the canvas menu width so long page names truncate
Unbinding a fill or stroke and saving handed Figma back the variable
the layer was imported with: the raw paint kept its `colorVar` and only
the bound path ever touched it. The unbound path now clears it, and an
emptied bindings record is dropped rather than written, since nothing
reads an empty map and it would otherwise sit in every exported file.
With that, nothing writes `colorVariableBinding`. It had no entry in
`fig.kiwi`, so encoding one meant splicing bytes into the encoded
message by searching for marker sequences; exports have written
`colorVar`, the field the schema defines, since the reader landed. The
splicing encoder and the three helpers it exposed are gone, leaving
`parseVariableId`. Importers still read the old field, so files earlier
versions wrote still open.
Co-authored-by: mrhard9090 <74858909+mrhard9090@users.noreply.github.com>
* fix(fig): give every layer on an exported canvas its own order key
Figma orders siblings by `parentIndex.position`. Shared styles, the
variable records and a canvas's own layers are written in three passes,
all landing on the internal canvas, and each numbered from the first
key — so up to five siblings claimed `!`. Exporting gold-preview left
402 records under one canvas with keys repeating up to five times.
Each pass now continues the keys already written for that canvas.
Counting them per canvas also covers the ordinary pages, whose layers
are written after the internal resources.
* fix(fig): keep the order keys a layer was imported with
Saving a document renumbered every sibling from the first key, so a
round trip rewrote positions Figma had assigned even where nothing
moved. A layer now keeps its imported key where that still orders it
after the previous sibling, and takes a key between its neighbours
otherwise.
Canvas children are excluded: shared styles and variable records are
written to the internal canvas by other passes that continue the keys
already there, so an imported key could collide with one of theirs.
Co-authored-by: mrhard9090 <74858909+mrhard9090@users.noreply.github.com>
* refactor(fig): look a sibling's order key up by id
`exportOrderKey` runs once per child and scanned the sibling list for
the node, so a parent with many layers cost O(n²) to export. The cached
entry now maps id to key directly.
Covers all three writers in the canvas test as well: an internal canvas
with its own layers, a shared style, and two variable collections, so
the pass boundaries that restarted a sequence are exercised rather than
only the page and variable passes.
* refactor(core): name the GUID type in the canvas order key test
---------
Co-authored-by: mrhard9090 <74858909+mrhard9090@users.noreply.github.com>
* refactor(fig): introduce occurrence-scoped instance interpreter
* refactor(fig): add direct occurrence materialization and render diagnostics
* fix(scene-graph): preserve nested edits and invalidate text layout caches
* refactor(fig): assemble indexed documents with occurrence provenance
* refactor(fig): validate document assembly against live scene oracles
* fix(text): preserve saved glyphs and supported run paints
* test(fig): share typed GUID fixture helper
* fix(fig): resolve component root keys in instance overrides
* fix(kiwi): reject malformed byte arrays before encoding
* fix(components): target properties by source identity through undo
* fix(fig): preserve editable occurrence export contracts
* refactor(fig): construct live component dependency closures
* fix(fig): invalidate inherited text geometry after occurrence overrides
* perf(fig): reuse component expansions and narrow payload copies
* perf(fig): avoid discarded metadata and instance definition copies
* perf(fig): transfer parsed records into archive reader ownership
* feat(fig): add incremental page sessions with load rollback
* test(fig): verify page deltas and stale revision rejection
* feat(fig): wire reader worker sessions and compact recovery checkpoints
* docs(fig): organize reader architecture and visual examples
* chore(fig): checkpoint WIP reader and writer overhaul
Preserve in-progress FIG reader, instance interpretation, editable export, and validation work on its feature branch. This is a backup checkpoint, not a release-ready or fully validated change.
* refactor(fig): resolve instance structure before expansion
Route swaps and property assignments down to the instance they configure
so each occurrence expands once with its effective component and complete
assignment list. Owners then apply property claims onto the built subtree,
which keeps values in the declaring owner's coordinate space and orders
inner owners before outer ones without re-expansion, recipes, or patch
restoration.
Track the components an occurrence expanded before an outer decision
replaced them, including intermediate swap assignments, so a claim that
resolved against a superseded component is retired while a genuinely
missing target still reports. Precedence is one rule: an explicit claim
keeps a field unless a strictly outer owner assigned it.
Drop the detached-lineage remap heuristic; unresolved assignments report
through the existing diagnostic instead of guessing a replacement target.
The Accordion source-closure fixture reports two stale overrides, not
three: the third came from a subtree the old interpreter expanded and
discarded.
* refactor(fig): derive override field handling from one registry
Describe each claimable raw field once, with its SceneGraph fields, kind,
and whether it is a length, and derive claim recording, layout-distance
scaling, and export serialization from it instead of maintaining parallel
tables.
Restore every field the uniform scaler touches from the instance record
after scaling. The record already describes the placed result, but corner
radii, dash patterns, and effects were previously scaled without being
restored, so a scaled instance with its own corner radius rendered it
doubled.
* fix(fig): retire nested swaps under a replaced component
A structural layer routed through an instance whose component an outer
owner replaced may still address the original component's children. Such
a layer is stale in the same way a property claim is: it resolved before
the outer decision and has no target now. Carry the replaced components
across that boundary and skip the layer instead of failing the file.
material3's List swaps a list item to another variant while the item's
own saved swap of a trailing checkbox still names the original variant's
child.
* fix(core): report stale Figma override records instead of refusing the file
Figma keeps override, assignment, and binding records that address nodes
it later deleted, and material3.fig could not open because the reader
ran the document session strictly. Share one set of session options
across the reader and recovery sessions that collects those records as
diagnostics and skips them; a swap whose replacement is missing remains
a structural failure.
The component-metadata expectation follows the visible Buttons page copy
of the component set, which the dependency closure now resolves instead
of an internal-only copy.
* fix(fig): keep instances of deleted components when opening a document
Figma retains instances whose main component was deleted, and material3's
Internal Only Canvas has 56 of them, so an edited document could not be
exported: export loads every page and the reader refused the page over
missing reachable sources.
The dependency closure now separates deleted components from broken
hierarchy, which remains fatal. With the new onMissingComponent option the
interpreter keeps such an instance as a childless occurrence that retains
its saved reference, applies only its root claims, and reports the owner;
strict interpretation still fails. The core reader opts in, shares one
diagnostics sink with recovery and export sessions, and exposes it through
readerDiagnostics(). Property defaults naming a deleted component are kept
the same way, so an edited export no longer rejects them.
* fix(fig): resolve variant property values through the component set
A variant's saved specs name variant definitions that its component set
owns, so occurrence conversion left them keyed by definition id. Resolve
them to names once the set is in the graph, as the previous importer did.
The component-metadata expectation follows the visible Buttons set's
axes; the Style axis belonged to an internal-only copy.
* refactor(fig): satisfy type-aware lint in the interpreter and export
* fix(fig): keep an instance fill override's variable alias across export
A fill or stroke override on an instance descendant lost its colour
variable on export: the paint claim was written without the alias, and a
boundVariables override for a paint colour produced no claim at all
because paint colours are not node-level consumption fields. The reopened
paint therefore bound to the component's default variable.
Write override paints through the same alias-aware builder as node
paints, serialize a paint colour binding override as the paint claim
itself, and on import record the binding claim alongside a claimed paint
that carries an alias so a later component sync cannot restore the
component's binding.
On an edited material3.fig round trip this removes all 10,329 fill
differences; 2,217 of 78,425 nodes still change, almost all text metadata
Figma keeps on outlined vectors.
* docs(fig): describe the single reader, its diagnostics policy, and paint claims
The status documents still said the replacement reader covered only some
worker paths and that old-reader removal was pending. Every import path
now uses it and the previous importer is deleted, so state that and move
the open items to fidelity and performance.
Record the contracts added recently: strict-by-default interpretation
with per-session diagnostic handlers that the application reader opts
into, instances of deleted components kept as childless instances, the
shared override field registry, paint colour aliases serialized inside
paint claims, and variant values resolved through the component set.
Correct the clipboard ownership rule in AGENTS.md: the envelope belongs
to fig, pasted records go through the same reader as documents.
* docs: note exported instance overrides in the changelog
* refactor(fig): share record indexing and symbol data access
Five modules built their own GUID-to-record index with the same idiom;
they now use the source index, or indexRecords when child order is not
needed. The Kiwi codec types only symbolID, so every reader cast
symbolData to reach overrides and the uniform scale; symbolDataOf,
symbolOverridesOf, and uniformScaleOf replace those casts. idOf and
parentIdOf name the record identity conversions used by ancestry walks.
* refactor(fig): share tree search and traversal across records and occurrences
The rule that a path segment may pass through ordinary containers but
never implicitly into an instance existed three times, once per tree.
findWithinBoundary owns it now, parameterized by a tree shape; the
occurrence resolver and the static record resolver are two callers.
An occurrences() iterator replaces hand-rolled recursion in the
component planner, closure, layout scaler, and correspondence linker,
forEachOverrideRecord replaces the record-plus-overrides walks in the
dependency scans, and one child-pairing generator serves both
source-children matchers.
* refactor(fig): serialize override claims from the field registry
Split export-node.ts: export-context.ts owns the serialization context,
GUID allocation, and paint builders; override-claims.ts owns instance
override serialization. The override serializer was a chain of field
checks that had to agree with the registry materialization records
claims from; it is now one switch over the registry's field kinds, the
export side of that table, with swaps and variable bindings as the two
cases the registry does not describe.
Decoded record streams for an edited gold-preview export and a
synthetic bound-fill export are identical before and after.
* fix(core): record instance overrides for FigmaAPI rename and resize
The name setter and resize() wrote to the graph directly, so a rename or
resize of an instance child through the Figma API was never recorded as
an override: component sync reverted it and export did not write it.
Route both through the shared recording update like every other setter.
* fix(fig): address overrides inside nested instances by the definition child
An override on a child of a nested instance was addressed through the
enclosing component's own copy of that child. That node lives inside an
instance and is never written as a record, so Figma could not resolve the
path and dropped the override. Follow the correspondence until it leaves
every instance, which yields the nested component's child, the record
Figma itself names in the same situation (verified against Figma's
clipboard encoding of the identical edit and by reopening the export).
* test(fig): record the Figma reopen of reader exports
* test(fig): compare reopened exports with the oracle tool
The interpreted-document comparison already reads Figma's interpretation
of an archive against the reader's; pointing it at an exported archive and
its imported Figma file makes it the reopen check. Captures need the
imported file to be the active document, so add an activate-tab operation
that brings a desktop tab to the front through the shell page. Record the
comparison results for the three reopened exports and document the
procedure.
* fix(scene-graph): keep a nested instance's correspondence across a swap
Children populated by cloning link to the enclosing component's record
through componentId. Swapping a nested instance replaced that field with
the new component, so the swap was exported against the replacement
component's GUID instead of the nested instance record and Figma could not
apply it. Record the correspondence as the owner's sourceComponentId
override and the swap as its componentId override, as materialized
documents already carry them.
* fix(core): treat applied shared styles as instance overrides
Style references were not instance sync fields, so a text style applied
inside an instance was neither recorded as an override nor exported, and a
component's style change did not reach its instances, although the reader
records styleIdForText claims from Figma. Add the style reference fields
to the sync set and expose them on the Figma API proxy under Figma's
names so assignments through the API record overrides.
* test: record the second Figma reopen round for the reader export
Figma confirmed stroke and corner-radius variable bindings, an applied
text style, nested-frame layout distances and sizing modes, visibility,
and a nested swap. A size claim on an auto-layout child inside an
instance is not applied, matching Figma's own resize refusal there.
* chore: format the merged structural export test
* refactor: group export and instance sync modules into domain folders
The node-change export context, node serializer, runtime, and override
claims move under node-change/export/, and the scene graph's instance
child sync and sync field lists move under instances/, keeping the
public instances module to its API.
* fix(fig): address exported instance overrides by override key
Figma resolves an override path segment through the target record's
override key, never its GUID: in gold-preview.fig all 10,341 override
and 12,838 derived-geometry segments resolve that way and none resolve
to a node GUID. A component imported from Figma keeps its keys, but one
authored here has none, so the writer addressed its descendants by GUID.
Figma tolerated that for most fields and silently dropped the geometry,
so a descendant resized inside an instance reopened at the component's
size.
Definition records — a component and everything inside it — now carry an
override key, minted from the shared identity counter when the node has
none, and paths name that key. One map spans the document because the
serializer runs once per top-level child.
The library content hash ignores the key, which identifies a record
rather than the component's content, and the clipboard export passes its
variable mode map as modeIdToGuid instead of propertyIdToGuid.
* docs: record how Figma resolves an override path
* Revert "fix(fig): address exported instance overrides by override key"
This reverts commit 38eebb2e5, except its clipboard argument fix.
The change came from gold-preview.fig, where every override path segment
resolves through a record's override key. material3.fig shows the
opposite: 51,332 of its segments are node GUIDs against 24 keys, and only
16 of 87,237 records carry a key at all. gold-preview is a file of
library instances, where the key is the cross-file identity; addressing
by GUID is what Figma writes for locally authored components, which is
what the writer already did. It was also not the reason Figma ignored a
descendant's size claim, which is still open.
The clipboard export keeps passing its variable mode map as modeIdToGuid
rather than propertyIdToGuid, which was an unrelated defect in the same
call.
* docs: correct the override addressing note and record the size gap
* docs: settle the descendant size gap as a Figma constraint
* chore: format the JSON fixtures this branch adds
format:check runs the formatter and fails on any change, so the fixtures
have to be committed as oxfmt writes them.
* test(tools): smoke the instance override subpath's current exports
populateAndApplyOverrides belonged to the importer this branch removes.
* perf(fig): index the archive once per document, not once per page
Selecting a page rebuilt both whole-document source indexes, so opening
material3.fig with its 33 pages indexed 87,237 records 33 times and
86,888 records another 33 times: 102 index builds where 36 are needed.
Only the page's own subset varies, so the full index and the component
interpreter move into state shared across selections, and the initial
read path passes its index to inheritance, style lookup, the dependency
closure and component planning rather than each building its own.
The paint and component-property passes iterate keys directly instead of
materializing an entry array for every node, most of which bind nothing.
Loading material3.fig goes from about 9.5s to about 7.5s on the same
machine, measured back to back with the machine otherwise idle.
* docs: note the faster multi-page .fig load
* perf(fig): apply document passes to the nodes a page materialized
Linking component property values, resolving variant values and applying
layout and paint bindings each walked the whole graph and skipped what
was already there, so every page load re-visited every node the earlier
pages had produced. On nuxtui.fig, 121 pages over a graph that reaches
354,000 nodes, those four passes were 22.7% of the profile after only
six pages and grew from there.
Each pass now takes the nodes just materialized. Component property
types are remembered across page loads instead, because an assignment on
a new node can name a definition an earlier page introduced; seeding that
cache is the only pass that still reads the whole graph, once per
document rather than once per page.
Pages 3 to 20 of nuxtui.fig fall from 90.0s to 51.6s. The first page is
unchanged: it materializes 256,354 nodes and is dominated by that.
* docs: note the per-page load improvement
* test(fig): keep the fig package suite off Core
Twenty package tests reached for Core's writer and editor through
@open-pencil/core, a package that depends on fig. Nothing declared that
edge, so the suite passed only because the workspace root hoists Core.
Their subject is the writer, so they move to tests/engine/io/fig, where
half the domain already spans both packages.
The package no longer escapes its own root: tsconfig drops the #tests/*
mapping, expectDefined is three lines beside the other helpers, and the
gold archive is read through the LFS-guarded fixture helper instead of a
hand-built ../../../../tests/fixtures URL.
#fig/ and #fig-tests/ join the steiger alias tables and the AGENTS.md
list, so the foreign-alias rule can see them. Fig's tests mirror its
source tree rather than sitting flat like kiwi's, so they address it by
alias instead of drilling, and the guid helper is imported one way.
* refactor(fig): drop code the reader replacement left behind
resolveDsdGeometry lost every production importer when the old derived
symbol data modules went, so it and the three tests that only exercised
it go too, and the folder collapses to one file. validateVariableAliases
was called only by its own test and wiring it in would mean a new public
diagnostic handler; it is removed rather than left dangling.
recordInstanceOverrideValue had no caller in either base or head, and
its comment began mid-sentence. SymbolOverrideFields had no consumers,
and savedTextEligibility is used only inside its module.
The clipboard's NON_VISUAL_TYPES was a hand-copied union of the two sets
behind isFigClipboardVisualType, which had no consumer of its own; the
classifier now serves both and leaves the root export.
FIG_PACKAGE_STATUS reads document-reader, and assertFigPackageReady is
gone: the package reads archives into a SceneGraph rather than telling
callers to use Core.
sceneNodeToKiwi takes its ten optional maps as an options object. That
removes the signature Core's wrapper had to restate, which was the last
clone blocking packages/fig/src from the duplication gate, and the
undefined holes at the clipboard's two call sites.
* refactor(core): share identity allocation between the two .fig writers
The clipboard allocated variable, mode and shared-style GUIDs its own
way while the document exporter did the same work in assignVariableGuids
and appendInternalResources. The two already disagreed: the exporter
reuses an id that is already GUID-shaped and dedupes against node source
GUIDs, the clipboard always minted a fresh sessionID 1. Both now call
one pair of helpers in variable-export.ts, so a change to how a document
names its resources reaches the clipboard too.
* refactor(fig): name the values that were spelled out in several places
exportSizing existed to name the HUG ternary but the inline layout
branch still wrote it out. The winding-rule conversions become
toKiwiWindingRule and fromKiwiWindingRule rather than the same ternary
three times and its inverse once. sameId duplicated sameGuid. The style
reference field list existed twice, and one site built a GUID string by
hand instead of calling guidToString. The opacity percent-to-unit factor
and the alias-or-expression test each have a name now.
fig.kiwi declares parameterConsumptionMap as a VariableDataMap and
PropRefValue as a variable value, but the codec typed neither, so four
call sites cast. Typing them in kiwi removes the casts, and the merge
that spread two maps now builds the only field the message has. Schema
coverage counts one more modeled field and one fewer raw-preserved.
* refactor(fig): require the index instead of rebuilding it behind a default
createScopedReader is private and always receives the shared state, and
the closure, component planning and property inheritance always get an
index from it; the optional parameters existed only so two tests could
omit them, and each hid a second full pass over every record. They are
required now, and the tests build an index the way production does.
materializeReader returned a fresh object that dropped definitionTypes,
so the first loadPage after createFigDocumentSession reseeded the cache
it was meant to reuse; it returns the state it was given.
The shared style reference shape is a named type built with the rest of
the export context rather than written inline twice and filled lazily
inside a getter, and the population client derives its two responses
from FigSessionResponse instead of restating one and casting to it.
* refactor(fig): give materializeInstance named options
Three of its seven parameters were defaulted maps that call sites passed
unnamed, so a call read as a list of empty collections. They become an
options object, matching how InterpretInstanceOptions is passed in the
same folder.
That change also caught a latent hazard: an empty array satisfies an
all-optional interface structurally, so a call site left on the old
positional form type-checked while silently dropping its source-child
map. Converting the remaining call sites fixed a component sync test
that had started failing for exactly that reason.
The DOCUMENT/VARIABLE guard is one assertion function rather than two
copies, and it narrows the node type for the creation that follows.
* refactor: group the prefixed siblings this PR left behind
instance-overrides kept layout-scale, text-scale, interpret-bindings and
variable-bindings as prefixed siblings while the same PR introduced
scene-graph/src/{scaling,variables}/. They become scale/{layout,text}
and bindings/{properties,variables}. The empty derived-symbol-data
folder is gone now that it holds one file.
STRING_BINDING_FIELDS and BOOLEAN_BINDING_FIELDS stayed in variables.ts
after NUMERIC_FIELDS moved to variables/fields.ts; all three live
together.
* docs(fig): describe the reader as it is, not as a replacement
The README, document-sessions, validation notes and several comments
still framed the work as pending: an old reader to delete, a migration
to finish, variables and lazy loading not yet integrated. All of that
landed. Error messages and a worker adapter that called themselves
"replacement reader" and "format-neutral" say what they are.
Comments that described the wrong function are reattached: the root
layer note belonged to resolveRoot rather than bindingHistory, the
expand note was duplicated onto bindRecord, the owner-scope note sat on
pairSourceChildren instead of linkInstanceSourceChildren, sync.ts put
its module summary on setSceneProp, and transfer/history.ts ended with
an orphan.
The visual oracle's interpret-instance and compare interpreted-document
are citty subcommands like the rest, its SCREAMING-CASE note folds into
packages/fig/docs/validation.md without the benchmark observation, and
its two tests mirror the source tree using the package alias.
* docs(fig): keep Figma observation records out of the fixture tree
Ten JSON records, twelve notes and a screenshot under tests/fixtures had
no code consumer: they are what Figma reported for a given document,
cited by packages/fig/docs. They move to packages/fig/docs/observations
beside the prose that reads them. The three JSON files tests do load,
and the eight screenshots the raster comparisons load, stay where the
tests expect them.
Fixture READMEs follow their fixtures: the gold layout and shared scale
notes to tests/engine/io/fig/instance, the export contract note to
tests/engine/io/fig/export. Numbers fused to the words before them are
separated throughout the notes.
Path failures assert the diagnostic reason through one helper rather
than matching 'found 0' or a full sentence, which is the pattern
materialize.test.ts already used.
* refactor(core): name the reader state module for what it owns
session/recovery.ts holds the per-graph reader state and, with it, page
population, diagnostics and export population as well as recovery. The
functions cannot move out without exporting that state map, so the file
takes an accurate name instead, and the state type follows.
io/formats/fig/index.ts keeps its aliased re-export: the relative path
is three levels up, which no-deep-parent-relative-imports rejects.
* chore: adopt the js-base64 rule master added
* test: move the new tests to the homes master's gate requires
#790 added check:test-homes: a new test under tests/engine is rejected,
and the baseline of existing ones shrinks. This branch had added 46.
Their owner is whichever package the test's subject lives in, not the
directory the old shard map implies. Forty test Core's writer, editor or
reader session and move to packages/core/tests, which gains the test
tsconfig and scripts the other packages already have; six test Fig alone
and move to packages/fig/tests. verifier-contracts covers the roundtrip
helpers that eight grandfathered engine tests share, so it stays beside
them and joins the baseline.
Package tests no longer reach outside their package for support: each
has local assert, guid, fixture and nested-binding helpers, and shared
archives under tests/fixtures are read through a helper path rather than
imported as modules across the root. interpretComponent,
materializeComponentClosure and the source-children helpers are public,
because tests outside Fig legitimately need them.
The steiger owner for #core/ and #fig/ is the package rather than its
src, since a package's own tests mirror the source tree and would
otherwise drill through ../../src.
* test: mirror each package's source tree in its test tree
The relocated tests kept their tests/engine directory names, which do
not match the packages they landed in: figma/api against src/figma-api,
render/canvas against src/canvas, io/fig against src/io/formats/fig, and
a fig tests/io and tests/text with no counterpart in that package. Each
now mirrors its source domain.
Two had no home in the package they were put in. The derived-text layout
invalidation test only exercises Scene Graph, so it moves there, and the
transfer plan test spans Scene Graph and Fig with neither owning it, so
it becomes the first tests/integration spec, which is what that
directory is for.
tests/AGENTS.md named a baseline path the tools reorganization moved,
and packages/fig/AGENTS.md now records its own test alias.
* fix(fig): open a file whose swap names a layer its component lost
Preline UI's `_header/navbar` keeps a swap addressing 4473:100430, a
node the archive no longer contains, while the replacement it names is
still there. Figma opens that file and so did the previous importer;
this reader refused it.
The rule was written for a swap whose replacement is missing, which
nothing can resolve, but the code threw for any unresolved swap. A path
that matches no record is a record Figma kept after deleting the layer
it named, which is the case the property and assignment diagnostics
already cover. A path that matches more than one record is a wrong
address rather than a stale one and still fails.
* fix(fig): address an override through the variant that holds its layer
An instance path names a layer by the identity it had in the variant the
override was written against. Switching variants keeps the override in
Figma, so a segment that names no layer of the variant an occurrence
expands now addresses the layer at the same position there, when the two
agree on type and name.
Resolution reports the path it took, so a claim recorded after a
translated segment stays addressable when the instance materializes.
Each component set's addressable layers are indexed once on first use
rather than rescanning every sibling variant per segment.
* fix(fig): read text bound to a string variable
Figma stores a bound layer's resolved characters, but an instance
override carries the binding alone, and a literal override of a bound
layer is retired rather than applied. Reading neither left the badge on
Preline's navbar showing its component's own text where Figma shows the
variable's value, and the input placeholder showing a literal override
Figma ignores.
Text joins font family as a bindable string field, the reader records a
TEXT_DATA alias like any other binding, and a post-pass resolves it once
hierarchy and modes exist, next to the paint bindings it mirrors.
Resolving after property claims is what makes a binding win over a
literal, the way Figma retires the override.
Validated by reopening an exported file in Figma: the collection, the
string variable, and the binding on both the component and its instance
survive the round trip.
* fix(fig): take a bound paint's transparency from its variable
A solid fill draws at its paint opacity, not its colour's alpha, so a
colour variable carrying transparency has to supply that opacity.
Resolving the binding into the colour alone left a translucent token
applied twice on Preline's navbar links, and left a Divider at the
opacity of an override the binding supersedes.
The variable now owns the whole colour: its alpha becomes the paint's
opacity and the colour keeps none of its own.
* test(tools): compare paint in the interpreted-document oracle
The oracle checked type, name, visibility, text, main component and box,
so every fill and stroke a reader produced went unchecked. A wrong fill
transparency on Preline's navbar passed it.
Paints are captured on both sides as the alpha drawing actually uses,
which is the paint's opacity for a solid, and reported as visible-paint
or hidden-paint like geometry. A Scene Graph stroke is always solid, so
it is encoded as one rather than through a type it does not carry.
* perf(fig): synchronise a component once per page load, not once per instance
Materializing an instance into an open document re-synchronised every
instance of its component, and synchronising walks each one's subtree.
A page that places a component many times therefore paid that walk once
per placement. Opening Preline's CMS page ran 954 synchronisations over
39225 instances for the 954 it placed.
Components are collected while the page is built and synchronised once
each afterwards: 31 calls over 1283 instances, and the page loads in
3.9s rather than 11.6s. The resulting graph is unchanged, by digest over
every node's geometry, text, paint, bindings and override keys for that
page and for a second page loaded on top of it.
* Revert "fix(fig): address an override through the variant that holds its layer"
This reverts commit fcdc7660f.
Figma does not carry an override onto the corresponding layer of another
variant, so translating a segment that way applies overrides it drops.
On Preline's Alerts frame the translation raises semantic differences
against live Figma from 2 to 54: 127 buttons read their own label where
Figma reads the component's. It fixed nothing visible — the five text
differences it was written for turned out to be string variable
bindings, fixed separately — so it only ever added wrong overrides.
* docs(fig): restore the guide rules the master merges dropped
Splitting the root guide into nested ones lost three rules this branch
had added, and left the fig guide claiming clipboard records are
converted to a SceneGraph in `@open-pencil/fig/clipboard`, which is now
`materializeFigFragment` driven from Core.
Records what the reader cannot do as well: a string binding resolves
once at read time, so text bound to a variable goes stale when the
variable or the node's mode changes, unlike a numeric or colour one.
Groups the four `*-bindings` siblings under `document/bindings/`, the
convention the branch already applied to `instance-overrides/bindings/`.
* perf(fig): copy archive records directly instead of structurally
Every expanded record is deep-copied so an occurrence shares no mutable
data with the archive, a contract two tests state. `structuredClone`
was a third of the time spent opening a page, and records are plain
Kiwi data, so copying them field by field is several times quicker —
43944 records of Preline UI clone identically either way, 218ms against
26ms. Byte buffers and anything else that is not an object literal keep
the structured algorithm.
Preline's CMS page now loads in 2.8s rather than 5.6s, and with the
per-component synchronisation fix in 0d1854a3a, 11.6s before either.
* test(tools): compare a reader's whole output, not one frame
`compare interpreted-document` checks one frame against live Figma. A
rule can leave that frame untouched and still change pages it does not
cover: addressing an override through a sibling variant reported no
difference on the frame under test while rewriting 127 button labels
elsewhere, and was reverted only after a whole-document comparison
found them.
`compare digest` captures every page a reader produces and diffs it
against an earlier capture, reusing the same node capture and
difference categories, so a before-and-after needs no Figma. Replaying
the reverted change against a baseline reports 110 semantic
differences. Unresolved-override counts are reported beside the nodes,
since a reader change usually moves those too.
* feat: preview streamed JSX on the canvas
Project incomplete JSX into isolated scene graphs and disposable pictures without mutating the document or adding intermediate undo entries. Share placement with final rendering and cover lifecycle and placement parity with AI SDK mocks and visual tests.
* test: require partial input for unfinished coordinates
Assert the complete partial object so rejecting the entire input cannot satisfy the truncated-exponent regression test. Addresses CodeRabbit's review finding on #692.
* feat(ai): keep a chat run on its page across page switches
Page switches go through the editor's preparation flow, and the chat panel treated every preparation as a document change: it dropped its Chat and reloaded history, detaching the panel from a reply still in progress. The panel now keeps the live chat unless the tab or the conversation changes.
AI tools also followed the page on screen, so a user browsing mid-run sent the next edits elsewhere, and the agent's own switch_page affected only one call. A run now pins the page where the message started; switch_page moves the run and the user's view, and streamed previews stay attached to the run's page, which the renderer draws only while that page is on screen.
Page snapshots now restore the page they were taken of, so undoing an AI edit works while another page is visible.
* refactor(core): share picture recording and export preparation with previews
Preview recording reimplemented three pieces Core already had: world-bounds picture recording (also duplicated by render chunks and the retained backing), font and layout preparation (prepareForExport), and page subgraph extraction. Extract recordWorldPicture and withWorldViewport for all three recorders, reuse prepareForExport, and add extractPageContext and findPageChildId next to the other subgraph helpers instead of editing a cloned graph's nodes.
prepareForExport also kept the shared layout text measurer overridden across an await, so a concurrent layout could measure with the export renderer. withTextMeasurer scopes the override to the synchronous layout.
* fix(design-jsx): inline nested fragments in streamed previews
The streaming projection kept a nested fragment as an empty-type node, which rendered trees inline, so a preview of <Frame><>…</></Frame> failed with 'Unknown element: <>'.
* refactor(ai): schedule previews and gate test streams with VueUse
The preview controller hand-rolled a trailing timer and abort-listener cleanup, and the test stream gate a promise resolver and listener set. Use useDebounceFn with maxWait (a lone delta still flushes, unlike useThrottleFn with leading off), useEventListener, and until(). Share the mock token usage between chat tests.
* fix(ai): keep previews alive through document edits and slow builds
Document edits finished every preview call, and onInputStart never restarts one, so a render call committing while a second was still streaming ended the second call's preview for good. Edits now invalidate: drop the shown artifact and rebuild on the new document.
A build that finished after another delta arrived was discarded, so a steady stream that outpaced staging and recording never showed a preview. Show it, then render the newer revision.
* docs(changelog): separate the Fixed heading from its entries
Add the blank line markdownlint (MD022) expects after the heading, and drop the one that split the Fixed list in two.
* build(core): import Markdown with unplugin-raw
Core inlined ?raw imports with a hand-written Rolldown plugin that also turned plain .md imports into strings, which nothing in Core uses. unplugin-raw already does this for the Vue SDK and design-jsx; use it here too and drop the unused *.md module declaration.
* refactor(pen): use the scene-graph color parser
pen/src/color.ts duplicated parseColor from @open-pencil/scene-graph/color line for line. Import it instead, which also drops pen's direct culori dependency.
* fix(figma-api): validate effects like Figma
The effects setter stored whatever a script passed, so scripts that Figma rejects ran here and malformed effects reached rendering and .fig export. Validate against Figma's effect shapes with Valibot, recorded from live Figma: strict objects, required shadow fields, radius >= 0, RGBA channels within 0..1, and no shadow fields on blurs. The getter now returns Figma's shape so node.effects = node.effects keeps working.
Closes#786
* fix(figma-api): reject infinite numbers in effects
Figma rejects Infinity in every effect number ("Number must be finite"), but v.number() accepts it, so infinite radii, offsets, and spreads reached the scene graph.
* fix(figma-api): store PASS_THROUGH shadow blend as NORMAL
PASS_THROUGH is a layer blend mode. Live Figma accepts it on drop and inner shadows but reads NORMAL back, so do the same instead of storing it.
* refactor(design-jsx): extract design JSX into its own package
Design JSX elements, helpers, schema, reference, and JSX export only need
the scene graph, yet lived in Core, so every consumer of the authoring API
pulled in the renderer, layout, and file formats.
@open-pencil/design-jsx now owns them and depends only on scene-graph. The
renderer takes icon lookup, SVG conversion, vector creation, and layout as
DesignJSXServices; Core binds its own and exports the bound renderJSX and
renderTree from @open-pencil/core/design-jsx.
* feat(design-jsx): export the JSX runtime for TSX authoring
The package already had a JSX runtime, but nothing exported it, so design
trees could only be written as function calls or JSX strings. Export
`./jsx-runtime` and `./jsx-dev-runtime` so `jsxImportSource` works, and make
`Fragment` produce the same empty-type node as `<>` in `renderJSX` strings.
* fix(design-jsx): render fragments nested in other elements
A fragment builds a node with an empty type, which only renderJSX expanded, and only at the root. Nested fragments and fragments passed to renderTree failed with 'Unknown element: <>'. Inline fragment children when trees are built, and share root expansion between renderTree and renderJSX.