Commit graph

129 commits

Author SHA1 Message Date
Danila Poyarkov 4eb3449b0c
fix(app): keep a closing tab's canvas off the next document (#958)
* fix(app): keep a closing tab's canvas off the next document

Tab components read the editor through useEditorStore, which resolves
to whichever tab is active at each call. When a tab closed, the next
tab became active before the closing canvas finished, so the canvas
registered its renderers with that document and its cleanup removed
them from the wrong one. Every closed document's renderers, WebGL
canvases, and detached editor DOM stayed in the remaining tab: about
1,200 DOM nodes, 320 listeners, and 2.5 MB of heap per open and close
in a production build.

EditorWorkspace now provides its tab's store to app components as it
already did for the SDK's useEditor, and useEditorStore returns it
inside the tab. App-level menus, shortcuts, and services outside any
tab keep following the active tab. Over eight open and close cycles,
DOM nodes, listeners, and the remaining tab's renderers stay flat.

* fix(app): bind the workspace itself to its tab's store

Vue resolves an injection from the parent, so EditorWorkspace could not
inject the store it provides and its own template kept following the
active tab, reading the next document while its tab closed. It now
takes its tab's store directly.

* refactor(app): pass the workspace its tab as a prop

EditorWorkspace read the active tab once during setup and relied on
WorkspaceView keying it by tab to keep that choice. WorkspaceView now
passes the tab, the workspace uses its store directly, and provides it
to the components below, so the binding is explicit in the template.

* test: keep the light-page setup from leaving documents unsaved

CanvasHelper switches a fresh document to Figma's light page so canvas
specs draw on it in the dark theme. Setting the page color counts as an
edit, so every spec's document started unsaved, and closing a tab asked
to save changes nobody made: both tab-close specs hit that dialog. The
store now exposes markDocumentSaved, which reload and save already use,
and the helper takes the light page as the saved state.

* test: wait for a closing menu before opening the next one

A menu stays in the page while it animates out, and a click on a menubar
trigger then is lost, so menu specs failed depending on which tests ran
before them. They now open menus through one helper that waits for the
previous menu to go and for the named menu to appear.

* test: configure storage through the current settings form

Storage settings moved the connection fields behind the provider row
and save them with one Save button, so the workspace spec timed out
looking for an Endpoint field that was not shown.
2026-10-07 21:09:58 +00:00
Danila Poyarkov 974905289a
fix(canvas): keep image-heavy documents within CanvasKit memory (#963)
* fix(canvas): bound image decoding for large Figma documents

* test(canvas): update lifecycle fixture for image caches

* test(canvas): align image regressions with contribution guides

Use native wheel zoom in the browser regression instead of inline store access. Keep export state restoration in the existing raster suite and explicitly format the new image tests omitted by the root format script.

* fix(harness): pin patched proxy address dependency

Override the transitive proxy-addr dependency with 2.0.8 to fix IPv4-mapped IPv6 trust subnet checks and pass the critical dependency audit (GHSA-jqcg-44mw-7w3h).

* build: drop the proxy-addr override master already resolves

Master's lockfile has proxy-addr 2.0.8 since the advisory was fixed
upstream, so the override this branch added is redundant.

* perf(canvas): encode image previews as WebP

PNG previews of photos ran to several megabytes each, so a page of them
filled the 64 MiB preview cache and evicted itself: page 2 of the #924
reproduction held 48 MB of PNG previews against 7.3 MB as WebP. Browsers
that cannot encode WebP hand back PNG.

Co-authored-by: sableangle <sableangle@gmail.com>

* perf(canvas): decode four image previews at once

The browser decodes previews off the main thread, so one at a time left
a page of photos filling in for seconds: page 2 of the #924 reproduction
took 4.6 s to finish and now takes 2.0 s.

Co-authored-by: sableangle <sableangle@gmail.com>

* refactor(canvas): decide on image previews once per document size

needsImagePreviews replaces useViewportImageRendering, which was not a
Vue composable, names its two thresholds, and keeps its answer per
document and image count instead of summing every image on each frame.
Choosing a preview by zoom carries a lint exception: preview mode draws
the scene uncached.

Co-authored-by: sableangle <sableangle@gmail.com>

* docs: describe the image memory fix in the changelog

Co-authored-by: sableangle <sableangle@gmail.com>

* fix(canvas): let go of a document's image previews when the document changes

Switching to a document that needs no previews left the preview cache
holding the previous graph and its encoded images, often hundreds of
megabytes, until previews were used again. The renderer now releases
them when its document changes, keeping the decoder for the next one.

---------

Co-authored-by: sableangle <sableangle@gmail.com>
2026-10-07 20:30:25 +00:00
Danila Poyarkov 759f9eb611
feat: show controls in a prebuilt demo and fix what saving it to .fig lost (#938)
* feat: show controls in the demo and tidy the component panels

The demo gains a Controls page: a switch, checkbox, slider, tabs, text
field, and a button with interaction states, written as Reka-named design
JSX, and a settings card of their instances to try in preview.

Go to main component and Detach instance move into the instance's panel
header as icon buttons. The Behaviour section's "Still needed" line is a
status rather than a button that only moved focus, and the rows it names
are marked instead.

The DOM projection writes flex-start for auto layout's start alignment:
CSS stretches children by default, so a hugging button in a column filled
its card in preview and HTML export. The paint page snapshot catches up
with the frame names #930 draws.

* perf: open the demo as a prebuilt .fig instead of generating it

/demo built every page in the browser and laid them all out in one task,
which froze the page for about six seconds. The demo's content moves to
tools/generate/demo, which builds public/demo.fig headlessly, before Vite
serves or bundles the app and only when its inputs changed; /demo opens
that file like any other, off the main thread and behind the loader. The
document is now lint-clean: small captions are 12 px, and the badge and
disabled button text meet contrast.

Opening the demo from .fig showed three bugs in saving and reading files,
fixed here:
- A layer's blend mode was never written, so Multiply and Screen reopened
  as pass-through.
- Underlines drawn from saved glyphs ran to the text box edge; glyphs now
  keep their advance, read from and written to .fig, and the underline
  ends where the text does.
- A frame laid out on its own because of a saved hug size ignored the
  width its parent stretched it to, so centred content moved to the start.

* feat(design-jsx): write Reka elements in TSX

The Reka element names were only part of the JSX string grammar, so code
that builds trees in TypeScript had to assemble strings by hand, unchecked.
@open-pencil/design-jsx now exports each Reka namespace as typed element
functions (Switch.Root, Slider.Thumb, Tabs.Trigger, …), derived from the
same table the string renderer reads, with props typed as the behaviour
props a root binds. The demo's controls are TSX built with them.

* fix(canvas): draw a component set's editing border at the live zoom

A component set without a stroke got its dashed border in the scene, sized
by dividing by the zoom. The scene is cached as retained pictures and
scaled while navigating, so the border was recorded at one zoom and grew
into thick dashes when zooming in, until the page was redrawn. It is
editor chrome, so it now comes from the overlay pass, which draws every
frame at the live zoom, as slot outlines do. The label cache tracks every
component set on the page for it, and image export no longer includes the
border.

* test: fit the P3 paint page to the canvas size it is captured at

The P3 test zoomed to the paint page while the wide-gamut notice still
took space, then dismissed it, so the first capture fit a shorter canvas
than the second and the two only matched by timing. focusPaintEffects
waits for the canvas to take a window resize, the test focuses again after
the notice goes, and the snapshot shows the demo as it now opens.

* refactor(canvas): fill sections and sets through one helper

Without its own fallback border, a component set fills and strokes its rounded bounds the way a section does; fillRoundedBounds fills them for both.

* test: check the demo controls on their own page and type pixel reads

The demo test built and round-tripped the whole four-page document to
check the controls, which took six seconds on CI and timed out; it now
builds the Controls page alone through the same measured-text setup the
build uses. The underline test reads CanvasKit pixels as any number array,
since readPixels may return floats.

* fix(canvas): measure long text extents without spreading glyphs

Spreading every glyph into Math.min/max overflows the argument limit on long texts.

* fix(demo): keep a tab touched while the demo loads and report a failed load

A file opened or a layer drawn during the fetch keeps the tab; a failed fetch shows the open-file error instead of an unhandled rejection.

* test: check a component set border stays one pixel wide when zoomed
2026-10-07 18:37:05 +00:00
Danila Poyarkov f8d2dbc7e6
feat(chat): attach dropped images and style tables, menus, and link prompts in replies (#955)
* feat(chat): attach images by dropping them on the chat

Images could be attached with the attach button or by pasting, but a
drop on the chat did nothing: the window's document drop handler left
images to the canvas, so they were dropped silently.

Images dropped anywhere on the chat panel now attach, through the same
path as the attach button and paste, with the same limits and errors.
While images are dragged over the panel, an outline over the composer
says where they go, or why they cannot be attached while a reply runs or
four images are already attached. SVG images are drawn as PNGs, since
vision models take raster images. A drag without images, such as a
.fig, is left to the window, which opens documents.

The canvas and the chat share AppDropOverlay, a typed feedback component
with a light tint over surfaces and a cover over fields.

* fix(chat): style tables, menus, and link prompts in AI replies

vue-stream-markdown drew tables with semibold, filled rows at 14px, with
copy, download, and fullscreen buttons above each; its tooltips doubled
with the browser's, and the link confirmation it teleports out of the
message had no app colors, since the token mapping sat on the message
element only.

The token mapping now lives in markdown.css on the renderer's
.stream-markdown elements, so teleported dialogs and menus use app
colors. Tables use regular weight, a light header, and the chat density's
size, with copy as their only action; task items show their checkbox in
place of the bullet; the copy menu and link prompt follow the app's
menus and dialogs. Hover labels use the app's tooltip, while click menus
keep the renderer's. Links no longer request each site's favicon.

* fix(chat): keep an SVG drawn after sending out of the next message

Drawing a dropped SVG as PNG takes a moment. A message sent or cleared
meanwhile no longer gets the image added to the following draft: each
draft has a generation that sending and clearing advance, and an image
finished for an earlier one is dropped.
2026-10-07 18:18:15 +00:00
Danila Poyarkov b3f35ab650
fix: keep stored page backgrounds and number layers by their own page (#949)
Follow-ups from review of #940. A document handed to the editor with a theme keeps its first page's stored background; only a page without one takes the theme default. Canvas layers are numbered from the page they go on rather than the page on screen. Script strokes are copied with copyStrokes so the node owns its nested paint values. CanvasHelper switches only a fresh document (one empty page on the exact dark default) to the light page, and the round-trip test imports SceneGraph from its own package.
2026-10-07 13:35:59 +00:00
Danila Poyarkov cff091bc6a
test: resolve repository files without climbing directories (#946)
* test: resolve repository files without climbing directories

Twelve tests and helpers reached shared fixtures, package assets, and workers with ../.. paths from import.meta, which the import rule does not see. They now go through repoPath and testPath, a workspaceRoot() that finds the root by its lockfile, the core package's own root, or the #core alias through import.meta.resolve. The root finder derives its folder from import.meta.url, so Playwright specs running under Node can use the helpers too. open-pencil/no-deep-parent-relative-paths rejects climbing two levels in new URL(…, import.meta.url) and in path calls that start from import.meta.

* fix(lint): catch Windows separators and wrapped import.meta paths, and stop at template expressions

The path rule missed '..\..' and a base such as dirname(fileURLToPath(import.meta.url)), and read `../${folder}` followed by '..' as climbing two levels.
2026-10-07 12:35:39 +00:00
Victor Wads 690c1247e4
feat(collab): show MCP agents, follow streamed JSX, and follow your agents as they work (#725)
* feat(MCP): follow agent activity in canvas

* feat(collab): show MCP, ACP, and harness sessions as agents

MCP clients worked on the document unseen: only the built-in chat had a
presence, and following agent activity meant a separate setting that
moved the viewport after every MCP tool. Each MCP session now shows as
an agent with a callsign in its owner's color, like the chat: the MCP
server forwards the session and the client's name with each tool call,
the app's own ACP and Pi harness chats mark their sessions with a
header, and the agent points at the layers each call reads or changes
on their page. It rests after a quiet spell, leaves when its session
ends or the server disconnects, and collaborators see it through
awareness. Following it works like following anyone else, from the
avatars, so the default-on follow setting and its viewport fitting go.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(ai): move the chat's agent through JSX as it streams

While the built-in chat streamed a render call, its preview grew on the
canvas but the agent stood still until the tool finished. The preview
now reports, after each update, the element that appeared last and the
bounds of what the JSX builds; the agent's cursor follows that element
and its outline traces the preview, for collaborators too, until the
tool runs and the agent outlines the real layers. Peers' outlines are
validated and capped like their selections.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(collab): glide cursors and the followed view instead of jumping

People's and agents' cursors jumped to each new point, which with
throttled awareness and an agent streaming JSX made them stutter, and
following re-centered the view in one jump on every update. Cursors now
ease to each new point from wherever they are drawn, and following pans
and zooms the view there the same way, stopping in place when you take
over. With animations off or reduced motion, both move at once. Each
cursor carries an id so it keeps its glide between updates.

Co-authored-by: Victor Wads <victor@wads.dev>

* test(collab): cover MCP agents and the streaming agent in the browser

Test runs send MCP requests through the bridge's own command handler, so
a browser test can show an MCP session as an agent to the editor and to
a collaborator without a separate server. The streaming JSX test checks
that the chat's agent cursor and outline follow the newest element.

Co-authored-by: Victor Wads <victor@wads.dev>

* docs: describe agent sessions, streaming cursors, and gliding follow

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(ai): keep the streaming agent's name off the text it writes

The chat's agent sat at the newest streamed element's top-left corner,
so its name label covered the text being written. It now sits at the
element's trailing corner, where content grows.

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(collab): end only a closed connection's own MCP sessions

When the app's connection to the MCP server closed, every MCP session's
agent left, including sessions that never came over that connection,
such as a second editor's. The bridge now remembers the sessions each
connection carried and ends only those.

Co-authored-by: Victor Wads <victor@wads.dev>

* feat(ai): follow your agents automatically while they work

Agents spun up from the chat or an MCP client worked out of sight and
then went idle, so people had to find what changed, and the agent skill
told agents to move the user's view and selection after every edit. A
Follow agents toggle in the AI panel's header, on by default, now has
the view follow our agents from the start of each run: whichever starts
first, then the next one at work once the followed agent rests. Leaving
the page, moving the view, Escape, or Stop following leaves that agent
alone until it rests; people are never followed this way. The skill no
longer asks agents to select and zoom to their work.

Co-authored-by: Victor Wads <victor@wads.dev>

* fix(collab): keep an MCP agent when a restarted bridge carries its session

Restarting MCP disconnects the old bridge and opens a new one at once,
but the browser reports the old socket's close only after its close
handshake. A tool call that reached the new connection in between was
undone by that close, which ended the session and removed its agent.
Sessions now record every connection their calls came over, across
bridges, and end only when the last of them closes.

The docs no longer say that following an agent keeps it from editing
out of sight: following moves your view, and the stale variables and
follow bullets the changelog's union merge brought back are removed.

Co-authored-by: Victor Wads <victor@wads.dev>

* test(collab): record what the bridge sends instead of an empty fake method

Co-authored-by: Victor Wads <victor@wads.dev>

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-07 10:04:58 +00:00
Danila Poyarkov 76988569b0
fix: match Figma's panel X, Y, rotation and themed page backgrounds (#940)
* fix(core): start new layers with Figma's defaults in the editor and plugin API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Sizing a boolean to its result also refits the groups around it, and undo left them refitted, so the operands came back displaced. createBooleanOperation returns the fit, and undo reverses it first. A component set's dashed outline now clamps its radius to its bounds as the fill does, and a script's resize refits the enclosing groups once, after the corner is restored.

* fix: show and edit X, Y, and rotation as Figma's properties panel does

Recorded in Figma desktop 126: the panel's X and Y are the top-left of a turned layer's box on the canvas, measured from its frame or page through any groups, rotation is counterclockwise with a flip reading as 180, a typed rotation turns the layer about its center, and a typed X or Y moves its box. The panel showed our clockwise angle and the unturned box. The conversions live in @open-pencil/core/geometry beside figmaRotation, which the plugin API now shares, and the position controls preview a change worked out for each selected node.

* fix: start new pages on Figma's background for the interface theme

Recorded in Figma desktop 126: a page made in the dark theme is #1E1E1E and in the light theme #F5F5F5, from the canvas and from scripts. New documents, pages the editor adds, and figma.createPage now store that background; existing pages keep theirs. The unused OS-scheme helper getDefaultCanvasBgColor goes. Playwright runs the app in its dark theme, so CanvasHelper keeps fresh documents on the light page the canvas snapshots were drawn on, and a dedicated spec covers both themes.

* test(e2e): switch the theme through the app module in the page background spec

Writing localStorage directly breaks the no-direct-storage-access rule. The spec now sets the light theme the way the chat harness does, reloads, and checks the theme took effect before reading the page color.
2026-10-07 10:02:52 +00:00
Danila Poyarkov af18eec514
fix(ai): polish chat history and stabilize chat tests (#923)
* fix(ai): confirm deleting a conversation in the standard dialog

Deleting a conversation asked inside the chat panel's header, unlike every other destructive confirmation, which uses AppConfirmationDialog. It now opens that dialog, which names the conversation; Cancel keeps it and Delete removes it.

* feat(ai): dim a reverted reply's content

A reverted reply still showed its tool cards with their success marks and its text as if the edits were there; only the line below said otherwise. Its content is now dimmed, while the Changes reverted or Restore changes line and the other actions stay at full strength.

* ci: typecheck the test suites in Code quality

bun run check runs check:test-types, but PR CI did not, so tests that no longer type-checked could merge and break bun run check on master, as the grid fixtures from #866 did. It runs as a step of Code quality, which already builds the package declarations it needs; the check takes about two seconds, less than a separate job's setup.

* test(ai): time the reasoning auto-close with the fake clock

The test slept 1200 ms of real time past the 1000 ms auto-close to show that a manually opened block stays open, which was timing-dependent under load. It now advances Playwright's clock past the timer. With manual opening ignored, it fails.

* test(ai): skip file identity on Playwright's bundled Chromium on macOS

There, reading a FileSystemFileHandle back from IndexedDB closes the page before any app code runs, so the test always failed locally on a Mac. Installed Google Chrome on the same machine reads and compares the handle correctly, and the app's resolveFile returns one ID for twelve concurrent calls there. The skip is limited to the bundled build on macOS.

* fix(settings): keep a model picker open while the editor slides in

The models panel focused the editor's first field after its enter transition, even when focus had already moved into the editor, closing a picker opened during the animation. It now leaves focus alone once it has moved since the editor opened.

* test: serve a fixed model catalog and wait for the editor before Settings shortcuts

Chat tests fetched the live models.dev catalog, so the model list changed under them; they now get a small fixed one. The credentials test pressed the Settings shortcut before the editor was ready.

* fix(settings): move drill-in focus with Reka's FocusScope

The drill-in focused the detail's first field after its slide-in ended, found by querying the DOM, and restored focus by hand, so a picker opened during the slide closed. FocusScope now focuses the detail as it mounts and returns focus to what opened it. A caller can prevent open-auto-focus to focus its own field, which the models panel does for the profile name through the editor's exposed focus().

* test(ai): answer the Vision model's request in chat tests

A message with images is analyzed by the Vision model, which calls OpenRouter directly rather than the mocked chat transport. With the test key the request failed, and since #916 a failure before dispatch takes the message back, so the WebKit history test never got a reply. The chat fixture now answers that request with fixed findings.

* test(ai): check that deletion persists and reverted replies dim

The delete test passed even if the conversation stayed in storage, since the panel clears its messages before the store removes it; it now reloads and checks the history. The revert test checked only data-reverted; it now checks the rendered opacity too.
2026-10-07 09:53:05 +00:00
Danila Poyarkov 8bf71bd92f
feat(ai): add guided AI setup for providers, coding agents, and Pi (#916)
* feat(storybook): prototype guided AI setup and task assignments

* refactor(storybook): adopt shared control foundations

* refactor(storybook): build AI setup on current settings foundations

Move the prototype to settings/ai-setup and compose SettingsSection, SettingsGroup, SettingsRow, AppAlert, AppBadge, and AppCheckbox instead of local section, status, and badge markup. Replace the nonexistent danger color and raw amber with the error and warning tokens.

* feat(ui): add a shared radio group

AppRadioGroup wraps the Reka radio group with typed options, labels each radio by its option text with any description as its accessible description, and supports all arrow keys unless an orientation is set. The AI setup wizard uses it for the spending choice, named by the step heading, and shares the choice card style with its checkboxes.

* fix(ui): draw unchecked checkboxes on the field background

AppCheckbox filled its box with the surface (text) color, so unchecked boxes were nearly black in the light theme and nearly white in the dark theme. Use the panel field background and accent hover border shared with the radio group and switch.

* refactor(storybook): drop the simplified AI connections panel

AI setup has two modes: skippable guided onboarding for most people and the existing advanced settings for power users, both editing the same model settings. Remove the third, simplified connections and tasks panel. The wizard's Advanced settings action and the returning-user screen now stand in for ModelsPanel, which offers Run guided setup. Removing Gateway's own Back button also fixes the blank screen it led to.

* feat(ai): plan guided AI setup from the model catalog

planOnboarding proposes design and vision models from the access a person already has, using the real provider and agent catalog, and falls back to OpenRouter only when pay-as-you-go is allowed. applyOnboardingPlan merges a confirmed plan into the model settings, reusing matching connections and profiles, keeping roles it was not asked about, and dropping only the empty fresh-install profile.

* refactor(ai): share model provider display names

Move the provider, agent, and Pi display-name lookup out of the model settings workflow so guided setup can reuse it.

* feat(ai): offer guided AI setup over the real model settings

Guided setup asks what AI should help with, what access the person
already has, and whether pay-as-you-go models are allowed, then
proposes design and vision models from the provider and agent catalog.
Connections reuse the provider key field and connection test, keys are
saved through the credential manager, and the confirmed plan is merged
into the model settings, keeping anything configured by hand. Saving
reports saved, partial, or failed like the profile editor.

A fresh install whose model settings are still the empty placeholder is
offered setup once with a skippable welcome; existing setups never see
it. Settings → AI & agents can run it again, and Advanced settings hands
off to the model editor. The Storybook fixtures for agents, OpenRouter
sign-in, Vercel AI Gateway, and the local server are replaced by the
real flow, with all copy translated.

Browser tests start with the offer dismissed through the shared
Playwright storage state; the first-run spec clears it.

* fix(ai): keep configured models and credentials safe in guided setup

Running guided setup again planned from the catalog defaults, so it
replaced hand-configured design and vision models and dropped their
settings; it now keeps a configured model while its access is still
selected or onboarding cannot offer that provider, and keeps vision when
nothing new covers it. Reused profiles must have the capabilities the
plan relies on, and an explicit vision assignment without image input is
cleared.

A server's saved key and its status now apply only to the connection at
the address being entered, and its connection test uses that
connection's API type. Entered keys are copied before saving, so closing
setup mid-save no longer drops them, and the Models list refreshes key
status after setup saves a key. Servers that do not check keys get a
hint to enter any value, and a step that only keeps configured models
says so instead of showing nothing.

* test(ai): check key status right after guided setup

The Models list must show a key saved by guided setup as connected without reopening Settings.

* feat(ai): sign in to OpenRouter from guided setup

OpenRouter can now be connected with its OAuth PKCE flow instead of a
pasted key. In the browser, sign-in opens in a popup that returns to a
static callback page on the app's origin, which relays the redirect to
the editor over a BroadcastChannel, so the editor never navigates away.
The desktop app opens the system browser and receives the redirect on
a one-shot 127.0.0.1 listener, the localhost callback OpenRouter
documents. Either way the editor checks the state, exchanges the code
for a key directly with OpenRouter, fills it in, and runs the
connection test. Waiting, blocked pop-ups, cancellation, expiry, and
failures are reported in the step, which keeps the pasted-key path.

Setup no longer offers Clear for a saved key, since removing keys
belongs to the advanced settings, and the service worker leaves
/oauth/ pages to the network.

* fix(settings): report unreadable model keys as unavailable

A saved key the browser credential store could not read, for example one left from an older session on the same origin, rejected the model status refresh and the startup credential check, which surfaced as a global error toast. Each read failure now marks only that connection as unavailable.

* feat(ai): map every role in guided setup and verify OpenRouter sign-in

Guided setup now proposes a model for design, vision, review, and fast
work, and the review step is a map of those roles with every suitable
model from the connected providers and a "Use recommended setup"
shortcut. Fast work defaults to the provider's catalog model tagged as
fast; behind an agent, review and fast work use the API model chosen
for vision. Review and fast work are never asked about, so a configured
choice, including none, stays unless it follows a design model it can
no longer follow.

The pay-as-you-go question only appears when the access already
selected leaves a requested role without a model, so choosing
OpenRouter or another account no longer asks it.

After signing in with OpenRouter, setup checks the key with
OpenRouter's key endpoint, which costs no credits, instead of a text
generation test. The step then says it is signed in, names the key,
warns when the account has no credits yet, and offers another account
in place of the key field and test button.

* feat(ai): offer OpenRouter only for goals nothing selected covers

The separate pay-as-you-go step asked an abstract question even when
the selected access already covered every goal. The connect step now
names a goal nothing selected can cover, such as visual review behind
a coding agent or a local server, and offers to add OpenRouter for it;
once added, it says OpenRouter fills the gap and can be removed again.
Setup can finish without visual review, but not without a design model.

A local or company server can be marked as able to read images, which
lets it cover visual review and makes it the preferred vision model
over a paid account.

* feat(ai): show provider logos and more providers in guided setup

Guided setup shows monochrome logos for coding agents, API accounts,
and local servers, from LobeHub's MIT-licensed static SVG set loaded as
an `ai` icon collection, so they follow the theme like Lucide icons.
DeepSeek, Z.ai, and MiniMax are offered under "More providers", and a
local server can start from the Ollama or LM Studio address instead of
typing it.

* feat(ai): guide coding agent setup in guided setup

Choosing Claude Code, Codex, or Gemini CLI in the desktop app now checks
whether the agent's ACP program and OpenPencil's MCP server, which
agents use to reach the canvas, are installed. An allowlisted
agent_lookup command finds the program on the same widened PATH as the
MCP lookup. The card shows install commands only for what is missing,
checks again on request, links a new setup guide, and copies a prompt
that asks an agent the person already uses to install both, confirm
they are on PATH, and sign in. In the browser, the agent section links
to the desktop app.

* fix(ai): space the More providers toggle like a group heading

The toggle sat flush against the account cards above and below it; it now reads as a group heading with the same rhythm as the other sections.

* feat(ai): detect and install coding agents in guided setup

Adopt the local agent discovery from #847. A desktop agent_lookup
command reports each agent's own CLI, its ACP adapter, npm, and
OpenPencil's MCP server on the widened PATH without starting any of
them, and the app can install a missing adapter or the MCP server with
npm, limited by the shell capability to those exact packages and the
MCP version that matches the app. Guided setup now tells "installed but
the OpenPencil adapter is missing" apart from "not installed", offers
one-click installs, links each vendor's own setup guide, and keeps the
manual commands and setup prompt for the browser, missing npm, or a
failed install. Codex install instructions move to
@agentclientprotocol/codex-acp, which replaces @zed-industries/codex-acp.

Kiro CLI support from the same pull request is left for a separate
change, since it needs ACP transport work.

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>

* build(app): resolve LobeHub icons with import.meta.resolve

The architecture lint forbids createRequire in ESM build code.

* test(app): seed AI setup specs through storageState

Follows the storage seeding used by other browser specs and the import type rule.

* feat(ai): set up Pi with its own sign-ins in guided setup

Pi now runs with the providers signed in to in the Pi CLI and Pi's
default model. The Harness companion reuses ~/.pi/agent; the app reads
only Pi's settings.json for the default model and never auth.json. A
saved key is still used as an AI Gateway key.

Guided setup offers Pi next to the other coding agents. On the desktop
it checks the Harness companion, the MCP server, and Pi's default
model, and installs the companion with one click through npm.

Agent discovery now reads the installed versions of the MCP server and
the Harness companion from their package.json without starting them.
Setup flags a version that does not match the app and shows the update
command for the package manager that installed it, instead of
reporting the server as installed and failing at the first message.

* feat(ai): check agent companions before a chat starts

A Pi chat without the Harness companion, or any agent chat whose
companion or MCP server version does not match the app, failed when the
process started and showed only the generic request error. The chat now
checks the companions through agent discovery first and names the fix,
with an action that opens guided setup. Pi sign-in and model problems
use the same path.

The Pi model editor shows the same companion, MCP server, and default
model status as guided setup, and no longer requires a model ID, since
Pi falls back to the default model set in Pi.

Supersedes the companion detection in #566, which ran the companion to
read its version and required an exact version match.

* fix(harness): start Pi sessions with MCP tools and keep unsent messages

Pi chats in the desktop app always configure OpenPencil's MCP server,
and three companion problems stopped them:

- @ai-sdk/harness-pi imports pi-mcp-adapter, which publishes TypeScript
  sources. Node refuses to strip types under node_modules, so the
  companion now strips them through a module load hook limited to
  TypeScript dependencies. Bun runs them as is.
- pi-mcp-adapter imports @earendil-works/pi-tui, declared only as an
  optional peer, so npm left it out. The companion depends on it at the
  version pi-coding-agent uses.
- Pi reports live-process resume, yet the service handed it state saved
  by an earlier session, and the just-bash sandbox cannot resume, so
  every later session with that ID failed. Live-process backends now
  start fresh and drop saved state.

When a chat cannot start, the composer now keeps the typed message
instead of discarding it.

* fix(harness): keep companion stdout for protocol messages

Pi prepares the packages listed in a person's Pi settings with npm,
which inherits the companion's stdout, and libraries log through
console.log. Both landed in the JSONL protocol stream, where the app
discarded them with warnings. The companion now keeps the real stdout
for protocol messages, sends other stdout writes to stderr, and quiets
npm on success through its environment.

The type-stripping hook no longer prints Node's experimental warning,
and the app logs companion stderr as diagnostics rather than errors,
since failures arrive as protocol errors.

Document Pi in the coding agents guide, the AI chat page, and the
README: guided setup installs the companion, Pi uses the Pi CLI's
sign-ins and default model, an AI Gateway key is optional, and the
companion needs Node.js 22.15 or later.

* test(harness): keep the pi-tui pin in step with pi-coding-agent

The companion depends on pi-tui only because pi-mcp-adapter imports it while declaring it optional (nicobailon/pi-mcp-adapter#805). Upgrading @ai-sdk/harness-pi moves pi-coding-agent, and a pin left behind would make npm install a second, mismatched pi-tui. The test fails until the pin matches.

* test(ai): follow the model catalog in guided setup tests

The plan and apply tests repeated catalog default and fast model IDs, so master's model update broke them without any change in setup behavior. They now read those models from the catalog.

* test(ai): keep the model catalog helper with the shared test helpers

Unit test homes under tests/app accept only *.test.ts files, so the onboarding tests' catalog helper moves to tests/helpers/ai.

* docs: tighten the guided setup and Pi changelog entries

Name every provider and server preset guided setup offers, describe the role step as it now works, and shorten the Pi entry.

* test(ai): type the guided setup test stubs for the test typecheck

Master now typechecks the test suites: fetch fakes go through fetchStub, the chat ref is shallow like the real one, and mocks declare the arguments the tests inspect.

* test: type the tabs module in the closed-documents spec

The spec imported the tabs module by its served URL without a type, which fails the test type check on master.

* test(ai): assert outcomes instead of copy in guided setup tests

Drop the setup-prompt test, which checked prompt prose, the onboarding wrapper cases that restated discovery, and the coversGoals case. Story plays and the OpenRouter E2E flow now assert controls and saved models instead of sentences and catalog model names, and the fast-model helper checks the planned model's catalog entry instead of recomputing the choice. tests/AGENTS.md states the rule.

* feat(ai): return desktop OpenRouter sign-in through a deep link

The desktop app ran a hand-written HTTP server on a localhost port to receive OpenRouter's redirect, and OpenRouter labels apps with a localhost callback by host and port. OpenRouter now redirects to a page on the web app that opens openpencil://oauth/openrouter with the same query, and the desktop shell forwards that link to the webview as an oauth-callback event. The attempt that started sign-in checks the state and exchanges the code with its PKCE verifier, which never leaves the app.

* feat(ai): ask OpenPencil's companions for their version

The desktop app read a companion's version by following its executable's symlink up to a package.json. That only worked for the Unix npm and bun layouts: Windows .cmd and .exe shims and version-manager shims such as Volta and mise are not links into the package, so the version was always unknown and an outdated companion went unreported. The MCP server, stdio bridge, and Harness companion now print their version for --version, and the app runs each installed one with --version --help under a timeout. A release older than --version prints its help or exits without a version line, which reads as outdated. A bun global install on Windows now gets the bun update command too.

* fix(ai): ask for a Pi sign-in when Pi has none

readPiAccount returned an account whenever a home folder existed, so a chat with no Pi sign-in reached the Harness and failed with a provider error instead of the guided pi-sign-in fix. It now reports whether Pi's auth.json exists, without reading it, and the capability allows that one check.

* fix(ai): keep the attachments of a message that was not sent

A message that never reached the chat came back to the composer as text only: its image previews were revoked and its referenced layers dropped. The composer now takes back the whole submission, or releases the previews when newer text replaced it. A message counts as sent once the chat holds it, so a failure after that no longer hands it back to be sent twice.

* refactor(app): read the app version from one constant

Four modules each derived the app version from the build define with the same test fallback.

* refactor(ai): report chat submission errors from their own module

Reverting turns from master and keeping unsent drafts together took useChatSubmission past the composition-root limit. The test for reverted turns now passes the setup messages the submission reports.

* refactor(app): keep the app version with the runtime config

Tools typecheck src/constants.ts through app imports without the Vite defines, so the version constant moves to src/app/runtime/version.ts.

* test(desktop): check npm installs of the companions at the app's release version

The scope test named the companion packages and version literally, so it would keep passing if the app requested something else. It now builds them from the app's package names and the release version a build embeds.

* refactor(ai): parse OpenRouter, Pi, and sign-in callback data with Valibot

The OpenRouter key info and code exchange checked their JSON with typeof chains, Pi's settings parsed JSON in a try before validating it, and the desktop sign-in trusted the shell's callback payload as typed. Each now goes through one schema.

* fix(ai): take back a message whose images could not be prepared

A message with images appears in the chat before its images are prepared, so a preparation failure counted as sent: the draft did not come back and its previews were already revoked. A message now counts as sent once it is dispatched; a failure before that removes the shown message and hands the draft back, and the composer's previews are revoked only after dispatch.

* fix(ai): restore an unsent message only in its own conversation

Switching conversations while a message was being sent could restore it into the newly opened one. The draft now comes back only if the conversation is unchanged, and its previews are released otherwise.

* refactor(app): keep one app version constant

The update window added an APP_VERSION to src/constants.ts beside the one in src/app/runtime/version.ts. The tools typecheck reaches src/constants.ts without the Vite defines, so the update window now reads the runtime one.

---------

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>
2026-10-07 08:46:57 +00:00
Danila Poyarkov 22d5dfe6b5
fix: refuse risky desktop writes and run every Storybook play function (#933)
* fix(desktop): refuse writes where a written file would run

The fs scope let the webview write, create folders, and delete anywhere. Documents may still be saved anywhere, but the global scope now denies login items and startup folders, PowerShell profiles, and the global package and executable folders where coding agents and OpenPencil's companions live, and writes to the MCP discovery files agents trust. requireLiteralLeadingDot keeps hidden files and folders, such as shell profiles and agent settings, out of ** on Windows as Tauri already does on macOS and Linux. A native test saves a document and is refused a LaunchAgents file, a home dotfile, and the discovery file.

* fix(ui): draw segmented controls at panel field height

Panel fields moved to 24px when sizing tokens became plain utilities, but segmented control items stayed 22px inside a 2px padding, so the Typography and resizing controls stood 2px taller than the fields beside them. The panel foundation story renders its inputs at the panel size and checks 24px.

* test(storybook): run every story and its play function

No test ran the play functions, and five had gone stale: the layer tree example labelled a wrapper with the same name as its row, the chat composer's label gained an ellipsis, the MCP failure story queried a test id attribute the app does not use, and the property primitives story still collapsed sections whose titles are static now. bun run test:storybook now renders every story and fails on a story or play function that throws.

* fix(desktop): deny protected folders themselves and writable opens of the discovery files

Each protected folder is denied alongside its contents, so a recursive remove cannot target the folder itself, and the MCP discovery files are denied to open as well as write, since opening with truncate would empty them. The native test opens the discovery file for writing without truncating, and removes only files it created. The story test waits for storyFinished, which follows afterEach, and judges exceptions and non-accessibility reports.

* test(desktop): run the file scope check only on macOS

Its protected paths are macOS ones, so other platforms skip it rather than pass for another reason.

* docs: note that documents opened from hidden folders can still be saved
2026-10-06 14:52:12 +00:00
Danila Poyarkov f7a013191b
refactor(fig): export the symbol readers and move the library tests home (#929)
The Kiwi codec types only symbolID, so every test reading symbolOverrides
or uniformScaleFactor repeated the same cast against a type the package
exported without the readers that go with it. symbolDataOf and
symbolOverridesOf are now exported and five call sites use them.

tests/engine/library exercises @/app/libraries, so it belongs under
tests/app by the placement rules; the shard list and the engine baseline
follow it.
2026-10-06 10:44:22 +00:00
Danila Poyarkov 9dce9fdcbc
feat(collab)!: sync layer trees as a CRDT and open each room in its own tab (#902)
* fix: stop ancestor walks from hanging on a parent cycle

A collaborator's concurrent reparent can leave two layers as each other's
parent. isDescendant, the design check's pageOf, and component sync walked
parentId without a bound, so applying such a change froze the editor.

Add SceneGraph.closest(), a bounded nearest-ancestor lookup, and use it for
these walks so bad data ends the walk instead of the tab.

* fix(collab): sync layer moves without parent cycles or stale child lists

Remote changes assigned each layer's synced parentId and childIds as plain
properties. Two peers moving layers into each other made them each other's
parent, a moved layer stayed listed under its old parent, reorders never
synced, and concurrent additions ended up in different orders or missing
from the parent's list.

Apply the tree after a change's properties: move layers to their synced
parents, skip a move that would make a layer its own ancestor and write the
layer's current parent and position back so every peer settles on it, and
derive each touched parent's childIds from its synced order followed by
unlisted children by id. Locally, a parent's child list syncs once after
each edit that adds, removes, moves, or reorders its children.

Fixes #888
Fixes #889

* refactor(scene-graph)!: move sibling order keys to scene-graph

Collaboration needs the same fractional keys as .fig export to order
siblings, and the app must not depend on @open-pencil/fig for them. Move
fractionalPosition, orderKeyBetween, and siblingOrderKeys to
@open-pencil/scene-graph/order-keys.

orderKeyBetween now always returns a key: when no printable key fits it
returns one above lo, which hasOrderKeyBetween detects, so callers no
longer branch on null. It takes an optional suffix, and siblingOrderKeys
can request one per key, so two peers inserting at one spot get distinct
keys. .fig export keeps its keys.

* fix(scene-graph): report instance child reorders as graph events

Instance sync sorted an instance's children in place, so nothing that
listens to graph events saw the new order; in a shared room the order
never reached other peers. Move each child that changes position with
insertChildAt, which reports the reorder.

* feat(collab): resolve the layer tree from each layer's parent history

Add a pure LayerTree for the shared document: each layer records every
parent it was moved under with a move counter and an order key. A layer
sits under its newest parent, ties broken by parent id; when concurrent
moves close a loop, the latest move in the loop falls back to the
layer's next entry until none is left, and a layer no parent can take
goes to its page (Evan Wallace's mutable tree hierarchy CRDT).

The result depends only on the entries, and resolution revisits only
changed, orphaned, and displaced layers. Seeded random runs check that
peers converge and that the incremental result matches a full one.

* fix(collab): sync layer moves as parent history and order keys

Each layer's shared map now records every parent it was moved under with
a move counter from a document-wide Lamport clock, and its order key
among siblings, instead of parentId and childIds. Every peer derives
parentId and childIds from these with LayerTree, so concurrent moves,
reorders, and additions merge, a loop from concurrent moves undoes its
latest move, and a layer whose new parent was deleted meanwhile returns
to its previous one.

A local edit's graph events are written once after the edit, in one
transaction. It records a parent entry for each layer whose parent
changed, a new entry for displaced layers on the touched paths so a move cannot pull
them back, and keys between the moved layers' neighbours with a random
suffix, so concurrent inserts at one spot get distinct keys. Remote
changes find their layers through each event's path, resolve only what
they touch, and move and sort layers through insertChildAt.

This replaces the childIds merge and the write-back of rejected moves:
a rejected move now resolves the same way on every peer from the shared
history, so nothing needs to be written back.

Fixes #888
Fixes #889

* feat(collab)!: convert saved rooms and keep mismatched builds apart

A room saved by an earlier build records parentId and childIds. When a
change brings in such layers, from this browser's storage or a peer,
convert them in one transaction: each layer's synced parent becomes its
only entry with counter 0, its position in the parent's synced childIds
becomes an order key, and the old fields go. The result depends only on
the document, so two peers converting at once write the same values,
and converting again writes nothing. The document's meta map records
treeFormat 2.

Builds that record the tree differently would corrupt each other's
rooms, so the collaboration namespace becomes openpencil/2 for Trystero
and the test relay alike, and each peer publishes its treeFormat in
awareness for future version messages.

* fix(collab): send a layer whose parents were all deleted back to its own page

Each layer's shared map records the page it was last placed on, and the
layers under a frame moved to another page are re-recorded. A layer whose
parent chain was deleted goes back to that page, falling back to the first
page only when the recorded one is gone. Saved rooms record pages when
they are converted.

* fix(collab): keep one root per room when peers edit their own documents

Joining a room keeps the joiner's earlier document in its graph, and an
undo or an edit made before the room arrived could still reach it. That
edit shared the joiner's root, every peer adopted it, and the room's
pages disappeared.

The room now records its root as claims in meta, each with the time it
was made, and every peer follows the earliest. Sharing claims the room,
and so does the first edit in a room nobody has shared, which now shares
the whole document as Share does. A peer shares only layers under the
room's root. Converted rooms claim the root with the most children.

Move counters must also be safe integers, so an oversized counter from
another peer cannot stop the move clock from advancing.

* fix(collab): rank root claims by how they were made, not by clocks

Root claims carried the claiming peer's wall-clock time, so a guest whose
clock ran behind the sharer's could still win the room with an edit made
before the room reached them. A claim now records whether it came from
Share or a converted room, or from the first edit in an unshared room;
a shared root outranks an edited one, and the lower id breaks a tie.
A claim also replaces an invalid value already stored for its root.

* fix(collab): keep every root claim through concurrent writes

A root claim was one key per root holding its kind, so two peers claiming
the same root by Share and by an edit at once kept only one of the two
values, and the shared claim could be lost. Each kind of claim on a root
is now its own key. Converting a saved room also claims its root unless
a shared claim exists, so a guest's earlier edited claim no longer keeps
the converted room from outranking it.

* fix(collab): mint layer IDs under a session of each editor window

Every editor window started its IDs at 0:1 from the same counter, so two
people adding layers to a shared room at once could mint the same IDs,
and one person's layers replaced the other's in the room. A joiner's
starting page also took the sharer's page ID and stayed in their list.

SceneGraph's default IDs now carry a session set with setIdSession, as
in Figma's sessionID:localID GUIDs. The editor picks a random 32-bit
session at startup, as Yjs does for each document's clientID; headless
tools keep session 0, so the CLI and MCP server give a file's layers the
same IDs on every run.

* fix(collab): let only Share set a room's root

A guest's first edit in a room whose contents had not arrived claimed
the room and wrote the guest's whole open document into it, images
included, and adopting the sharer's root later only hid it. Every room
starts with someone sharing a document, so a guest has nothing to claim:
only Share, or converting a saved room, now sets the room's root, as a
single value in meta, and a peer writes nothing until the root is known.

Unbinding a room also writes an edit still waiting to be sent, so a move
or deletion made just before leaving reaches the room.

* feat(collab)!: open each room in a tab of its own

Joining a room bound it to whatever tab was active, so a pasted link
could turn a saved file into the room's document, and a share link first
showed an editable blank document. A room is now a document: joining
always opens it in a new tab, or switches to the tab already showing it,
and only Share puts an existing tab's document into a room.

Every room tab owns its session (src/app/collab/rooms.ts and
session.ts), so several rooms can be live at once and keep syncing in
the background. The collaboration panel, presence, following, and the
/share/<id> address follow the active tab, and a canvas publishes its
cursor and selection only to its own tab's room.

A room tab derives its state: joining while its saved copy loads, then
waiting, with an explanation, while nobody who has the file is online;
live with others, or alone on this device's copy. Until the document
arrives the room's screen replaces the editor. Reloading a share link
rejoins it; leaving a room you joined keeps its file as a local unsaved
copy. "Connected" now means another peer answered. Pasted links and IDs
are normalised and validated, and invalid ones say so.

People join right away under a generated name such as "Teal Fox", with
a hint to set one; the one app-wide name is set in the share panel or
in Settings. On a phone, Share copies the room's link instead of making
a new room, and the presence popover shows the room's state.

* feat(desktop): open rooms from openpencil://join links and Home

The desktop app could not receive a share link: links point at the web
app, and openpencil:// only opened files. openpencil://join?room=<id>
now opens the room in a tab of its own. The native parser refuses
anything but a room ID, queues rooms for the frontend through
take_pending_rooms, and a second launch on Windows and Linux forwards
its link through the single-instance handler.

In a browser on a computer, the room's screen and the share panel offer
Open in desktop app, a link the browser hands to the app on click; it
never opens the app by itself. Home gains Join room…, which takes a
pasted room link or ID and opens the room in a new tab.

* feat(collab): set your name on the room screen

The room screen told someone joining under a generated name to set
their name but offered nowhere to do it before the file arrived. It now
has the same name field as the room panel.

* feat(collab): offer the desktop download beside Open in desktop app

A browser on a computer offers a room's openpencil://join link, which
does nothing where the app is not installed. The room screen and panel
now link to the latest release beside it.

* docs: describe joining rooms in the German, Polish, and Russian guides

Bring the translated collaboration pages up to the English one: Share as
the only way into a room, joining in a tab of its own, the waiting
screen, leaving with a local copy, and how layer moves merge. The
English page now names the panel's Leave room button.

* fix(collab): lay out the room screens like the app's empty states

The joining and waiting screens were a left-aligned card with a stray
spinner, a primary Copy link button beside an outline button and a
bare link, and a name field on a screen that lasts seconds. They now use
AppPlaceholder, centred over the tab: a heading, the explanation, the
two hints, secondary Copy link and Leave, a 'You'll appear as' line
whose Change opens a small rename popover, and the desktop handoff on
one muted line. The room panel lines its status dot up with wrapped
text, no longer selects the room link when it opens, and puts the
desktop links and Leave room on one footer line.

* fix(collab): show what a room tab is doing instead of a timed guess

A joined tab said nobody with the file was online five seconds after it
opened, whether or not it had reached the signaling service or met the
people already in the room. Its state now follows what the tab can
observe: connecting until the service answers, looking for people for as
long as that transport takes to introduce everyone, getting the file
from someone who says they have it, waiting when nobody who has it
showed up (naming other guests waiting too), and a can't-connect screen
when the service cannot be reached. Each peer says in its presence
whether it has the room's file.

* fix(collab): list other waiting guests with the explanation

The line naming other guests who are waiting too is information, not an
action, so it follows the hints above the buttons. Peers' hasFile flag
is optional, as older builds do not send it.

* fix(collab): send a canvas's cursor and selection to its tab's room again

Canvases read the editor through a proxy that follows the active tab,
and the room lookup by store never matched it, so pointer moves and
selections stopped reaching the room: collaborators lost each other's
cursors, selections, and page markers. The canvas now looks up its
room by its tab's own store, and its selection listener ends when the
canvas unmounts instead of piling up across tab switches.

* feat(collab): one avatar stack for the toolbar, the mobile pill, and pages

The toolbar, the page list, and the mobile HUD each drew the people in a
room their own way, and the mobile pill read 'Online: 3' in hard-coded
English beside a status dot too small to render. AvatarStack now draws
people overlapping with their agent counts and '+N', and every place
uses it: the toolbar wraps each avatar in its menu or hover card, the
mobile pill shows the room's state dot and the stack with a translated
name, and hovering a page with people on it opens a card with the stack
and who is there, with their agents, to follow. The mobile list is the
shared presence list, so it is translated and can follow agents too.

* docs: note the page hover card and mobile avatars in the changelog

* fix(collab): stop listening to a room once its tab leaves it

A session left its Yjs observers and its awareness listener attached
after dispose, relying on destroy() and the order of teardown not to
touch the tab again. It now removes them explicitly and clears its peer
list. Also fix a missing comma in the Polish collaboration guide.
2026-10-06 10:40:25 +00:00
Danila Poyarkov 8d1ad54193
feat(ai): revert, regenerate, and edit chat turns (#844)
* 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(ai): revert, regenerate, and edit chat turns

A reply that went wrong could only be undone step by step from the Edit
menu, and asking again meant typing a new message on top of the old
edits. Each run now keeps the undo entries its document edits push, and
the reply offers Revert changes while those entries are still the
newest on the undo stack, so it never undoes an edit made since. The
last reply can be regenerated, and the last message without
attachments edited and sent again; both undo the replaced reply's edits
first when they can.

UndoManager gains peekUndo so a caller can tell whether its entries
are still on top, and turn state follows the store's history:changed
event. The submission's chat dependency narrows to the members it uses.

* 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.

* chore: format the merged AI tool exports

* 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.

* fix(ai): keep a turn revertable when it ends with a view change

Every mutating AI tool pushes an undo entry, but a turn recorded only those of tools that change the document. A run that closed with viewport_zoom_to_fit left that entry on top of the undo stack, outside the turn, so Revert changes never appeared and Regenerate did not undo the reply. The turn now records every entry its run pushes.

* test(ai): give the fake chat Chat's sendMessage signature

The test type check added in #896 rejects a fake whose sendMessage requires text; Chat's takes an optional message, and the fake reads only its ID.

* feat(ai): keep reverted replies marked and tell the model about them

Reverting a reply undid its edits and hid the button, so the chat still read as if the edits were there, and the model's next request still carried the tool calls and results that made them, so it could build on nodes that no longer existed. A reverted reply is now marked in its message metadata, which conversations store, and shows Changes reverted. The next request carries a hidden note, in the way referenced nodes are passed, for each revert no request in the history has reported; a resent message reports again what the message it replaces reported. Edit > Redo bringing the edits back removes the mark. The revert bookkeeping lives in submission/reverts.ts, and ChatInstance moves to submission/types.ts beside ChatSubmission.

* fix(ai): keep reverted replies cloneable so the chat still saves

Marking a reply copied the chat's reactive message, so the copy carried proxied parts and saving the conversation failed with DataCloneError on the revert and on every save after it. The message is unwrapped before it is copied. The unit test's chat now keeps messages in a deep ref like @ai-sdk/vue's Chat and saves them synchronously like the history does, which reproduces the error. A new browser test drives the app's own tool loop with a scripted model: it reverts a real render, checks the mark, the note in the next request, that the chat saves without errors, and that the mark is still there after reopening the conversation.

* feat(ai): restore a reverted reply's changes from the chat

A reverted reply only said Changes reverted, so bringing its edits back meant Edit > Redo. While nothing has been edited since the revert, the reply now offers Restore changes, which redoes exactly its edits; the existing Redo listener then removes the mark. Once other edits close Redo, the reply stays marked. UndoManager.peekRedo mirrors peekUndo, so a turn can tell its entries are the next Redo applies.
2026-10-06 09:54:42 +00:00
Danila Poyarkov aa86873dd7
test: typecheck the test suites and fix what that found (#896)
* build: typecheck the test suites

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

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

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

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

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

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

* docs: record what typechecking the tests established

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

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

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

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

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

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

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

* chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
Danila Poyarkov 24db186ac8
feat: select layers at Figma's click depth (#900)
* feat: select layers at Figma's click depth

Checked against Figma desktop 126 with the same pointer input in both
editors. A click walks from the page down to the deepest layer under the
cursor and stops at the first layer that is not open: top-level frames
(on the page or in a section) and sections that hold layers, component
sets, and every ancestor of the selection are open. So a click inside a
top-level frame selects its direct child, the empty part of a top-level
frame or section selects nothing, and a selected layer's siblings and
cousins are one click away. Double-click goes one level deeper and
Cmd/Ctrl-click reaches the deepest layer.

A marquee started inside a top-level frame or section selects that
container's layers; from the page, a frame or section holding layers is
selected only when fully enclosed.

* fix(vue): compare marquee hits by canvas bounds

A marquee scoped to a rotated frame mapped only two corners into its space, and page-level enclosure used a frame's unrotated rectangle. Both now compare each layer's axis-aligned canvas bounds with the marquee, so rotated frames and their children are selected by what is drawn.
2026-10-05 12:39:04 +00:00
Danila Poyarkov 510cdbc36f
feat(scene-graph)!: let a SceneGraph take its ID generator (#887)
* feat(scene-graph): let a SceneGraph take its ID generator

The constructor accepts an ID generator, used for the root node and for
nodes, variables, and collections created later; generated IDs skip any
node, variable, collection, or mode ID already in the graph.

Refs #770

Signed-off-by: Marc Went <marc@went.io>

* perf(scene-graph): check mode IDs without allocating per created entity

Generating an ID spread every collection into a new array and scanned it
for each candidate, which runs on every createNode. Check the node,
variable, and collection maps first and walk the modes with an early exit.
An index of mode IDs is not kept because history snapshots, transfer, and
undo replace collection maps and edit modes in place, which would leave it
stale.

* test(scene-graph): cover injected ID generators and collision skipping

* docs: note the SceneGraph ID generator in the changelog

* test(core): keep reopened .fig GUIDs when a sibling is inserted before them

Refs #770

* test(collab): cover concurrent additions, same-property writes, and delete versus edit

Move the synced-store harness to tests/helpers/collab so the new cases
live in tests/app/collab, and let it bind graph events as a collab session
does. The cases edit both peers while disconnected and check the
converged graphs.

Refs #770

* fix(scene-graph): keep a collection's IDs apart and stop on an exhausted generator

createCollection asks for the collection and default mode IDs before
registering either, so a generator repeating a candidate gave both the
same ID. A generator that only returned taken IDs looped forever, even in
the constructor; it now throws after a bounded number of attempts.

* test: require the inserted GUID and a real disconnect in new tests

The GUID test passed even if export dropped the inserted layer, and the
concurrent-edit helper would have tested sequential sync had its peers
not been disconnectable.

* feat(scene-graph): create variable modes through the graph's ID generator

The editor minted added and duplicated modes as mode:<random hex>, so an
injected generator governed every graph entity except modes added after
a collection's first. SceneGraph.createMode mints the ID and adds the
mode; undo and redo replay it with addMode.

* test(core): type the reopened .fig buffer in the GUID test

* refactor(scene-graph)!: share random helpers and one component property ID

randomHex, randomInt, and randomIndex move from @open-pencil/core/random
to @open-pencil/scene-graph/random, so format packages can use them:
design-jsx and the MCP test server dropped private copies. Component
property IDs, written as prop:<random hex> in six places across Core and
design-jsx, come from createComponentPropertyId.

* docs: point the randomness rule at the shared ID and random helpers

---------

Signed-off-by: Marc Went <marc@went.io>
Co-authored-by: Marc Went <marc@went.io>
2026-10-05 08:21:22 +00:00
Danila Poyarkov 01e58a3ad0
feat: write variables as a CSS token stylesheet (#856)
* feat: write variables as a CSS token stylesheet

Copy a collection as CSS custom properties or a Tailwind v4 theme from the variables dialog, print it with openpencil tokens, and rebuild design_to_tokens on the same generator. Default modes go in :root or @theme, other modes override under their condition, and aliases are declared again in each mode scope so they follow it.

* fix: give modes that slug alike their own selector and variant

Two modes in one collection whose names reduce to the same slug, such as Dark and dark!, shared one default selector and Tailwind variant, so the later mode silently overrode the earlier one. Slugs are now numbered in mode order, as variable names already are.
2026-10-04 18:15:52 +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 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
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 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 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 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 68c2af5600
feat(fig): occurrence-scoped instance interpretation as the single .fig reader
* refactor(fig): introduce occurrence-scoped instance interpreter

* refactor(fig): add direct occurrence materialization and render diagnostics

* fix(scene-graph): preserve nested edits and invalidate text layout caches

* refactor(fig): assemble indexed documents with occurrence provenance

* refactor(fig): validate document assembly against live scene oracles

* fix(text): preserve saved glyphs and supported run paints

* test(fig): share typed GUID fixture helper

* fix(fig): resolve component root keys in instance overrides

* fix(kiwi): reject malformed byte arrays before encoding

* fix(components): target properties by source identity through undo

* fix(fig): preserve editable occurrence export contracts

* refactor(fig): construct live component dependency closures

* fix(fig): invalidate inherited text geometry after occurrence overrides

* perf(fig): reuse component expansions and narrow payload copies

* perf(fig): avoid discarded metadata and instance definition copies

* perf(fig): transfer parsed records into archive reader ownership

* feat(fig): add incremental page sessions with load rollback

* test(fig): verify page deltas and stale revision rejection

* feat(fig): wire reader worker sessions and compact recovery checkpoints

* docs(fig): organize reader architecture and visual examples

* chore(fig): checkpoint WIP reader and writer overhaul

Preserve in-progress FIG reader, instance interpretation, editable export, and validation work on its feature branch. This is a backup checkpoint, not a release-ready or fully validated change.

* refactor(fig): resolve instance structure before expansion

Route swaps and property assignments down to the instance they configure
so each occurrence expands once with its effective component and complete
assignment list. Owners then apply property claims onto the built subtree,
which keeps values in the declaring owner's coordinate space and orders
inner owners before outer ones without re-expansion, recipes, or patch
restoration.

Track the components an occurrence expanded before an outer decision
replaced them, including intermediate swap assignments, so a claim that
resolved against a superseded component is retired while a genuinely
missing target still reports. Precedence is one rule: an explicit claim
keeps a field unless a strictly outer owner assigned it.

Drop the detached-lineage remap heuristic; unresolved assignments report
through the existing diagnostic instead of guessing a replacement target.
The Accordion source-closure fixture reports two stale overrides, not
three: the third came from a subtree the old interpreter expanded and
discarded.

* refactor(fig): derive override field handling from one registry

Describe each claimable raw field once, with its SceneGraph fields, kind,
and whether it is a length, and derive claim recording, layout-distance
scaling, and export serialization from it instead of maintaining parallel
tables.

Restore every field the uniform scaler touches from the instance record
after scaling. The record already describes the placed result, but corner
radii, dash patterns, and effects were previously scaled without being
restored, so a scaled instance with its own corner radius rendered it
doubled.

* fix(fig): retire nested swaps under a replaced component

A structural layer routed through an instance whose component an outer
owner replaced may still address the original component's children. Such
a layer is stale in the same way a property claim is: it resolved before
the outer decision and has no target now. Carry the replaced components
across that boundary and skip the layer instead of failing the file.

material3's List swaps a list item to another variant while the item's
own saved swap of a trailing checkbox still names the original variant's
child.

* fix(core): report stale Figma override records instead of refusing the file

Figma keeps override, assignment, and binding records that address nodes
it later deleted, and material3.fig could not open because the reader
ran the document session strictly. Share one set of session options
across the reader and recovery sessions that collects those records as
diagnostics and skips them; a swap whose replacement is missing remains
a structural failure.

The component-metadata expectation follows the visible Buttons page copy
of the component set, which the dependency closure now resolves instead
of an internal-only copy.

* fix(fig): keep instances of deleted components when opening a document

Figma retains instances whose main component was deleted, and material3's
Internal Only Canvas has 56 of them, so an edited document could not be
exported: export loads every page and the reader refused the page over
missing reachable sources.

The dependency closure now separates deleted components from broken
hierarchy, which remains fatal. With the new onMissingComponent option the
interpreter keeps such an instance as a childless occurrence that retains
its saved reference, applies only its root claims, and reports the owner;
strict interpretation still fails. The core reader opts in, shares one
diagnostics sink with recovery and export sessions, and exposes it through
readerDiagnostics(). Property defaults naming a deleted component are kept
the same way, so an edited export no longer rejects them.

* fix(fig): resolve variant property values through the component set

A variant's saved specs name variant definitions that its component set
owns, so occurrence conversion left them keyed by definition id. Resolve
them to names once the set is in the graph, as the previous importer did.

The component-metadata expectation follows the visible Buttons set's
axes; the Style axis belonged to an internal-only copy.

* refactor(fig): satisfy type-aware lint in the interpreter and export

* fix(fig): keep an instance fill override's variable alias across export

A fill or stroke override on an instance descendant lost its colour
variable on export: the paint claim was written without the alias, and a
boundVariables override for a paint colour produced no claim at all
because paint colours are not node-level consumption fields. The reopened
paint therefore bound to the component's default variable.

Write override paints through the same alias-aware builder as node
paints, serialize a paint colour binding override as the paint claim
itself, and on import record the binding claim alongside a claimed paint
that carries an alias so a later component sync cannot restore the
component's binding.

On an edited material3.fig round trip this removes all 10,329 fill
differences; 2,217 of 78,425 nodes still change, almost all text metadata
Figma keeps on outlined vectors.

* docs(fig): describe the single reader, its diagnostics policy, and paint claims

The status documents still said the replacement reader covered only some
worker paths and that old-reader removal was pending. Every import path
now uses it and the previous importer is deleted, so state that and move
the open items to fidelity and performance.

Record the contracts added recently: strict-by-default interpretation
with per-session diagnostic handlers that the application reader opts
into, instances of deleted components kept as childless instances, the
shared override field registry, paint colour aliases serialized inside
paint claims, and variant values resolved through the component set.
Correct the clipboard ownership rule in AGENTS.md: the envelope belongs
to fig, pasted records go through the same reader as documents.

* docs: note exported instance overrides in the changelog

* refactor(fig): share record indexing and symbol data access

Five modules built their own GUID-to-record index with the same idiom;
they now use the source index, or indexRecords when child order is not
needed. The Kiwi codec types only symbolID, so every reader cast
symbolData to reach overrides and the uniform scale; symbolDataOf,
symbolOverridesOf, and uniformScaleOf replace those casts. idOf and
parentIdOf name the record identity conversions used by ancestry walks.

* refactor(fig): share tree search and traversal across records and occurrences

The rule that a path segment may pass through ordinary containers but
never implicitly into an instance existed three times, once per tree.
findWithinBoundary owns it now, parameterized by a tree shape; the
occurrence resolver and the static record resolver are two callers.
An occurrences() iterator replaces hand-rolled recursion in the
component planner, closure, layout scaler, and correspondence linker,
forEachOverrideRecord replaces the record-plus-overrides walks in the
dependency scans, and one child-pairing generator serves both
source-children matchers.

* refactor(fig): serialize override claims from the field registry

Split export-node.ts: export-context.ts owns the serialization context,
GUID allocation, and paint builders; override-claims.ts owns instance
override serialization. The override serializer was a chain of field
checks that had to agree with the registry materialization records
claims from; it is now one switch over the registry's field kinds, the
export side of that table, with swaps and variable bindings as the two
cases the registry does not describe.

Decoded record streams for an edited gold-preview export and a
synthetic bound-fill export are identical before and after.

* fix(core): record instance overrides for FigmaAPI rename and resize

The name setter and resize() wrote to the graph directly, so a rename or
resize of an instance child through the Figma API was never recorded as
an override: component sync reverted it and export did not write it.
Route both through the shared recording update like every other setter.

* fix(fig): address overrides inside nested instances by the definition child

An override on a child of a nested instance was addressed through the
enclosing component's own copy of that child. That node lives inside an
instance and is never written as a record, so Figma could not resolve the
path and dropped the override. Follow the correspondence until it leaves
every instance, which yields the nested component's child, the record
Figma itself names in the same situation (verified against Figma's
clipboard encoding of the identical edit and by reopening the export).

* test(fig): record the Figma reopen of reader exports

* test(fig): compare reopened exports with the oracle tool

The interpreted-document comparison already reads Figma's interpretation
of an archive against the reader's; pointing it at an exported archive and
its imported Figma file makes it the reopen check. Captures need the
imported file to be the active document, so add an activate-tab operation
that brings a desktop tab to the front through the shell page. Record the
comparison results for the three reopened exports and document the
procedure.

* fix(scene-graph): keep a nested instance's correspondence across a swap

Children populated by cloning link to the enclosing component's record
through componentId. Swapping a nested instance replaced that field with
the new component, so the swap was exported against the replacement
component's GUID instead of the nested instance record and Figma could not
apply it. Record the correspondence as the owner's sourceComponentId
override and the swap as its componentId override, as materialized
documents already carry them.

* fix(core): treat applied shared styles as instance overrides

Style references were not instance sync fields, so a text style applied
inside an instance was neither recorded as an override nor exported, and a
component's style change did not reach its instances, although the reader
records styleIdForText claims from Figma. Add the style reference fields
to the sync set and expose them on the Figma API proxy under Figma's
names so assignments through the API record overrides.

* test: record the second Figma reopen round for the reader export

Figma confirmed stroke and corner-radius variable bindings, an applied
text style, nested-frame layout distances and sizing modes, visibility,
and a nested swap. A size claim on an auto-layout child inside an
instance is not applied, matching Figma's own resize refusal there.

* chore: format the merged structural export test

* refactor: group export and instance sync modules into domain folders

The node-change export context, node serializer, runtime, and override
claims move under node-change/export/, and the scene graph's instance
child sync and sync field lists move under instances/, keeping the
public instances module to its API.

* fix(fig): address exported instance overrides by override key

Figma resolves an override path segment through the target record's
override key, never its GUID: in gold-preview.fig all 10,341 override
and 12,838 derived-geometry segments resolve that way and none resolve
to a node GUID. A component imported from Figma keeps its keys, but one
authored here has none, so the writer addressed its descendants by GUID.
Figma tolerated that for most fields and silently dropped the geometry,
so a descendant resized inside an instance reopened at the component's
size.

Definition records — a component and everything inside it — now carry an
override key, minted from the shared identity counter when the node has
none, and paths name that key. One map spans the document because the
serializer runs once per top-level child.

The library content hash ignores the key, which identifies a record
rather than the component's content, and the clipboard export passes its
variable mode map as modeIdToGuid instead of propertyIdToGuid.

* docs: record how Figma resolves an override path

* Revert "fix(fig): address exported instance overrides by override key"

This reverts commit 38eebb2e5, except its clipboard argument fix.

The change came from gold-preview.fig, where every override path segment
resolves through a record's override key. material3.fig shows the
opposite: 51,332 of its segments are node GUIDs against 24 keys, and only
16 of 87,237 records carry a key at all. gold-preview is a file of
library instances, where the key is the cross-file identity; addressing
by GUID is what Figma writes for locally authored components, which is
what the writer already did. It was also not the reason Figma ignored a
descendant's size claim, which is still open.

The clipboard export keeps passing its variable mode map as modeIdToGuid
rather than propertyIdToGuid, which was an unrelated defect in the same
call.

* docs: correct the override addressing note and record the size gap

* docs: settle the descendant size gap as a Figma constraint

* chore: format the JSON fixtures this branch adds

format:check runs the formatter and fails on any change, so the fixtures
have to be committed as oxfmt writes them.

* test(tools): smoke the instance override subpath's current exports

populateAndApplyOverrides belonged to the importer this branch removes.

* perf(fig): index the archive once per document, not once per page

Selecting a page rebuilt both whole-document source indexes, so opening
material3.fig with its 33 pages indexed 87,237 records 33 times and
86,888 records another 33 times: 102 index builds where 36 are needed.
Only the page's own subset varies, so the full index and the component
interpreter move into state shared across selections, and the initial
read path passes its index to inheritance, style lookup, the dependency
closure and component planning rather than each building its own.

The paint and component-property passes iterate keys directly instead of
materializing an entry array for every node, most of which bind nothing.

Loading material3.fig goes from about 9.5s to about 7.5s on the same
machine, measured back to back with the machine otherwise idle.

* docs: note the faster multi-page .fig load

* perf(fig): apply document passes to the nodes a page materialized

Linking component property values, resolving variant values and applying
layout and paint bindings each walked the whole graph and skipped what
was already there, so every page load re-visited every node the earlier
pages had produced. On nuxtui.fig, 121 pages over a graph that reaches
354,000 nodes, those four passes were 22.7% of the profile after only
six pages and grew from there.

Each pass now takes the nodes just materialized. Component property
types are remembered across page loads instead, because an assignment on
a new node can name a definition an earlier page introduced; seeding that
cache is the only pass that still reads the whole graph, once per
document rather than once per page.

Pages 3 to 20 of nuxtui.fig fall from 90.0s to 51.6s. The first page is
unchanged: it materializes 256,354 nodes and is dominated by that.

* docs: note the per-page load improvement

* test(fig): keep the fig package suite off Core

Twenty package tests reached for Core's writer and editor through
@open-pencil/core, a package that depends on fig. Nothing declared that
edge, so the suite passed only because the workspace root hoists Core.
Their subject is the writer, so they move to tests/engine/io/fig, where
half the domain already spans both packages.

The package no longer escapes its own root: tsconfig drops the #tests/*
mapping, expectDefined is three lines beside the other helpers, and the
gold archive is read through the LFS-guarded fixture helper instead of a
hand-built ../../../../tests/fixtures URL.

#fig/ and #fig-tests/ join the steiger alias tables and the AGENTS.md
list, so the foreign-alias rule can see them. Fig's tests mirror its
source tree rather than sitting flat like kiwi's, so they address it by
alias instead of drilling, and the guid helper is imported one way.

* refactor(fig): drop code the reader replacement left behind

resolveDsdGeometry lost every production importer when the old derived
symbol data modules went, so it and the three tests that only exercised
it go too, and the folder collapses to one file. validateVariableAliases
was called only by its own test and wiring it in would mean a new public
diagnostic handler; it is removed rather than left dangling.
recordInstanceOverrideValue had no caller in either base or head, and
its comment began mid-sentence. SymbolOverrideFields had no consumers,
and savedTextEligibility is used only inside its module.

The clipboard's NON_VISUAL_TYPES was a hand-copied union of the two sets
behind isFigClipboardVisualType, which had no consumer of its own; the
classifier now serves both and leaves the root export.

FIG_PACKAGE_STATUS reads document-reader, and assertFigPackageReady is
gone: the package reads archives into a SceneGraph rather than telling
callers to use Core.

sceneNodeToKiwi takes its ten optional maps as an options object. That
removes the signature Core's wrapper had to restate, which was the last
clone blocking packages/fig/src from the duplication gate, and the
undefined holes at the clipboard's two call sites.

* refactor(core): share identity allocation between the two .fig writers

The clipboard allocated variable, mode and shared-style GUIDs its own
way while the document exporter did the same work in assignVariableGuids
and appendInternalResources. The two already disagreed: the exporter
reuses an id that is already GUID-shaped and dedupes against node source
GUIDs, the clipboard always minted a fresh sessionID 1. Both now call
one pair of helpers in variable-export.ts, so a change to how a document
names its resources reaches the clipboard too.

* refactor(fig): name the values that were spelled out in several places

exportSizing existed to name the HUG ternary but the inline layout
branch still wrote it out. The winding-rule conversions become
toKiwiWindingRule and fromKiwiWindingRule rather than the same ternary
three times and its inverse once. sameId duplicated sameGuid. The style
reference field list existed twice, and one site built a GUID string by
hand instead of calling guidToString. The opacity percent-to-unit factor
and the alias-or-expression test each have a name now.

fig.kiwi declares parameterConsumptionMap as a VariableDataMap and
PropRefValue as a variable value, but the codec typed neither, so four
call sites cast. Typing them in kiwi removes the casts, and the merge
that spread two maps now builds the only field the message has. Schema
coverage counts one more modeled field and one fewer raw-preserved.

* refactor(fig): require the index instead of rebuilding it behind a default

createScopedReader is private and always receives the shared state, and
the closure, component planning and property inheritance always get an
index from it; the optional parameters existed only so two tests could
omit them, and each hid a second full pass over every record. They are
required now, and the tests build an index the way production does.

materializeReader returned a fresh object that dropped definitionTypes,
so the first loadPage after createFigDocumentSession reseeded the cache
it was meant to reuse; it returns the state it was given.

The shared style reference shape is a named type built with the rest of
the export context rather than written inline twice and filled lazily
inside a getter, and the population client derives its two responses
from FigSessionResponse instead of restating one and casting to it.

* refactor(fig): give materializeInstance named options

Three of its seven parameters were defaulted maps that call sites passed
unnamed, so a call read as a list of empty collections. They become an
options object, matching how InterpretInstanceOptions is passed in the
same folder.

That change also caught a latent hazard: an empty array satisfies an
all-optional interface structurally, so a call site left on the old
positional form type-checked while silently dropping its source-child
map. Converting the remaining call sites fixed a component sync test
that had started failing for exactly that reason.

The DOCUMENT/VARIABLE guard is one assertion function rather than two
copies, and it narrows the node type for the creation that follows.

* refactor: group the prefixed siblings this PR left behind

instance-overrides kept layout-scale, text-scale, interpret-bindings and
variable-bindings as prefixed siblings while the same PR introduced
scene-graph/src/{scaling,variables}/. They become scale/{layout,text}
and bindings/{properties,variables}. The empty derived-symbol-data
folder is gone now that it holds one file.

STRING_BINDING_FIELDS and BOOLEAN_BINDING_FIELDS stayed in variables.ts
after NUMERIC_FIELDS moved to variables/fields.ts; all three live
together.

* docs(fig): describe the reader as it is, not as a replacement

The README, document-sessions, validation notes and several comments
still framed the work as pending: an old reader to delete, a migration
to finish, variables and lazy loading not yet integrated. All of that
landed. Error messages and a worker adapter that called themselves
"replacement reader" and "format-neutral" say what they are.

Comments that described the wrong function are reattached: the root
layer note belonged to resolveRoot rather than bindingHistory, the
expand note was duplicated onto bindRecord, the owner-scope note sat on
pairSourceChildren instead of linkInstanceSourceChildren, sync.ts put
its module summary on setSceneProp, and transfer/history.ts ended with
an orphan.

The visual oracle's interpret-instance and compare interpreted-document
are citty subcommands like the rest, its SCREAMING-CASE note folds into
packages/fig/docs/validation.md without the benchmark observation, and
its two tests mirror the source tree using the package alias.

* docs(fig): keep Figma observation records out of the fixture tree

Ten JSON records, twelve notes and a screenshot under tests/fixtures had
no code consumer: they are what Figma reported for a given document,
cited by packages/fig/docs. They move to packages/fig/docs/observations
beside the prose that reads them. The three JSON files tests do load,
and the eight screenshots the raster comparisons load, stay where the
tests expect them.

Fixture READMEs follow their fixtures: the gold layout and shared scale
notes to tests/engine/io/fig/instance, the export contract note to
tests/engine/io/fig/export. Numbers fused to the words before them are
separated throughout the notes.

Path failures assert the diagnostic reason through one helper rather
than matching 'found 0' or a full sentence, which is the pattern
materialize.test.ts already used.

* refactor(core): name the reader state module for what it owns

session/recovery.ts holds the per-graph reader state and, with it, page
population, diagnostics and export population as well as recovery. The
functions cannot move out without exporting that state map, so the file
takes an accurate name instead, and the state type follows.

io/formats/fig/index.ts keeps its aliased re-export: the relative path
is three levels up, which no-deep-parent-relative-imports rejects.

* chore: adopt the js-base64 rule master added

* test: move the new tests to the homes master's gate requires

#790 added check:test-homes: a new test under tests/engine is rejected,
and the baseline of existing ones shrinks. This branch had added 46.

Their owner is whichever package the test's subject lives in, not the
directory the old shard map implies. Forty test Core's writer, editor or
reader session and move to packages/core/tests, which gains the test
tsconfig and scripts the other packages already have; six test Fig alone
and move to packages/fig/tests. verifier-contracts covers the roundtrip
helpers that eight grandfathered engine tests share, so it stays beside
them and joins the baseline.

Package tests no longer reach outside their package for support: each
has local assert, guid, fixture and nested-binding helpers, and shared
archives under tests/fixtures are read through a helper path rather than
imported as modules across the root. interpretComponent,
materializeComponentClosure and the source-children helpers are public,
because tests outside Fig legitimately need them.

The steiger owner for #core/ and #fig/ is the package rather than its
src, since a package's own tests mirror the source tree and would
otherwise drill through ../../src.

* test: mirror each package's source tree in its test tree

The relocated tests kept their tests/engine directory names, which do
not match the packages they landed in: figma/api against src/figma-api,
render/canvas against src/canvas, io/fig against src/io/formats/fig, and
a fig tests/io and tests/text with no counterpart in that package. Each
now mirrors its source domain.

Two had no home in the package they were put in. The derived-text layout
invalidation test only exercises Scene Graph, so it moves there, and the
transfer plan test spans Scene Graph and Fig with neither owning it, so
it becomes the first tests/integration spec, which is what that
directory is for.

tests/AGENTS.md named a baseline path the tools reorganization moved,
and packages/fig/AGENTS.md now records its own test alias.

* fix(fig): open a file whose swap names a layer its component lost

Preline UI's `_header/navbar` keeps a swap addressing 4473:100430, a
node the archive no longer contains, while the replacement it names is
still there. Figma opens that file and so did the previous importer;
this reader refused it.

The rule was written for a swap whose replacement is missing, which
nothing can resolve, but the code threw for any unresolved swap. A path
that matches no record is a record Figma kept after deleting the layer
it named, which is the case the property and assignment diagnostics
already cover. A path that matches more than one record is a wrong
address rather than a stale one and still fails.

* fix(fig): address an override through the variant that holds its layer

An instance path names a layer by the identity it had in the variant the
override was written against. Switching variants keeps the override in
Figma, so a segment that names no layer of the variant an occurrence
expands now addresses the layer at the same position there, when the two
agree on type and name.

Resolution reports the path it took, so a claim recorded after a
translated segment stays addressable when the instance materializes.
Each component set's addressable layers are indexed once on first use
rather than rescanning every sibling variant per segment.

* fix(fig): read text bound to a string variable

Figma stores a bound layer's resolved characters, but an instance
override carries the binding alone, and a literal override of a bound
layer is retired rather than applied. Reading neither left the badge on
Preline's navbar showing its component's own text where Figma shows the
variable's value, and the input placeholder showing a literal override
Figma ignores.

Text joins font family as a bindable string field, the reader records a
TEXT_DATA alias like any other binding, and a post-pass resolves it once
hierarchy and modes exist, next to the paint bindings it mirrors.
Resolving after property claims is what makes a binding win over a
literal, the way Figma retires the override.

Validated by reopening an exported file in Figma: the collection, the
string variable, and the binding on both the component and its instance
survive the round trip.

* fix(fig): take a bound paint's transparency from its variable

A solid fill draws at its paint opacity, not its colour's alpha, so a
colour variable carrying transparency has to supply that opacity.
Resolving the binding into the colour alone left a translucent token
applied twice on Preline's navbar links, and left a Divider at the
opacity of an override the binding supersedes.

The variable now owns the whole colour: its alpha becomes the paint's
opacity and the colour keeps none of its own.

* test(tools): compare paint in the interpreted-document oracle

The oracle checked type, name, visibility, text, main component and box,
so every fill and stroke a reader produced went unchecked. A wrong fill
transparency on Preline's navbar passed it.

Paints are captured on both sides as the alpha drawing actually uses,
which is the paint's opacity for a solid, and reported as visible-paint
or hidden-paint like geometry. A Scene Graph stroke is always solid, so
it is encoded as one rather than through a type it does not carry.

* perf(fig): synchronise a component once per page load, not once per instance

Materializing an instance into an open document re-synchronised every
instance of its component, and synchronising walks each one's subtree.
A page that places a component many times therefore paid that walk once
per placement. Opening Preline's CMS page ran 954 synchronisations over
39225 instances for the 954 it placed.

Components are collected while the page is built and synchronised once
each afterwards: 31 calls over 1283 instances, and the page loads in
3.9s rather than 11.6s. The resulting graph is unchanged, by digest over
every node's geometry, text, paint, bindings and override keys for that
page and for a second page loaded on top of it.

* Revert "fix(fig): address an override through the variant that holds its layer"

This reverts commit fcdc7660f.

Figma does not carry an override onto the corresponding layer of another
variant, so translating a segment that way applies overrides it drops.
On Preline's Alerts frame the translation raises semantic differences
against live Figma from 2 to 54: 127 buttons read their own label where
Figma reads the component's. It fixed nothing visible — the five text
differences it was written for turned out to be string variable
bindings, fixed separately — so it only ever added wrong overrides.

* docs(fig): restore the guide rules the master merges dropped

Splitting the root guide into nested ones lost three rules this branch
had added, and left the fig guide claiming clipboard records are
converted to a SceneGraph in `@open-pencil/fig/clipboard`, which is now
`materializeFigFragment` driven from Core.

Records what the reader cannot do as well: a string binding resolves
once at read time, so text bound to a variable goes stale when the
variable or the node's mode changes, unlike a numeric or colour one.

Groups the four `*-bindings` siblings under `document/bindings/`, the
convention the branch already applied to `instance-overrides/bindings/`.

* perf(fig): copy archive records directly instead of structurally

Every expanded record is deep-copied so an occurrence shares no mutable
data with the archive, a contract two tests state. `structuredClone`
was a third of the time spent opening a page, and records are plain
Kiwi data, so copying them field by field is several times quicker —
43944 records of Preline UI clone identically either way, 218ms against
26ms. Byte buffers and anything else that is not an object literal keep
the structured algorithm.

Preline's CMS page now loads in 2.8s rather than 5.6s, and with the
per-component synchronisation fix in 0d1854a3a, 11.6s before either.

* test(tools): compare a reader's whole output, not one frame

`compare interpreted-document` checks one frame against live Figma. A
rule can leave that frame untouched and still change pages it does not
cover: addressing an override through a sibling variant reported no
difference on the frame under test while rewriting 127 button labels
elsewhere, and was reverted only after a whole-document comparison
found them.

`compare digest` captures every page a reader produces and diffs it
against an earlier capture, reusing the same node capture and
difference categories, so a before-and-after needs no Figma. Replaying
the reverted change against a baseline reports 110 semantic
differences. Unresolved-override counts are reported beside the nodes,
since a reader change usually moves those too.
2026-10-01 11:20:27 +04:00
Danila Poyarkov 418457bfb5
feat: preview streamed JSX on the canvas (#692)
* feat: preview streamed JSX on the canvas

Project incomplete JSX into isolated scene graphs and disposable pictures without mutating the document or adding intermediate undo entries. Share placement with final rendering and cover lifecycle and placement parity with AI SDK mocks and visual tests.

* test: require partial input for unfinished coordinates

Assert the complete partial object so rejecting the entire input cannot satisfy the truncated-exponent regression test. Addresses CodeRabbit's review finding on #692.

* feat(ai): keep a chat run on its page across page switches

Page switches go through the editor's preparation flow, and the chat panel treated every preparation as a document change: it dropped its Chat and reloaded history, detaching the panel from a reply still in progress. The panel now keeps the live chat unless the tab or the conversation changes.

AI tools also followed the page on screen, so a user browsing mid-run sent the next edits elsewhere, and the agent's own switch_page affected only one call. A run now pins the page where the message started; switch_page moves the run and the user's view, and streamed previews stay attached to the run's page, which the renderer draws only while that page is on screen.

Page snapshots now restore the page they were taken of, so undoing an AI edit works while another page is visible.

* refactor(core): share picture recording and export preparation with previews

Preview recording reimplemented three pieces Core already had: world-bounds picture recording (also duplicated by render chunks and the retained backing), font and layout preparation (prepareForExport), and page subgraph extraction. Extract recordWorldPicture and withWorldViewport for all three recorders, reuse prepareForExport, and add extractPageContext and findPageChildId next to the other subgraph helpers instead of editing a cloned graph's nodes.

prepareForExport also kept the shared layout text measurer overridden across an await, so a concurrent layout could measure with the export renderer. withTextMeasurer scopes the override to the synchronous layout.

* fix(design-jsx): inline nested fragments in streamed previews

The streaming projection kept a nested fragment as an empty-type node, which rendered trees inline, so a preview of <Frame><>…</></Frame> failed with 'Unknown element: <>'.

* refactor(ai): schedule previews and gate test streams with VueUse

The preview controller hand-rolled a trailing timer and abort-listener cleanup, and the test stream gate a promise resolver and listener set. Use useDebounceFn with maxWait (a lone delta still flushes, unlike useThrottleFn with leading off), useEventListener, and until(). Share the mock token usage between chat tests.

* fix(ai): keep previews alive through document edits and slow builds

Document edits finished every preview call, and onInputStart never restarts one, so a render call committing while a second was still streaming ended the second call's preview for good. Edits now invalidate: drop the shown artifact and rebuild on the new document.

A build that finished after another delta arrived was discarded, so a steady stream that outpaced staging and recording never showed a preview. Show it, then render the newer revision.

* docs(changelog): separate the Fixed heading from its entries

Add the blank line markdownlint (MD022) expects after the heading, and drop the one that split the Fixed list in two.
2026-10-01 10:52:36 +04:00
Danila Poyarkov 8404cee664
refactor(design-jsx): extract design JSX into its own package (#793)
* refactor(design-jsx): extract design JSX into its own package

Design JSX elements, helpers, schema, reference, and JSX export only need
the scene graph, yet lived in Core, so every consumer of the authoring API
pulled in the renderer, layout, and file formats.

@open-pencil/design-jsx now owns them and depends only on scene-graph. The
renderer takes icon lookup, SVG conversion, vector creation, and layout as
DesignJSXServices; Core binds its own and exports the bound renderJSX and
renderTree from @open-pencil/core/design-jsx.

* feat(design-jsx): export the JSX runtime for TSX authoring

The package already had a JSX runtime, but nothing exported it, so design
trees could only be written as function calls or JSX strings. Export
`./jsx-runtime` and `./jsx-dev-runtime` so `jsxImportSource` works, and make
`Fragment` produce the same empty-type node as `<>` in `renderJSX` strings.

* fix(design-jsx): render fragments nested in other elements

A fragment builds a node with an empty type, which only renderJSX expanded, and only at the root. Nested fragments and fragments passed to renderTree failed with 'Unknown element: <>'. Inline fragment children when trees are built, and share root expansion between renderTree and renderJSX.
2026-09-30 20:55:27 +04:00
Danila Poyarkov 87bff8620c
test(core): move the editor suite into the package (#792)
The 54 editor engine tests move to packages/core/tests/editor with their helpers, importing Core's public subpaths or source modules relatively. The package gains test and typecheck scripts; the first type check fixed nullable lookups, stale imports, and renderer doubles, and graph events now declare the GraphEventRenderer surface they invalidate. The engine baseline drops to 534 files.
2026-09-30 10:22:42 +04:00
Danila Poyarkov e532f616ba
refactor!: move shared primitives below dom-css and core (#771)
* refactor!: move shared primitives below dom-css and core

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

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

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

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

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

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

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

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

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

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

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

`exportHTMLBundle` accepts documents parsed from outside HTML, and
js-base64 drops characters it cannot decode, so extracting an invalid
image data URL wrote different bytes. Such images now stay inline.
2026-09-26 11:39:11 +04:00
Danila Poyarkov a029e267c2
feat(updater): show download progress while installing an update
Report desktop update downloads through persistent determinate or indeterminate toasts while retaining native confirmation. Use a spinner during progress and cancel pending expiry when progress resumes.

Standardize substantial Storybook fixtures as colocated example SFCs, preserve shared SDK documentation examples, and enforce semantic anatomy instead of shared-layer test IDs.
2026-09-25 23:15:14 +04:00
Danila Poyarkov 8131401ead
fix: explain unsupported browsers instead of a blank window (#745)
* fix: explain unsupported browsers instead of a blank window

The desktop app on macOS 13 with WebKit older than Safari 17.4 opened an
empty window because startup called Promise.withResolvers, which Vite lowers
nothing for: build.target only rewrites syntax and never polyfills APIs, and
the target itself was an implicit Vite default (#744).

Make the supported baseline explicit in src/app/shell/support/baseline.ts and
feed it to build.target, a lint rule that rejects newer static built-ins in
browser-shipped sources, and the documented system requirements. Replace
Promise.withResolvers with a createDeferred() helper.

Turn src/main.ts into a small gate that checks sentinel features before
dynamically importing the app, so an old engine still evaluates enough code
to render platform-specific update guidance: macOS/Safari via Software
Update, WebKitGTK and WebView2 on Linux and Windows, and each browser's
own update path on the web, with a prefilled bug report link. Render-blocking
errors during the first route are captured through app.config.errorHandler
and shown the same way instead of leaving the window blank.

Desktop facts come from tauri-plugin-os and a webview_version command; the
bundle now declares macOS 13 as its minimum system version.

* build: enforce the browser baseline from compatibility data

Replace the hand-maintained list of built-ins newer than the baseline with
two data-driven checks. The app and browser-shipped packages pin their
TypeScript lib to ES2023, the last edition Chrome 111, Firefox 128 and
Safari 16.4 implement in full, so a newer built-in such as
Promise.withResolvers fails type-checking. Web APIs, which lib.dom does not
version, go through eslint-plugin-compat under oxlint with the same browsers
in settings.browsers, scoped to sources that ship to a browser.

A unit test keeps the oxlint browser list and the tsconfig libs derived from
src/app/shell/support/baseline.ts, so the three cannot drift apart.

* fix: recognise production error codes in the boot observer

Vue passes the error reference URL as the errorHandler info argument in
production builds instead of the development string, so the observer never
classified a setup or render failure as fatal in the shipped app and the
boot-failure notice only appeared on the dev server. Match Vue's exported
ErrorCodes in both forms, and cover the component-setup path in the E2E
spec; the scenario was also verified against a production build.
2026-09-22 14:40:59 +04:00
Victor Wads 0f6420d9cd
fix(MCP): add read-only file lifecycle tools (#722)
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-18 11:55:06 +03:00
Danila Poyarkov 899fecf845
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-18 00:43:10 +03:00
Danila Poyarkov 92977234cf
ci: shard unit tests by owner and cut the quick suite from 100 s to 13 s (#715) 2026-09-17 10:18:16 +03:00
Danila Poyarkov 73fc732d20 test: honor the configured browser test origin
Use the configured baseURL for manually created pages and same-origin DOM/CSS imports. Repair the hover helper serialized argument uncovered once its startup reached the intended server. Preserve all existing pixel and behavior assertions.
2026-09-16 12:46:13 +03:00
Danila Poyarkov c59879105c fix(editor): close preview and history lifecycle gaps
Discard provisional undo batches on graph replacement without replaying old document edits or clearing committed history. Reset failed preview controllers and refresh selected projections for plain-state SDK consumers while pausing inactive subscriptions.

Cover the verified review findings with negative controls, fix static Vue host insertion, and preserve the shared exact renderer oracle with its independently verified sRGB selection color.
2026-09-16 03:36:44 +03:00
Danila Poyarkov 3220fd1798 fix(canvas): preserve paint geometry and color fidelity
Use native paragraph foreground paints instead of independent outline layout, keeping mutable shader paragraphs transient. Render transformed diamond gradients with a retained, owned runtime program and align Skia surface encoding with the browser drawing buffer.

Add independent pixel and ownership regressions and correct only the reviewed text, gradient and FIT-image visual oracles. Existing arrow/blur snapshot failures and the separate 84-pixel comparison remain unresolved; no tolerances are relaxed.
2026-09-16 02:57:56 +03:00
Danila Poyarkov b458e3c3ae perf(canvas): reuse labels with shared font fallback
Acquire section paragraphs once, reuse proven fitting layouts, and bound retained text with borrowed native-resource lifetimes. Share document text family selection and Arabic/CJK coverage resolution rather than preserving missing glyphs. Validate exact zoom parity with real existing font fixtures.
2026-09-15 23:08:53 +03:00
Danila Poyarkov 453b27f15f perf(properties): retain inactive panels safely
Retain one selection-property subtree while frame presets are shown. Suspend opted-in descendant scopes after cleanup, cancel drafts before detachment can blur inputs, close portals, and release previews and pending resource work.

Track focused-element removal with VueUse so WebKit shortcuts resume after input teardown. Cover retained identity, hidden inactivity, binding rollback, async disposal, undo, popup reactivation and browser focus behavior.
2026-09-15 17:01:44 +03:00
Danila Poyarkov 10c873399d test: fix paragraph probe counter lint 2026-09-15 17:01:44 +03:00
Danila Poyarkov dceae73c6e perf(text): preserve coverage across paragraph eviction
Keep successful glyph coverage weakly owned by source nodes rather than repeating shaping when dense scans evict native paragraphs. Preserve font/input invalidation and bound pending ID invalidations without increasing native cache limits.

Add a dense-preview regression with exact raster comparison and count paragraph builds across all canvas renderers. The previous compiled build rebuilds 3,600 paragraphs over three held frames; the fixed build rebuilds none.
2026-09-15 16:32:07 +03:00
Danila Poyarkov f4f3736034 test(native): open a document before editor fixtures
The Home tab exposes an editor store without a mounted canvas or text editor. Open New design during fixture setup and await actual editor readiness before populating nodes or starting text input. Reuse already-open documents and keep WebDriver input assertions unchanged.

Full check passes. Native WebKit text input, fixture reuse and Settings checks pass (4 tests); Windows-only layer dragging skips on macOS. Upstream Tauri service bridge/teardown warnings remain; this does not claim IME or native clipboard coverage.
2026-09-15 14:43:31 +03:00
Danila Poyarkov d16400b3e6 feat(editor): checkpoint live interaction improvements
Unify preview-aware transforms across scene drawing, labels, selection and input. Give live property and creation edits explicit preview ownership, preserve cancellation and one-step undo, and reuse bounded text preparation resources.

Include retained device-grid handling, worktree HMR coverage, and nested/reflected interaction regressions in this cohesive progress checkpoint.

Validation: full check passes; 734 scoped unit tests and 53 targeted browser tests pass. This is NOT merge-ready: the unchanged exact nested filled-section held/released regression still fails with 63 differing pixels (maximum channel delta 5/255). The browser pass count excludes that separately run failure and the previously classified paint-field-width baseline. Raster-origin investigation and broader acceptance remain outstanding.
2026-09-15 11:36:17 +03:00
Danila Poyarkov 31d886ca27 fix: preserve replacement documents after demo preparation 2026-09-14 01:12:26 +03:00
Danila Poyarkov 21ecce1f38 feat: assemble an editable demo document
Compose announcement, typography, and paint workbenches with native linked components and editable effects. Preserve the original examples on a reference page.

Wait for the canvas and existing fonts before layout, fit demo pages on first visits, and preserve user viewports thereafter.
2026-09-14 00:36:53 +03:00
Danila Poyarkov 4077dcbd44 fix: preserve authored instance sizing through synchronization 2026-09-14 00:24:52 +03:00
Danila Poyarkov a779edd08a
refactor: scope browser test instrumentation and global types (#667) 2026-09-10 14:26:36 +03:00
Danila Poyarkov 6cec1b2c58
fix: prevent repeated Keychain prompts and isolate native tests (#664)
* fix: prevent repeated Keychain prompts and isolate native tests

* fix: make credential settings contextual and actionable

* fix: refresh credential recovery state in open Settings

* fix: allow retrying credential status checks

* fix: recover credential access after backend panics

* test: centralize native Tauri invocation

* refactor: clarify credential preferences workflow path
2026-09-09 13:41:55 +03:00
Danila Poyarkov b4e479d9d0
feat: persist AI conversations and add chat history (#661)
* feat: persist AI conversations and add chat history

Store transcripts and attachment previews in IndexedDB, separate conversation history from transport lifetime, and add document-aware switching with shared Storybook coverage. Preserve interrupted activity and guard stale writes and async switches.

* test: group chat history tests by domain

* feat: simplify chat history navigation and diagnostics

Use a compact header with searchable document-scoped history and a correctly anchored conversation menu. Move diagnostic copying into the menu with copy-result feedback, and separate Storybook fixtures from composition.

* fix: address chat history review findings

* test: cover harness shutdown and restored chat scrolling

* fix: preserve Portless proxy port in MCP routes

* docs: clarify UI animation conventions

* feat: configure reasoning display and animate disclosure

* fix: separate transcript following from reasoning disclosure

* fix: restore selected chat after document recovery

* refactor: separate chat history persistence and sessions

* style: format reasoning story imports
2026-09-08 11:19:07 +03:00
Danila Poyarkov 076f7ce0d9
fix: restore engine test coverage and isolation (#653)
* ci: require complete engine test shard coverage

* test: provide path data in the font editing gate fixture

* test: remove stale Tauri runtime markers during cleanup

* fix(collab): deduplicate pending page switches

* test: simplify pending page switch regression setup
2026-09-06 23:29:45 +03:00