* fix(canvas): bound image decoding for large Figma documents
* test(canvas): update lifecycle fixture for image caches
* test(canvas): align image regressions with contribution guides
Use native wheel zoom in the browser regression instead of inline store access. Keep export state restoration in the existing raster suite and explicitly format the new image tests omitted by the root format script.
* fix(harness): pin patched proxy address dependency
Override the transitive proxy-addr dependency with 2.0.8 to fix IPv4-mapped IPv6 trust subnet checks and pass the critical dependency audit (GHSA-jqcg-44mw-7w3h).
* build: drop the proxy-addr override master already resolves
Master's lockfile has proxy-addr 2.0.8 since the advisory was fixed
upstream, so the override this branch added is redundant.
* perf(canvas): encode image previews as WebP
PNG previews of photos ran to several megabytes each, so a page of them
filled the 64 MiB preview cache and evicted itself: page 2 of the #924
reproduction held 48 MB of PNG previews against 7.3 MB as WebP. Browsers
that cannot encode WebP hand back PNG.
Co-authored-by: sableangle <sableangle@gmail.com>
* perf(canvas): decode four image previews at once
The browser decodes previews off the main thread, so one at a time left
a page of photos filling in for seconds: page 2 of the #924 reproduction
took 4.6 s to finish and now takes 2.0 s.
Co-authored-by: sableangle <sableangle@gmail.com>
* refactor(canvas): decide on image previews once per document size
needsImagePreviews replaces useViewportImageRendering, which was not a
Vue composable, names its two thresholds, and keeps its answer per
document and image count instead of summing every image on each frame.
Choosing a preview by zoom carries a lint exception: preview mode draws
the scene uncached.
Co-authored-by: sableangle <sableangle@gmail.com>
* docs: describe the image memory fix in the changelog
Co-authored-by: sableangle <sableangle@gmail.com>
* fix(canvas): let go of a document's image previews when the document changes
Switching to a document that needs no previews left the preview cache
holding the previous graph and its encoded images, often hundreds of
megabytes, until previews were used again. The renderer now releases
them when its document changes, keeping the decoder for the next one.
---------
Co-authored-by: sableangle <sableangle@gmail.com>
* fix: keep bound text and visibility in step with their variables
Text bound to a string variable was resolved only when a .fig file was read, and visibility bound to a boolean never, so editing the value or switching a mode left them showing the old one. The binding reconcile, renamed reconcileVariableBindings, now resolves text and visibility with numbers, scoped the same way, and .fig export writes them resolved in each collection's default mode. A bound font family is left as stored, since changing it needs the font loaded.
* fix: keep text and visibility an instance overrides over their bindings
Typing in a bound layer inside an instance or toggling its eye records an override but keeps the binding, so the next value edit or mode switch replaced what was typed or hidden. An overridden text or visibility now keeps its value, as an overridden width or height does.
* feat: show controls in the demo and tidy the component panels
The demo gains a Controls page: a switch, checkbox, slider, tabs, text
field, and a button with interaction states, written as Reka-named design
JSX, and a settings card of their instances to try in preview.
Go to main component and Detach instance move into the instance's panel
header as icon buttons. The Behaviour section's "Still needed" line is a
status rather than a button that only moved focus, and the rows it names
are marked instead.
The DOM projection writes flex-start for auto layout's start alignment:
CSS stretches children by default, so a hugging button in a column filled
its card in preview and HTML export. The paint page snapshot catches up
with the frame names #930 draws.
* perf: open the demo as a prebuilt .fig instead of generating it
/demo built every page in the browser and laid them all out in one task,
which froze the page for about six seconds. The demo's content moves to
tools/generate/demo, which builds public/demo.fig headlessly, before Vite
serves or bundles the app and only when its inputs changed; /demo opens
that file like any other, off the main thread and behind the loader. The
document is now lint-clean: small captions are 12 px, and the badge and
disabled button text meet contrast.
Opening the demo from .fig showed three bugs in saving and reading files,
fixed here:
- A layer's blend mode was never written, so Multiply and Screen reopened
as pass-through.
- Underlines drawn from saved glyphs ran to the text box edge; glyphs now
keep their advance, read from and written to .fig, and the underline
ends where the text does.
- A frame laid out on its own because of a saved hug size ignored the
width its parent stretched it to, so centred content moved to the start.
* feat(design-jsx): write Reka elements in TSX
The Reka element names were only part of the JSX string grammar, so code
that builds trees in TypeScript had to assemble strings by hand, unchecked.
@open-pencil/design-jsx now exports each Reka namespace as typed element
functions (Switch.Root, Slider.Thumb, Tabs.Trigger, …), derived from the
same table the string renderer reads, with props typed as the behaviour
props a root binds. The demo's controls are TSX built with them.
* fix(canvas): draw a component set's editing border at the live zoom
A component set without a stroke got its dashed border in the scene, sized
by dividing by the zoom. The scene is cached as retained pictures and
scaled while navigating, so the border was recorded at one zoom and grew
into thick dashes when zooming in, until the page was redrawn. It is
editor chrome, so it now comes from the overlay pass, which draws every
frame at the live zoom, as slot outlines do. The label cache tracks every
component set on the page for it, and image export no longer includes the
border.
* test: fit the P3 paint page to the canvas size it is captured at
The P3 test zoomed to the paint page while the wide-gamut notice still
took space, then dismissed it, so the first capture fit a shorter canvas
than the second and the two only matched by timing. focusPaintEffects
waits for the canvas to take a window resize, the test focuses again after
the notice goes, and the snapshot shows the demo as it now opens.
* refactor(canvas): fill sections and sets through one helper
Without its own fallback border, a component set fills and strokes its rounded bounds the way a section does; fillRoundedBounds fills them for both.
* test: check the demo controls on their own page and type pixel reads
The demo test built and round-tripped the whole four-page document to
check the controls, which took six seconds on CI and timed out; it now
builds the Controls page alone through the same measured-text setup the
build uses. The underline test reads CanvasKit pixels as any number array,
since readPixels may return floats.
* fix(canvas): measure long text extents without spreading glyphs
Spreading every glyph into Math.min/max overflows the argument limit on long texts.
* fix(demo): keep a tab touched while the demo loads and report a failed load
A file opened or a layer drawn during the fetch keeps the tab; a failed fetch shows the open-file error instead of an unhandled rejection.
* test: check a component set border stays one pixel wide when zoomed
* test: resolve repository files without climbing directories
Twelve tests and helpers reached shared fixtures, package assets, and workers with ../.. paths from import.meta, which the import rule does not see. They now go through repoPath and testPath, a workspaceRoot() that finds the root by its lockfile, the core package's own root, or the #core alias through import.meta.resolve. The root finder derives its folder from import.meta.url, so Playwright specs running under Node can use the helpers too. open-pencil/no-deep-parent-relative-paths rejects climbing two levels in new URL(…, import.meta.url) and in path calls that start from import.meta.
* fix(lint): catch Windows separators and wrapped import.meta paths, and stop at template expressions
The path rule missed '..\..' and a base such as dirname(fileURLToPath(import.meta.url)), and read `../${folder}` followed by '..' as climbing two levels.
* fix: select top-level auto layout frames by their empty area
Checked against Figma desktop 126 with real pointer input. The gaps and
padding of a top-level frame with auto layout (any direction or grid,
filled or not, on the page or in a section) select, highlight, and drag
the frame, while clicks on its layers still select those layers. A plain
top-level frame's empty area stays background, as #900 recorded; its
oracle scenarios only used frames without auto layout.
Auto layout frames are hit by their bounds even without a fill, and a
press on such a frame's empty area no longer starts a marquee.
* feat(canvas): outline auto layout children with dotted lines, as in Figma
Hovering a horizontal or vertical auto layout frame outlines its visible
direct children, and a single selected layer dots the border of its auto
layout parent, in the container's outline color. Grid and plain frames,
grandchildren, and multi-selections show none. Line weight and dash were
measured from Figma 126 screenshots: 1 px with 1.75 px dashes and 1.25 px
gaps in screen space.
The editor gains a transforming flag that canvas input sets while layers
move, resize, or rotate; the outlines and the padding and gap markers of a
selected auto layout frame hide while it is set, as Figma's do.
The children outline a selected auto layout frame drew under the pointer
now uses the same stroke, and the dash effect is freed after each draw.
* fix(canvas): select unfilled frames by their empty area only on a click
Live Figma 126, with real pointer input on a top-level card holding an
unfilled auto layout row and an unfilled plain frame, with and without
auto layout on the card: a click on either frame's empty area selects
that frame, while a ⌘-click looks through it to the card.
Only top-level auto layout frames now own their empty area in hit
testing, so a deep hit no longer stops at a nested unfilled auto layout
frame. A click that ends on an open container selects the frame child
under the point by its bounds, filled or not, which plain frames did not
get before; boards that open by themselves keep their empty area as
background.
* fix(canvas): select an empty unfilled frame on the page by its bounds
Live Figma 126: a click inside an empty frame without a fill selects it,
on the page and in a section. The section case already did after the
previous commit; on the page the click found nothing, since an unfilled
frame without children is not hit. A click that hits nothing now looks
for a frame under the point among the scope's children, which skips
boards that hold layers, as their empty area stays background.
* refactor(scene-graph): read the containers the selection opens in their own helper
* fix(canvas): open a selected container, so repeated clicks reach deeper layers
In Figma, clicking again at the same point inside a selected frame,
component, or instance selects the layer under the pointer, so repeated
clicks walk down to a text in a nested component such as shadcn's
calendar. The click hit test opened only the ancestors of the selection,
so a selected nested container stopped the walk at itself and only a
double-click went deeper.
A selected container with layers now opens like its ancestors; groups
and booleans stay closed, as in Figma. Pressing inside the selection
still drags it, and its empty area keeps it selected.
* 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(canvas): show every top-level frame's name and select frames by it
Since clicks follow Figma's depth, the empty part of a top-level frame
that holds layers selects nothing, but a frame's name was drawn and
hit-tested only while that frame was already selected. Once a top-level
frame held layers it could not be selected from the canvas, as when one
frame is dragged into another and the outer frame is then out of reach.
Every frame on the page or in a section now shows its name, faded in
the canvas's text color and in the selection color while selected or
hovered, from the same viewport-culled label catalog as section and
component labels. Its name is a hit target whether or not the frame is
selected, so clicking it selects the frame, dragging it moves the frame,
and hovering it highlights the frame; locked frames stay out of reach.
SkiaRenderer.hitTestFrameTitle no longer takes the selected IDs.
* fix(canvas): draw and hit labels whose node is just outside the view
Label catalogs culled nodes by their own bounds, but frame, section, and
component names sit outside the node, so a node just below the view hid
a name that was on screen and could not be clicked. Label lookups now use
the viewport widened by how far labels reach. Presses and hover also test
component labels, then section titles, then frame names, the reverse of
the order they are drawn, so overlapping labels pick the one on top.
* fix(fig): write text glyphs from the layout the renderer draws
Text without saved glyphs was written to .fig with outlines from a
character-by-character fallback: one unwrapped line at y = lineHeight,
no alignment, advances in pixels. Figma lays saved text out from that
data, so every wrapped OpenPencil label opened in Figma on one line, and
since saved glyphs now draw before the paragraph, OpenPencil did the same
after a reopen (#914). The Figma clipboard had a second writer with its
own shaping that matched outlines to characters by index.
One builder in @open-pencil/fig now writes derivedTextData for both. It
keeps glyphs a layer already has and otherwise asks the export runtime to
shape the text. Core shapes with the paragraph the renderer draws, so
lines, alignment, and baselines match, and takes each outline from the
glyph ID CanvasKit chose, so ligatures and contextual forms keep their
shapes. CanvasKit does not say which font drew a run, so outlines are
written only when the run's own font covers it; otherwise the layout is
written without outlines and readers lay the text out themselves.
Advances are in em units and the character offset map has one entry per
character, as in Figma's files.
The reader drops glyphs the earlier fallback wrote, recognised by its
offset map one entry longer than the text with every glyph on the one
written baseline, and any glyph set with a missing outline.
* fix(scene-graph): reflow resized text instead of stretching its glyphs
Resizing scaled a text layer's saved glyphs into the new box. That suits
path text, whose glyphs follow its path, but flat text reflows, and with
saved glyphs drawn before the paragraph a resized Figma text layer was
drawn stretched. Scale glyphs only for path text, including baked path
text that kept only rotated glyphs, and let the width change drop the
rest.
* docs(fig): describe how derived text is written
Figma draws saved text from derivedTextData even when it has the font, so the export docs record which glyphs the shared writer keeps, shapes, or leaves without outlines, and the units it writes. The README names the runtime service by what it now does.
* fix(fig): write text without glyphs when shaping fails
Glyphs are derived data, so a shaper error must not fail the .fig save or Figma clipboard copy that writes them. The builder now writes the layer without glyphs and logs a warning; the clipboard used to swallow the error silently, which hid a font-provider mismatch.
* 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
* feat(app): add slot property controls and a shared picker
AppPicker is a searchable, grouped list that opens beside the properties
panel, built on Reka's popover and listbox so search keeps arrow-key
navigation. It has comfortable rows with a thumbnail and description and
compact rows for plain names, plus an optional footer action.
The slot property row shows whether an instance's slot is Default or
Modified, its item count, its limits with a checklist popover, Add
instances on AppPicker, and Reset slot and Delete contents. These are
presentational; wiring them to the editor follows. Strings are English
only until the locale files catch up.
* feat(app): open variable, style, and instance-swap choices in the shared picker
The variable binding picker, the shared style fields, and instance-swap
properties now open AppPicker: a titled panel beside the properties panel
with search that keeps arrow-key navigation, a check on the current
choice, and footer actions. Variable binding keeps its detach and
create-variable actions. Instance-swap choices list the property's
preferred components first; instanceSwapOptions keeps the preferred flag
it already computed. AppPickerField gives select-shaped fields the same
picker with a combobox trigger.
* feat(core): edit instance slot content
Only an instance's slots take layers now. slotScope classifies a parent as
free, a slot of an instance, or the locked rest of an instance. Moves,
reorders, layer-panel drops, paste, duplicate, and instance creation
claim an untouched slot first, as Figma does on the first edit, and
refuse the locked part; drops over it land in the instance's parent.
Claiming keeps the layers but unlinks them from the component and moves
the instance's overrides on nested instances onto those instances.
Reset slot, Delete contents, and Add instance are editor actions, each
one undo step that restores the instance's subtree. A canvas drop or
reorder that claims a slot undoes together with the move.
* feat(app): show and edit slot properties of the selected instance
The component properties section lists each slot of the selected instance
with its state, item count and limits, and adds instances, resets or clears
its content through the editor's slot actions. The slot model lives in the
Vue SDK as useSlotProperties.
* feat(app): outline slots on the canvas and mark them in the layers panel
Hovering or selecting a component, an instance, or a layer inside a slot
draws its slots with a dashed pink outline and tints empty ones. Slot
frames are selected and hovered in pink and show a dashed-square icon in
the layers panel.
* test(app): cover slot outlines with canvas snapshots
* feat(app): translate slot and picker strings
* docs: note slot editing and the shared picker
* refactor: share slot test and story setup
* refactor(app): name the picker's header prop heading
* feat(core): create, configure, and remove slots on main components
A frame of a main component becomes a slot through a SLOT property
named after it; other sibling layers are first wrapped in an auto
layout frame. Slot settings and removal are single undo steps.
Instances now follow the component's property bindings on sync, so a
slot created or removed on the component reaches existing instances.
Slot helpers move to a slots domain folder in Scene Graph and Core.
* feat(app): create and configure slots from the menu and properties panel
Create slot joins the canvas context menu for layers of a main
component. A Slots section on main components and their frames
renames slots, sets their description, layer limits, and preferred
components, and removes them.
* fix: keep slot claims in the same undo step and refuse wraps inside instances
Creating an instance and pasting HTML now batch the slot claim with the
edit. Undoing an added slot instance restores the previous selection.
Grouping or wrapping layers in the locked part of an instance is
refused, and a section that cannot move no longer claims a slot. The
picker clears its search however it closes, and its close and slot
actions labels are translated.
* feat(core): create and inspect slots through the plugin API
component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so
on, bound to a new SLOT property, as live Figma does. Slot frames read
type SLOT, resetSlot() restores an instance slot's component content,
and limitViolations reports BELOW_MIN, ABOVE_MAX, and
HAS_NON_PREFERRED for instance slots. addComponentProperty and
editComponentProperty take a description and slotSettings, a cloned
slot is a plain frame, and componentPropertyReferences uses property
keys in both directions. The limit checks move to Scene Graph so the
Vue SDK and the plugin API share them.
* fix: keep nested slot content across swaps and read nested instance properties
A nested instance points at the instance it was cloned from, so its
component properties resolved to nothing. Properties now resolve through
those links to the main component, and a swap or variant switch parks
the slot content nested instances own and restores it into nested
instances of the same names, as live Figma does. Plugin appendChild and
insertChild claim the slot they add to and refuse the locked part of an
instance with Figma's error.
* test(core): record resetSlot on a main component slot; fix the Slots roadmap row
* fix(core): refuse deleting layers outside an instance's slots
As in Figma, delete and plugin remove() leave an instance's own layers
and its slot frames alone, while removing slot content claims the slot
in the same undo step as the delete.
* test(app): wait for the bulk rename dialog to close before the next shortcut
* feat(app): unify add, remove, and settings controls in the properties panel
A section's + adds an item and a row's - removes it everywhere: grid
tracks and variant properties now follow the fill and effect lists, and
the header + of a component set adds Property 1 ready to rename instead
of an inline form. Removing a variant is Delete, as for any layer.
Row settings share the sliders icon, the variables button opens with
its own icon, every icon button requires a label, and the instance
header buttons use sentence case. Create Slot joins the app menu.
* docs: note the unified properties panel controls
* feat(app): animate floating panels alike and share the severity icon
Popovers, menus, selects, comboboxes, and pickers now fade and grow in
from the side they open on and fade out on close, from one motion
preset that respects reduced motion; tooltips keep the tooltip preset.
SeverityIcon and its colours move from the design check to shared
feedback UI, and slot limits use them, so a broken limit reads like a
design-check warning.
* docs: note consistent popover and menu animation
* feat(app): give every floating panel one surface and close it at once
Popovers, menus, selects, comboboxes, pickers, presence cards, chat
history, and the issue tooltip share one rounded surface with a 1px
ring that outlines it in both themes; one-line tooltips keep a compact
shape with the same edge. The select theme's radius and elevation
options and local shadow overrides are gone. Panels still grow in from
their side but now close immediately: a fading modal menu kept blocking
the canvas and shortcuts until it unmounted.
* fix(app): keep a reopened context menu open and name option-drag undo Duplicate
Closing the canvas menu hands focus back to the canvas; when that
landed just after a quick reopen, the new menu closed as focus moved
outside it. Shortcuts no longer wait for a panel that is already
closing, and an option-drag duplicate that claims a slot is undone as
Duplicate rather than Move.
* test(app): wait for menus and popovers to close before the next key
* fix: validate parsed JSON at untrusted boundaries with Valibot
Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.
The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.
open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.
* refactor: validate parsed JSON in tests and tooling
Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.
* fix: validate clipboard geometry bytes, library images and model catalogs
Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging.
* refactor: extend the JSON validation lint to .json() results
no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas.
* test: validate the RPC request body in the CLI app export test
* test: validate CLI JSON output in the tool and app command tests
* test: compare the malformed models.dev fallback with the curated list
* 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.
* feat: check designs live with a Check panel and canvas issue markers
Design lint only ran from the CLI and AI tools, and its rules were too noisy
to show continuously: on a real imported page 786 of 888 layers had a
warning. The rules now report where a finding is actionable (a hardcoded
color only when a variable matches it, nesting only where the limit is
crossed, instance sublayers through their main component) and carry
structured data, and Recommended keeps warnings for likely problems.
The app checks the current page after edits settle. The Check tab groups
issues by rule with hover highlighting, reveal on click, and one-step
variable binding. Errors and warnings are marked on the canvas with
clustered markers that roll up to visible ancestors when zoomed out; markers
explain themselves on hover, open Check on click, and toggle with
View > Design issues.
* fix: keep the right panel and markers stable
The Check tab made the right-panel tab row overflow at common window widths,
so focusing the zoom menu scrolled the row and shifted the panel. Code and
AI tabs now drop their labels to screen readers when the row is narrow.
Touch target names are matched as whole words: "Rectangle" contained "cta"
and marked every rectangle. Markers also stay drawn during interactive edits
instead of blinking while a value is scrubbed.
* fix(ui): show right panel tab labels whenever they fit
* fix(ui): name the design check tab Lint and keep panel tabs consistent
The tab was an unlabelled icon between labelled Code and AI tabs. It is now
Lint, with the same icon and label anatomy as its neighbours, and its icon
takes the severity color instead of a count badge. All labelled tabs show
their labels when the row fits and drop them together when it does not.
* refactor(ui): build the Lint panel from shared components
Issue groups use AppCollapsible, actions use AppButton, and the severity
filters are a Reka toggle group with keyboard navigation. Issue rows no
longer nest a button inside a button. Panel state, visibility and the
focused-issue scroll live in useDesignCheckPanel, the rules menu is its own
component, and rule preferences change through preference actions.
Severity ordering reuses Core's ranking, detail numbers follow the app
language, and the check debounce uses useTimeoutFn.
* fix(lint): check the WCAG AA touch target size in the Recommended preset
Recommended flagged a 394 × 39 input because it required the 44 × 44 AAA size. It now checks the 24 × 24 AA minimum through a minSize option; Strict and Accessibility keep 44 × 44.
* feat(lint): fix design issues from rules, the Lint panel, the CLI, and agents
Rules attach fixes as data: a safe fix keeps the design as it looks (bind a
color to the variable it matches, round subpixel geometry that layout does
not own), a suggestion changes values (snap radius and spacing to the
scale, raise small text to the minimum). One Core applier re-validates
each fix against the current graph and merges changes per layer.
The Lint panel offers a fix per row and Fix all for safe fixes as one undo
step; openpencil lint --fix writes the fixed document; the lint and
lint_fix tools expose the same to MCP and AI chat.
The design-check spec's Close button is now 24 x 20: at 24 x 24 it passes
the WCAG AA touch target size that Recommended checks.
* feat(lint): pin issues outside the view to the canvas edge
Errors and warnings on layers outside the viewport had no marker, so a
check could report issues nobody could see. They are now pinned to the
canvas edge where a ray from the viewport center toward them leaves it,
with a chevron pointing their way; pins in one direction merge like
markers. Hovering lists them under the direction they lie in, and
clicking reveals and opens the most severe, nearest one.
Pins keep clear of UI floating over the canvas: the toolbar marks itself
with data-canvas-obstacle, and canvases report such rectangles to the
renderer through getOverlayObstacles each frame.
* feat(lint): mark layers with design issues in the Layers panel
Like an IDE marks files with problems and the folders holding them, a
layer with errors or warnings shows the most severe as an icon, and a
collapsed layer with issues inside it shows a dot in that color.
Suggestions stay in the Lint panel, as on the canvas, and the marks
follow the View → Design issues toggle.
* feat(lint): show issues per page and across the document
Loaded pages beyond the current one are now checked in the background,
one page at a time while the editor is idle, and checked again only when
an edit touches them; pages a large .fig file has not loaded are left
alone until opened rather than forced in. The page list shows each page's
errors and warnings like an IDE's problem count, and the Lint panel gains
a Document scope that lists every page's issues, tags the ones on other
pages, and switches to a row's page when it is opened.
* test(lint): use the core-tests alias and no comma operator in lint tests
Master now rejects ../../ imports and the comma operator in tests.
* refactor(app): create the Lint session with the editor store modules
The composition root passed its line budget once master added recent
pages; the Lint session belongs with the other per-editor services that
the modules factory creates and disposes.
* docs(changelog): keep master's latest Unreleased entries
* feat(scene-graph)!: make a stroke a paint
Stroke extends Fill, so a stroke carries the same paint vocabulary a
fill does instead of a lone color. Every construction site now states
a solid paint type, which keeps today's behaviour exactly; rendering,
.fig conversion, and the stroke panel still read solid strokes only.
copyFill is generic over the paint shape so copyStroke reuses it
rather than repeating the deep copy of gradient stops, transforms and
pattern fields.
Groundwork for the gradient and image strokes in #797.
* test(scene-graph): cover a stroke's nested paint data in copyStroke
A stroke is a paint now, so its gradient stops and transform must copy
as deeply as a fill's; the fixture was solid and proved only the color
and dash pattern.
* feat(canvas): draw agents' cursors as outlined sparkles
Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts.
* feat(app): publish AI agent presence to collaborators
The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only.
Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded.
* docs(collaboration): list the agent model among shared presence
* test(vue): a canvas story for presence cursors
Storybook now serves CanvasKit, so a story can render the real canvas:
people's arrows and agents' outlined sparkles, with controls for names,
colors, and zoom.
* feat(canvas): mark agents with a sparkle label instead of a sparkle cursor
A sparkle on its own did not read as a pointer. Agents now point with
the same filled arrow as people, in their owner's color, and their
outlined label starts with a sparkle.
* fix(editor): keep the centered point when zooming to a fixed level
zoomToLevel found the world point at the viewport center, changed the zoom, then set the pan from that point without scaling it by the new zoom, so zooming to 100% from any other level moved the view away.
* fix(canvas): shape collaborator cursor names
Cursor names were drawn with Canvas.drawText, which neither kerns nor falls back to other fonts, so names like Orbit showed visible gaps. Draw them through the label paragraph cache like section and component titles, which also ellipsizes long names.
* fix(ui): drop icons from three canvas context menu items
Flatten, Outline text, and Outline stroke were the only context menu items with icons, so the menu looked uneven. The icon map also listed boolean operations that the context menu never shows; remove it.
* fix(app): cap the canvas menu width so long page names truncate
* 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.
* 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.
Report desktop update downloads through persistent determinate or indeterminate toasts while retaining native confirmation. Use a spinner during progress and cancel pending expiry when progress resumes.
Standardize substantial Storybook fixtures as colocated example SFCs, preserve shared SDK documentation examples, and enforce semantic anatomy instead of shared-layer test IDs.
* feat(demo): rebuild the first page as a component library with staged loading
The demo generated its document behind an empty canvas with no progress,
and the first page still carried a legacy library in a dark section that
matched neither the current showcase nor the rest of the demo.
Drive the existing editor preparation from demo generation so the canvas
overlay and tab indicator report the real phases and progress, and rebuild
the first page around a component library authored in the current style:
a Button component set with two variants, linked instances, text and
boolean component properties, and the variable collections. The deleted
effects, typography, and app-preview examples are already covered by pages
02 and 03.
* fix(demo): record demo generation failures as preparation failures
Demo generation reported a thrown error as a cancellation, because the catch
only warned and the trailing finally always cancelled the handle. Report the
failure through the preparation handle so diagnostics distinguish a broken
demo build from an abandoned one.
The finally needs no failure guard: failing clears the handle, so the
following cancel already returns early, as the preparation controller test
asserts.
* fix(demo): roll back an abandoned demo build
A preparation that superseded the demo's handle — a page switch, a font
retry, or a closed tab — left the document half-built: the first page stayed
renamed, the extra pages and sections remained, and the one-page precondition
then blocked any later attempt in that session.
Track what the build creates and remove it whenever the build does not
finish, restoring the original page name and clearing the variables it added.
The cleanup is guarded by graph identity because node and page IDs are only
unique within one graph, so a document that replaced the demo is never touched.
Splitting the build into its own function keeps the entry point to
orchestration and stays within the complexity budget.
* 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.
Attribute the historical 84-pixel residual to fractional overscan backing history with controlled old/new origin and direct-first probes. Preserve the original paint sequence in an exact-pixel regression with glyph and ink guards for both emulated color gamuts. No runtime or tolerance changes.
* feat: refresh branding with generated platform icons
Keep the approved mark and optical micro master as the source of truth. Generate web, documentation, and native assets at their owning build boundaries instead of storing raster variants or linking web icons to desktop output.
* fix: refine small brand marks and loading artwork
* feat: adopt blue editing-handle brand mark
* feat: refine teal branding for light and dark themes
* feat: apply ivory tiles across brand surfaces
Use edge-to-edge web tiles with a larger mark and optical micro favicons. Preserve native spacing and platform-owned maskable/touch cropping. Address review feedback on story props, native dimensions, and brand test type checking.
* test: allow cold npm startup in packaging fixture
CI hit Bun's five-second default while running npm pack --dry-run. Give this integration test a scoped 30-second budget without changing production timeouts or other tests.
The stored arrow and blur images still included rulers despite explicit no-rulers fixtures. Inspect and update only these two oracles, preserving the arrow budget and exact settled reversal comparison. Check reversal parity before comparing the stored blur image so a stale oracle cannot hide that contract.
Discard provisional undo batches on graph replacement without replaying old document edits or clearing committed history. Reset failed preview controllers and refresh selected projections for plain-state SDK consumers while pausing inactive subscriptions.
Cover the verified review findings with negative controls, fix static Vue host insertion, and preserve the shared exact renderer oracle with its independently verified sRGB selection color.
Use native paragraph foreground paints instead of independent outline layout, keeping mutable shader paragraphs transient. Render transformed diamond gradients with a retained, owned runtime program and align Skia surface encoding with the browser drawing buffer.
Add independent pixel and ownership regressions and correct only the reviewed text, gradient and FIT-image visual oracles. Existing arrow/blur snapshot failures and the separate 84-pixel comparison remain unresolved; no tolerances are relaxed.
Acquire section paragraphs once, reuse proven fitting layouts, and bound retained text with borrowed native-resource lifetimes. Share document text family selection and Arabic/CJK coverage resolution rather than preserving missing glyphs. Validate exact zoom parity with real existing font fixtures.
Choose readable opposite edges for rotated frame titles and size badges, sharing placement with hit testing. Render section titles as compact inset badges with contrast-aware borders and hover feedback.
Backing installation advances the preview baseline but previously stamped old whole-scene pictures with the new scene version. Zooming outside backing coverage could replay deleted content and hide newly created shapes.
Discard mismatched pictures before advancing that baseline, preserving valid pictures. Cover scene, preview, page and font invalidation plus exact visible pixels through zoom reversals in retained and tiled renderers.
Keep successful glyph coverage weakly owned by source nodes rather than repeating shaping when dense scans evict native paragraphs. Preserve font/input invalidation and bound pending ID invalidations without increasing native cache limits.
Add a dense-preview regression with exact raster comparison and count paragraph builds across all canvas renderers. The previous compiled build rebuilds 3,600 paragraphs over three held frames; the fixed build rebuilds none.
Skia analytic coverage depends on framebuffer dimensions as well as raster origin. Replay existing retained subtree pictures into the live viewport at settlement, while preserving overscan images for navigation. Keep settlement pending until the viewport pass and report the presentation path accurately. No additional viewport cache is allocated.
Preserve exact held/released assertions and cover odd/even/fractional pans at DPR 1, 1.25, 1.5 and 2. Verify native picture and backing identity reuse; update one pixel in the existing baseline only after proving equality with direct rendering.
Validation: full check, 735 scoped unit tests, and 58 targeted browser tests pass. The targeted set still excludes the previously classified paint-field-width baseline. An additional tiled large-blur test fails identically on parent d16400b3e, with byte-identical actual PNGs. Broader release acceptance remains separate.
Unify preview-aware transforms across scene drawing, labels, selection and input. Give live property and creation edits explicit preview ownership, preserve cancellation and one-step undo, and reuse bounded text preparation resources.
Include retained device-grid handling, worktree HMR coverage, and nested/reflected interaction regressions in this cohesive progress checkpoint.
Validation: full check passes; 734 scoped unit tests and 53 targeted browser tests pass. This is NOT merge-ready: the unchanged exact nested filled-section held/released regression still fails with 63 differing pixels (maximum channel delta 5/255). The browser pass count excludes that separately run failure and the previously classified paint-field-width baseline. Raster-origin investigation and broader acceptance remain outstanding.
Compose announcement, typography, and paint workbenches with native linked components and editable effects. Preserve the original examples on a reference page.
Wait for the canvas and existing fonts before layout, fit demo pages on first visits, and preserve user viewports thereafter.
- 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
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
* perf(canvas): add traced navigation benchmarks
- Record and replay timestamped pan and zoom gestures through DOM and CDP input paths\n- Correlate input, viewport, render, long-task, and retained-backing events in Chromium traces\n- Report frame pacing, latency, jump, anchor drift, and crisp-settlement metrics
* perf(canvas): stabilize navigation comparisons
- Separate low-overhead metric runs from optional CPU-profile traces\n- Warm scenarios before recording and use a consistent SwiftShader browser configuration\n- Add a canonical momentum-pan reversal gesture alongside pinch reversal
* fix(canvas): require hardware GPU navigation benchmarks
- Run macOS performance captures through Metal-backed ANGLE and reject accidental SwiftShader fallback\n- Record the GL renderer and reserve software GPU mode for portable correctness smoke runs
* perf(canvas): cache shadow rasters for crisp backing
- Rasterize local drop and inner shadows only while constructing retained scene backing\n- Bound native image memory and invalidate cached entries with node and renderer lifecycle changes\n- Quantize zoom-aware raster resolution and reuse nearby scales without lowering normal scene quality
* test(canvas): verify retained shadow raster fidelity
- Compare settled retained-backing shadow output with direct CanvasKit rendering\n- Keep backdrop blur on the picture fallback and exercise graph-driven cache invalidation\n- Cover updates, deletion, and reparenting through actual SceneGraph events
* perf(canvas): benchmark real FIG fixtures
- Serve exact local fixture bytes through an isolated Playwright route for production preview runs\n- Wait for document loading and page population before zooming to fit and recording navigation\n- Record the resolved fixture path in benchmark environment artifacts
* fix(canvas): preserve nested effect subtree pictures
- Keep deeply nested shadow documents on one retained subtree picture instead of exploding them into per-node image draws\n- Restrict shadow raster acceleration to effect-bearing page children\n- Cover nested shadow fallback and restore gold-preview FIG pinch performance to master levels
* refactor(canvas): share recorded wheel sample type
* perf(canvas): defer backing settlement across zoom reversals
- Track explicit navigation phases and gesture generations instead of inferring idle from viewport timing\n- Cancel or defer retained backing construction while pan, zoom, momentum, or tentative settlement is active\n- Add a repeated short-pause pinch reversal fixture based on the user trace
* perf(canvas): index bounded render chunks
- Split oversized painter subtrees into self-paint and bounded descendant chunks without dropping container visuals\n- Bulk-load chunk visual bounds into RBush for selective world-space queries\n- Cover bounded updates and gold-preview build/query complexity before tile rendering consumes the index
* refactor(canvas): namespace render chunk coverage
* perf(canvas): model chunk paint context
- Preserve ancestor transform and clip dependencies for independently renderable chunks\n- Keep opacity, blend, blur, and mask isolation subtrees atomic until command-level splitting exists\n- Report oversized atomic chunks and lock gold-preview to bounded painter units
* perf(canvas): record pixel-correct render chunks
- Record interruptible chunks in world coordinates with ancestor transforms, clips, and chunk-local culling bounds\n- Draw opacity, blend, blur, and mask isolation chunks directly into destination surfaces in painter order\n- Compare composited chunk output with direct CanvasKit rendering instead of relaxing visual thresholds
* perf(canvas): render selective world tiles
- Map world regions to fixed 256-device-pixel tile targets and quantized sharpness levels\n- Query only intersecting render chunks and preserve atomic destination compositing\n- Match multi-tile CanvasKit output against direct rendering and measure gold-preview tile cost
* perf(canvas): cache chunk pictures across tiles
- Reuse world-space chunk command pictures for every intersecting tile\n- Pool 256-pixel tile surfaces and expose allocation, draw, flush, and snapshot timings\n- Keep expensive atomic foreground blur visible as an over-budget scheduler constraint
* perf(canvas): schedule cached tile rendering
- Bound tile images with an LRU cache and reuse pooled CanvasKit surfaces\n- Plan mandatory holes, stale visible refreshes, and overscan by navigation and content generation\n- Stop jobs at a strict deadline while reporting fallbacks, stale work, overruns, and over-budget effects
* perf(canvas): integrate progressive tiled rendering
- Keep retained scene output as the interaction fallback while exact tiles refine only after navigation becomes idle
- Centralize runtime URL flags and pass renderer selection through the typed Vue canvas API
- Replace benchmark sleeps with explicit mode-aware renderer settlement and report exact tiled coverage
- Preserve bounded scheduler metrics, generation cancellation, native resource cleanup, and shared visual-bounds logic
* refactor(app): centralize runtime query configuration
- Parse collaboration, recent-files, benchmark, presentation, and renderer flags in one typed app module
- Remove ad hoc URL parsing from workspace and collaboration runtime consumers
- Cover supported values and production-safe defaults without adding a repository lint rule
* fix(canvas): replace fallback pixels with exact tiles
- Render opaque page-background tile cells and install them with source replacement instead of double-compositing translucent scene content
- Exercise the live progressive controller against direct rendering across masks, effects, blend isolation, images, fallback text, transforms, and clipping
- Preserve the bounded reversal path with zero Long Tasks and exact settlement near 128 ms on gold-preview.fig
* perf(canvas): invalidate tiled content selectively
- Index chunk dependencies across contained nodes and transform or clipping ancestors
- Re-record affected chunk pictures and invalidate tiles intersecting old or new visual bounds
- Advance unaffected cached tiles to the new scene generation instead of rebuilding the full chunk index and tile cache
- Keep structural graph mutations on the safe full-rebuild path and cover selective refresh end to end
* perf(canvas): bound atomic blur tile refresh
- Render atomic blur chunks with tile-local isolation bounds and blur halos instead of replaying full-subtree layers
- Keep content refresh behind the retained fallback, cap GPU submissions to four tile jobs per frame, and adapt estimates from measured work
- Preserve large-radius CPU over-budget visibility while preventing Metal-backed refresh bursts and deferred GPU overload
- Add deterministic node-mutation benchmarks and summarize scheduler throughput, job duration, overruns, and exact content settlement
* perf(canvas): cancel obsolete tile refresh generations
- Count and report queued jobs removed by content or navigation generation changes
- Add deterministic mutation-then-reversal benchmark support without sleeps
- Assert exact tile work remains suspended during navigation and resumes for the final viewport
- Summarize cancellation alongside scheduler throughput, overruns, and settlement metrics
* test(canvas): cover live tiled blur settlement
- Load gold-preview.fig through the real tiled canvas surface and wait on explicit renderer settlement
- Commit the settled radius-210 large-blur browser snapshot
- Replay the canonical zoom reversal during refresh and require byte-identical final canvas convergence
* fix(canvas): harden renderer resource lifecycle
- Release tiled surfaces, images, pictures, and queued work across surface, font, graph, page, structure, and renderer lifecycle boundaries
- Restore pooled canvas, viewport, and backing state through exception-safe native recording and raster paths
- Rebuild tiled chunk topology only when isolation requirements actually change, preserving selective blur mutation performance
- Document deterministic active-renderer settlement and add lifecycle, graph replacement, cache failure, and surface replacement regressions
* test(canvas): remove source-matching renderer claims
- Delete the autopsy suite that inferred runtime correctness from source text, regexes, line placement, and symbol counts
- Keep renderer ordering, cache cleanup, effect behavior, and pixel fidelity covered by executable behavioral and lifecycle tests
* perf(canvas): present retained backing during tiled navigation
- Profile production reversal traces and attribute tiled p95 cost to GPU command-buffer flushes from full-scene fallback replay and tile presentation
- Use the retained backing as the moving fallback while tile scheduling and cached lookup remain allocation-free
- Defer tile image presentation until idle and expose visible versus presented tile counts in navigation telemetry
- Reduce tiled reversal render p95 from about 8ms to 0.3ms while preserving exact idle replacement and visual parity
* perf(canvas): prioritize visible tile settlement
- Profile per-tile allocation, draw, flush, snapshot, and chunk costs through scheduler telemetry
- Defer overscan until all visible exact tiles are covered
- Replace the four-job idle cap with a higher safety ceiling while the measured five-millisecond deadline controls cheap work
- Reduce mutation-plus-reversal exact settlement from about 272ms to 160ms without Long Tasks, overruns, or over-budget jobs
* refactor(canvas): clarify renderer lifecycle boundaries
- Extract retained backing state types and navigation preview timing\n- Isolate tiled scheduler telemetry from frame orchestration\n- Document settlement and CanvasKit ownership invariants\n- Preserve hot drawing loops, budgets, cache limits, and rendering decisions
* fix(canvas): preserve current label rendering
Retain the merged paragraph-label cache lifecycle and substituted-font readiness while reconstructing the renderer stack on current master.
* test(canvas): keep tile benchmark assertions deterministic
Keep performance timing in benchmark telemetry while asserting structural tile selectivity and cache behavior in CI.
* feat(canvas): expose experimental tiled rendering
- Persist retained or tiled canvas mode in General settings\n- Keep retained rendering as the default and apply changes after reload\n- Preserve URL overrides for deterministic benchmarks and support reproduction
* refactor(app): centralize renderer preference state
Expose renderer override provenance from runtime configuration and keep the settings control's derived state separate from its explicit persistence action.
* refactor(app): share settings layout anatomy
Reuse slot-based section headers and bordered groups while keeping each settings control row explicit.
* fix(canvas): harden tiled renderer boundaries
- Bound low-zoom tile planning and handle failed tile surface allocation\n- Preserve effect raster dependencies, runtime-safe clocks, and navigation timing contracts\n- Keep benchmarks deterministic, backward compatible, and accurately localized
* fix(canvas): invalidate dependent node pictures
Track first-child shadow dependencies for retained node pictures so child geometry updates cannot leave stale parent shadows.
* fix(canvas): edit section labels inline
- Vertically center shaped section titles using cached paragraph metrics\n- Add generalized canvas-label edit state and a reusable inline label control\n- Match dark and light label presentation while editing and commit renames with undo
* fix(canvas): align label contrast handling
Use shared WCAG contrast selection for rendered and edited canvas labels while retaining exact node-fill presentation in the editing overlay.
* fix(canvas): composite translucent label colors
Choose label foreground contrast after compositing translucent section fills against the active canvas background.
* fix(text): prepare browser fonts atomically
- Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures
- Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation
- Shape frame, section, and component labels through a bounded native Paragraph cache
- Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior
* test(canvas): cover substitution and label shaping
- Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output
- Assert text-picture and derived-glyph invalidation with finalized substituted readiness
- Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation
* fix(text): preserve same-origin fetches during font resolution
- Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests
- Keep the external provider HTTPS allowlist enforced for cross-origin font resources
- Cover the production race discovered while loading gold-preview.fig during provider initialization
* fix(text): preserve substituted path glyphs
- Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted
- Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle
- Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection
- Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage
* fix(text): use browser font transport without desktop warning
- Keep the browser provider implementation aligned with current settings behavior after splitting from preparation
* test(text): inject same-origin browser font context
- Keep the transport test deterministic outside a Window global
* fix(text): preserve final browser font transport hardening
- Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot
* fix(text): initialize font transport outside Window contexts
- Keep unit and headless module imports safe while browsers use their current origin
* test(text): satisfy async visual fixture contract
- Return from the resolver setup continuation after requesting render
* test(canvas): include paragraph cache lifecycle
* fix(text): cancel queued browser font loads
- Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing
* fix(text): close font queue cancellation races
- Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels
* fix(text): abort active provider resolution
- Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
* feat(app): show atomic document loading progress
- Preserve the existing full-canvas pencil loader while adding phase, detail, accessible status, and honest determinate progress
- Keep one generation-safe load owner across FIG decoding, graph preparation, page population, fonts, fallbacks, layout, viewport fitting, and first-render fade
- Prevent nested page setup and viewport cleanup from revealing partially prepared documents
- Cover obsolete sessions, font-resolution ownership, and staged loader UI
* refactor(app): scope editor preparation per tab
- Replace the shared loading boolean with one reactive preparation snapshot and one imperative controller per editor store
- Keep Core page work progress-only and inject canvas suspension from the app boundary
- Route FIG, storage, recovery, DOM import, and page switching through reusable tab-local preparation handles
- Abort only the closing tab's operation and cover generation safety, multi-tab isolation, progress UI, and disposal
* fix(editor): commit prepared pages atomically
- Prepare population, fonts, fallbacks, and layout without changing the visible page
- Reject cancelled and stale prepared pages before committing viewport, selection, and page events
- Keep the preparation overlay until the committed scene version is presented
- Cover call order, cancellation, stale generations, and presentation acknowledgement
* fix(app): stage imported documents before commit
- Prepare imported graphs in an isolated Core editor before replacing the live document
- Share font loading while keeping live selection, graph, renderers, and history untouched during staging
- Preserve the previous graph when staging is cancelled or fails and remove the duplicate pre-font layout pass
* refactor(app): namespace preparation UI
- Move canvas and tab preparation presentations into focused subfolders with concise component names
- Share progress and phase presentation helpers across preparation surfaces
- Show tab-local preparation status without covering the active canvas for background work
* fix(app): cancel preparation work at source
- Publish typed per-store preparation lifecycle events with explicit completion, cancellation, and failure outcomes
- Propagate tab-local AbortSignals through FIG parsing, population workers, and browser font downloads
- Keep cancellable font requests outside shared in-flight caches while retaining globally completed font registrations
- Stop FIG manifest previews from replacing the live graph before atomic document commit
* fix(app): cancel storage and DOM preparation
- Propagate preparation signals through S3 downloads, byte progress, local-cache boundaries, and DOM/CSS conversion checkpoints
- Reuse merged diagnostics and localized toasts for document, storage, and presentation failures
- Replace manual font concurrency and presentation timers with es-toolkit limitAsync and withTimeout
- Guard stalled first presentation and fix the merged recovery dialog title bindings
* fix(app): stage reload and font retry
- Prepare reload graphs in isolation and preserve the current document on read, decode, font, or layout failure
- Restore page and viewport state only after atomic graph commit with cancellable reload reads
- Run font Retry as a tab-local preparation with cache reset, final layout, picture invalidation, and presentation acknowledgement
- Keep completed document pixels visible while Retry reports activity in the tab
* fix(app): enforce exclusive preparation outcomes
- Complete document, storage, recovery, and DOM preparations only after successful commit
- Keep failed and cancelled handles terminal so lifecycle events cannot report contradictory outcomes
- Preserve external AbortError identity across storage timeouts and cancel streamed readers without returning partial bytes
- Cover credential-free pre-abort, mid-stream cancellation, progress cutoff, and terminal outcome exclusivity
* feat(diagnostics): record preparation outcomes
- Persist completed, cancelled, and failed preparation lifecycles through the validated diagnostics recorder
- Store only operation kind, outcome, cancellation or failure category, terminal phase, and coarse duration bucket
- Exclude document subjects, font families, storage identities, URLs, raw durations, messages, and stack traces
* chore(app): keep browser font tests with typography split
- Remove the browser font transport test inherited from a mixed cancellation commit; the source and coverage remain on the typography branch and safety snapshot
* test(vue): assert injected render suspension
- Exercise shouldSuspendRender instead of removed Core loading state\n- Preserve the contract that rendering resumes without a version change
* test(app): complete atomic preparation contracts
- Acknowledge first presentation in headless file-open tests\n- Assert the cancellable font-loading signature at the Tauri fallback boundary
* fix(app): preserve preparation cancellation
- Stage imported graphs before mutating live tabs and propagate aborts through page, DOM, font, and storage work\n- Use the accessible progress primitive and clamp determinate values\n- Cover fallback-font cancellation and yield pending-open test polling to the task queue
* test(text): await fallback font request cancellation
Start the mocked remote font request before aborting so the test proves that the active request receives the preparation signal.