* 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>
The variables dialog now uses the tokens panel, so useVariablesEditor, useVariablesTable, useVariablesDialogState, and the table-only formatModeValue, parseVariableValue, and shortName helpers have no users. Removing them drops the @tanstack/vue-table dependency; their SDK pages redirect to useVariables.
* feat: prototype the design tokens panel
Undoable editor actions for token fields and mode conditions, a token view model, and table, inspector, collection and stylesheet panels shown in a Storybook story with a real editor.
* feat: lay the tokens panel out for mobile
Below the mobile breakpoint the collection tabs become a select, the list shows one chosen mode with the CSS name under each token, and the token, the modes, and the stylesheet each open over the list behind Back. CodeViewer can fill its container for the full-screen stylesheet.
* feat: share drill-in navigation and put tokens on a listbox
PanelDrillIn gives detail views one back control that names where it returns, a slide preset from theme/motion, and focus that moves into the detail and back to what opened it. The Settings model editor and the tokens panel's mobile views use it. The token list is a Reka Listbox with arrow-key navigation and labelled groups, and the inspector and stylesheet swap with a short fade under the motion policy.
* feat: lay the tokens panel out by container width
The panel switches to the compact drill-in by its own measured width, and the list's columns follow the list's width through container queries, so the panel adapts inside dialogs and split views, not only on phones. src/AGENTS.md now says when to use container queries, measured size, and viewport breakpoints.
* test(core): group the variable undo tests in a domain folder
* feat: edit variables as tokens in the variables dialog
The variables dialog now hosts the tokens panel: a grouped token list with CSS names, an inspector for each token and for the collection and its modes, and the live stylesheet. It keeps adding variables by type, search, collection and mode management, and copying the document's stylesheet, and lays out by its own width down to phones.
useVariables().collections returns copies, so components given a collection see modes added or renamed in place, and addVariable returns the new ID so the panel can open it.
* fix: satisfy the type-aware lint in token updates and panel stories
* docs: describe the tokens panel in the translated variables guides
* feat: say when each mode applies in plain words
A mode's condition was a raw CSS field that designers could not read. Each non-default mode now picks when it applies (switched manually, system dark or light mode, high contrast, reduced motion, screen or container width, or custom CSS), with the CSS it writes shown underneath and a note on how the mode behaves on the canvas and in exported code. Presets only write the condition string modes already store, so files round-trip unchanged. Column headers name the condition in words, and deleting the collection moves into a menu beside its name.
* feat: bring the variables dialog up to Figma's
The dialog now covers what Figma's variables dialog offers: collections and groups in a sidebar with counts, a group, search and type filter, type icons, names and values edited in place, drag to reorder, multi-select with a context menu, the Delete key and a side panel to duplicate, group and delete, aliases chosen from a variable picker and detached back to the value they showed, hiding from publishing, and an expand toggle. It opens from View → Variables… and the command palette as well as the Design panel.
It also fixes review findings: a single-mode collection labels its value Value, column titles share one font, a CSS name is typed after a fixed -- and checked by the CSS parser before it is saved, and the stylesheet previews CSS with the format chosen when copying. AppInput applies an instance's input classes last so they can override adornment padding.
* feat: undo and redo inside the variables dialog
Canvas shortcuts stop while any dialog is open, so Cmd+Z did nothing in the variables dialog although every edit there is on the editor's history. Undo and redo now take a document scope: they also run when the topmost layer is a dialog that edits the document, which the variables dialog marks, while menus, pickers and Settings still hold them back and a field with uncommitted text keeps its own undo.
Enter commits a field and returns focus to the list. The panel drops selections, group filters and collections that undo removed. A color picker session undoes as one step through a coalesce key on updateVariableValue, and undoing a deletion puts the variable back in its place.
* feat: search variables like the command palette
The variables search matched a substring of the name only, so a CSS name, a hex color or a description found nothing, and the palette, AppPicker and AppCombobox each spelled out the same Fuse.js options. One helper in @open-pencil/vue now owns the matching: fuzzySearch ranks results for lists people pick from, and fuzzyFilter keeps a list's own order for lists people arrange. The panel searches names, CSS names, descriptions and every mode's value, alias names included; useVariables() searches names and descriptions.
* fix: say a token group once when its name repeats it
Kits that mirror Tailwind classes name tokens like Gap/gap-1, which derived --gap-gap-1. A group the next segment repeats is now said once, giving --gap-1.
* fix: keep the add variable menu under its button after a resize
The toolbar swaps the icon button for the labelled one when the dialog widens. Reka keeps the anchor it mounted with, so the menu opened at the window corner; keying the trigger remounts it with the new button.
* fix: leave bound layers alone when variables are added or reordered
Adding, copying, or reordering variables re-resolved every bound layer in the document. In the shadcn kit two Avatar instances are saved at 24 while their binding gives 40, so adding a number resized them and relaid out about 7,800 layers, freezing the browser for seconds. These changes alter no bound value and now only request a render, as renaming already did.
* refactor: let the variables dialog own its undo shortcuts
Undo and redo in the dialog went through a document scope in the global shortcut registry, which decided whether the dialog was on top by querying Reka's dismissable layers in DOM order. The dialog now listens on its own content with a tinykeys handler built from the command keybindings: menus and pickers it opens portal elsewhere, so their keys never reach it, and the registry is back to one scope.
* refactor: read mode conditions with css-tree
Presets were recognized by normalizing the condition with regular expressions and matching another. css-tree, which Core already ships through unifont, now parses the condition into its media or container feature, so spacing, case and comments are handled as CSS does, and a non-breaking space, which CSS does not treat as whitespace, no longer turns a custom condition into a preset.
* refactor: take the mode attribute hint from modeAttribute
The hint for a manually switched mode cut the brackets off its selector with a regular expression. It now reads the attribute name and value from modeAttribute, which the selector is built from.
* fix: keep a refused CSS name in focus and color picker sessions apart
Enter on a CSS name the parser refuses no longer hands the keyboard back to the list, so the name can be corrected. The typed name is read with parseCSSName instead of stripping a leading -- by hand, so a pasted var(--name) works too.
Each color picker session takes a random undo key; a counter restarted when the inspector remounted, so two sessions on the same token could merge into one undo step.
* fix: keep token expressions and cleared fields in step with their variable
Editing a number left its CSS expression recording the old number, so reopening the file dropped the expression as edited elsewhere. A number now updates its expression, and an alias drops it, in the same undo step.
Undoing a token field that had been unset kept the key with an undefined value; it is now removed.
* fix: preview and detach aliases in their own mode
An alias in the Dark column showed, and detached to, what its target gives in the mode the canvas is in. Both now resolve in the column's mode.
* fix: drop tokens a filter hides from the selection
A search, group, or type filter could hide selected tokens that stayed selected, so the inspector, the bulk actions, and the Delete key still acted on rows the list no longer showed.
* fix: keep a drill-in's list out of the tab order while its detail slides
The list stayed focusable until the detail finished sliding in, and became focusable again under a detail sliding out. It is now inert from the moment the detail opens and the departing detail is inert. A detail with no field focuses its back control, and hidden inputs are skipped.
* docs: drop a duplicated Stroke entry from the changelog
Two merges left the Stroke-extends-Fill breaking change twice; the copy that still said strokes render solid only is outdated, since gradient and image strokes now import and render.
* 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
* build: update dependencies
Update the AI SDK providers, Vue, Reka UI, Valibot, Zod, es-toolkit,
CodeMirror, Storybook, Playwright, Hono and other dependencies to their
current releases, consistently across workspaces.
The Tauri plugin packages must match their Rust crates, and the new plugin
crates require Tauri 2.12, so Cargo.lock, @tauri-apps/api and the Tauri CLI
move to 2.12 as well.
* build(harness): update the AI SDK harness packages
@ai-sdk/harness 1.0.74 pinned ai 7.0.67, so the workspace carried a second
copy of ai next to the root one; 1.0.138 depends on the same ai release.
The Pi adapter no longer takes a model: HarnessAgent does. The settings
were spread from untyped records, so the compiler could not reject the
stale key and the chosen model would have been dropped; they are plain
literals now.
PiAuthOptions is now PiAuthenticationMode, and auth accepts an environment
record. Pass the gateway key that way instead of writing it into the
process-wide environment while a session is created. Derive the thinking
level from the adapter's settings, which adds 'max'.
* build: hold vue-tsc at 3.3.11
vue-tsc 3.3.12 no longer sees a v-slot binding inside a component that
also has an event listener, so check:vue reports "Cannot find name
'control'" in MCPConnectionEditor and ProfileEditor. 3.3.11 checks them
cleanly.
* fix(ai): keep retryability for provider errors reported mid-stream
From ai 7.0.80 a provider error after the response stream starts is a StreamProviderError rather than an APICallError, so classifyAIChatError lost its isRetryable.
* feat(desktop): accept updates only when signed for their version
Tauri CLI 2.12 records the app version in each updater signature, and
updater 2.13 checks it against the version latest.json announces. With
requireSignedVersion it also rejects signatures that carry no version, so a
tampered manifest cannot pair a newer version number with an older, still
validly signed bundle.
Release assembly now fails when a signature does not name the version
being released, instead of shipping one that installed apps would reject.
* docs: note the dependency update's security fixes in the changelog
* feat(ai): recommend the latest models
The provider packages now know Claude Sonnet 5.5 and Opus 5.5 and the GPT-6
series. Make Sonnet 5.5 and GPT-6.1 Sol the defaults, list Opus 5.5, Fable
5.1, GPT-6 Astra and GPT-6 Luna, and replace the two free OpenRouter models
that OpenRouter no longer serves.
* build: align the fig package's valibot with the workspace
* refactor: rename @open-pencil/codegen to @open-pencil/emit
In design tools codegen means design-to-code, which is dom-css's job and
Core's codegen tools'. The package builds and prints the syntax trees
exporters emit, so name it for that. It has not been released yet.
* fix: alias @open-pencil/emit to its source in Vite
The rename missed the escaped regex, so Vite resolved the package through
its built dist output.
* feat(core): add visual diff and patch apply tools
diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.
diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.
Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.
* feat(ai): show what each AI edit changed in its tool call
Reviewing an AI run meant reading tool output or undoing steps to see
what moved. Each document-changing call now rebuilds its page before
and after from snapshots taken around it, and diffs each top-level
layer's JSX with jsdiff, the same patch diff_jsx returns, to find the
layers it changed. After the call returns, the changed region renders
in both states at one size and pixelmatch highlights the difference.
The tool card opens on a Changes view with a before/after slider, the
pixel highlight, and a CodeMirror merge view of the JSX. Records are
saved with the conversation next to attachments.
Calls snapshot their page individually instead of through one shared
variable, so concurrent calls in a step no longer overwrite each
other's undo state. Core gains graphFromPageSnapshot for rebuilding a
past page state, diffPageLayersJSX and jsxPatch (now shared with
diff_jsx), renderRegionToImage for rendering two states of one region
pixel for pixel, and comparePNGs on the raster codec. Settings > Chat >
Change previews sets the stored image size or turns images off.
* feat(cli): add diff commands and agent diff guidance
openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.
The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.
* feat(ai): render tool calls as summarized, highlighted cards
Every tool call showed only a status and its output as a JSON string,
so render calls hid their JSX, export_image dumped base64, and long
runs filled the transcript with identical rows.
A call now shows a one-line summary read from its input and chips that
select and zoom to the layers it touched, switching to the run's page
when needed. Expanded, it shows the JSX or script it wrote and its
JSON input and output in a read-only CodeMirror view, and exported
images inline. Render calls can be expanded while their input streams,
so the JSX appears alongside the canvas preview. Consecutive calls
beyond three fold into one row that keeps the latest call visible.
CodeMirror loads with the first expanded call. The code theme gains a
monospace fallback because the editor font variable is not always
emitted.
* feat(ai): let the chat AI diff its run against the starting state
The diff tools compare two nodes, so checking an edit meant cloning a
reference first, which the agent rarely did. diff_changes compares the
current page, or one node under it, with the page as it was before the
run first edited it, in diff_create's patch format. The app keeps that
page snapshot per run and exposes it through FigmaAPI.changeBaseline;
MCP and WebMCP have no run, so the tool is offered only to the AI chat,
where it is enabled by default and the prompt asks for it before
reporting.
* feat(core): diff and patch node trees as JSX attributes
diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.
Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.
design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.
* feat(ai): report diff_changes as a patch diff_apply can replay
diff_changes printed a unified diff of the JSX, which agents could read
but not apply. It now diffs the run's baseline against the live page
with the patch engine, matching nodes by ID, so a rename is a changed
name and the output replays on the starting state with diff_apply. The
chat's Changes view keeps the JSX line diff, which is for people.
* fix(core): keep diff_apply atomic and diff files honest about differences
- Added nodes render before anything else changes; if one fails, for
example on a missing component, the rendered ones are deleted and
nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
one document has gets its status but no patch, since patches do not
add or remove pages. diff files uses it, so an added empty page no
longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
is not a non-negative integer, exiting 2; diff_create's depth is
validated the same way.
* refactor(ai): drop the unused tool JSON slot and place the JSX summary comment
* refactor(ai): find a tool change's clipping region with jsdiff
clipChangedJSX scanned both JSX sources character by character for their common start and end. diffLines gives the unchanged lines before the first change and after the last; the app now declares the diff dependency Core already uses.
* feat(ai): render tool calls as summarized, highlighted cards
Every tool call showed only a status and its output as a JSON string,
so render calls hid their JSX, export_image dumped base64, and long
runs filled the transcript with identical rows.
A call now shows a one-line summary read from its input and chips that
select and zoom to the layers it touched, switching to the run's page
when needed. Expanded, it shows the JSX or script it wrote and its
JSON input and output in a read-only CodeMirror view, and exported
images inline. Render calls can be expanded while their input streams,
so the JSX appears alongside the canvas preview. Consecutive calls
beyond three fold into one row that keeps the latest call visible.
CodeMirror loads with the first expanded call. The code theme gains a
monospace fallback because the editor font variable is not always
emitted.
* refactor(ai): drop the unused tool JSON slot and place the JSX summary comment
* fix(ai): keep an opened tool call in place instead of following the output
Opening reasoning already stopped the transcript from following new output; tool calls and tool groups did not, so expanding one near the bottom re-pinned the bottom on every animation frame and slid the card away as it opened. Any disclosure in the transcript now stops following.
* fix(ai): show a pointer over chat tool calls, tool groups, and reasoning
* refactor(app): share CodeMirror setup between the code editor and viewer
CodeViewer repeated CodeEditor's view lifecycle: mounting the EditorView, label, theme, and language compartments, the app-theme watcher, and teardown. useCodeMirror owns that once; each component passes its own fixed and reactive extensions.
* refactor(ai): move tool node lookup and focusing into useToolNodes
ToolNodeChips looked nodes up in the active document and ran the show-on-canvas flow, with its superseded-switch and error handling, inside the component. The composable owns both; the component renders the chips.
* refactor(ai): derive tool call state and input once
ToolCallCard and ToolCallGroup each rebuilt classifyToolState's input from the part, and the card decided inline whether a call had input to show. toolCallState and toolHasInput own those rules beside the other per-call helpers.
* fix(app): use the thin app scrollbar in code editors and viewers
CodeMirror scrolls its own .cm-scroller, which fell back to the platform scrollbar, thick and light in the dark chat. The hosts now give it the shared scrollbar-thin utility.
* feat(core): add visual diff and patch apply tools
diff_visual renders two nodes at one scale through the existing raster export, compares them with pixelmatch, and returns the diff PNG with the changed ratio and region in source-node coordinates. It takes export_image's scale and maxEdge inputs. FigmaAPI gains a CanvasKit-backed raster codec and a pageId export option, so the app and headless CLI decode pixels and render nodes off the current page.
diff_apply applies diff_create and diff_show patches through the Figma API, validates every node before changing any, and supports dryRun and force. diff_show now simulates changes on a detached copy with the same property code. One serializer and parser back all three. diffDocuments compares two documents page by page by name path.
Image tool results now reach models as media with their metadata as text, for any tool rather than export_image alone. diff_create, diff_jsx, and diff_visual join the default AI tool set, and the diff tools are no longer hidden from WebMCP.
* feat(cli): add diff commands and agent diff guidance
openpencil diff create, jsx, show, apply, and visual run the Core diff tools on a file or the running app; apply writes back with --write or --output like eval. diff files compares two documents page by page and exits 1 when they differ.
The chat prompt asks the agent to edit in place and to verify risky edits against a reference copy with diff_jsx, diff_create, and diff_visual. The skill, CLI reference, MCP tool table, and a new Comparing Designs page document the commands and tools.
* feat(core): diff and patch node trees as JSX attributes
diff_create, diff_show, diff_apply, and diffDocuments used a hand-rolled
`key: value` property format that covered about fifteen properties,
matched children by name path, and could not see moves.
Nodes are now projected to the attributes the JSX export prints, and
jsondiffpatch matches children (by ID or by name path) and detects
moves. Patches list `-`/`+` attribute lines per node plus moved, added,
and removed children. diff_apply checks every hunk first, applies
attribute changes through the renderer's prop handling, and changes only
the fields an attribute moves, so IDs, instance links, and other state
survive. diff_show takes JSX attributes instead of a JSON props object.
design-jsx gains sceneNodeAttributes, parseJSXAttributes, and
jsxNodeFields for this, and the export round-trip property table is
shared so every case is also diffed and applied. `diff files` loads its
documents in order so node IDs, and so its patches, are deterministic.
* fix(core): keep diff_apply atomic and diff files honest about differences
- Added nodes render before anything else changes; if one fails, for
example on a missing component, the rendered ones are deleted and
nothing else is committed.
- A hunk with an attribute the renderer ignores fails instead of
reporting "unchanged".
- diffDocuments reports `changed` from page statuses, and a page only
one document has gets its status but no patch, since patches do not
add or remove pages. diff files uses it, so an added empty page no
longer reads as a match.
- diff files rejects a --page neither document has and a --depth that
is not a non-negative integer, exiting 2; diff_create's depth is
validated the same way.
* refactor(codegen): share syntax-tree code generation between exporters
dom-css printed Tailwind JSX with its own esrap JSX builders and kept TypeScript template helpers under its Storybook export. The OpenPencil JSX exporter needs the same JSX builders, and design-jsx and dom-css may not depend on each other.
@open-pencil/codegen holds both: es for ESTree templates and modules (moved from dom-css) and jsx for JSX elements, attributes, text, and printing, including the literal rules that keep exported strings from being reinterpreted. dom-css no longer depends on acorn and esrap directly.
* refactor(design-jsx): print JSX export as syntax trees
sceneNodeToJSX concatenated strings with hand-written escaping and indentation. It now collects typed props (moved to export/props.ts) and prints them with @open-pencil/codegen's JSX builders. Output is unchanged except for text: special characters print as a string expression instead of entities, and multi-line text keeps its line breaks, which the old line-splitting lost on render.
* docs(codegen): fix package metadata and dependency rules
codegen's repository.directory still named design-jsx, the README mentioned es without showing it, and the design-jsx and dom-css guides still said they depend only on scene-graph.
* test(core): move the JSX export round-trips to the design-jsx test home
Covers tabs in layer names and text, which JSX keeps as written.
* test(core): move the fig round-trip suite into the package test home
The reader change added `verifier-contracts.test.ts` under
`tests/engine` and grew the migration baseline to admit it, which the
testing architecture forbids: new tests go to the canonical home and
the baseline only shrinks. It also started
`packages/core/tests/io/formats/fig/roundtrip/`, leaving two homes for
one domain.
These tests drive Core's exporter, so Core's home is the canonical one.
The directory moves whole — eleven files, its helpers and raw verifiers
with it — and picks up the local `assert`, `traversal`, `test-utils`
and fixture helpers already there. The baseline loses nine entries.
* refactor(core): address test helpers by alias instead of drilling
`open-pencil/no-deep-parent-relative-imports` already forbids `../../`
drilling, but `lint:structure` names the directories it checks and
`packages/{core,scene-graph,vue}/tests` were never added — so the 72
drilled imports in Core's tests, including the ones the round-trip move
just wrote, were never seen.
Core gains `#core-tests/*` beside fig's `#fig-tests/*`, registered with
the architecture rules, and its tests reach their helpers through it
and their source through `#core/*`. The three test directories join
`lint:structure`, which also surfaced six errors they had never been
checked for: four duplicated imports, an empty `noop`, and a
self-assignment the test meant, now written through a named binding.
Both guides state the rule, since a lint nobody can find in prose is
how this went unnoticed.
* fix(tools): point the heavy unit patterns at the relocated round-trip tests
`HEAVY_UNIT_TEST_PATTERNS` still listed the three heavy round-trip
tests under `tests/engine`. `test:unit` discovers them by directory so
the move looked clean, but `--heavy-only` selects by path and silently
stopped choosing them: 8 files, 52 tests, where it now runs 11 and 80.
* refactor(fig): introduce occurrence-scoped instance interpreter
* refactor(fig): add direct occurrence materialization and render diagnostics
* fix(scene-graph): preserve nested edits and invalidate text layout caches
* refactor(fig): assemble indexed documents with occurrence provenance
* refactor(fig): validate document assembly against live scene oracles
* fix(text): preserve saved glyphs and supported run paints
* test(fig): share typed GUID fixture helper
* fix(fig): resolve component root keys in instance overrides
* fix(kiwi): reject malformed byte arrays before encoding
* fix(components): target properties by source identity through undo
* fix(fig): preserve editable occurrence export contracts
* refactor(fig): construct live component dependency closures
* fix(fig): invalidate inherited text geometry after occurrence overrides
* perf(fig): reuse component expansions and narrow payload copies
* perf(fig): avoid discarded metadata and instance definition copies
* perf(fig): transfer parsed records into archive reader ownership
* feat(fig): add incremental page sessions with load rollback
* test(fig): verify page deltas and stale revision rejection
* feat(fig): wire reader worker sessions and compact recovery checkpoints
* docs(fig): organize reader architecture and visual examples
* chore(fig): checkpoint WIP reader and writer overhaul
Preserve in-progress FIG reader, instance interpretation, editable export, and validation work on its feature branch. This is a backup checkpoint, not a release-ready or fully validated change.
* refactor(fig): resolve instance structure before expansion
Route swaps and property assignments down to the instance they configure
so each occurrence expands once with its effective component and complete
assignment list. Owners then apply property claims onto the built subtree,
which keeps values in the declaring owner's coordinate space and orders
inner owners before outer ones without re-expansion, recipes, or patch
restoration.
Track the components an occurrence expanded before an outer decision
replaced them, including intermediate swap assignments, so a claim that
resolved against a superseded component is retired while a genuinely
missing target still reports. Precedence is one rule: an explicit claim
keeps a field unless a strictly outer owner assigned it.
Drop the detached-lineage remap heuristic; unresolved assignments report
through the existing diagnostic instead of guessing a replacement target.
The Accordion source-closure fixture reports two stale overrides, not
three: the third came from a subtree the old interpreter expanded and
discarded.
* refactor(fig): derive override field handling from one registry
Describe each claimable raw field once, with its SceneGraph fields, kind,
and whether it is a length, and derive claim recording, layout-distance
scaling, and export serialization from it instead of maintaining parallel
tables.
Restore every field the uniform scaler touches from the instance record
after scaling. The record already describes the placed result, but corner
radii, dash patterns, and effects were previously scaled without being
restored, so a scaled instance with its own corner radius rendered it
doubled.
* fix(fig): retire nested swaps under a replaced component
A structural layer routed through an instance whose component an outer
owner replaced may still address the original component's children. Such
a layer is stale in the same way a property claim is: it resolved before
the outer decision and has no target now. Carry the replaced components
across that boundary and skip the layer instead of failing the file.
material3's List swaps a list item to another variant while the item's
own saved swap of a trailing checkbox still names the original variant's
child.
* fix(core): report stale Figma override records instead of refusing the file
Figma keeps override, assignment, and binding records that address nodes
it later deleted, and material3.fig could not open because the reader
ran the document session strictly. Share one set of session options
across the reader and recovery sessions that collects those records as
diagnostics and skips them; a swap whose replacement is missing remains
a structural failure.
The component-metadata expectation follows the visible Buttons page copy
of the component set, which the dependency closure now resolves instead
of an internal-only copy.
* fix(fig): keep instances of deleted components when opening a document
Figma retains instances whose main component was deleted, and material3's
Internal Only Canvas has 56 of them, so an edited document could not be
exported: export loads every page and the reader refused the page over
missing reachable sources.
The dependency closure now separates deleted components from broken
hierarchy, which remains fatal. With the new onMissingComponent option the
interpreter keeps such an instance as a childless occurrence that retains
its saved reference, applies only its root claims, and reports the owner;
strict interpretation still fails. The core reader opts in, shares one
diagnostics sink with recovery and export sessions, and exposes it through
readerDiagnostics(). Property defaults naming a deleted component are kept
the same way, so an edited export no longer rejects them.
* fix(fig): resolve variant property values through the component set
A variant's saved specs name variant definitions that its component set
owns, so occurrence conversion left them keyed by definition id. Resolve
them to names once the set is in the graph, as the previous importer did.
The component-metadata expectation follows the visible Buttons set's
axes; the Style axis belonged to an internal-only copy.
* refactor(fig): satisfy type-aware lint in the interpreter and export
* fix(fig): keep an instance fill override's variable alias across export
A fill or stroke override on an instance descendant lost its colour
variable on export: the paint claim was written without the alias, and a
boundVariables override for a paint colour produced no claim at all
because paint colours are not node-level consumption fields. The reopened
paint therefore bound to the component's default variable.
Write override paints through the same alias-aware builder as node
paints, serialize a paint colour binding override as the paint claim
itself, and on import record the binding claim alongside a claimed paint
that carries an alias so a later component sync cannot restore the
component's binding.
On an edited material3.fig round trip this removes all 10,329 fill
differences; 2,217 of 78,425 nodes still change, almost all text metadata
Figma keeps on outlined vectors.
* docs(fig): describe the single reader, its diagnostics policy, and paint claims
The status documents still said the replacement reader covered only some
worker paths and that old-reader removal was pending. Every import path
now uses it and the previous importer is deleted, so state that and move
the open items to fidelity and performance.
Record the contracts added recently: strict-by-default interpretation
with per-session diagnostic handlers that the application reader opts
into, instances of deleted components kept as childless instances, the
shared override field registry, paint colour aliases serialized inside
paint claims, and variant values resolved through the component set.
Correct the clipboard ownership rule in AGENTS.md: the envelope belongs
to fig, pasted records go through the same reader as documents.
* docs: note exported instance overrides in the changelog
* refactor(fig): share record indexing and symbol data access
Five modules built their own GUID-to-record index with the same idiom;
they now use the source index, or indexRecords when child order is not
needed. The Kiwi codec types only symbolID, so every reader cast
symbolData to reach overrides and the uniform scale; symbolDataOf,
symbolOverridesOf, and uniformScaleOf replace those casts. idOf and
parentIdOf name the record identity conversions used by ancestry walks.
* refactor(fig): share tree search and traversal across records and occurrences
The rule that a path segment may pass through ordinary containers but
never implicitly into an instance existed three times, once per tree.
findWithinBoundary owns it now, parameterized by a tree shape; the
occurrence resolver and the static record resolver are two callers.
An occurrences() iterator replaces hand-rolled recursion in the
component planner, closure, layout scaler, and correspondence linker,
forEachOverrideRecord replaces the record-plus-overrides walks in the
dependency scans, and one child-pairing generator serves both
source-children matchers.
* refactor(fig): serialize override claims from the field registry
Split export-node.ts: export-context.ts owns the serialization context,
GUID allocation, and paint builders; override-claims.ts owns instance
override serialization. The override serializer was a chain of field
checks that had to agree with the registry materialization records
claims from; it is now one switch over the registry's field kinds, the
export side of that table, with swaps and variable bindings as the two
cases the registry does not describe.
Decoded record streams for an edited gold-preview export and a
synthetic bound-fill export are identical before and after.
* fix(core): record instance overrides for FigmaAPI rename and resize
The name setter and resize() wrote to the graph directly, so a rename or
resize of an instance child through the Figma API was never recorded as
an override: component sync reverted it and export did not write it.
Route both through the shared recording update like every other setter.
* fix(fig): address overrides inside nested instances by the definition child
An override on a child of a nested instance was addressed through the
enclosing component's own copy of that child. That node lives inside an
instance and is never written as a record, so Figma could not resolve the
path and dropped the override. Follow the correspondence until it leaves
every instance, which yields the nested component's child, the record
Figma itself names in the same situation (verified against Figma's
clipboard encoding of the identical edit and by reopening the export).
* test(fig): record the Figma reopen of reader exports
* test(fig): compare reopened exports with the oracle tool
The interpreted-document comparison already reads Figma's interpretation
of an archive against the reader's; pointing it at an exported archive and
its imported Figma file makes it the reopen check. Captures need the
imported file to be the active document, so add an activate-tab operation
that brings a desktop tab to the front through the shell page. Record the
comparison results for the three reopened exports and document the
procedure.
* fix(scene-graph): keep a nested instance's correspondence across a swap
Children populated by cloning link to the enclosing component's record
through componentId. Swapping a nested instance replaced that field with
the new component, so the swap was exported against the replacement
component's GUID instead of the nested instance record and Figma could not
apply it. Record the correspondence as the owner's sourceComponentId
override and the swap as its componentId override, as materialized
documents already carry them.
* fix(core): treat applied shared styles as instance overrides
Style references were not instance sync fields, so a text style applied
inside an instance was neither recorded as an override nor exported, and a
component's style change did not reach its instances, although the reader
records styleIdForText claims from Figma. Add the style reference fields
to the sync set and expose them on the Figma API proxy under Figma's
names so assignments through the API record overrides.
* test: record the second Figma reopen round for the reader export
Figma confirmed stroke and corner-radius variable bindings, an applied
text style, nested-frame layout distances and sizing modes, visibility,
and a nested swap. A size claim on an auto-layout child inside an
instance is not applied, matching Figma's own resize refusal there.
* chore: format the merged structural export test
* refactor: group export and instance sync modules into domain folders
The node-change export context, node serializer, runtime, and override
claims move under node-change/export/, and the scene graph's instance
child sync and sync field lists move under instances/, keeping the
public instances module to its API.
* fix(fig): address exported instance overrides by override key
Figma resolves an override path segment through the target record's
override key, never its GUID: in gold-preview.fig all 10,341 override
and 12,838 derived-geometry segments resolve that way and none resolve
to a node GUID. A component imported from Figma keeps its keys, but one
authored here has none, so the writer addressed its descendants by GUID.
Figma tolerated that for most fields and silently dropped the geometry,
so a descendant resized inside an instance reopened at the component's
size.
Definition records — a component and everything inside it — now carry an
override key, minted from the shared identity counter when the node has
none, and paths name that key. One map spans the document because the
serializer runs once per top-level child.
The library content hash ignores the key, which identifies a record
rather than the component's content, and the clipboard export passes its
variable mode map as modeIdToGuid instead of propertyIdToGuid.
* docs: record how Figma resolves an override path
* Revert "fix(fig): address exported instance overrides by override key"
This reverts commit 38eebb2e5, except its clipboard argument fix.
The change came from gold-preview.fig, where every override path segment
resolves through a record's override key. material3.fig shows the
opposite: 51,332 of its segments are node GUIDs against 24 keys, and only
16 of 87,237 records carry a key at all. gold-preview is a file of
library instances, where the key is the cross-file identity; addressing
by GUID is what Figma writes for locally authored components, which is
what the writer already did. It was also not the reason Figma ignored a
descendant's size claim, which is still open.
The clipboard export keeps passing its variable mode map as modeIdToGuid
rather than propertyIdToGuid, which was an unrelated defect in the same
call.
* docs: correct the override addressing note and record the size gap
* docs: settle the descendant size gap as a Figma constraint
* chore: format the JSON fixtures this branch adds
format:check runs the formatter and fails on any change, so the fixtures
have to be committed as oxfmt writes them.
* test(tools): smoke the instance override subpath's current exports
populateAndApplyOverrides belonged to the importer this branch removes.
* perf(fig): index the archive once per document, not once per page
Selecting a page rebuilt both whole-document source indexes, so opening
material3.fig with its 33 pages indexed 87,237 records 33 times and
86,888 records another 33 times: 102 index builds where 36 are needed.
Only the page's own subset varies, so the full index and the component
interpreter move into state shared across selections, and the initial
read path passes its index to inheritance, style lookup, the dependency
closure and component planning rather than each building its own.
The paint and component-property passes iterate keys directly instead of
materializing an entry array for every node, most of which bind nothing.
Loading material3.fig goes from about 9.5s to about 7.5s on the same
machine, measured back to back with the machine otherwise idle.
* docs: note the faster multi-page .fig load
* perf(fig): apply document passes to the nodes a page materialized
Linking component property values, resolving variant values and applying
layout and paint bindings each walked the whole graph and skipped what
was already there, so every page load re-visited every node the earlier
pages had produced. On nuxtui.fig, 121 pages over a graph that reaches
354,000 nodes, those four passes were 22.7% of the profile after only
six pages and grew from there.
Each pass now takes the nodes just materialized. Component property
types are remembered across page loads instead, because an assignment on
a new node can name a definition an earlier page introduced; seeding that
cache is the only pass that still reads the whole graph, once per
document rather than once per page.
Pages 3 to 20 of nuxtui.fig fall from 90.0s to 51.6s. The first page is
unchanged: it materializes 256,354 nodes and is dominated by that.
* docs: note the per-page load improvement
* test(fig): keep the fig package suite off Core
Twenty package tests reached for Core's writer and editor through
@open-pencil/core, a package that depends on fig. Nothing declared that
edge, so the suite passed only because the workspace root hoists Core.
Their subject is the writer, so they move to tests/engine/io/fig, where
half the domain already spans both packages.
The package no longer escapes its own root: tsconfig drops the #tests/*
mapping, expectDefined is three lines beside the other helpers, and the
gold archive is read through the LFS-guarded fixture helper instead of a
hand-built ../../../../tests/fixtures URL.
#fig/ and #fig-tests/ join the steiger alias tables and the AGENTS.md
list, so the foreign-alias rule can see them. Fig's tests mirror its
source tree rather than sitting flat like kiwi's, so they address it by
alias instead of drilling, and the guid helper is imported one way.
* refactor(fig): drop code the reader replacement left behind
resolveDsdGeometry lost every production importer when the old derived
symbol data modules went, so it and the three tests that only exercised
it go too, and the folder collapses to one file. validateVariableAliases
was called only by its own test and wiring it in would mean a new public
diagnostic handler; it is removed rather than left dangling.
recordInstanceOverrideValue had no caller in either base or head, and
its comment began mid-sentence. SymbolOverrideFields had no consumers,
and savedTextEligibility is used only inside its module.
The clipboard's NON_VISUAL_TYPES was a hand-copied union of the two sets
behind isFigClipboardVisualType, which had no consumer of its own; the
classifier now serves both and leaves the root export.
FIG_PACKAGE_STATUS reads document-reader, and assertFigPackageReady is
gone: the package reads archives into a SceneGraph rather than telling
callers to use Core.
sceneNodeToKiwi takes its ten optional maps as an options object. That
removes the signature Core's wrapper had to restate, which was the last
clone blocking packages/fig/src from the duplication gate, and the
undefined holes at the clipboard's two call sites.
* refactor(core): share identity allocation between the two .fig writers
The clipboard allocated variable, mode and shared-style GUIDs its own
way while the document exporter did the same work in assignVariableGuids
and appendInternalResources. The two already disagreed: the exporter
reuses an id that is already GUID-shaped and dedupes against node source
GUIDs, the clipboard always minted a fresh sessionID 1. Both now call
one pair of helpers in variable-export.ts, so a change to how a document
names its resources reaches the clipboard too.
* refactor(fig): name the values that were spelled out in several places
exportSizing existed to name the HUG ternary but the inline layout
branch still wrote it out. The winding-rule conversions become
toKiwiWindingRule and fromKiwiWindingRule rather than the same ternary
three times and its inverse once. sameId duplicated sameGuid. The style
reference field list existed twice, and one site built a GUID string by
hand instead of calling guidToString. The opacity percent-to-unit factor
and the alias-or-expression test each have a name now.
fig.kiwi declares parameterConsumptionMap as a VariableDataMap and
PropRefValue as a variable value, but the codec typed neither, so four
call sites cast. Typing them in kiwi removes the casts, and the merge
that spread two maps now builds the only field the message has. Schema
coverage counts one more modeled field and one fewer raw-preserved.
* refactor(fig): require the index instead of rebuilding it behind a default
createScopedReader is private and always receives the shared state, and
the closure, component planning and property inheritance always get an
index from it; the optional parameters existed only so two tests could
omit them, and each hid a second full pass over every record. They are
required now, and the tests build an index the way production does.
materializeReader returned a fresh object that dropped definitionTypes,
so the first loadPage after createFigDocumentSession reseeded the cache
it was meant to reuse; it returns the state it was given.
The shared style reference shape is a named type built with the rest of
the export context rather than written inline twice and filled lazily
inside a getter, and the population client derives its two responses
from FigSessionResponse instead of restating one and casting to it.
* refactor(fig): give materializeInstance named options
Three of its seven parameters were defaulted maps that call sites passed
unnamed, so a call read as a list of empty collections. They become an
options object, matching how InterpretInstanceOptions is passed in the
same folder.
That change also caught a latent hazard: an empty array satisfies an
all-optional interface structurally, so a call site left on the old
positional form type-checked while silently dropping its source-child
map. Converting the remaining call sites fixed a component sync test
that had started failing for exactly that reason.
The DOCUMENT/VARIABLE guard is one assertion function rather than two
copies, and it narrows the node type for the creation that follows.
* refactor: group the prefixed siblings this PR left behind
instance-overrides kept layout-scale, text-scale, interpret-bindings and
variable-bindings as prefixed siblings while the same PR introduced
scene-graph/src/{scaling,variables}/. They become scale/{layout,text}
and bindings/{properties,variables}. The empty derived-symbol-data
folder is gone now that it holds one file.
STRING_BINDING_FIELDS and BOOLEAN_BINDING_FIELDS stayed in variables.ts
after NUMERIC_FIELDS moved to variables/fields.ts; all three live
together.
* docs(fig): describe the reader as it is, not as a replacement
The README, document-sessions, validation notes and several comments
still framed the work as pending: an old reader to delete, a migration
to finish, variables and lazy loading not yet integrated. All of that
landed. Error messages and a worker adapter that called themselves
"replacement reader" and "format-neutral" say what they are.
Comments that described the wrong function are reattached: the root
layer note belonged to resolveRoot rather than bindingHistory, the
expand note was duplicated onto bindRecord, the owner-scope note sat on
pairSourceChildren instead of linkInstanceSourceChildren, sync.ts put
its module summary on setSceneProp, and transfer/history.ts ended with
an orphan.
The visual oracle's interpret-instance and compare interpreted-document
are citty subcommands like the rest, its SCREAMING-CASE note folds into
packages/fig/docs/validation.md without the benchmark observation, and
its two tests mirror the source tree using the package alias.
* docs(fig): keep Figma observation records out of the fixture tree
Ten JSON records, twelve notes and a screenshot under tests/fixtures had
no code consumer: they are what Figma reported for a given document,
cited by packages/fig/docs. They move to packages/fig/docs/observations
beside the prose that reads them. The three JSON files tests do load,
and the eight screenshots the raster comparisons load, stay where the
tests expect them.
Fixture READMEs follow their fixtures: the gold layout and shared scale
notes to tests/engine/io/fig/instance, the export contract note to
tests/engine/io/fig/export. Numbers fused to the words before them are
separated throughout the notes.
Path failures assert the diagnostic reason through one helper rather
than matching 'found 0' or a full sentence, which is the pattern
materialize.test.ts already used.
* refactor(core): name the reader state module for what it owns
session/recovery.ts holds the per-graph reader state and, with it, page
population, diagnostics and export population as well as recovery. The
functions cannot move out without exporting that state map, so the file
takes an accurate name instead, and the state type follows.
io/formats/fig/index.ts keeps its aliased re-export: the relative path
is three levels up, which no-deep-parent-relative-imports rejects.
* chore: adopt the js-base64 rule master added
* test: move the new tests to the homes master's gate requires
#790 added check:test-homes: a new test under tests/engine is rejected,
and the baseline of existing ones shrinks. This branch had added 46.
Their owner is whichever package the test's subject lives in, not the
directory the old shard map implies. Forty test Core's writer, editor or
reader session and move to packages/core/tests, which gains the test
tsconfig and scripts the other packages already have; six test Fig alone
and move to packages/fig/tests. verifier-contracts covers the roundtrip
helpers that eight grandfathered engine tests share, so it stays beside
them and joins the baseline.
Package tests no longer reach outside their package for support: each
has local assert, guid, fixture and nested-binding helpers, and shared
archives under tests/fixtures are read through a helper path rather than
imported as modules across the root. interpretComponent,
materializeComponentClosure and the source-children helpers are public,
because tests outside Fig legitimately need them.
The steiger owner for #core/ and #fig/ is the package rather than its
src, since a package's own tests mirror the source tree and would
otherwise drill through ../../src.
* test: mirror each package's source tree in its test tree
The relocated tests kept their tests/engine directory names, which do
not match the packages they landed in: figma/api against src/figma-api,
render/canvas against src/canvas, io/fig against src/io/formats/fig, and
a fig tests/io and tests/text with no counterpart in that package. Each
now mirrors its source domain.
Two had no home in the package they were put in. The derived-text layout
invalidation test only exercises Scene Graph, so it moves there, and the
transfer plan test spans Scene Graph and Fig with neither owning it, so
it becomes the first tests/integration spec, which is what that
directory is for.
tests/AGENTS.md named a baseline path the tools reorganization moved,
and packages/fig/AGENTS.md now records its own test alias.
* fix(fig): open a file whose swap names a layer its component lost
Preline UI's `_header/navbar` keeps a swap addressing 4473:100430, a
node the archive no longer contains, while the replacement it names is
still there. Figma opens that file and so did the previous importer;
this reader refused it.
The rule was written for a swap whose replacement is missing, which
nothing can resolve, but the code threw for any unresolved swap. A path
that matches no record is a record Figma kept after deleting the layer
it named, which is the case the property and assignment diagnostics
already cover. A path that matches more than one record is a wrong
address rather than a stale one and still fails.
* fix(fig): address an override through the variant that holds its layer
An instance path names a layer by the identity it had in the variant the
override was written against. Switching variants keeps the override in
Figma, so a segment that names no layer of the variant an occurrence
expands now addresses the layer at the same position there, when the two
agree on type and name.
Resolution reports the path it took, so a claim recorded after a
translated segment stays addressable when the instance materializes.
Each component set's addressable layers are indexed once on first use
rather than rescanning every sibling variant per segment.
* fix(fig): read text bound to a string variable
Figma stores a bound layer's resolved characters, but an instance
override carries the binding alone, and a literal override of a bound
layer is retired rather than applied. Reading neither left the badge on
Preline's navbar showing its component's own text where Figma shows the
variable's value, and the input placeholder showing a literal override
Figma ignores.
Text joins font family as a bindable string field, the reader records a
TEXT_DATA alias like any other binding, and a post-pass resolves it once
hierarchy and modes exist, next to the paint bindings it mirrors.
Resolving after property claims is what makes a binding win over a
literal, the way Figma retires the override.
Validated by reopening an exported file in Figma: the collection, the
string variable, and the binding on both the component and its instance
survive the round trip.
* fix(fig): take a bound paint's transparency from its variable
A solid fill draws at its paint opacity, not its colour's alpha, so a
colour variable carrying transparency has to supply that opacity.
Resolving the binding into the colour alone left a translucent token
applied twice on Preline's navbar links, and left a Divider at the
opacity of an override the binding supersedes.
The variable now owns the whole colour: its alpha becomes the paint's
opacity and the colour keeps none of its own.
* test(tools): compare paint in the interpreted-document oracle
The oracle checked type, name, visibility, text, main component and box,
so every fill and stroke a reader produced went unchecked. A wrong fill
transparency on Preline's navbar passed it.
Paints are captured on both sides as the alpha drawing actually uses,
which is the paint's opacity for a solid, and reported as visible-paint
or hidden-paint like geometry. A Scene Graph stroke is always solid, so
it is encoded as one rather than through a type it does not carry.
* perf(fig): synchronise a component once per page load, not once per instance
Materializing an instance into an open document re-synchronised every
instance of its component, and synchronising walks each one's subtree.
A page that places a component many times therefore paid that walk once
per placement. Opening Preline's CMS page ran 954 synchronisations over
39225 instances for the 954 it placed.
Components are collected while the page is built and synchronised once
each afterwards: 31 calls over 1283 instances, and the page loads in
3.9s rather than 11.6s. The resulting graph is unchanged, by digest over
every node's geometry, text, paint, bindings and override keys for that
page and for a second page loaded on top of it.
* Revert "fix(fig): address an override through the variant that holds its layer"
This reverts commit fcdc7660f.
Figma does not carry an override onto the corresponding layer of another
variant, so translating a segment that way applies overrides it drops.
On Preline's Alerts frame the translation raises semantic differences
against live Figma from 2 to 54: 127 buttons read their own label where
Figma reads the component's. It fixed nothing visible — the five text
differences it was written for turned out to be string variable
bindings, fixed separately — so it only ever added wrong overrides.
* docs(fig): restore the guide rules the master merges dropped
Splitting the root guide into nested ones lost three rules this branch
had added, and left the fig guide claiming clipboard records are
converted to a SceneGraph in `@open-pencil/fig/clipboard`, which is now
`materializeFigFragment` driven from Core.
Records what the reader cannot do as well: a string binding resolves
once at read time, so text bound to a variable goes stale when the
variable or the node's mode changes, unlike a numeric or colour one.
Groups the four `*-bindings` siblings under `document/bindings/`, the
convention the branch already applied to `instance-overrides/bindings/`.
* perf(fig): copy archive records directly instead of structurally
Every expanded record is deep-copied so an occurrence shares no mutable
data with the archive, a contract two tests state. `structuredClone`
was a third of the time spent opening a page, and records are plain
Kiwi data, so copying them field by field is several times quicker —
43944 records of Preline UI clone identically either way, 218ms against
26ms. Byte buffers and anything else that is not an object literal keep
the structured algorithm.
Preline's CMS page now loads in 2.8s rather than 5.6s, and with the
per-component synchronisation fix in 0d1854a3a, 11.6s before either.
* test(tools): compare a reader's whole output, not one frame
`compare interpreted-document` checks one frame against live Figma. A
rule can leave that frame untouched and still change pages it does not
cover: addressing an override through a sibling variant reported no
difference on the frame under test while rewriting 127 button labels
elsewhere, and was reverted only after a whole-document comparison
found them.
`compare digest` captures every page a reader produces and diffs it
against an earlier capture, reusing the same node capture and
difference categories, so a before-and-after needs no Figma. Replaying
the reverted change against a baseline reports 110 semantic
differences. Unresolved-override counts are reported beside the nodes,
since a reader change usually moves those too.
* feat: preview streamed JSX on the canvas
Project incomplete JSX into isolated scene graphs and disposable pictures without mutating the document or adding intermediate undo entries. Share placement with final rendering and cover lifecycle and placement parity with AI SDK mocks and visual tests.
* test: require partial input for unfinished coordinates
Assert the complete partial object so rejecting the entire input cannot satisfy the truncated-exponent regression test. Addresses CodeRabbit's review finding on #692.
* feat(ai): keep a chat run on its page across page switches
Page switches go through the editor's preparation flow, and the chat panel treated every preparation as a document change: it dropped its Chat and reloaded history, detaching the panel from a reply still in progress. The panel now keeps the live chat unless the tab or the conversation changes.
AI tools also followed the page on screen, so a user browsing mid-run sent the next edits elsewhere, and the agent's own switch_page affected only one call. A run now pins the page where the message started; switch_page moves the run and the user's view, and streamed previews stay attached to the run's page, which the renderer draws only while that page is on screen.
Page snapshots now restore the page they were taken of, so undoing an AI edit works while another page is visible.
* refactor(core): share picture recording and export preparation with previews
Preview recording reimplemented three pieces Core already had: world-bounds picture recording (also duplicated by render chunks and the retained backing), font and layout preparation (prepareForExport), and page subgraph extraction. Extract recordWorldPicture and withWorldViewport for all three recorders, reuse prepareForExport, and add extractPageContext and findPageChildId next to the other subgraph helpers instead of editing a cloned graph's nodes.
prepareForExport also kept the shared layout text measurer overridden across an await, so a concurrent layout could measure with the export renderer. withTextMeasurer scopes the override to the synchronous layout.
* fix(design-jsx): inline nested fragments in streamed previews
The streaming projection kept a nested fragment as an empty-type node, which rendered trees inline, so a preview of <Frame><>…</></Frame> failed with 'Unknown element: <>'.
* refactor(ai): schedule previews and gate test streams with VueUse
The preview controller hand-rolled a trailing timer and abort-listener cleanup, and the test stream gate a promise resolver and listener set. Use useDebounceFn with maxWait (a lone delta still flushes, unlike useThrottleFn with leading off), useEventListener, and until(). Share the mock token usage between chat tests.
* fix(ai): keep previews alive through document edits and slow builds
Document edits finished every preview call, and onInputStart never restarts one, so a render call committing while a second was still streaming ended the second call's preview for good. Edits now invalidate: drop the shown artifact and rebuild on the new document.
A build that finished after another delta arrived was discarded, so a steady stream that outpaced staging and recording never showed a preview. Show it, then render the newer revision.
* docs(changelog): separate the Fixed heading from its entries
Add the blank line markdownlint (MD022) expects after the heading, and drop the one that split the Fixed list in two.
* 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.
Tools live under tools/<role>/<domain> (checks, generate, release, ci, dev), every tool is a workspace named @open-pencil/<domain>-tools, a shared tools/tsconfig.json backs the new check:tools gate that fixed 55 latent type errors, test:tools runs through bun --filter, the placement check is its own checks/test-homes package, and every tool resolves the repository through resolveWorkspaceRoot. Bun, Node, and mdast types live in a tools-root workspace so they never reach the app program.
Scene Graph unit tests move to packages/scene-graph/tests with a package-local assert helper and test type-checking; five Core- and fig-owned tests move to their owners' engine homes. check:test-homes rejects any new test under tests/engine against a reviewed baseline so the migration debt only shrinks.
* 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.
* 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.
* test(native): cover the desktop MCP server lifecycle
`tests/e2e/native/**` had no MCP coverage, so nothing verified that the
real Tauri shell finds and spawns a server. Browser E2E cannot: the spawn
path short-circuits to the Vite-managed server in development.
Add a native spec that asserts what the app actually resolved: a missing
install reports its own reason, a resolvable binary either becomes healthy
with a discovery file and answering port or is refused with a version
explanation, and hiding the resolved binary then reports the missing
install without relaunching.
Also generate the desktop icons in `build:native-test`, since they are
ignored and the build failed on a clean checkout, and let the snapping
spec select its own section instead of assuming the dialog state.
* test(native): resolve the home directory portably
process.env.HOME is unset on Windows, which put the discovery override
outside the home directory. The app accepts a server-reported discovery
path only inside it, so it fell back to its own path and could not read
the test server's token.
* fix(app): protect unsaved documents when closing
Mark tabs with unsaved content updates and ask whether to save before
closing them. The prompt now covers tab closes, the desktop window close
button, and the application Quit action, which previously discarded work
when autosave had no writable target.
Track a content revision separately from scene and recovery versions so a
save only clears the indicator when it wrote the revision it captured.
Cancelled pickers, failed writes, and edits made during a save keep the
document open. Desktop uses the platform alert; the browser keeps the
styled dialog.
The desktop menu replaces the predefined Quit item so the accelerator and
Dock-independent quit path request confirmation instead of exiting.
* fix(ai): resolve credentials only when used
Opening a document, creating a chat, or browsing chat history connected
the provider and read saved secrets, which triggered system credential
prompts without user intent.
Startup now reads credential status only, migration runs inside the first
explicit resolution, and the chat panel initializes local history without
creating a transport. Stock-photo keys resolve per search instead of at
settings refresh, and credentials still marked legacy count as configured
so upgrading does not appear to lose them.
* refactor(ai): export diagnostics from Settings only
Chat kept its own debug log, copied mixed app-wide usage into a
conversation export, and reported a missing cache rate as zero. Remove
that surface and record AI requests, model steps, and tool activity as
correlated diagnostic events instead.
Settings remains the single export location, usage summaries can now
distinguish unreported telemetry from zero, and transcript or tool
payloads are no longer part of the export.
* fix(ai): clear legacy credentials for real
Clearing a Pexels, Unsplash, or provider key only removed the current
store entry. A value that still lived in legacy storage kept the key
configured, so a later search migrated and used the credential the user
had just removed.
Migrate before mutating so clearing also removes the legacy value, and
share one in-flight migration so the media and provider paths cannot
migrate the same plaintext twice.
* fix(ai): scope credential migration per source
Sharing one migration promise process-wide let a second storage return
the first migration's result, leaving its own legacy keys unmigrated
while reporting success. Track in-flight migrations per storage and
serialize them, because every migration writes to the same store and
concurrent runs could overwrite each other.
* fix(app): destroy the window after a confirmed close
Tauri's onCloseRequested helper destroys the window itself when a handler
returns without preventing the event. Approving a close therefore invoked
plugin:window|destroy, which the capability set did not grant, so the
window stayed open with a permission error after saving.
Always intercept the request and destroy the window explicitly once the
choice is confirmed, and grant core🪟allow-destroy in place of the
now-unused close permission.
* fix(app): show a filled dot for unsaved tabs
The unsaved indicator used a stroked Lucide circle whose fill attribute
kept it an empty outline, reading as a disabled control. Draw the
indicator as a filled accent dot matching the status dots used elsewhere
in the app.
* refactor(app): focus the unsaved prompt with VueUse
Replace the manual watcher, nextTick, and component $el focus with
useFocus, which focuses the Save button when the dialog mounts. Assert the
focus in the close-protection test so the Return-saves behavior stays
covered.
* refactor(app): route Quit through the shared menu channel
The Quit item emitted a bespoke app:request-exit event and the close
module listened for it, while every other native item travels as a
menu-event id dispatched by the shell and editor menu composables.
Emit menu-event "quit" for both the Quit item and the platform exit
request, handle it in useShellMenu beside check-updates, and share one
confirmAppExit so window closes and app exits agree on a single approval.
* refactor(app): generate the macOS app menu entries
The application menu hardcoded its labels and the Quit accelerator in
Rust while every other menu entry is generated from APP_MENU_SCHEMA.
Move the custom app entries (About, Check for Updates, Quit) into
APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id
so the native builder cannot silently drop a label.
Placement stays in Rust because the OS-predefined items sit between them,
and the menu title now comes from the packaged product name.
* build(tauri-menu): check generated menus against the schema
The generated menu files are committed but nothing verified them, so a
schema edit could silently leave desktop/generated stale until the next
release build regenerated it.
Split the renderers from the write step, register the tool as a workspace
so its dependencies resolve, and compare the committed files with the
schema in a test that runs with the other tool checks.
* fix(app): serialize exit confirmations
The window close handler and the Quit item both call confirmAppExit, and
the per-handler closing flag does not cover the two paths. Both could run
close preparation, so an unsaved document could be prompted twice.
Share one in-flight confirmation and clear it when it settles, so a
cancelled or failed attempt still prompts again on the next request.
* feat: refresh branding with generated platform icons
Keep the approved mark and optical micro master as the source of truth. Generate web, documentation, and native assets at their owning build boundaries instead of storing raster variants or linking web icons to desktop output.
* fix: refine small brand marks and loading artwork
* feat: adopt blue editing-handle brand mark
* feat: refine teal branding for light and dark themes
* feat: apply ivory tiles across brand surfaces
Use edge-to-edge web tiles with a larger mark and optical micro favicons. Preserve native spacing and platform-owned maskable/touch cropping. Address review feedback on story props, native dimensions, and brand test type checking.
* test: allow cold npm startup in packaging fixture
CI hit Bun's five-second default while running npm pack --dry-run. Give this integration test a scoped 30-second budget without changing production timeouts or other tests.
Welcome AI-assisted contributions while reserving co-author trailers for human credit. Reuse commitlint and Git trailer parsing to reject known assistant identities in new commits without altering existing history or legitimate credits.
Remove the disabled widening audit and its resolver. Fix Bun aliases and bounded reducer slices, reject parser failures in shared fixtures, and retain upstream attribution in NOTICE.
Share canonical tool inputs with AI and browser adapters through Valibot and Standard Schema while retaining MCP numeric coercion and existing transports.
BREAKING CHANGE: programmatic integrations use SDK v2 server/client types; paramToZod is removed in favor of the shared Core tool input contract.
Prevent module registry mocking, repeated reducer accumulator copies, and local widen-then-assert flows. Keep known-value widening available for audits without enabling its noisy global policy, and record upstream provenance for the adapted rules.
* fix(packages): make packed exports runtime-safe
Make checked-in package manifests truthful for ordinary npm and Bun packing, and verify installed artifacts under both runtimes. Centralize package discovery, artifact inspection, and bounded process execution so CI and release publication share the same contracts.
* ci: build package dependencies before checks
Keep workspace jobs independent of ignored dist output now that public package exports consistently resolve built artifacts.
* ci: preserve source-first engine tests
Keep the broader dependency build for package and repository validation, but retain Core-only setup for engine shards so workspace tests continue exercising source modules.
* ci: build engine shard dependencies
Build the seven workspace packages imported by engine tests in dependency order. This preserves a single module instance per package and keeps each clean CI shard independent of ignored dist output.
* ci: restore established package build boundaries
Keep the original repository and engine job setup, and add installed artifact verification only after the existing package build. Avoid changing which module copies unrelated tests execute.
* fix(packages): preserve Bun source identity in tarballs
Retain source-first workspace resolution and ship complete source trees for Bun conditions. Verify clean installed consumers without overlaying archives, reuse consumer validation before release publication, and repair Bun 1.3.10 private source alias resolution.
* refactor(tooling): reuse package and process utilities
Use pkg-types for manifest I/O and types, tinyexec for subprocess lifecycle, and npm's pack listing for release staging. Preserve archive verification and project release invariants rather than reimplementing package-manager file selection.
* refactor(tooling): resolve workspace roots at CLI boundaries
Discover and validate the nearest workspace once, support explicit roots, and pass roots to reusable checks. Replace subprocess entrypoint dispatch with direct calls and preserve aggregate package diagnostics without adding arbitrary test timeout increases.
* fix(release): enforce publication boundaries
Use root version alignment and shared validated npm output parsing. Enforce the public npm registry policy and test verification-before-publication, mismatched artifacts, and partial retries without registry writes.
* refactor(tooling): validate package responses with Valibot
Express npm pack and manifest identity contracts as schemas, infer parsed output types, and preserve contextual failures and relative-path safety. Document Valibot as the first-party validation convention while retaining Zod at SDK boundaries.
* refactor(tooling): validate manifests at input boundaries
Share Valibot schemas for consumed manifest fields, recursive exports, supported workspace declarations, and npm registry responses. Infer domain types and reject malformed metadata instead of silently skipping it downstream.
* refactor(tooling): group package helpers by ownership
Colocate manifest and workspace contracts, separate npm response parsing from generic JSON handling, and split smoke packing, installation, and runtime checks. Remove the release tarball forwarding shim and consolidate its coverage in package-artifacts. Preserve public tooling exports and CLI commands.
- Keep one canonical Playwright project for browser tests\n- Exclude @real-llm from the default command and select it explicitly when requested\n- Remove the redundant config and project
- Replace the shared serial page with isolated configured-chat fixtures\n- Split composer, attachment, Markdown, profile, navigation, and response behavior\n- Use Playwright's structured @real-llm tag with a credential-gated config\n- Keep mock stream construction and page operations in focused helpers
- Keep deterministic chat tests permanently bound to the mock transport\n- Add an explicit @real-llm Playwright project and package script\n- Skip the paid OpenRouter smoke test clearly when its credential is absent
- Replace the fork-era Markdown integration with Comark and an explicit Shiki extension\n- Centralize OpenPencil theming, parser lifecycle, controls, and URL hardening\n- Remove Mermaid stubs and bundled math or diagram dependencies\n- Address contextual composer review findings and extend browser coverage\n\nCo-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
- Add a typed, modular custom Oxlint rule package with direct regression coverage
- Replace complex conditional object spreads with explicit construction across the repository
- Preserve all existing custom rule registrations and diagnostic behavior
* perf(canvas): add traced navigation benchmarks
- Record and replay timestamped pan and zoom gestures through DOM and CDP input paths\n- Correlate input, viewport, render, long-task, and retained-backing events in Chromium traces\n- Report frame pacing, latency, jump, anchor drift, and crisp-settlement metrics
* perf(canvas): stabilize navigation comparisons
- Separate low-overhead metric runs from optional CPU-profile traces\n- Warm scenarios before recording and use a consistent SwiftShader browser configuration\n- Add a canonical momentum-pan reversal gesture alongside pinch reversal
* fix(canvas): require hardware GPU navigation benchmarks
- Run macOS performance captures through Metal-backed ANGLE and reject accidental SwiftShader fallback\n- Record the GL renderer and reserve software GPU mode for portable correctness smoke runs
* perf(canvas): cache shadow rasters for crisp backing
- Rasterize local drop and inner shadows only while constructing retained scene backing\n- Bound native image memory and invalidate cached entries with node and renderer lifecycle changes\n- Quantize zoom-aware raster resolution and reuse nearby scales without lowering normal scene quality
* test(canvas): verify retained shadow raster fidelity
- Compare settled retained-backing shadow output with direct CanvasKit rendering\n- Keep backdrop blur on the picture fallback and exercise graph-driven cache invalidation\n- Cover updates, deletion, and reparenting through actual SceneGraph events
* perf(canvas): benchmark real FIG fixtures
- Serve exact local fixture bytes through an isolated Playwright route for production preview runs\n- Wait for document loading and page population before zooming to fit and recording navigation\n- Record the resolved fixture path in benchmark environment artifacts
* fix(canvas): preserve nested effect subtree pictures
- Keep deeply nested shadow documents on one retained subtree picture instead of exploding them into per-node image draws\n- Restrict shadow raster acceleration to effect-bearing page children\n- Cover nested shadow fallback and restore gold-preview FIG pinch performance to master levels
* refactor(canvas): share recorded wheel sample type
* perf(canvas): defer backing settlement across zoom reversals
- Track explicit navigation phases and gesture generations instead of inferring idle from viewport timing\n- Cancel or defer retained backing construction while pan, zoom, momentum, or tentative settlement is active\n- Add a repeated short-pause pinch reversal fixture based on the user trace
* perf(canvas): index bounded render chunks
- Split oversized painter subtrees into self-paint and bounded descendant chunks without dropping container visuals\n- Bulk-load chunk visual bounds into RBush for selective world-space queries\n- Cover bounded updates and gold-preview build/query complexity before tile rendering consumes the index
* refactor(canvas): namespace render chunk coverage
* perf(canvas): model chunk paint context
- Preserve ancestor transform and clip dependencies for independently renderable chunks\n- Keep opacity, blend, blur, and mask isolation subtrees atomic until command-level splitting exists\n- Report oversized atomic chunks and lock gold-preview to bounded painter units
* perf(canvas): record pixel-correct render chunks
- Record interruptible chunks in world coordinates with ancestor transforms, clips, and chunk-local culling bounds\n- Draw opacity, blend, blur, and mask isolation chunks directly into destination surfaces in painter order\n- Compare composited chunk output with direct CanvasKit rendering instead of relaxing visual thresholds
* perf(canvas): render selective world tiles
- Map world regions to fixed 256-device-pixel tile targets and quantized sharpness levels\n- Query only intersecting render chunks and preserve atomic destination compositing\n- Match multi-tile CanvasKit output against direct rendering and measure gold-preview tile cost
* perf(canvas): cache chunk pictures across tiles
- Reuse world-space chunk command pictures for every intersecting tile\n- Pool 256-pixel tile surfaces and expose allocation, draw, flush, and snapshot timings\n- Keep expensive atomic foreground blur visible as an over-budget scheduler constraint
* perf(canvas): schedule cached tile rendering
- Bound tile images with an LRU cache and reuse pooled CanvasKit surfaces\n- Plan mandatory holes, stale visible refreshes, and overscan by navigation and content generation\n- Stop jobs at a strict deadline while reporting fallbacks, stale work, overruns, and over-budget effects
* perf(canvas): integrate progressive tiled rendering
- Keep retained scene output as the interaction fallback while exact tiles refine only after navigation becomes idle
- Centralize runtime URL flags and pass renderer selection through the typed Vue canvas API
- Replace benchmark sleeps with explicit mode-aware renderer settlement and report exact tiled coverage
- Preserve bounded scheduler metrics, generation cancellation, native resource cleanup, and shared visual-bounds logic
* refactor(app): centralize runtime query configuration
- Parse collaboration, recent-files, benchmark, presentation, and renderer flags in one typed app module
- Remove ad hoc URL parsing from workspace and collaboration runtime consumers
- Cover supported values and production-safe defaults without adding a repository lint rule
* fix(canvas): replace fallback pixels with exact tiles
- Render opaque page-background tile cells and install them with source replacement instead of double-compositing translucent scene content
- Exercise the live progressive controller against direct rendering across masks, effects, blend isolation, images, fallback text, transforms, and clipping
- Preserve the bounded reversal path with zero Long Tasks and exact settlement near 128 ms on gold-preview.fig
* perf(canvas): invalidate tiled content selectively
- Index chunk dependencies across contained nodes and transform or clipping ancestors
- Re-record affected chunk pictures and invalidate tiles intersecting old or new visual bounds
- Advance unaffected cached tiles to the new scene generation instead of rebuilding the full chunk index and tile cache
- Keep structural graph mutations on the safe full-rebuild path and cover selective refresh end to end
* perf(canvas): bound atomic blur tile refresh
- Render atomic blur chunks with tile-local isolation bounds and blur halos instead of replaying full-subtree layers
- Keep content refresh behind the retained fallback, cap GPU submissions to four tile jobs per frame, and adapt estimates from measured work
- Preserve large-radius CPU over-budget visibility while preventing Metal-backed refresh bursts and deferred GPU overload
- Add deterministic node-mutation benchmarks and summarize scheduler throughput, job duration, overruns, and exact content settlement
* perf(canvas): cancel obsolete tile refresh generations
- Count and report queued jobs removed by content or navigation generation changes
- Add deterministic mutation-then-reversal benchmark support without sleeps
- Assert exact tile work remains suspended during navigation and resumes for the final viewport
- Summarize cancellation alongside scheduler throughput, overruns, and settlement metrics
* test(canvas): cover live tiled blur settlement
- Load gold-preview.fig through the real tiled canvas surface and wait on explicit renderer settlement
- Commit the settled radius-210 large-blur browser snapshot
- Replay the canonical zoom reversal during refresh and require byte-identical final canvas convergence
* fix(canvas): harden renderer resource lifecycle
- Release tiled surfaces, images, pictures, and queued work across surface, font, graph, page, structure, and renderer lifecycle boundaries
- Restore pooled canvas, viewport, and backing state through exception-safe native recording and raster paths
- Rebuild tiled chunk topology only when isolation requirements actually change, preserving selective blur mutation performance
- Document deterministic active-renderer settlement and add lifecycle, graph replacement, cache failure, and surface replacement regressions
* test(canvas): remove source-matching renderer claims
- Delete the autopsy suite that inferred runtime correctness from source text, regexes, line placement, and symbol counts
- Keep renderer ordering, cache cleanup, effect behavior, and pixel fidelity covered by executable behavioral and lifecycle tests
* perf(canvas): present retained backing during tiled navigation
- Profile production reversal traces and attribute tiled p95 cost to GPU command-buffer flushes from full-scene fallback replay and tile presentation
- Use the retained backing as the moving fallback while tile scheduling and cached lookup remain allocation-free
- Defer tile image presentation until idle and expose visible versus presented tile counts in navigation telemetry
- Reduce tiled reversal render p95 from about 8ms to 0.3ms while preserving exact idle replacement and visual parity
* perf(canvas): prioritize visible tile settlement
- Profile per-tile allocation, draw, flush, snapshot, and chunk costs through scheduler telemetry
- Defer overscan until all visible exact tiles are covered
- Replace the four-job idle cap with a higher safety ceiling while the measured five-millisecond deadline controls cheap work
- Reduce mutation-plus-reversal exact settlement from about 272ms to 160ms without Long Tasks, overruns, or over-budget jobs
* refactor(canvas): clarify renderer lifecycle boundaries
- Extract retained backing state types and navigation preview timing\n- Isolate tiled scheduler telemetry from frame orchestration\n- Document settlement and CanvasKit ownership invariants\n- Preserve hot drawing loops, budgets, cache limits, and rendering decisions
* fix(canvas): preserve current label rendering
Retain the merged paragraph-label cache lifecycle and substituted-font readiness while reconstructing the renderer stack on current master.
* test(canvas): keep tile benchmark assertions deterministic
Keep performance timing in benchmark telemetry while asserting structural tile selectivity and cache behavior in CI.
* feat(canvas): expose experimental tiled rendering
- Persist retained or tiled canvas mode in General settings\n- Keep retained rendering as the default and apply changes after reload\n- Preserve URL overrides for deterministic benchmarks and support reproduction
* refactor(app): centralize renderer preference state
Expose renderer override provenance from runtime configuration and keep the settings control's derived state separate from its explicit persistence action.
* refactor(app): share settings layout anatomy
Reuse slot-based section headers and bordered groups while keeping each settings control row explicit.
* fix(canvas): harden tiled renderer boundaries
- Bound low-zoom tile planning and handle failed tile surface allocation\n- Preserve effect raster dependencies, runtime-safe clocks, and navigation timing contracts\n- Keep benchmarks deterministic, backward compatible, and accurately localized
* fix(canvas): invalidate dependent node pictures
Track first-child shadow dependencies for retained node pictures so child geometry updates cannot leave stale parent shadows.