Commit graph

2900 commits

Author SHA1 Message Date
Danila Poyarkov 69dba7002f
refactor: define OpenPencil plugin data in one typed registry (#878)
* fix: validate parsed JSON at untrusted boundaries with Valibot

Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.

The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.

open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.

* refactor: validate parsed JSON in tests and tooling

Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.

* refactor: define OpenPencil plugin data in one typed registry

Every plugin-data key OpenPencil writes is now a field of OPEN_PENCIL_PLUGIN_DATA in scene-graph, with the Valibot schema that reads it; readPluginData and withPluginData replace per-key constants, JSON.parse and hand-matched pluginId/key filters across fig, core, and vue. Moving OkHCL onto it fixes picking a colour rewriting the layer's other plugin data as OkHCL entries.
2026-10-04 17:32:18 +00:00
Danila Poyarkov 7ad6475e2b
feat: create, fill, and edit slots (#862)
* 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
2026-10-04 17:02:28 +00:00
Danila Poyarkov e2a3aa3f80
fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* fix: validate parsed JSON at untrusted boundaries with Valibot

Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.

The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.

open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.

* refactor: validate parsed JSON in tests and tooling

Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.

* fix: validate clipboard geometry bytes, library images and model catalogs

Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging.

* refactor: extend the JSON validation lint to .json() results

no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas.

* test: validate the RPC request body in the CLI app export test

* test: validate CLI JSON output in the tool and app command tests

* test: compare the malformed models.dev fallback with the curated list
2026-10-04 17:01:50 +00:00
Danila Poyarkov b9753aa016
docs: sync translations with documentation changes since v0.15.1 (#886)
Eleven translated pages changed in English since the last release without
matching updates: system requirements, the Lint tab, AI chat diff tools,
collaboration with agents, design JSX imports and export fidelity,
Storybook and live-app export, lint fixes, page navigation, and Checking
Designs. Bring de, es, fr, it, pl, and ru up to date, add the sections
the abridged translations lacked where a change landed, use each
language's UI labels, and point the export page at documents list.
2026-10-04 16:38:35 +00:00
Danila Poyarkov 403cbd24ab
docs: trim the README CLI section and align its examples (#885)
The CLI section repeated the reference docs: nine export variants, a
DOM/CSS walkthrough with an unrelated snippet, and two lint and analyze
blocks, with trailing comments in ragged columns. Keep one aligned
example block per task and link the CLI reference for the rest. Shorten
the Pi and MCP file-access notes to the one thing each reader needs.
2026-10-04 16:38:28 +00:00
MrHard 1d51583b64
feat(core): export resolvePasteTarget from the editor entry (#827)
* feat(core): export resolvePasteTarget from the editor entry

* docs(changelog): limit the resolvePasteTarget note to ordinary paste and drop

* docs(changelog): word the paste target export like other entries

* fix(core): let resolvePasteTarget take the editor createEditor returns

It required the internal EditorContext, which the public Editor does not
satisfy, so embedders could not pass their editor. It only reads graph and
state.

* docs(changelog): limit the paste target entry to ordinary paste

---------

Co-authored-by: mrhard9090 <moltrax88@gmail.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-04 16:20:03 +00:00
Danila Poyarkov b52d7e2651
feat: control documents, history, settings, and tools from the CLI and MCP (#871)
* fix(app): record MCP and CLI structural edits as undo steps

The automation bridge ran non-atomic tools, render, and eval without an
undo entry, so Edit > Undo could not revert layers an MCP client or the
CLI created, deleted, or rearranged. Snapshot the page around these
edits as the AI chat does, and skip the entry when nothing changed so
read-only scripts leave the history alone.

* feat(app): activate documents, undo, redo, and change settings over automation

Add activate_document, undo, redo, get_settings, and update_settings to
the app's automation bridge. Settings cover appearance, snapping, canvas
rendering, recovery, and chat preferences, validated with Valibot and
applied through their owning stores; credentials, models, MCP
connections, storage, and tool access stay out of reach.

* feat(mcp): expose document activation, history, and settings tools

* feat(cli): manage documents, history, settings, and tools in the running app

Turn documents into a command group (list, open, new, save, close,
activate), add undo, redo, and settings get/set, and add tool
list/describe/call so every MCP tool runs from the shell, against the
running app or headlessly on a file.

* docs: document app control from the CLI and MCP

* fix: never prompt in the app from automation closes and saves

close_file opened the app's Save changes dialog, which an agent cannot
answer: the call timed out and the dialog stayed open. It now fails on
unsaved changes unless the caller passes unsaved "save" or "discard"
(CLI --save or --discard). save_file and new_document no longer open a
Save dialog for a document that was never saved, report a failed save
as an error, and leave the document untouched when the path is refused.

* docs: describe non-interactive close and save

* fix: address review findings in app automation

Keep a document's source when a save to a new path fails, report
vector-edit undo and redo no-ops as unapplied, echo only the applied
patch from update_settings so writing cannot read settings, reject
tool call --write/--output without a file, and stop settings get from
following inherited keys.

* fix(app): record render undo on the page that receives the layers

A render into a parent on another page was snapshotted against the
target page, so undo left the new layers in place. Snapshot the page
that contains the parent instead, and document that eval edits made
after switching pages stay outside the undo step.

* feat(app): limit automation undo to its own steps and expose design check settings

The undo history is shared with the person in the editor, so an agent's
undo could revert the user's last edit. Automation undo and redo now act
only on steps made through the bridge, and only while they are newest;
otherwise they fail and leave the history alone. Vector edit mode's
session history is off limits entirely. Settings automation also covers
the design check preferences that landed on master.
2026-10-04 16:02:36 +00:00
MrHard dc0922038b
feat(core): export flatten and outline stroke geometry from canvas (#826)
* feat(core): export flatten and outline stroke geometry from canvas

* docs(changelog): word the flatten geometry export like other entries

---------

Co-authored-by: mrhard9090 <moltrax88@gmail.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-04 14:13:08 +00:00
Danila Poyarkov 8d10b9ca27
fix: export layers and pages that are not on screen in app mode (#877)
* fix(automation): export layers from a page that is not on screen

The app's raster export rendered against the page on screen unless the
caller passed a page, so MCP export_image with ids on any other page, or
with page_id naming another page, failed with "Raster export selection
must stay on a single page". Automation shares one app between clients,
so the page on screen says nothing about what a request means.

Render on the page that holds the requested layers instead. The user's
view and selection stay where they were.

* fix(cli): export the requested page from the running app

`openpencil export --page` never reached the app: `exportViaApp` only
forwarded `--document-id` and `--page-id`, and the app's `export` RPC
exported the given nodes or the selection on screen, ignoring the target
page. `--page` and `--page-id` therefore exported whatever was selected.

The CLI now resolves `--page` to a page ID through `list_documents` and
asks for a page-scoped export. The app answers a page-scoped export with
the layers of the target page, loading a `.fig` page that has not been
shown yet without switching to it.

CLI tests address the package source by `#cli/`, as Core and fig tests
already do, so the alias owner widens to the whole package.

* fix(automation): prepare fonts and layout for a page exported off screen

A page export loaded the layers of a page that had not been shown, but not
its fonts or layout, so text and auto layout could render differently from
the screen. preparePageNodes runs the same font and layout pass as a page
switch, once per page, without switching or superseding a switch.

The CLI export test now writes its own discovery file, so it no longer
replaces or removes the record of an app that is running.

* fix(automation): prepare a .fig page before running a tool on it

A `.fig` opens with only its first page populated; the others get their
layers, fonts and layout when first shown. The automation tool handler built
its FigmaAPI on the target page without loading it, so MCP tools aimed at a
page nobody had opened (`page_id`) saw an empty page: find_nodes found
nothing, export_image reported "No visible nodes to export", and create_shape
added a shape to a page that then held only that shape.

Prepare the target page first with preparePageNodes, as page exports do:
layers, fonts and layout, once per page. The page on screen does not change.

* fix(automation): render explicit export IDs on the page that holds them

Since the visual diff tools, the automation FigmaAPI passes its target page
with every raster export, so export_image with IDs from another page asked to
render them on the target page and failed with "Raster export selection must
stay on a single page". The page now names which layers to export only when no
IDs are given; an ID list is rendered on its own page.

* fix(core): share one off-screen page preparation between concurrent callers

Two concurrent preparePageNodes calls for the same page both populated it
and resolved its fonts, and the font manager's blocked-node set has no
reference count, so the first to finish unblocked text the second was still
resolving. Callers now share the in-flight preparation, which is kept once
it succeeds and retried after a failure.

preparePageNodes also reports whether the page is ready, so a caller can
refuse to run on a page whose document was closed or replaced mid-way
instead of acting on a page with no layers. Its unused options are gone:
one caller's signal cannot cancel a shared preparation.

* fix(automation): prepare the target page once for every command

Preparing an unshown .fig page lived in the page export handler, so explicit
export IDs, export_jsx, eval, tools, and the RPC fallback still saw such a
page as empty. The request dispatcher now prepares the resolved target page
before any page-targeted command, and stops with an error when the page's
document closed while it loaded.

* docs(changelog): fold the off-screen page fixes into one entry

* refactor(automation): rely on the dispatcher to prepare a tool's target page

The request dispatcher now prepares the target page before every
page-targeted command, so the tool handler no longer does it itself. The
tests run tools through the dispatcher, which is where that guarantee lives.

* docs(changelog): drop the tool entry now covered by the off-screen page fix

---------

Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:55:23 +00:00
Danila Poyarkov de0c48f47d
fix(core): save an unedited worker-opened .fig instead of hanging (#880)
* fix(core): save an unedited worker-opened .fig instead of hanging

Saving a .fig that was opened through the session worker and not edited
yet never finished. The export asks the worker for the original archive,
but the reply listener lived in port1.onmessage, which
registerFigPopulationWorker replaces once the graph arrives. The worker
answered and the reply was dropped, so the save promise never settled.

Listen for archive results with addEventListener so the population
client can own onmessage.

* test(core): drive the session worker open directly in the archive save test

IS_BROWSER is read once at import and Bun shares the module cache, so when another session test loads the reader first, parseFigFile took the main-thread path and the test passed without the fix. Export the worker open from read.ts and call it directly, release the worker afterwards, and drop the global window patch and the uncleared race timer.

---------

Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:41:21 +00:00
Danila Poyarkov d2e380ea9d
fix(core): draw gradient and image strokes as the paint they are (#868)
* fix(core): draw gradient and image strokes as the paint they are

A stroke carried the paint vocabulary already, but nothing read it: the
.fig reader sent every stroke paint through resolvedPaintColor, which
returns black for a gradient or image, the renderer set a flat color on
strokePaint, and the writer emitted a SOLID paint.

Strokes now go through the same conversion fills do in both directions,
and applyGradientFill and applyImageFill take the target Paint so a
stroke reuses the fill shader path instead of growing a second one.
forVisibleStrokes is the single place every stroke draw passes through,
so the shader is set and cleared there rather than threaded through each
draw helper.

Closes #797 for rendering and .fig; authoring a gradient stroke from the
stroke panel is still to come.

* feat(app): author gradient and image strokes from the stroke panel

StrokeSection opened a solid-only colour picker and synthesised a fake
fill for the swatch, so a stroke could never be anything but one flat
colour. It now opens FillPicker like the fill panel does, and
applyStrokePaint keeps the stroke's weight, align, cap, join and dashes
across a paint change.

Completes #797.

* fix(core): let a gradient stroke reach vector outlines and arrowheads

A vector stroke draws its outline as a filled shape with fillPaint, a
dashed one strokes the path, and arrowheads are filled shapes of their
own; each cleared the shader first, so a gradient or image stroke on a
vector drew black. The stroke pass now configures both paints and owns
clearing them, and those helpers keep what it set.

Resolve each gradient stop against the stroke's own colour binding
rather than the stop's position, which looked up another stroke's.

Reported in review of #868.

* fix(core): release the shaders a paint no longer owns

Every gradient and image shader was handed to a paint and then leaked:
the paint takes its own reference, so the caller's handle has to go or
WASM memory grows with each redraw. Only the diamond branch did this.

A gradient stroke now configures two paints, which doubled the leak.

Reported in review of #868.

* test(render): model a shader handle the caller deletes

The pattern shader double returned a plain string, so deleting the
handle the paint no longer owns threw instead of passing.
2026-10-04 13:25:45 +00:00
Danila Poyarkov fa3672c39c
fix(core): fill open subpaths of filled SVG paths (#876)
* fix(core): keep SVG icon stroke caps and joins after saving

Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.

* fix(core): fill open subpaths of filled SVG paths

SVG fills every subpath as if it were closed, but parseSVGPath put only
closed subpaths in the fill region. The renderer filled the open ones as
a separate path, so a hole formed by an open subpath and a closed one
under the path's fill rule was filled in, as in some Font Awesome icons.

Add an includeOpenRegions option to parseSVGPath, off by default, and set
it for filled paths without a stroke from Iconify icons and import_svg,
and for SVG clip paths. Stroked paths keep open subpaths out of the
region so their closing edges are not stroked. A filled polyline now
flattens with adjacent filled shapes like a polygon does.

* docs(changelog): state where open SVG subpaths are now filled

The app has no icon picker. The fix reaches Design JSX <Icon> and
inline <svg> through scalePathInfos, dropped and pasted SVG files and
clip paths through svgToVectorPaths, and makes filled polylines render
filled. Name the unstroked-path limit instead of an unqualified claim.

* test(core): import SceneGraph from its owning package

Scene Graph owns the graph type; the Core barrel only re-exports it for
compatibility. importVectors also returns the graph, matching the same
helper in #832 so the shared test files reconcile cleanly.

* docs(changelog): name every path that keeps SVG stroke caps

The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.

* test(core): import SceneGraph from its owning package

Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.

* refactor(core): read the first SVG path stroke with at(0)

Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.

---------

Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:25:23 +00:00
Danila Poyarkov 045efbf4b5
test(fig): cover reopened instance overrides through component sync (#879)
* test(fig): cover reopened instance overrides through component sync

Instance overrides read from a saved .fig must survive the live editor's
main-component sync, a second save and reopen, and lazy population of a
later page through the session worker. Regression coverage for #750, which
the occurrence-scoped reader (#646) fixes.

* test(core): move reopened override coverage to its Core home

check:test-homes rejects new tests under tests/engine; the test drives createEditor and Core fig internals, so it belongs beside component-sync.test.ts. The second-save case passed on v0.15.1 and now runs as the tail of the first case.

---------

Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:21:09 +00:00
Danila Poyarkov f2485d6926
fix(desktop): allow reading recent .fig files for thumbnails (#872)
* fix(desktop): allow reading recent .fig files for thumbnails

The recent-files thumbnail loader stats a .fig file, opens it, and seeks
and reads its thumbnail entry, but the desktop capability granted none of
those fs commands, so the WebView rejected the first call ("fs.stat not
allowed") and no thumbnail ever loaded. Grant stat and open with the same
path scope as read-file, plus the handle commands they lead to.

A native spec runs the loader's command sequence on a real .fig file.

* test(desktop): check the bytes the recent-file thumbnail read returns

The native spec discarded the read result, so a read returning nothing would pass. Assert the zip signature and the byte count the fs plugin appends, through a bytes variant of the native invoke helper.
2026-10-04 13:03:20 +00:00
Danila Poyarkov daef57d52d
build: update dependencies (#873)
* build: update dependencies

Update the AI SDK providers, Vue, Reka UI, Valibot, Zod, es-toolkit,
CodeMirror, Storybook, Playwright, Hono and other dependencies to their
current releases, consistently across workspaces.

The Tauri plugin packages must match their Rust crates, and the new plugin
crates require Tauri 2.12, so Cargo.lock, @tauri-apps/api and the Tauri CLI
move to 2.12 as well.

* build(harness): update the AI SDK harness packages

@ai-sdk/harness 1.0.74 pinned ai 7.0.67, so the workspace carried a second
copy of ai next to the root one; 1.0.138 depends on the same ai release.

The Pi adapter no longer takes a model: HarnessAgent does. The settings
were spread from untyped records, so the compiler could not reject the
stale key and the chosen model would have been dropped; they are plain
literals now.

PiAuthOptions is now PiAuthenticationMode, and auth accepts an environment
record. Pass the gateway key that way instead of writing it into the
process-wide environment while a session is created. Derive the thinking
level from the adapter's settings, which adds 'max'.

* build: hold vue-tsc at 3.3.11

vue-tsc 3.3.12 no longer sees a v-slot binding inside a component that
also has an event listener, so check:vue reports "Cannot find name
'control'" in MCPConnectionEditor and ProfileEditor. 3.3.11 checks them
cleanly.

* fix(ai): keep retryability for provider errors reported mid-stream

From ai 7.0.80 a provider error after the response stream starts is a StreamProviderError rather than an APICallError, so classifyAIChatError lost its isRetryable.

* feat(desktop): accept updates only when signed for their version

Tauri CLI 2.12 records the app version in each updater signature, and
updater 2.13 checks it against the version latest.json announces. With
requireSignedVersion it also rejects signatures that carry no version, so a
tampered manifest cannot pair a newer version number with an older, still
validly signed bundle.

Release assembly now fails when a signature does not name the version
being released, instead of shipping one that installed apps would reject.

* docs: note the dependency update's security fixes in the changelog

* feat(ai): recommend the latest models

The provider packages now know Claude Sonnet 5.5 and Opus 5.5 and the GPT-6
series. Make Sonnet 5.5 and GPT-6.1 Sol the defaults, list Opus 5.5, Fable
5.1, GPT-6 Astra and GPT-6 Luna, and replace the two free OpenRouter models
that OpenRouter no longer serves.

* build: align the fig package's valibot with the workspace
2026-10-04 12:48:24 +00:00
Danila Poyarkov bb69735d27
feat(lint): suggest group-to-frame and hidden-layer fixes; keep canvas edits in code previews (#870)
* feat(lint): suggest converting groups to frames and deleting hidden layers

no-groups and no-hidden-layers now carry suggestions the Lint panel, the
lint_fix tool and editor.applyLintFixes can apply. A group becomes a frame
in place, keeping its id, children, bounds and look; a hidden layer is
deleted with its children. Neither is offered for locked layers or inside
components and instances, and both are checked again when applied.

The editor gains convertGroupToFrame and deleteNodes, which deleteSelected
now uses, and applies lint fixes through a bridge so structure changes and
property updates share one undo step. lint_fix becomes a document mutation
because atomic tools cannot remove layers.

* fix(code): keep canvas edits made while replaced code waits to render

Replacing all the code drops its layer links until the preview links it
again, so a canvas edit in the preview delay could not be patched into the
code and the preview drew over it. Edits made while code waits to render
are now applied again once the preview has linked the code, in the same
undo step, and reach the code like any other canvas change.

* fix(figma-api): default paint opacity and visibility like Figma

Plugin scripts may leave out a paint's opacity and visible; Figma reads
them back as 1 and true. The fills and strokes setters stored the paint
as given, so the Design panel received an undefined opacity.

* build(dev): forward only errors from the browser console

Vite forwards browser logs when an agent starts the dev server. Serializing
a Vue warning's component props walks the editor state and freezes the
tab, so warnings stay in the browser console.

* fix(code): follow values while they are dragged or scrubbed

Live previews change layers without a new scene version, so the code kept
the old value until the gesture ended. The Code tab now follows preview
updates once per frame, as the Design panel does, and goes back when the
gesture is cancelled.

* fix(code): keep canvas edits when the replaced code fails to preview

A failed preview took the canvas edits waiting for it and dropped them,
and stopped recording new ones, so correcting the code drew over them.
The edits now wait for the next preview.
2026-10-04 12:46:06 +00:00
Danila Poyarkov 767fd99493
fix(core): keep SVG icon stroke caps and joins after saving (#875)
* fix(core): keep SVG icon stroke caps and joins after saving

Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.

* docs(changelog): name every path that keeps SVG stroke caps

The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.

* test(core): import SceneGraph from its owning package

Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.

* refactor(core): read the first SVG path stroke with at(0)

Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.

---------

Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 12:44:45 +00:00
Danila Poyarkov 3e4e3d0b8b
refactor: rename @open-pencil/codegen to @open-pencil/emit (#822)
* refactor: rename @open-pencil/codegen to @open-pencil/emit

In design tools codegen means design-to-code, which is dom-css's job and
Core's codegen tools'. The package builds and prints the syntax trees
exporters emit, so name it for that. It has not been released yet.

* fix: alias @open-pencil/emit to its source in Vite

The rename missed the escaped regex, so Vite resolved the package through
its built dist output.
2026-10-04 11:22:20 +00:00
Danila Poyarkov 46c678e18f
feat: model variables as CSS design tokens (#852)
* feat(scene-graph): model variables as CSS tokens

A variable now has a CSS custom property name, a unit, raw CSS expressions
per mode, and each mode a CSS condition (a selector or @media prelude), so
code export can treat variables as design tokens rather than resolved
literals.

Names are derived when not set: Tailwind v4 theme namespaces from the type,
the scopes or the leading name segment, so Gray/50 is --color-gray-50. The
first token to claim an explicit name keeps it; Figma files contain
duplicates, and later claimants fall back to a derived name. FLOAT tokens
infer px except for opacity and font weights. Lengths stay in canvas pixels
and only convert when written, so rem does not change what the canvas or
Figma sees.

In .fig, the name goes to codeSyntax.WEB in the form the snippet already
uses, or to plugin data when WEB holds something else such as a Tailwind
class. Unit, expressions and conditions are OpenPencil plugin data,
validated with Valibot. Conditions and expressions reject braces and
semicolons because they are written into stylesheets, and an expression
whose mode value was edited elsewhere is dropped so the number stays
authoritative.

* refactor: move token naming to dom-css and keep fig to persistence

CSS naming, namespaces and units are CSS projection, which dom-css owns;
scene-graph keeps only the token data and the px/rem storage conversion,
and fig only persists plugin data, validated for shape.

Drop Variable.cssName: codeSyntax.WEB is the single place a token's name
lives, read with postcss-value-parser when it is --x or var(--x), so no
second copy has to stay in sync with Figma's field. Derived names use
es-toolkit kebabCase and twirlwind's Tailwind namespace table, which
excludes opacity since Tailwind v4 has no such namespace.

Whether a condition or expression is valid CSS is no longer guessed with
a regex in fig; the stylesheet generator will check it with cssom where
the string enters a stylesheet.

* refactor(fig): parse token plugin data with Valibot's parseJson

Invalid JSON becomes a validation issue like any wrong shape instead of
a caught exception, and the plugin data lookup reuses
getOpenPencilPluginValue rather than repeating it.

* refactor: use es-toolkit for token expression keys and name segments

mapKeys re-keys expressions by file mode id instead of a manual loop, and
compact drops empty name segments. Reading expressions keeps the plain
filter: pickBy returns Partial<T>, which would need a cast.

* fix: keep token expressions on float32 values and rem precision

.fig stores numbers as float32 while plugin data keeps the resolved value
as a double, so a value such as 1234.567 differed by more than the 1e-6
tolerance and its expression was dropped as stale on reopen. Compare both
at float32 precision.

Token numbers were written with four decimals, which turned 0.5px into
0.0313rem; six keep every pixel step down to 1/1024px exact.

Also note that derived names can collide, so stylesheets take them from
variableCSSNames.
2026-10-04 10:50:46 +00:00
Danila Poyarkov 64c9461407
docs: describe rebasing and merging stacked pull requests (#869)
GitHub requires a linear history across a stack, so layers are rebased and
pushed with --force-with-lease. A stack merges only through the UI or the
asynchronous merge API; gh pr merge goes through auto-merge, which stacks
do not support.
2026-10-04 10:17:30 +00:00
Danila Poyarkov 12b6662ba3
fix(fig): keep Figma's order keys on instances and untouched siblings (#819)
* fix(fig): keep Figma's order keys on instances and untouched siblings

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

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

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

A moved layer in front of the lowest key ('!') left no room below it, so
the anchor was dropped and every sibling up to the next anchor re-keyed.
Such a key no longer starts the kept run when a sibling precedes it.
2026-10-04 10:16:01 +00:00
Danila Poyarkov 6a3960a5d4
feat(code): link the Code tab to canvas layers and sync both ways (#805)
* feat(code): link code to canvas layers and underline design issues

Code in the Code tab and layers on the canvas were unrelated: finding the
element behind a layer, or the layer behind an element, meant reading names.

Generated Design JSX and Tailwind JSX report the layer behind each element
in the order elements open, and edited Design JSX keeps the source line of
every element through the sandbox and renderer, so hovering an element
highlights its layer, Cmd/Ctrl-click brings it into view without changing
the selection the code shows, and errors and warnings from the design check
are underlined on the property that causes them.

* feat(code): explain the Code tab when nothing is selected

With no selection the editor showed a starter frame that read like a real
layer. The tab now says it shows the selected layers' code and offers Write
JSX, which opens the editor focused on the starter template.

* refactor(code): group code-to-layer linking into its own domain

Layer link types, issue mapping, and the hover and reveal behavior move
from the Code panel and a component file into src/app/code/layers, with
useCodeLayers as the panel's entry point, so app code no longer imports
types from components.

* fix(code): underline off-scale gaps after the spacing rule renamed its property

* feat(code): mark the layer of the element around the cursor

Hover highlighting and ⌘-click reveal replaced by one model: the element
around the cursor marks its opening and closing tag names and outlines its
layer on the canvas while the editor has focus. ⌘-click also collided
with CodeMirror's add-a-cursor gesture. Read-only Tailwind JSX now takes a
cursor so it links the same way.

Leaving the editor now ends a live Design JSX edit as one undo step.
Before, canvas edits made after typing never reached the code until the
tab was reopened, and their undo entries landed before the edit's.

* feat(code): sync the Code tab and the canvas both ways by patching

Canvas edits now patch the Design JSX a person wrote instead of waiting
for them to leave the editor: each linked element remembers the layer as
Design JSX last wrote it, and a canvas change rewrites only the attributes,
text and child elements that differ from that base, as CodeMirror changes
that keep the cursor, comments, formatting and history. Attributes written
as expressions are never overwritten; the code marks them when the canvas
now differs. Untouched code is regenerated with a minimal text change.

Code edits update layers in place: the new render is reconciled into the
existing layers (reconcileRenderedLayers), which keep their ids, so links,
selection and canvas edits survive typing. Each edit is one coalesced undo
step, replacing the restore-and-rerender preview and the commit on blur.

* feat(code): patch reordered layers and aliased properties in edited code

Reordering layers on the canvas now moves their elements in code a person
wrote: each child element and the blank lines and comments above it form a
block kept as written, and the children are written again in the new
order, staying linked. Children that cannot move safely, such as a loop
between them, keep their order and are marked.

Properties accepted under several names now come from one alias table in
the Design JSX schema, which the renderer resolves through and the patcher
and issue underlines use, so a canvas change to `w` patches `width` where
the person wrote that, instead of adding a second attribute.

* feat(code): keep the cursor in moved code and patch values written in style

A reorder rewrites the children span in one change, which collapsed a
cursor or out-of-sync marker inside a moved element to the span's edge.
The patch now carries where each block moved and places selections and
markers inside it at their new position.

Properties the renderer also reads from style={{ … }} come from a table in
the Design JSX schema instead of a hand-written list, keeping the rule
that an attribute under any of its names wins. The patcher uses it to
update a value written in style where it is, as a number or a px string
as written; values the renderer cannot read, such as '50%', are marked.

The layer patcher is split by concern: syntax helpers, attribute and
style patches, child patches, out-of-sync state and transaction assembly.

* feat(code): show the code's layer on the canvas as a tinted box

The layer of the element around the cursor used the canvas hover slot, so
moving the pointer over the canvas replaced it and the two read the same.
It now has its own shared editor state, codeFocusNodeId, drawn as the hover
outline over a light tint in every pane: hover stays an outline and the
selection keeps its handles, without borrowing the dashed outlines that
already mean component sets, drag parents and ghosts.

* fix(code): write added and removed layers when a reorder cannot move the code

When children could not be moved, such as two written on one line, the
patch marked the order and returned before adding or removing elements,
so a layer created in the same change never reached the code. It now
marks the order and still writes additions and removals.

* refactor(design-jsx): format the rebased layer description and stroke aliases

* feat(code): mount the layer-linked code editor through useCodeMirror

Master moved the code editor onto the shared useCodeMirror composable.
Its layer links, issue underlines, canvas patches, minimal text updates,
autofocus and read-only cursor now sit on that composable instead of a
hand-mounted view.
2026-10-04 10:08:40 +00:00
Danila Poyarkov f6848434ec
feat: check designs live with a Lint panel, canvas markers, and fixes (#804)
* 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
2026-10-04 10:08:39 +00:00
Danila Poyarkov b2499d8342
ci: run CI and the PR title check for the merge queue (#867)
* ci: run CI and the PR title check for the merge queue

A merge queue tests each queued change on top of the ones ahead of it and
waits for the required checks, CI result and PR title, on that merge
group. Both workflows now run on merge_group, reading the base and head
from either event. The title was checked on the pull request, so the
queue run reports success without a title to read.

* docs: explain stacked pull requests and the merge queue

Agents had to be told what a stacked pull request is each time.
CONTRIBUTING.md now covers stacks with gh stack, keeping them linear,
and landing them through the merge queue; AGENTS.md links to it, and
tools/AGENTS.md records that required checks must run on merge_group.

* docs: shorten the stacked pull request and merge queue guidance

The AGENTS.md line loads into every agent context, so keep it to the
rules; CONTRIBUTING.md keeps the commands.
2026-10-04 13:51:51 +04:00
Danila Poyarkov f55b887d2b
feat(scene-graph)!: make a stroke a paint (#864)
* feat(scene-graph)!: make a stroke a paint

Stroke extends Fill, so a stroke carries the same paint vocabulary a
fill does instead of a lone color. Every construction site now states
a solid paint type, which keeps today's behaviour exactly; rendering,
.fig conversion, and the stroke panel still read solid strokes only.

copyFill is generic over the paint shape so copyStroke reuses it
rather than repeating the deep copy of gradient stops, transforms and
pattern fields.

Groundwork for the gradient and image strokes in #797.

* test(scene-graph): cover a stroke's nested paint data in copyStroke

A stroke is a paint now, so its gradient stops and transform must copy
as deeply as a fill's; the fixture was solid and proved only the color
and dash pattern.
2026-10-04 13:38:05 +04:00
Danila Poyarkov 4939cd4982
fix(fig): write bound fields resolved when exporting .fig (#859)
Figma draws the value a bound paint or number stores until something makes
it resolve the variable again. OpenPencil resolves bound colours only when
drawing, so exports wrote the colour from before the binding, and a graph
bound without the editor kept stale numbers too: a file opened in Figma
showed bound fills in their old colour.

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

The resolution moves into scene-graph as resolvedPaintBindings and
resolvedNumericBindings, which the reader and reconcileNumericVariableBindings
now share, and the node resolvers take a fallback of 'active' or 'default'.
2026-10-04 08:59:42 +00:00
Danila Poyarkov 5146e7f7e5
fix(fig): keep variable metadata, plugin data and default mode on save (#848)
* fix(fig): keep variable metadata, plugin data and default mode on save

Saving a .fig rebuilt every variable record from the model, which held none
of Figma's variable metadata, so each save wrote variables as published to
all scopes with no description or code names, dropped other plugins' data,
and made the first mode the default.

Variables now carry scopes, codeSyntax and pluginData, collections carry
pluginData, and the reader and writer translate description,
symbolDescription, isPublishable, variableScopes and codeSyntax. Figma has
no default-mode field, so setting a default moves that mode first (undo
restores the order) and the writer orders the default first.

This is the base for design tokens: the CSS name will live in
codeSyntax.WEB and unit and mode conditions in OpenPencil plugin data.

* refactor(scene-graph): share the default-first mode order

setDefaultMode and the .fig writer both moved the default mode first by
hand; modesDefaultFirst does it once, with es-toolkit's partition.

* fix(core): undo a default mode change on the collection currently in the graph

Undoing a collection's removal restores a copy of it, so the inverse of
setDefaultMode wrote the previous order and default to an object the
graph no longer held when both were undone. Look the collection up by id
when undoing, as the forward step already does.
2026-10-04 12:53:04 +04:00
Danila Poyarkov b422ea53a4
build: update twirlwind to 0.4.0 (#851)
0.4.0 maps var() references to @theme variables onto their utilities,
which design-token export will use, and parses CSS values with
postcss-value-parser instead of regular expressions, fixing lossy
shorthand, filter, transform and media-query conversion.
2026-10-04 12:36:14 +04:00
Danila Poyarkov aeba9a6bd5
fix(scene-graph): leave a field a component property drives to its instance (#843)
* fix(scene-graph): leave a field a component property drives to its instance

A component states the default for a field a property reference drives,
but the enclosing instance's assignment decides the value. Synchronising
copied the default over it, so loading a page into an open document —
which synchronises every component it places — reset those layers. A
label a navigation item assigned came back as the component's default
when its page loaded on its own, while reading the whole archive gave
the assigned text.

Synchronising now skips a field whose reference names a property an
enclosing instance assigns, and carries everything else as before.

* fix(scene-graph): read an own assignment, not an inherited key

A document-authored component property id can collide with an Object
prototype key, and `in` would then report an assignment the instance
never made, silently leaving the referenced field unsynchronised.

* test(core): cover visibility and swap on an independent page load

The fix skips any field a component property reference drives, so the
page-scoped test should exercise VISIBLE and INSTANCE_SWAP alongside
TEXT rather than leaving the other two to full-document reads.

* fix(scene-graph): leave slot content to its own guard

#850 added a SLOT_CONTENT reference field, which drives a slot frame's
children rather than a scalar field, so the property-driven field map
no longer claims to cover every reference field.
2026-10-04 01:38:23 +04:00
Danila Poyarkov 8d132ce070
feat(app): show who works on each page (#815)
* fix(vue): keep the command palette open when a command opens a step

CommandPaletteRoot emitted select for every item, including one that only opens its children, so a host that closes on select closed the palette instead of showing the step. useCommandPalette.select now reports whether a command ran, and the root emits only then.

Disabled items were marked only with Reka's data-disabled; expose aria-disabled so assistive technology announces them.

* feat(app): jump between pages from the command palette

The palette had no way to reach a page. It now lists the pages visited recently in the tab, offers a Go to page step with every page, and finds any page by name.

Recent pages are tracked per editor session from page changes and reset when the document is replaced. Palette items can be search-only, so pages beyond the recent ones appear only when the query matches them. The divider-page rule moves out of PageListRoot so the palette skips dividers the same way, and useCommandPalette is exported from the package root.

* feat(canvas): draw agents' cursors as outlined sparkles

Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts.

* feat(app): publish AI agent presence to collaborators

The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only.

Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded.

* feat(app): follow agents and list them in the share panel

Following lived in collab and only knew people. It moves into the presence registry with a person-or-agent target, so you can follow anyone's agent, including your own outside a room: the view goes to the agent's page and keeps its cursor centered, stays attached while it idles between replies, and lets go when it leaves. A new editor action, centerOn, replaces reading the canvas size from the DOM. Peer cursors keep their zoom so following a person still matches it.

The share panel lists everyone in the room with their agents, each with its status, page, and a follow toggle, and your own agents can be renamed inline. CollabPanel moves to collab-panel, and the two-browser relay helpers move out of the collab spec into tests/helpers/collab.

* feat(app): show who works on each page

Agents now publish the page they work on, set when a reply starts on its pinned page and moved by switch_page, so a page is marked before the agent's first edit. presenceByPage groups people and working agents by page.

The Pages panel marks those pages with people's dots and agents' outlined sparkles in their owner colors, the command palette names who is on each page, and the chat says which page a reply is working on, with Go to page, while you view another one.

* docs(collaboration): list the agent model among shared presence

* test(app): stories for page presence markers and the chat's run location

The run location notice reads app state, so it moves into
useChatRunLocation and the component takes the agent and page as props.

* test(vue): a canvas story for presence cursors

Storybook now serves CanvasKit, so a story can render the real canvas:
people's arrows and agents' outlined sparkles, with controls for names,
colors, and zoom.

* feat(canvas): mark agents with a sparkle label instead of a sparkle cursor

A sparkle on its own did not read as a pointer. Agents now point with
the same filled arrow as people, in their owner's color, and their
outlined label starts with a sparkle.

* refactor(app): split the collaboration theme by component

One 18-slot theme served five components that each used a few slots,
with variants that applied to one slot. Avatars, the share button, the
presence list, page markers, and the mobile presence popover now have
their own themes, exported as tv() like the rest of src/theme.

* fix(app): truncate an agent's status before its name in the presence list

In a narrow share panel the status kept its width and the callsign
shrank to its first letter.

* feat(app): right-align page badges in a trailing area of the page row

Presence markers followed the page name. The row now has a trailing area,
right-aligned with its own spacing, where markers and later page badges
go.

* fix(app): key page markers by person or agent, not by name

Two people with the same name on a page, such as two Anonymous peers,
gave page markers duplicate keys. Entries now carry a stable id.
2026-10-04 01:18:16 +04:00
Danila Poyarkov 31e2e84b08
fix(core): send image tool results in the AI SDK's file shape (#857)
AI SDK 7 has no 'media' tool output part; images are { type: 'file', mediaType, data: { type: 'data', data } }. After diff_visual, export_image, or any other image result, the next step's prompt failed validation with AI_InvalidPromptError and the chat run stopped. toModelOutput is now typed against the SDK's ToolResultPart output, and a test validates the resulting tool message with the SDK's own schema.
2026-10-04 01:00:27 +04:00
Danila Poyarkov c47b5f8d1d
fix(fig): read, render, and write Figma slots (#850)
* fix(fig): read, render, and write Figma slots

Instances of a component with a slot showed the component's default
content instead of their own, and saving to .fig dropped slot properties,
their settings, and every instance's content.

Figma stores an instance's slot content as a frame on the internal canvas
and assigns the slot property that frame's GUID. The reader follows the
assignment while expanding the slot frame and pulls content frames into
the dependency closure without making them layers. The scene graph gains
a SLOT property type with its settings, a SLOT_CONTENT binding, and the
rule that an assigned slot's content belongs to the instance, which
component sync now leaves alone. The writer emits content frames on the
internal canvas and binds slot frames through the parameter map, as
Figma does.

The occurrence and diagnostic types move from the interpreter to
instance-overrides/occurrence.ts to keep it under the size limit.

* fix(fig): keep slot content through swaps and missing content frames

A slot assignment whose content frame the archive lacks no longer refuses
the document: the reader reports it through onMissingSlotContent, like a
missing component, and the slot keeps its component's content.

Swapping an instance's component, which variant switches do, now carries
the instance's own slot content to the new component's slot of the same
name instead of dropping it, as Figma does.

Component sync reads which slot a frame is from the component, whose
bindings instance copies do not receive. Clipboard export numbers slot
content after the other records on its dependency canvas, and the reader
and writer share Figma's default slot value.

* docs: note slot content kept across variant switches

* fix(fig): pair clipboard text by record and drop dangling slot assignments

The Figma clipboard paired text records with source text nodes by
traversal order, but instance-owned slot content is written after the
selected layers, so slotted text and the text after it swapped shaping
data. Records are now paired with their nodes through their GUIDs.

An instance assignment whose slot content frame is missing is dropped
along with the reported diagnostic, so the slot keeps following its
component instead of looking instance-owned.

* ci: pull the slots fixture for unit tests

* test: use GUID and Array.from in the slot tests

* refactor(fig): group occurrence types and paths in one folder

occurrence.ts and occurrence-path.ts became sibling prefixes when the
occurrence types moved out of the interpreter; they now live in
instance-overrides/occurrence/ as types.ts and path.ts.
2026-10-04 00:45:10 +04:00
Danila Poyarkov 82a600b512
feat(app): follow people and agents from the toolbar avatars (#807)
* feat(canvas): draw agents' cursors as outlined sparkles

Editor state's remoteCursors becomes presenceCursors with a kind, since the list now includes local agents. People keep the filled arrow; an agent is a sparkle outlined in its owner's color, with an outlined name pill, so whose agent it is reads from the outline. Cursor drawing moves out of the pen overlay into canvas/overlays/presence.ts.

* feat(app): publish AI agent presence to collaborators

The built-in chat now appears as an agent with a callsign while it replies, at the nodes its tools touch on the run's page, and goes idle (off the canvas) when the reply ends. Agents live in a per-document presence registry and are published in their owner's awareness state, so collaborators see each other's agents in the owner's color; the payload is metadata only.

Peer awareness was cast without checks. It is now validated with Valibot, invalid fields are dropped rather than the peer, and names, selections, and agent counts are bounded.

* feat(app): follow agents and list them in the share panel

Following lived in collab and only knew people. It moves into the presence registry with a person-or-agent target, so you can follow anyone's agent, including your own outside a room: the view goes to the agent's page and keeps its cursor centered, stays attached while it idles between replies, and lets go when it leaves. A new editor action, centerOn, replaces reading the canvas size from the DOM. Peer cursors keep their zoom so following a person still matches it.

The share panel lists everyone in the room with their agents, each with its status, page, and a follow toggle, and your own agents can be renamed inline. CollabPanel moves to collab-panel, and the two-browser relay helpers move out of the collab spec into tests/helpers/collab.

* docs(collaboration): list the agent model among shared presence

* test(vue): a canvas story for presence cursors

Storybook now serves CanvasKit, so a story can render the real canvas:
people's arrows and agents' outlined sparkles, with controls for names,
colors, and zoom.

* feat(canvas): mark agents with a sparkle label instead of a sparkle cursor

A sparkle on its own did not read as a pointer. Agents now point with
the same filled arrow as people, in their owner's color, and their
outlined label starts with a sparkle.

* refactor(app): split the collaboration theme by component

One 18-slot theme served five components that each used a few slots,
with variants that applied to one slot. Avatars, the share button, the
presence list, page markers, and the mobile presence popover now have
their own themes, exported as tv() like the rest of src/theme.

* fix(app): truncate an agent's status before its name in the presence list

In a narrow share panel the status kept its width and the callsign
shrank to its first letter.

* feat(app): show presence and following on the toolbar avatars

The share popover held who was online, the Share button turned into a
Connected status, and following gave no feedback. Collaborators' avatars
now count their agents and list them on hover, your avatar holds your
agents and Leave room, and +N collects the rest. A frame and bar in the
followed color show whom you follow; your own input or Escape stops it.
The share popover keeps the room link, and Share keeps its label.

* fix(app): address review of following from the avatars

Escape stops following from the follow frame, once and not while typing
or after another control handled it. The frame shows the agent sparkle
as an icon. A test pins that following survives the target changing
pages mid-switch, and the test relay tolerates frames that are not JSON.

* fix(app): follow until you leave, wait for silent peers, reach agents by keyboard

Following records the page it put you on, so any other page change ends
it, even of a resting agent that would otherwise pull you back later. A
present peer without a cursor yet is waited for instead of dropped. The
room list button is always there, so keyboard users reach agents that
hover cards only show to the mouse. centerOn ignores points too far away
to represent, and the docs describe following from the avatars.

* fix(app): stop following on any zoom of yours, and keep follow switches from restarting

Keyboard and menu zoom changed the view without the pointer or wheel
input the frame listens for, so following kept going and later undid the
zoom; any viewport change following did not make now ends it. Cursor
updates no longer restart a switch already heading to the same page,
which could keep a slow page from committing. The room's connected
store is reactive, and agent rename starts on a single click.

* fix(app): never loop on a followed cursor's unknown page, and stop on zoom mid-switch

A peer's cursor could name a page this document lacks; following then
retried a switch that never moved, forever. Following now waits on such
cursors and only re-syncs after a switch that landed. A page switch
restores its viewport without viewport:changed, so your zoom during a
follow switch now stops following too.

* fix(app): cancel a loading follow switch when following stops

Stopping following, by Escape, your own input, or zoom, left a follow
page switch loading, which then took you to their page anyway. Stopping
now overtakes it with a switch to the page you are on.
2026-10-04 00:09:40 +04:00
Danila Poyarkov d2fd6db141
feat(ai): show what each AI edit changed in its tool call (#812)
* feat(core): add visual diff and patch apply tools

diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.

diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.

Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.

* feat(ai): show what each AI edit changed in its tool call

Reviewing an AI run meant reading tool output or undoing steps to see
what moved. Each document-changing call now rebuilds its page before
and after from snapshots taken around it, and diffs each top-level
layer's JSX with jsdiff, the same patch diff_jsx returns, to find the
layers it changed. After the call returns, the changed region renders
in both states at one size and pixelmatch highlights the difference.
The tool card opens on a Changes view with a before/after slider, the
pixel highlight, and a CodeMirror merge view of the JSX. Records are
saved with the conversation next to attachments.

Calls snapshot their page individually instead of through one shared
variable, so concurrent calls in a step no longer overwrite each
other's undo state. Core gains graphFromPageSnapshot for rebuilding a
past page state, diffPageLayersJSX and jsxPatch (now shared with
diff_jsx), renderRegionToImage for rendering two states of one region
pixel for pixel, and comparePNGs on the raster codec. Settings > Chat >
Change previews sets the stored image size or turns images off.

* feat(cli): add diff commands and agent diff guidance

openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.

The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.

* feat(ai): render tool calls as summarized, highlighted cards

Every tool call showed only a status and its output as a JSON string,
so render calls hid their JSX, export_image dumped base64, and long
runs filled the transcript with identical rows.

A call now shows a one-line summary read from its input and chips that
select and zoom to the layers it touched, switching to the run's page
when needed. Expanded, it shows the JSX or script it wrote and its
JSON input and output in a read-only CodeMirror view, and exported
images inline. Render calls can be expanded while their input streams,
so the JSX appears alongside the canvas preview. Consecutive calls
beyond three fold into one row that keeps the latest call visible.

CodeMirror loads with the first expanded call. The code theme gains a
monospace fallback because the editor font variable is not always
emitted.

* feat(ai): let the chat AI diff its run against the starting state

The diff tools compare two nodes, so checking an edit meant cloning a
reference first, which the agent rarely did. diff_changes compares the
current page, or one node under it, with the page as it was before the
run first edited it, in diff_create's patch format. The app keeps that
page snapshot per run and exposes it through FigmaAPI.changeBaseline;
MCP and WebMCP have no run, so the tool is offered only to the AI chat,
where it is enabled by default and the prompt asks for it before
reporting.

* feat(core): diff and patch node trees as JSX attributes

diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.

Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.

design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.

* feat(ai): report diff_changes as a patch diff_apply can replay

diff_changes printed a unified diff of the JSX, which agents could read
but not apply. It now diffs the run's baseline against the live page
with the patch engine, matching nodes by ID, so a rename is a changed
name and the output replays on the starting state with diff_apply. The
chat's Changes view keeps the JSX line diff, which is for people.

* fix(core): keep diff_apply atomic and diff files honest about differences

- Added nodes render before anything else changes; if one fails, for
  example on a missing component, the rendered ones are deleted and
  nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
  reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
  one document has gets its status but no patch, since patches do not
  add or remove pages. diff files uses it, so an added empty page no
  longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
  is not a non-negative integer, exiting 2; diff_create's depth is
  validated the same way.

* refactor(ai): drop the unused tool JSON slot and place the JSX summary comment

* refactor(ai): find a tool change's clipping region with jsdiff

clipChangedJSX scanned both JSX sources character by character for their common start and end. diffLines gives the unchanged lines before the first change and after the last; the app now declares the diff dependency Core already uses.
2026-10-03 23:32:37 +04:00
Danila Poyarkov 1a22005960
refactor(core): use es-toolkit for diff tool collection helpers (#854)
The patch applier, page and attribute name merging, and diff visual's option filtering hand-rolled pickBy, difference, uniq, union, and omitBy.
2026-10-03 23:19:56 +04:00
Danila Poyarkov 2f7a41dc10 chore: sync bun.lock with jsondiffpatch's declared range 2026-10-03 22:35:11 +04:00
Danila Poyarkov 249f0cca87
feat(ai): render tool calls as summarized, highlighted cards (#811)
* feat(ai): render tool calls as summarized, highlighted cards

Every tool call showed only a status and its output as a JSON string,
so render calls hid their JSX, export_image dumped base64, and long
runs filled the transcript with identical rows.

A call now shows a one-line summary read from its input and chips that
select and zoom to the layers it touched, switching to the run's page
when needed. Expanded, it shows the JSX or script it wrote and its
JSON input and output in a read-only CodeMirror view, and exported
images inline. Render calls can be expanded while their input streams,
so the JSX appears alongside the canvas preview. Consecutive calls
beyond three fold into one row that keeps the latest call visible.

CodeMirror loads with the first expanded call. The code theme gains a
monospace fallback because the editor font variable is not always
emitted.

* refactor(ai): drop the unused tool JSON slot and place the JSX summary comment

* fix(ai): keep an opened tool call in place instead of following the output

Opening reasoning already stopped the transcript from following new output; tool calls and tool groups did not, so expanding one near the bottom re-pinned the bottom on every animation frame and slid the card away as it opened. Any disclosure in the transcript now stops following.

* fix(ai): show a pointer over chat tool calls, tool groups, and reasoning

* refactor(app): share CodeMirror setup between the code editor and viewer

CodeViewer repeated CodeEditor's view lifecycle: mounting the EditorView, label, theme, and language compartments, the app-theme watcher, and teardown. useCodeMirror owns that once; each component passes its own fixed and reactive extensions.

* refactor(ai): move tool node lookup and focusing into useToolNodes

ToolNodeChips looked nodes up in the active document and ran the show-on-canvas flow, with its superseded-switch and error handling, inside the component. The composable owns both; the component renders the chips.

* refactor(ai): derive tool call state and input once

ToolCallCard and ToolCallGroup each rebuilt classifyToolState's input from the part, and the card decided inline whether a call had input to show. toolCallState and toolHasInput own those rules beside the other per-call helpers.

* fix(app): use the thin app scrollbar in code editors and viewers

CodeMirror scrolls its own .cm-scroller, which fell back to the platform scrollbar, thick and light in the dark chat. The hosts now give it the shared scrollbar-thin utility.
2026-10-03 22:04:31 +04:00
Danila Poyarkov 3f594fdc3a
feat: add visual diff and patch apply tools and openpencil diff (#810)
* feat(core): add visual diff and patch apply tools

diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.

diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.

Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.

* feat(cli): add diff commands and agent diff guidance

openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.

The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.

* feat(core): diff and patch node trees as JSX attributes

diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.

Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.

design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.

* fix(core): keep diff_apply atomic and diff files honest about differences

- Added nodes render before anything else changes; if one fails, for
  example on a missing component, the rendered ones are deleted and
  nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
  reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
  one document has gets its status but no patch, since patches do not
  add or remove pages. diff files uses it, so an added empty page no
  longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
  is not a non-negative integer, exiting 2; diff_create's depth is
  validated the same way.
2026-10-03 21:00:13 +04:00
Danila Poyarkov 4895e55c22
fix(app): end a page switch quietly when another one takes over (#845)
Switching pages again before the previous page finished preparing
aborted the first switch, which rejected with an AbortError. Callers
start page switches without awaiting them, so the rejection surfaced as
an error toast.
2026-10-03 20:18:15 +04:00
Danila Poyarkov 67dbd7156f
feat(design-jsx): export every property the renderer accepts (#814)
* feat(design-jsx): export every property the renderer accepts

JSX export wrote only part of a layer: one solid fill, one stroke
without its alignment, shadows as repeated attributes, background blurs
as layer blurs, and nothing for hidden children, constraints, size
limits, absolute positioning, vertical text alignment, masks, or
variable bindings. Rendering an export lost those properties, and JSX
diffs could not see changes to them.

The export now writes them, using paint and effect helper calls when a
shorthand cannot express a value exactly, and leaves out values the
renderer would infer, so ordinary output stays as it was. Prop values
can now hold objects, arrays, and helper calls, printed through
@open-pencil/codegen's builders, which gain a call expression. The
language gains `visible`, `locked`, `constraints` (Figma's constraints
object with lowercase values, as `blendMode` uses), `italic`,
`strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now
applies `strokeAlign`, `strokeDash`, and the size limits, which it
accepted but ignored. Per-corner radii are written even when the
uniform radius is 0. A round-trip test renders each case's export and
checks the fields and that exporting again changes nothing.

* docs(fig): name the saved-glyph fixture by its repository path

The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere.

* fix(design-jsx): export the node-level dash pattern

A node's own dashPattern was not written, so a node dashed at node level
came back solid and the DOM/CSS export chose a solid border. It now
round-trips as a separate dashPattern prop; strokeDash stays the
stroke-local dash.
2026-10-03 15:40:21 +04:00
Danila Poyarkov 749d7baec9
feat: show AI agents on the canvas and to collaborators (#803)
* 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.
2026-10-03 14:01:36 +04:00
Danila Poyarkov 0ff6b414e3
feat(ai): choose a thinking level per message (#809)
Reasoning effort was a free-text profile field that only reached OpenAI
and OpenRouter, so Anthropic, Google, and DeepSeek models never thought
in direct chat. AI SDK 7 standardizes a `reasoning` call option that
those providers map to their own thinking settings, so profiles now
store one typed thinking level, shared with Pi, and requests pass it
through that option. OpenRouter's provider ignores the standard option
and receives its own reasoning option instead.

The composer offers the level next to the Design profile and reads it
per request, so a change applies to the next message without
rebuilding the transport. Saved profiles migrate from the Pi level or
the old effort string. Finished reasoning shows how long the model
thought while the block streamed.
2026-10-03 13:30:05 +04:00
Danila Poyarkov f133957b0b
fix(tests): let the app Playwright suite load every spec (#842)
`bun run test` stopped before running anything. Moving Figma
observation records out of tests/fixtures also moved
styled-saved-glyphs.json, which the saved-glyph and occurrence-text
canvas specs import, so both failed to load. The record moves back to
the fixture tree, and its observation note links to it there.

The real-LLM smoke spec threw at import when OPENROUTER_API_KEY was
unset. `bun run test` excludes it by tag but still loads the file, so
the throw aborted the whole run. The key is now read when the test
runs, so `test:real-llm` still fails without one.
2026-10-03 12:39:53 +04:00
Danila Poyarkov b7126322eb
fix(core): show imported page backgrounds and stop fixed text collapsing
Three defects a confidential Figma file surfaced, each reproduced by a
test that fails without the change.

A page's background lived only in viewport state, so an imported canvas
colour never reached the canvas and an edit was lost on the next page
switch or save. It is read from and written to the page's paints, which
the Figma API and export already carry.

Fixed-size text in a Hug container reported no intrinsic cross-axis
size, so a stretched label collapsed and clipped beside smaller
siblings. Yoga now measures it.

Populating a page resynchronised an instance whose text the file had
already resolved, replacing it with the component's default.

Co-authored-by: Victor Wads <victor@wads.dev>
2026-10-02 12:10:06 +04:00
Danila Poyarkov 7fff3de1ba
fix(vue): start CanvasKit once CanvasSurface provides the canvas (#823)
CanvasRoot started CanvasKit on mount, but its CanvasSurface child hands
over the canvas element only afterwards, so startup found no canvas and
gave up. Wait for the element instead. Storybook now serves CanvasKit so
a story can render the SDK canvas.
2026-10-02 00:23:49 +04:00
Danila Poyarkov d1159a7d07
refactor(kiwi)!: drop the colorVariableBinding paint field
`fig.kiwi` never defined the field, so nothing Figma writes carries one
and nothing OpenPencil writes has since `colorVar` replaced it. What
remained were readers kept so that files written by earlier OpenPencil
builds still restored their bindings — a compatibility path the project
does not keep before 1.0.

The field, the `VariableBinding` type and both readers are gone.
Reopening one of those files leaves the paint's colour unbound; binding
it again records it the way Figma does.
2026-10-01 23:47:51 +04:00
Danila Poyarkov 8528a917c0
fix: zoom in place, shape cursor names, and align context menu items (#818)
* 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
2026-10-01 21:34:36 +04:00
Danila Poyarkov 9b418de13e
refactor: print OpenPencil JSX export as syntax trees (#799)
* refactor(codegen): share syntax-tree code generation between exporters

dom-css printed Tailwind JSX with its own esrap JSX builders and kept TypeScript template helpers under its Storybook export. The OpenPencil JSX exporter needs the same JSX builders, and design-jsx and dom-css may not depend on each other.

@open-pencil/codegen holds both: es for ESTree templates and modules (moved from dom-css) and jsx for JSX elements, attributes, text, and printing, including the literal rules that keep exported strings from being reinterpreted. dom-css no longer depends on acorn and esrap directly.

* refactor(design-jsx): print JSX export as syntax trees

sceneNodeToJSX concatenated strings with hand-written escaping and indentation. It now collects typed props (moved to export/props.ts) and prints them with @open-pencil/codegen's JSX builders. Output is unchanged except for text: special characters print as a string expression instead of entities, and multi-line text keeps its line breaks, which the old line-splitting lost on render.

* docs(codegen): fix package metadata and dependency rules

codegen's repository.directory still named design-jsx, the README mentioned es without showing it, and the design-jsx and dom-css guides still said they depend only on scene-graph.

* test(core): move the JSX export round-trips to the design-jsx test home

Covers tabs in layer names and text, which JSX keeps as written.
2026-10-01 21:22:43 +04:00
Danila Poyarkov 79fee711c7
feat(app): jump between pages from the command palette (#801)
* fix(vue): keep the command palette open when a command opens a step

CommandPaletteRoot emitted select for every item, including one that only opens its children, so a host that closes on select closed the palette instead of showing the step. useCommandPalette.select now reports whether a command ran, and the root emits only then.

Disabled items were marked only with Reka's data-disabled; expose aria-disabled so assistive technology announces them.

* feat(app): jump between pages from the command palette

The palette had no way to reach a page. It now lists the pages visited recently in the tab, offers a Go to page step with every page, and finds any page by name.

Recent pages are tracked per editor session from page changes and reset when the document is replaced. Palette items can be search-only, so pages beyond the recent ones appear only when the query matches them. The divider-page rule moves out of PageListRoot so the palette skips dividers the same way, and useCommandPalette is exported from the package root.

* fix(vue): list every item in a command palette step

The result limit also applied to a step's unfiltered list, so Go to page showed only the first 12 pages. A step the user opened now lists all of its items until they search; search results and the top-level list keep the limit.

The palette spec's page setup and current-page observation move to tests/helpers/pages as a setup mutation, a probe, and a Pages-panel driver.

* test(pages): match page rows by exact name

* refactor(app): express page lists with es-toolkit

Recent pages are take(uniq([visited, ...previous]), max); the palette builds its lookup with keyBy and its lists with without, compact, take, and difference instead of hand-built maps, sets, and slices. Tests count with range.
2026-10-01 20:26:35 +04:00
Danila Poyarkov c48886920d
fix(fig)!: clear a colour variable binding when the layer is unbound
Unbinding a fill or stroke and saving handed Figma back the variable
the layer was imported with: the raw paint kept its `colorVar` and only
the bound path ever touched it. The unbound path now clears it, and an
emptied bindings record is dropped rather than written, since nothing
reads an empty map and it would otherwise sit in every exported file.

With that, nothing writes `colorVariableBinding`. It had no entry in
`fig.kiwi`, so encoding one meant splicing bytes into the encoded
message by searching for marker sequences; exports have written
`colorVar`, the field the schema defines, since the reader landed. The
splicing encoder and the three helpers it exposed are gone, leaving
`parseVariableId`. Importers still read the old field, so files earlier
versions wrote still open.

Co-authored-by: mrhard9090 <74858909+mrhard9090@users.noreply.github.com>
2026-10-01 17:06:00 +04:00