Commit graph

106 commits

Author SHA1 Message Date
Danila Poyarkov 11e708de39
fix(layout): size each axis on its own and lay out before geometry reads (#942)
* fix(figma-api): lay out pending edits before geometry reads

Scripts read x, y, width, height, transforms, and bounds as they were before the script ran until the tool finished and laid out its changes. Figma lays out on read, so a hugging parent reports its new size right after a child is added.

The graph now records the scope of edits outside layout application, and the geometry getters lay that scope out first through the same runner tools use after a call. One recorder exists per graph; a new FigmaAPI starts it afresh because the editor lays out its own edits.

* fix(layout): size each axis on its own, as Figma does

Fill is stored on the child, as layoutGrow along the parent's primary axis and STRETCH across it, with a grid laid out like a row; primaryAxisSizing and counterAxisSizing only fix or hug. A shared layoutSizing helper in scene-graph reads and writes per-axis sizing, and layout, the Figma API, the properties panel, design JSX, DOM/CSS export, and .pen import use it.

This fixes grid children filling both axes when set to fill one, auto-layout children that stretch but kept a fixed size, the Figma API writing Fill to a frame's own sizing (which .fig export dropped), and JSX and HTML exports losing grid and cross-axis fill. Behavior was checked against live Figma, and imported layouts were compared with the geometry stored in material3.fig and nuxtui.fig.

* fix(layout): keep pending edits until laid out and opt out of inherited stretch

A new FigmaAPI cleared the edits recorded on its graph, so after a script failed before its tool laid out its changes, the next script read stale geometry; edits now stay recorded until a read lays them out, and return to the record if that layout throws. Setting a child to Fixed or Hug across a parent that stretches every child left it filling; it now opts out with MIN, as frame presets do.
2026-10-07 11:52:08 +00:00
Danila Poyarkov 7992516d81
fix: match Figma's transforms, strokes, booleans, sections, and names (#931)
* fix(core): start new layers with Figma's defaults in the editor and plugin API

The plugin API created bare nodes: frames, components, and shapes without fills, and lines and vectors without strokes, so scripts written for Figma drew nothing. Drawn lines also had a black fill instead of a stroke and were invisible. Both paths now share newLayerDefaults, recorded from Figma desktop 126: frames and components white with frames clipping their content, shapes #D9D9D9, lines and vectors a black 1 px stroke, text black. A stroke a script adds gets the 1 px default weight, and an empty vector has no render bounds.

* fix(core): combine variants as Figma does from the canvas and from scripts

The plugin API and the editor command each built component sets their own way, both with 40 px of padding and a grey fill. Figma's command pads the variants by 20 and outlines the set with a 1 px dashed #8A38F5 stroke; its plugin API wraps them exactly with no fill or stroke. One variantSetProps now places and styles the set for both, with a canvas or script style, and applyVariantProperties derives variant properties for both.

* fix(core): report group children in their container's space in the plugin API

Figma's plugin API places children of groups and booleans relative to the nearest real container and refits a group whenever a script changes one of its children. Ours reported group-relative positions and never refit, so scripts placing layers inside groups landed them in the wrong place. x, y, and relativeTransform now map through the groups around a node, and geometry changes, appendChild, insertChild, and remove refit the surrounding groups. The refit moves to Scene Graph as fitEnclosingGroups, shared by the canvas (with undo) and the plugin API.

* test(core): pass script-style strokes and typed components in parity tests

* test(e2e): expect Figma's default shape grey in the scene freshness spec

* fix(vue): draw lines by length and angle as Figma does

The Line tool sized a line as the box spanned by the drag. With the stroke a new line now gets, that box drew as a rectangle outline. A line now starts at the press point with the drag length as its width, no height, and the drag angle as its rotation, as Figma's Line tool makes it; Shift snaps the angle to 45° steps, as the docs already described, and a click makes a 100 px horizontal line.

* fix(core): give each new layer its own copy of the default paints

The defaults spread each paint shallowly, so every layer shared the colour object of the module-level default and editing one layer's colour in place changed the next new layer. Copy the paints with the Scene Graph copy helpers.

* fix(core): group, ungroup, and combine layers through shared code in the plugin API

The plugin API wrapped layers, ungrouped, made booleans, and made components from layers with its own code. Ungroup moved the children to the top of the stack, booleans were named "Boolean union", and a component made from a frame cloned its children under new ids. These now run through the editor's shared wrap, ungroup, and boolean functions, with the placement and defaults recorded in Figma desktop 126: a group or boolean without an index goes on top, ungrouped children take the group's place, booleans are named after the operation and filled with the default grey, a frame becomes a component in its place with its children, and any other layer is wrapped in a white component named after it. Undoing a wrap in the editor now returns each layer to its own place in the stack.

* fix(core): group, frame, combine, and make components from the canvas as Figma does

Recorded in Figma desktop 126: a container made from the canvas takes the topmost selected layer's place, Frame selection adds no fill and does not clip, a component wrapped around layers is white and takes a single layer's name, and a boolean is filled like its topmost operand, or its base for Subtract, without strokes. The canvas commands and the plugin API now share the wrap parent check, stack ordering, component rules, and boolean paints, and the plugin API's createComponentFromNode converts groups in place as Figma does. Undoing a boolean returns each operand to its own place in the stack.

* refactor(core): reuse translate when centering pasted layers

* fix: match Figma's transforms, strokes, booleans, sections, and names

Each behaviour was recorded in Figma desktop 126 with the same script, or
from its canvas, and both the editor and the plugin API now share it.

- Scripts turn a layer counterclockwise about its top-left corner, read x
  and y as that corner, keep it in place on resize, can set
  relativeTransform, and get absoluteBoundingBox around the turned layer.
  appendChild and insertChild keep x, y, and rotation in the new parent
  instead of the canvas position, which the create and reparent tools
  inherit.
- A layer keeps its stroke weight and alignment without strokes, in the
  model and through .fig export and import. strokeWeight and strokeAlign
  apply to every stroke, and a stroke added from the panel or a script
  takes the layer's.
- Booleans size to their result when a renderer can measure it, from the
  canvas, from scripts, and when an operand moves.
- New sections take Figma's fill for the interface theme, a faint white
  stroke, and 2 px corners; sections and component sets draw with their
  own radius.
- Layers drawn on the canvas and the containers it wraps layers in are
  numbered past the highest number on the page; scripts keep plain names.

* test(core): dispose the editor in the canvas boolean bounds test

Attaching CanvasKit installs a global text measurer, and the test left it installed, so later text measurement tests in the same process found it.

* fix(core): undo a boolean's group refit and clamp dashed set corners

Sizing a boolean to its result also refits the groups around it, and undo left them refitted, so the operands came back displaced. createBooleanOperation returns the fit, and undo reverses it first. A component set's dashed outline now clamps its radius to its bounds as the fill does, and a script's resize refits the enclosing groups once, after the corner is restored.
2026-10-07 08:36:50 +00:00
Danila Poyarkov 6c6f24409e
feat: let a collection name the attribute its modes switch by (#913)
* feat: let a collection name the attribute its modes switch by

Manually switched modes were always selected by data-<collection name>, so a collection called New wrote data-new and a codebase already using data-color-scheme could not be matched. A collection can now name its attribute in the inspector, validated so a stylesheet can select it as is. The stylesheet and exported code use it, setting it is undoable, and .fig files keep it in the collection's plugin data.

* fix: show the switch attribute only where there are modes to switch

* fix: return to the list after committing the switch attribute

* fix: keep a collection's switch attribute when a new one is refused

setModeAttribute cleared the attribute when given a name a stylesheet cannot select on; it now leaves the current one, and only an empty name restores the default. In the inspector, Enter on a refused name keeps the focus so it can be corrected.

* docs: describe the switch attribute in the mode conditions

The Switched manually row still said the attribute is named after the collection. The changelog had collected four copies of the variables dialog entry through rebases; one remains, with the switch attribute in it.
2026-10-06 12:35:41 +00:00
Danila Poyarkov e77eeff11c
fix(fig): write text glyphs from the layout the renderer draws (#928)
* 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.
2026-10-06 11:37:02 +00:00
Danila Poyarkov f7a013191b
refactor(fig): export the symbol readers and move the library tests home (#929)
The Kiwi codec types only symbolID, so every test reading symbolOverrides
or uniformScaleFactor repeated the same cast against a type the package
exported without the readers that go with it. symbolDataOf and
symbolOverridesOf are now exported and five call sites use them.

tests/engine/library exercises @/app/libraries, so it belongs under
tests/app by the placement rules; the shard list and the engine baseline
follow it.
2026-10-06 10:44:22 +00:00
Danila Poyarkov 9dce9fdcbc
feat(collab)!: sync layer trees as a CRDT and open each room in its own tab (#902)
* 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 #888
Fixes #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 #888
Fixes #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.
2026-10-06 10:40:25 +00:00
Danila Poyarkov aa86873dd7
test: typecheck the test suites and fix what that found (#896)
* build: typecheck the test suites

Tests were in no TypeScript program: no tsconfig included tests/** or
packages/*/tests/**, and bun strips types without checking them, so a
fixture could drop a required field and keep passing until something
read it.

@types/bun moves to the root because it was installed per package only,
and #cli-tests/* joins the paths the root config already carries.

* test: fix the type errors the test suites were hiding

Typechecking the tests turned up 1123 errors. Most were ordinary
strictness, but some were real: `NodeChange` bound to Figma's plugin
typings rather than the Kiwi codec in thirteen .fig tests,
materializeInstance was called with six arguments against five so the
blobs and source children were dropped, CanvasKit pixels were written
to a plain object that never reached WASM, and assertions were made
through accessors that do not exist, so they asserted nothing.

Fixtures that had quietly lost a required field now carry it, nullable
results are narrowed through the existing expectDefined helper rather
than assumed, and stand-ins for CanvasKit and the editor go through one
named helper instead of an unexplained cast at each site.

No test was deleted, skipped, or weakened, and no `any`, non-null
assertion, or ts-expect-error was introduced.

* docs: record what typechecking the tests established

Pins the app program's global types with an assertion rather than a
note, since an unpinned types list lets any root @types package decide
which platform src/** is judged against.

The two environment faults that look like code regressions — Vite's
dependency pre-bundle outliving a package rebuild, and heavy .fig
suites failing under load — go to the development docs, where an
explanation belongs.

* fix: align @types/bun and keep node types resolvable when extended

The root manifest declared a newer @types/bun than every package, which
check:monorepo rejects, and pinning the app program's types left them
unresolvable from a config that extends this one out of tree.

* fix: fail the test typecheck when the compiler itself fails

The gate matched diagnostics by substring, so a compiler or config
failure that named no test file printed a pass while having checked
nothing. Diagnostics are now split by whether they name a file: an
unscoped one is the run failing and stops the gate, a test file's is a
finding, and a source file's stays out by design.

Also drops the parameter planComponentConstruction never read, and
makes the inner-shadow verification script exit non-zero when it
renders no image instead of logging and succeeding.

* chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
Danila Poyarkov 69dba7002f
refactor: define OpenPencil plugin data in one typed registry (#878)
* 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.
2026-10-04 17:32:18 +00:00
Danila Poyarkov e2a3aa3f80
fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* 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
2026-10-04 17:01:50 +00:00
Danila Poyarkov d2e380ea9d
fix(core): draw gradient and image strokes as the paint they are (#868)
* 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.
2026-10-04 13:25:45 +00:00
Danila Poyarkov 46c678e18f
feat: model variables as CSS design tokens (#852)
* 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.
2026-10-04 10:50:46 +00:00
Danila Poyarkov 12b6662ba3
fix(fig): keep Figma's order keys on instances and untouched siblings (#819)
* fix(fig): keep Figma's order keys on instances and untouched siblings

Materialized instance occurrences were built without the record's
parentIndex position, so every imported instance lost its key and was
renumbered on save. They now carry it like other imported nodes.

Sibling keys anchored greedily on the first imported key, so a layer moved
up past its siblings kept its key and pushed every sibling after it onto a
new one. Keep the longest increasing run of imported keys instead, so only
the moved and inserted layers are re-keyed.

* fix(fig): don't anchor sibling keys on one nothing can precede

A moved layer in front of the lowest key ('!') left no room below it, so
the anchor was dropped and every sibling up to the next anchor re-keyed.
Such a key no longer starts the kept run when a sibling precedes it.
2026-10-04 10:16:01 +00:00
Danila Poyarkov f55b887d2b
feat(scene-graph)!: make a stroke a paint (#864)
* 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.
2026-10-04 13:38:05 +04:00
Danila Poyarkov 4939cd4982
fix(fig): write bound fields resolved when exporting .fig (#859)
Figma draws the value a bound paint or number stores until something makes
it resolve the variable again. OpenPencil resolves bound colours only when
drawing, so exports wrote the colour from before the binding, and a graph
bound without the editor kept stale numbers too: a file opened in Figma
showed bound fills in their old colour.

The exporter now writes each node with its bindings resolved, without
changing the document: colours as the variable's RGB at full alpha with
its alpha as the paint's opacity, which is how Figma stores them in every
fixture, and numbers in scene units. Nodes without an explicit mode take
the collection's default, because the mode the editor shows is not saved.

The resolution moves into scene-graph as resolvedPaintBindings and
resolvedNumericBindings, which the reader and reconcileNumericVariableBindings
now share, and the node resolvers take a fallback of 'active' or 'default'.
2026-10-04 08:59:42 +00:00
Danila Poyarkov 5146e7f7e5
fix(fig): keep variable metadata, plugin data and default mode on save (#848)
* 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.
2026-10-04 12:53:04 +04:00
Danila Poyarkov c47b5f8d1d
fix(fig): read, render, and write Figma slots (#850)
* 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.
2026-10-04 00:45:10 +04:00
Danila Poyarkov d1159a7d07
refactor(kiwi)!: drop the colorVariableBinding paint field
`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.
2026-10-01 23:47:51 +04:00
Danila Poyarkov c48886920d
fix(fig)!: clear a colour variable binding when the layer is unbound
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>
2026-10-01 17:06:00 +04:00
Danila Poyarkov 2101279e1d
refactor(fig): group prefixed siblings into folders
* refactor(tools): group the oracle captures and mirror the bindings tests

Two names broke the rule that a multi-file domain gets a subfolder
rather than repeated sibling prefixes. `document-capture.ts` repeated
its own folder and sat beside `capture-scene.ts`, so both become
`document/capture/{document,scene}.ts` behind `#visual/capture/*`.

The document bindings tests were split between `document/binding/` and
a `text-bindings.test.ts` one level above, while their source is
`document/bindings/`. They move into `document/bindings/` and take the
source file names, so the test tree mirrors it.

* refactor(fig): group the node-change prefix families into folders

`node-change/` held five pairs of files sharing a leading segment —
derived-text, style, text, variable, vector — plus `instance-geometry`
beside an `instance-linkage/` folder. The guide asks for a subfolder
rather than a repeated prefix, so each becomes one: `style/{refs,runs}`,
`variable/{bindings,expression}`, and so on, with `instance-linkage/`
folded in beside `instance/geometry.ts`.

Imports across seventeen files follow. Two of the moved files reached
out of the package tree with `../../` once they were a level deeper;
those use `#fig/*` instead.
2026-10-01 16:52:40 +04:00
Danila Poyarkov b7d9c02ebc
fix(fig): give every exported layer its own order key, and keep the imported ones
* 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>
2026-10-01 14:57:46 +04:00
Danila Poyarkov 68c2af5600
feat(fig): occurrence-scoped instance interpretation as the single .fig reader
* 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.
2026-10-01 11:20:27 +04:00
Danila Poyarkov e532f616ba
refactor!: move shared primitives below dom-css and core (#771)
* refactor!: move shared primitives below dom-css and core

dom-css depended on core for color conversion, base64 helpers, text
direction, and web-font assets, so core could not use dom-css and every
caller special-cased HTML and Tailwind output.

Color conversion and management, base64 helpers, and text/layout
direction now live in scene-graph under `color`, `bytes`, and
`text-direction`. dom-css takes web-font resolution as an injected
`fonts` option and owns the font face types, so it depends only on
scene-graph and core can depend on it.

BREAKING CHANGE: `@open-pencil/core/color` and `@open-pencil/core/bytes`
are removed, and the direction helpers are no longer exported from
`@open-pencil/core/text`; import them from `@open-pencil/scene-graph`
subpaths. `exportHTMLBundle` takes a font resolver in `fonts` instead of
`'assets'`.

* fix(tools): import color parsing from scene-graph in visual bisect

* fix(mcp): declare the scene-graph dependency

MCP imports `@open-pencil/scene-graph/bytes` since base64 helpers moved
there, but only reached scene-graph through core, so isolated installs
and package checks depended on transitive resolution.

* refactor!: use js-base64 directly instead of a base64 wrapper

Base64 helpers had moved into scene-graph only to sit below dom-css,
but they are a thin wrapper over js-base64 and unrelated to the graph;
fig already called js-base64 directly.

Callers use js-base64 and check `isValid` where input comes from outside
(clipboard, imported HTML, tool arguments, the plugin API). A new
`open-pencil/no-hand-rolled-base64` lint rule rejects atob, btoa, and
Buffer Base64 conversions, and AGENTS.md records the convention.

BREAKING CHANGE: `@open-pencil/core/bytes` is removed; use `js-base64`.

* fix(dom-css): keep images with invalid Base64 inline in HTML export

`exportHTMLBundle` accepts documents parsed from outside HTML, and
js-base64 drops characters it cannot decode, so extracting an invalid
image data URL wrote different bytes. Such images now stay inline.
2026-09-26 11:39:11 +04:00
Danila Poyarkov 2d9e83d58a
refactor(export): derive export format lists from the IO registry (#755)
The Export panel, SDK helpers, app menus, and CLI each kept their own
hand-written format lists, so new formats such as PPTX reached some
surfaces and not others.

Scene Graph now owns the persisted export-setting format ids, Core IO
adapters carry literal ids so that list is checked against real adapters,
and the panel labels, scale handling, app format types, and CLI format
validation/help are derived from the registry. PPTX joins the Export
panel as a result.
2026-09-25 15:42:42 +04:00
Danila Poyarkov 899fecf845
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing

Instance text property edits only reached the canvas on Enter or blur, so
the canvas and layer tree lagged behind the field. Emit model updates as
the text changes, commit on blur or Enter, and route bursts through the
existing interactive-edit lease and undo batch so rapid edits collapse
into one transaction.

* fix(vue): present the canvas in sRGB to keep P3 blends correct

CanvasKit 0.41 wraps sRGB on-screen surfaces as RGBA8 but every other
color space as RGBA16F, while browser drawing buffers stay RGBA8 when
their color space changes. Requesting DISPLAY_P3 therefore produced
invalid destination copies and broken blends: black rectangles and brown
Overlay fills over Display-P3 documents. Keep presentation in sRGB and
read the buffer back rather than trusting the setter, leaving the
document color space and its stored colors untouched.

* perf(fig): encode glyph path commands without per-coordinate allocation

Glyph outline encoding allocated an ArrayBuffer, DataView, and Uint8Array
for every coordinate and spread each byte into a number array, so recovery
snapshots and text-heavy exports blocked the main thread for 159-167ms.
Size the output once and write through a single DataView; encoded bytes are
unchanged.

* refactor(vue): separate component property edit resolution from batching

The live text path had grown a boolean flag through a single applyValue that
resolved the edit, chose the batch, and mutated instances, which made the
two entry points differ only by that flag.

Resolve an edit once, keep a named batch key, and let setValue and
setTextValue state their own batching policy. Watch the page and selection
sources directly now that selection is replaced by identity, drop the
redundant scene dependencies the useSceneComputed wrapper already tracks,
move the variant option projection next to the swap projection, and resolve
the swap candidate list once per controls pass instead of once per control.

* feat(vue): restore wide-gamut P3 presentation where the renderer supports it

CanvasKit wraps sRGB on-screen surfaces as RGBA_8888 and every other color
space as RGBA_F16, with the pixel format deliberately not exposed, so a
Display-P3 surface only matches the browser buffer when that buffer is
floating point. Chromium 122+ provides drawingBufferStorage for that; this
negotiates the pairing, keeps the sRGB fallback everywhere else, and warns
with the existing dismissible banner when a Display-P3 document cannot be
presented in wide gamut.

Software rasterizers advertise the float extensions but fail an offscreen
framebuffer attach on the first content frame, so they stay on sRGB, as do
WebKit and Firefox, which have no drawingBufferStorage. A new
document:color-space-changed event recreates the surface when a P3 document
arrives after mount, which previously kept whatever surface the first
document created.

* refactor(web): report the canvas presentation instead of re-deriving it

The wide-gamut notice decided availability from a capability probe, which can
disagree with the surface: configurePresentation also falls back when the
float storage install is rejected or the color space setter is ignored. Pass
the surface's actual result through a new onPresentation option, mirror the
document color space into app state, and let the notice read both, so it
appears exactly when a Display-P3 document is really presented in sRGB. That
also removes two editor-event subscriptions and a tab watcher.

Rename SafariBanner to FileApiBanner, since the condition is the File System
Access API rather than Safari, and move the availability check and picker call
into one capability module instead of repeating them at each save site.

* refactor(web): point capability notices at one neutral support reference

The file API notice linked "Use Chrome" to a Chrome download page while
naming Edge as inert text, and the wide-gamut notice offered no browser
guidance at all. Both now link to the caniuse support table for the API that
decides the capability, so the advice is vendor-neutral and stays correct as
versions move.

External link behavior moves into one primitive: SettingsLink and both
notices share it, gaining rel="noopener noreferrer" and the desktop opener
path, which the notices need because the wide-gamut notice also renders in
Tauri where a raw anchor cannot open an external page.

* fix(fig): stop failing .fig export on unencodable OpenType feature tags

The Kiwi schema types toggledOn/OffOTFeatures as its OpenTypeFeature enum,
which has no PNUM, TNUM, LNUM, ONUM, FRAC, SMCP, C2SC, SUPS, or SUBS member.
Features that map to a typed axis were only written when enabled, so a
disabled one fell through to a raw tag, and encoding then rejected it:
`Invalid value "PNUM" for enum "OpenTypeFeature"`. Because save and recovery
snapshots share that export path, any text using those features could not be
written to a `.fig` file at all — the demo's own typography comparison hit it
in every run.

Disabled mapped tags now clear their axis to the schema's neutral NORMAL value,
an enabled tag on the same axis wins over a disabled sibling so "TNUM on, PNUM
off" still means tabular figures, and tags with no Kiwi representation are
dropped instead of poisoning the whole export.

* perf(core): recompute layout only for the pages a component edit affects

Editing a component recomputed layout for the entire graph, which cost tens
of milliseconds per edit in documents with several populated pages. Layout now
runs once per affected page: the pages of the edited subtrees, their
components, and every instance of those components, which may live on another
page.

The layout function is injected so the scoping contract is testable, and the
existing behaviour is kept when no page can be resolved.

* feat(core): follow the document colour profile when painting

Numbers in a document are coordinates in the profile that document declares,
so painting into a surface with a different profile has to convert them.
Nothing did: stored values were handed to the GPU as-is, which is why a
Display-P3 document looked more saturated on a wide-gamut display than on an
sRGB one, and why export labels and stored values disagreed.

Rendering now resolves each colour from the document's profile into the
surface's profile, reporting clipping when a wider profile does not fit, and
OKHCL colours resolve into the requested target instead of being baked to
sRGB. New documents also default to sRGB, matching Figma, so Display P3 is
reserved for documents that declare it rather than being assumed for
everything OpenPencil creates.

* test(canvas): exercise the P3 spec on the paint page

The P3 rendering spec used the demo's reference page and the shared
`selectDemoReferencePage` helper. The paint page covers the same ground —
gradients, shadows, blurs, multiply and screen blends, an alpha mask — and
the helper is going away with the reference page, so this keeps the spec
independent of that demo content.

The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-18 00:43:10 +03:00
Danila Poyarkov ca17b2ac55 chore: update instance linkage with master 2026-09-13 15:03:02 +03:00
Danila Poyarkov fbacd1a34a fix(clipboard): embed images with Figma-compatible hashes 2026-09-13 13:22:17 +03:00
Danila Poyarkov 449d09542f chore: update clipboard fidelity with master 2026-09-13 13:00:21 +03:00
Danila Poyarkov f8f5f0fc4e fix(instances): preserve ambiguous imported extras 2026-09-11 10:57:18 +03:00
Danila Poyarkov ff1c157a86 refactor(fig): clarify imported instance reconciliation 2026-09-11 10:30:03 +03:00
Danila Poyarkov b436ee4d2b refactor(fig): own Figma clipboard encoding and conversion 2026-09-11 00:36:03 +03:00
Danila Poyarkov 9d4fe4e421
fix(fig): preserve marker-based instance text edits on export (#648) 2026-09-10 19:16:03 +03:00
Danila Poyarkov ec0aacd4f6
build: upgrade oxfmt and format Vue imports (#669)
* build: upgrade oxfmt for Vue import sorting

* style: apply Vue-aware import sorting
2026-09-10 02:45:13 +03:00
Danila Poyarkov e971f735ef fix(fig): preserve explicit text alignment metadata 2026-09-02 22:54:13 +03:00
Danila Poyarkov f2ce4ac9a0 fix(fig): preserve explicit blend modes 2026-09-02 19:38:42 +03:00
Danila Poyarkov 1cc4c252dc fix(fig): preserve implicit fixed text sizing 2026-09-02 00:46:09 +03:00
Danila Poyarkov 9bb9478833
refactor: replace complex conditional object spreads
- Add a typed, modular custom Oxlint rule package with direct regression coverage
- Replace complex conditional object spreads with explicit construction across the repository
- Preserve all existing custom rule registrations and diagnostic behavior
2026-09-01 19:49:57 +03:00
Danila Poyarkov 1e1c38182d refactor(scene-graph): model instance overrides structurally 2026-09-01 11:44:46 +03:00
Danila Poyarkov 4eb9694d58 fix(fig): clean up consolidated instance override fix 2026-09-01 02:01:05 +03:00
Danila Poyarkov fcbd938b80 fix(fig): consolidate instance override propagation 2026-09-01 01:07:19 +03:00
Danila Poyarkov 29aba9e953 fix(fig): restore instance constraint propagation 2026-09-01 01:04:31 +03:00
Danila Poyarkov 7e325e4072 fix(scene-graph): share instance sync fields 2026-09-01 01:04:22 +03:00
Danila Poyarkov 07e4cb2438 Merge remote-tracking branch 'origin/master' into consolidate-pr551-next 2026-09-01 01:02:35 +03:00
Ed-Key 9b48e47329 feat(canvas): render arrow stroke caps on lines and open vectors
ARROW_LINES and ARROW_EQUILATERAL stroke caps were accepted by the schema
and round-tripped from .fig files, but the renderer mapped every cap to
Butt, so imported arrows lost their heads and arrows could not be drawn
locally.

Add a pure arrow-cap geometry module to scene-graph (open-endpoint
collection with per-vertex cap overrides, weight-scaled head geometry)
and draw heads in the stroke color for LINE nodes and open VECTOR
centerline strokes, including the dashed-centerline path. Fold the head
reach into strokeOverflow and the node picture margin so heads are not
cropped by visual bounds, caches, or culling. Round-trip per-vertex
stroke caps through the .fig vector style override table so one-ended
Figma arrows import correctly. Expose both caps in the stroke inspector
cap picker with localized labels, and cover the renderer with an
arrow-stroke-caps Playwright canvas snapshot.

Claude-Session: https://claude.ai/code/session_012zjEbyLSBYyPpJP4fu1XNB
2026-08-31 21:25:38 +03:00
Danila Poyarkov 40b56dab5e fix(fig): guard unresolved component property swaps 2026-08-31 16:50:45 +03:00
Danila Poyarkov b0e9995e4c fix(fig): tolerate empty component property assignments 2026-08-31 16:31:42 +03:00
PAVEL TKACHEV 992e09a5cd fix(fig): open legacy raw fig-kiwi files
- Detect unwrapped fig-kiwi containers before ZIP extraction
- Preserve trailing PNG thumbnail chunks and cover the format with a regression test
2026-08-31 09:01:54 +03:00
Danila Poyarkov 376447a5dd Merge remote-tracking branch 'origin/master' into pr-550-push-integration
# Conflicts:
#	CHANGELOG.md
2026-08-20 17:20:25 +03:00
Danila Poyarkov 9f7ce64bf6
Merge pull request #562 from open-pencil/guide-authoring
feat(editor): author canvas and frame guides from rulers
2026-08-20 09:39:19 +03:00
Victor Wads 51f8464621
Merge remote-tracking branch 'source/master' into victorwads/fast-pages-open-recent
# Conflicts:
#	CHANGELOG.md
#	desktop/src/lib.rs
#	desktop/src/menu.rs
2026-08-19 14:47:08 -03:00
Danila Poyarkov c141663291 fix(editor): address guide authoring review
- Dispose CanvasKit dash effects and render nested frame guides
- Validate imported guide GUIDs and invalidate stale raw guide metadata
- Resolve frame owners through nested hit ancestry and require primary-button ruler drags
- Share guide preview types through a neutral editor module
2026-08-19 20:13:57 +03:00