Commit graph

143 commits

Author SHA1 Message Date
Danila Poyarkov 6e68366bc6
fix(recovery): store snapshots of any size (#966)
* fix(recovery): snapshot a document only once it has unsaved edits

Opening a document lays it out, which bumps the scene version, so crash
recovery wrote a full .fig snapshot of every document opened from a file
without a writable source, edited or not. For the 401 MiB file from #924
that copied the whole archive a few seconds after opening and killed the
tab. Recovery now asks the document's change tracking, which ignores
layout, whether anything is unsaved.

* fix(recovery): store snapshots as Blobs so large documents fit

Chromium refuses a single IndexedDB value over 127 MiB, so a snapshot of
any larger document failed and recovery fell back to memory for the
rest of the session, holding another copy of the document. A Blob is
kept as a file at any size: a 421 MB snapshot now writes in IndexedDB.
Writes also no longer copy the archive first, and snapshots stored as
byte arrays by earlier versions still read back.

* test(recovery): drop the scene version from the large-buffer snapshot
2026-10-07 21:30:07 +00:00
Danila Poyarkov cb075b3e1a
perf: speed up opening large pages (#953)
* perf(canvas): find guide owners from an index instead of walking the page

Drawing and hit-testing guides visited every layer of the page on each frame to find the few that carry guides; on a large page that took about 1.2s of a load's rendering. The graph now keeps the set of layers with guides current from its node events, and both read it.

* perf(layout): write only changed geometry and request one render per layout pass

Layout wrote every laid-out layer's geometry whether or not it changed, so each pass over a large page notified every graph listener for every layer; a page load spent about 1s handling those updates. Layout now writes only the fields that changed, and the updates a layout pass makes ask for a single render, queued for before the next frame, instead of bumping the editor's reactive versions once per layer.

* test(canvas): drop the graph double the guide tests no longer use

* perf(fig): copy an opened file once on the main thread instead of three times

Opening a .fig in the browser copied the file for a main-thread fallback the caller's buffer already served, then copied it twice more for the worker, which parses from one and keeps the other as the original archive. A 109MB file briefly took over 400MB on the main thread. The fallback now reuses the caller's buffer, and the worker receives one copy and makes its archive copy itself.

* docs(changelog): note faster large-page loads

* fix(app): run recovery and autosave after edits, not redraws

Recovery and autosave watched sceneVersion, a render counter that every
requestRender bumps: opening a file, layout passes, fonts, and each layer a
page loads. Safari has no File System Access API, so its opened files have
no writable source and always get recovery snapshots. Loading a page also
invalidated the original archive, so a snapshot encoded the whole document
again; on the Preline UI kit's CMS page that ran about 100 s on the main
thread in WebKit before failing.

Recovery, autosave, and saved versions now follow the content revision of
createDocumentChanges. A page's layers loading from the opened file, which
runs inside applyImportedStateDuring on both the worker and main-thread
paths, no longer counts as an edit for that revision or for the original
archive, and asks for one render rather than one per layer.

In WebKit the CMS page switch drops from 105-133 s to 21 s, and its longest
main-thread stall from 90-117 s to 1.4 s.

* refactor(app): stop storing a version with recovery snapshots

Adopting a snapshot now protects the document's current content revision,
so nothing reads the version a snapshot was taken at. Snapshots saved by
earlier versions carry a sceneVersion field instead; dropping the field
keeps the metadata type true for both. Tests tell snapshots apart by the
bytes they were built with.
2026-10-07 20:57:57 +00:00
Danila Poyarkov 759f9eb611
feat: show controls in a prebuilt demo and fix what saving it to .fig lost (#938)
* feat: show controls in the demo and tidy the component panels

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: check a component set border stays one pixel wide when zoomed
2026-10-07 18:37:05 +00:00
Victor Wads 68ffd72839
feat(mcp): let MCP clients read only the selection, with a compact get_selection (#732)
* feat(MCP): follow agent activity in canvas

* fix(fig): preserve imported design fidelity

Keep component overrides, variable-backed icon colors, page backgrounds, and fixed text sizing intact across lazy FIG materialization.

* feat: add selection-context MCP tools and mode

* chore: scope work branch to MCP selection and canvas follow

* fix: honor MCP-only tool contracts in CI

* refactor(mcp): drop the follow and selection-context tools this branch carried

Following agents landed in #725, through the agents registry and the
chat's follow toggle, so this branch's MCP follow setting and its
follow-agent module are superseded. The see_user_selection and
get_user_selection_details tools duplicated get_selection, get_node,
describe, get_page_tree, and export_image; the selection-only workflow
they served is rebuilt on those tools in the following commits.

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

* feat(mcp): make get_selection the compact entry point with a depth

get_selection returned every selected layer's whole subtree, which is
too much as the first call when the user points at a large frame. It now
returns the selection with direct children by default, counts deeper
children as childCount, and takes a depth.

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

* feat(mcp): share only the selection with MCP clients

A selection scope, set with Share only the selection in the local
server settings or OPENPENCIL_MCP_SCOPE=selection, limits MCP clients
to get_selection, get_node, get_page_tree, describe, and export_image
on the selected layers and what they hold.

The server enforces the scope on everything it sends to the app: MCP
sessions and /rpc, which stdio clients also go through, carry only
those tool calls and the session-closed notice, each stamped with the
scope, so a client cannot reach other tools or the settings that would
widen it. The app's bridge rejects node IDs outside the selection,
points describe and export_image at the selection when they name no
nodes, and asks get_page_tree for a root inside it. A stdio client can
ask for the scope itself while the server shares the whole document.

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

* fix(mcp): keep selection-scoped clients from writing files or listing wider tools

export_image writes its result to a file when given a path and an MCP
root is set, which reaches past reading the selection. A path is now
refused in selection scope, by the tool registration before the call
and by the app's bridge, so a client with a stale scope cannot write
either; the image itself is still returned.

A stdio client follows the narrower of its own scope and the scope the
server records, instead of letting OPENPENCIL_MCP_SCOPE=document list
tools a selection-scoped server rejects.

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

* test(mcp): name the selection scope's tools instead of reading the allowlist

The server test compared the listed tools with SELECTION_SCOPE_TOOLS,
the same list that decides registration, so a tool added to it by
mistake would still pass. It now names the five tools the scope offers.

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

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-07 13:01:28 +00:00
Danila Poyarkov 85d8c69ccc
fix(app): keep opened documents saved until they are edited (#945)
* fix(app): keep opened documents saved until they are edited

Opening a .fig file lays out its first page, and every node:updated counted as a change, so the recomputed auto-layout sizes and positions marked the document unsaved right after it was marked saved. Updates made while the graph applies layout no longer count: layout only derives geometry, and an edit that relays out a page has already counted. A unit test runs a real layout pass, and an E2E test opens an auto-layout file and closes it without a save prompt.

* test(app): reach the fixture through testPath
2026-10-07 12:50:03 +00:00
Victor Wads 690c1247e4
feat(collab): show MCP agents, follow streamed JSX, and follow your agents as they work (#725)
* feat(MCP): follow agent activity in canvas

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-10-07 10:04:58 +00:00
Danila Poyarkov 2f79278df1
refactor: read stored settings and external responses with Valibot (#937)
* refactor(settings): read stored preferences with a Valibot schema

Each field falls back to its default on its own, so one bad value keeps the rest, as before. Tests pin the behavior; they pass against the previous implementation too.

* refactor(mcp): read stored MCP connections with a Valibot schema

A connection schema checks the ID, trimmed name, transport, and URL rule; a bearer token is kept only for the connection's own credential. A new test covers the credential check and normalization, and passes against the previous implementation.

* refactor(clipboard): read Figma's image batch response with a Valibot schema

* refactor(mcp): read tool descriptors with a Valibot schema

The descriptor type now follows the schema, and tests cover what a descriptor must contain.

* refactor(ai): read stored model settings with Valibot schemas

Connections and model profiles are schemas with their defaults and bounds; the thinking-level migration and the checks between connections, models, and role assignments stay as code. Characterization tests written against the previous implementation pin the behavior.
2026-10-07 09:28:42 +00:00
Danila Poyarkov 8bf71bd92f
feat(ai): add guided AI setup for providers, coding agents, and Pi (#916)
* feat(storybook): prototype guided AI setup and task assignments

* refactor(storybook): adopt shared control foundations

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

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

* feat(ui): add a shared radio group

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

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

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

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

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

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

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

* refactor(ai): share model provider display names

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The architecture lint forbids createRequire in ESM build code.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: tighten the guided setup and Pi changelog entries

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(app): keep one app version constant

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

---------

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>
2026-10-07 08:46:57 +00:00
Danila Poyarkov 29e0d3acf2
refactor(vue)!: remove the variables table composables (#908)
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.
2026-10-06 12:35:41 +00:00
Danila Poyarkov aa86873dd7
test: typecheck the test suites and fix what that found (#896)
* build: typecheck the test suites

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

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

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

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

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

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

* docs: record what typechecking the tests established

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

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

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

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

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

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

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

* chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
Danila Poyarkov e2a3aa3f80
fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* fix: validate parsed JSON at untrusted boundaries with Valibot

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

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

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

* refactor: validate parsed JSON in tests and tooling

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

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

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

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

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

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

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

* test: compare the malformed models.dev fallback with the curated list
2026-10-04 17:01:50 +00:00
Danila Poyarkov daef57d52d
build: update dependencies (#873)
* build: update dependencies

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

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

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

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

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

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

* build: hold vue-tsc at 3.3.11

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

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

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

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

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

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

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

* feat(ai): recommend the latest models

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

* build: align the fig package's valibot with the workspace
2026-10-04 12:48:24 +00:00
Danila Poyarkov f6848434ec
feat: check designs live with a Lint panel, canvas markers, and fixes (#804)
* feat: check designs live with a Check panel and canvas issue markers

Design lint only ran from the CLI and AI tools, and its rules were too noisy
to show continuously: on a real imported page 786 of 888 layers had a
warning. The rules now report where a finding is actionable (a hardcoded
color only when a variable matches it, nesting only where the limit is
crossed, instance sublayers through their main component) and carry
structured data, and Recommended keeps warnings for likely problems.

The app checks the current page after edits settle. The Check tab groups
issues by rule with hover highlighting, reveal on click, and one-step
variable binding. Errors and warnings are marked on the canvas with
clustered markers that roll up to visible ancestors when zoomed out; markers
explain themselves on hover, open Check on click, and toggle with
View > Design issues.

* fix: keep the right panel and markers stable

The Check tab made the right-panel tab row overflow at common window widths,
so focusing the zoom menu scrolled the row and shifted the panel. Code and
AI tabs now drop their labels to screen readers when the row is narrow.

Touch target names are matched as whole words: "Rectangle" contained "cta"
and marked every rectangle. Markers also stay drawn during interactive edits
instead of blinking while a value is scrubbed.

* fix(ui): show right panel tab labels whenever they fit

* fix(ui): name the design check tab Lint and keep panel tabs consistent

The tab was an unlabelled icon between labelled Code and AI tabs. It is now
Lint, with the same icon and label anatomy as its neighbours, and its icon
takes the severity color instead of a count badge. All labelled tabs show
their labels when the row fits and drop them together when it does not.

* refactor(ui): build the Lint panel from shared components

Issue groups use AppCollapsible, actions use AppButton, and the severity
filters are a Reka toggle group with keyboard navigation. Issue rows no
longer nest a button inside a button. Panel state, visibility and the
focused-issue scroll live in useDesignCheckPanel, the rules menu is its own
component, and rule preferences change through preference actions.
Severity ordering reuses Core's ranking, detail numbers follow the app
language, and the check debounce uses useTimeoutFn.

* fix(lint): check the WCAG AA touch target size in the Recommended preset

Recommended flagged a 394 × 39 input because it required the 44 × 44 AAA size. It now checks the 24 × 24 AA minimum through a minSize option; Strict and Accessibility keep 44 × 44.

* feat(lint): fix design issues from rules, the Lint panel, the CLI, and agents

Rules attach fixes as data: a safe fix keeps the design as it looks (bind a
color to the variable it matches, round subpixel geometry that layout does
not own), a suggestion changes values (snap radius and spacing to the
scale, raise small text to the minimum). One Core applier re-validates
each fix against the current graph and merges changes per layer.

The Lint panel offers a fix per row and Fix all for safe fixes as one undo
step; openpencil lint --fix writes the fixed document; the lint and
lint_fix tools expose the same to MCP and AI chat.

The design-check spec's Close button is now 24 x 20: at 24 x 24 it passes
the WCAG AA touch target size that Recommended checks.

* feat(lint): pin issues outside the view to the canvas edge

Errors and warnings on layers outside the viewport had no marker, so a
check could report issues nobody could see. They are now pinned to the
canvas edge where a ray from the viewport center toward them leaves it,
with a chevron pointing their way; pins in one direction merge like
markers. Hovering lists them under the direction they lie in, and
clicking reveals and opens the most severe, nearest one.

Pins keep clear of UI floating over the canvas: the toolbar marks itself
with data-canvas-obstacle, and canvases report such rectangles to the
renderer through getOverlayObstacles each frame.

* feat(lint): mark layers with design issues in the Layers panel

Like an IDE marks files with problems and the folders holding them, a
layer with errors or warnings shows the most severe as an icon, and a
collapsed layer with issues inside it shows a dot in that color.
Suggestions stay in the Lint panel, as on the canvas, and the marks
follow the View → Design issues toggle.

* feat(lint): show issues per page and across the document

Loaded pages beyond the current one are now checked in the background,
one page at a time while the editor is idle, and checked again only when
an edit touches them; pages a large .fig file has not loaded are left
alone until opened rather than forced in. The page list shows each page's
errors and warnings like an IDE's problem count, and the Lint panel gains
a Document scope that lists every page's issues, tags the ones on other
pages, and switches to a row's page when it is opened.

* test(lint): use the core-tests alias and no comma operator in lint tests

Master now rejects ../../ imports and the comma operator in tests.

* refactor(app): create the Lint session with the editor store modules

The composition root passed its line budget once master added recent
pages; the Lint session belongs with the other per-editor services that
the modules factory creates and disposes.

* docs(changelog): keep master's latest Unreleased entries
2026-10-04 10:08:39 +00:00
Danila Poyarkov 249f0cca87
feat(ai): render tool calls as summarized, highlighted cards (#811)
* feat(ai): render tool calls as summarized, highlighted cards

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CodeMirror scrolls its own .cm-scroller, which fell back to the platform scrollbar, thick and light in the dark chat. The hosts now give it the shared scrollbar-thin utility.
2026-10-03 22:04:31 +04:00
Danila Poyarkov 0ff6b414e3
feat(ai): choose a thinking level per message (#809)
Reasoning effort was a free-text profile field that only reached OpenAI
and OpenRouter, so Anthropic, Google, and DeepSeek models never thought
in direct chat. AI SDK 7 standardizes a `reasoning` call option that
those providers map to their own thinking settings, so profiles now
store one typed thinking level, shared with Pi, and requests pass it
through that option. OpenRouter's provider ignores the standard option
and receives its own reasoning option instead.

The composer offers the level next to the Design profile and reads it
per request, so a change applies to the next message without
rebuilding the transport. Saved profiles migrate from the Pi level or
the old effort string. Finished reasoning shows how long the model
thought while the block streamed.
2026-10-03 13:30:05 +04:00
Danila Poyarkov 418457bfb5
feat: preview streamed JSX on the canvas (#692)
* feat: preview streamed JSX on the canvas

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

* test: require partial input for unfinished coordinates

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add the blank line markdownlint (MD022) expects after the heading, and drop the one that split the Fixed list in two.
2026-10-01 10:52:36 +04:00
Marc Went 802091b051
feat: export components as Storybook stories (#751)
* feat(cli): export components as Storybook stories

Add `openpencil export -f storybook`, which writes one CSF3 `.stories.ts`
file per component set or component. Each variant becomes a story and the
variant properties become select controls, so the story renders the matching
variant; an unknown combination throws instead of showing another variant.

Stories embed the existing inline-style HTML projection, so consumers need no
OpenPencil runtime. `--framework react|vue|html` only changes the render
wrapper and the Meta/StoryObj import. When the document sits under the current
directory, stories carry an `openpencil://` design link for
@storybook/addon-designs.

Refs #727

* fix(pen): size auto-width text from its content on import

Text without a width in an auto-layout parent was imported 10000px wide, a placeholder the app's text measurer replaces. Headless layout keeps stored sizes, so CLI HTML and Storybook exports stretched hugging frames to over 10000px. Import the width as 0 so the importer's existing text-length estimate applies, and headless layout estimates the rest.

* feat(app): follow layer links to other pages

openpencil:// and web ?node= links only searched the current page, so a Storybook story linking to a component on another page reported it missing. When the current page has no match, load the other pages without showing them and switch to the first that carries the name.

* feat(cli): add design images and watch mode to Storybook export

Each story now links to its own variant when the layer name is unique, and carries a 2x PNG of the variant for @storybook/addon-designs, imported so Vite bundles it. --watch re-exports on every save. Re-exports replace the stories a previous export of the same document generated, including those of deleted components, and refuse to overwrite hand-written stories or another document's.

Refs #727

* fix(cli): reference Storybook design images without ambient PNG types

Import design images with new URL(..., import.meta.url) instead of an import declaration, so consumers need no vite/client types to typecheck the stories. Document that exports should run from the same directory.

* fix(app): search other pages for a link without cancelling page switches

The cross-page layer search prepared each page with preparePage, which advances the page-switch generation, so a page switch the user had in progress could be dropped, and every searched page paid for fonts and layout. Add loadPageNodes, which populates a page's layers through the same worker path without touching the switch generation, and report a failed search as an error instead of a missing layer.

* fix(pen): never import width-less text zero wide

Text without a width now imports at width 0 and relies on the importer's text-length estimate, which skipped single-glyph text. Estimate zero-width text of any length.

* fix(cli): harden Storybook export ownership, titles, and links

- A --page export replaces only its own stories, and names files as a full export does, so it cannot delete or overwrite other pages' stories.
- Same-named components on a page get distinct titles, so Storybook story ids do not collide.
- Read the generated header through CRLF line endings, and refuse a source containing a line break, which would end the header comment and start code.
- Link a story only to a layer name no other layer carries.
- Document the --page default for Storybook export.

Refs #727

* fix(app): let a page switch overtake a link's layer search

A link search that loads other pages could resume after the user started switching pages and move them to the matching page. Expose pageSwitchCount, which advances whenever a page switch starts, and abandon the search when it changes. An overtaken search reports neither a match nor a missing layer.

* fix(pen): estimate only omitted text widths

Estimate a width-less text node's width when it is imported, instead of estimating every zero-width text node afterwards, so an explicit width of 0 is kept.

* fix(cli): track Storybook story ownership by document path and page

- Identify the document by its path relative to the output directory rather than a basename or cwd-relative path, so same-named documents do not share stories and the export no longer depends on the working directory.
- Record the page in each story's header; a --page export replaces all of that page's stories and asks for a full export when renumbered file names land on another page's.
- Check every target, including design images, before removing anything, and refuse to overwrite files this export does not own.
- Quote the header fields as JSON with U+2028/U+2029 escaped, so any path stays inside the comment, instead of refusing line breaks.
- Deduplicate titles by Storybook id, which ignores case and punctuation.

Refs #727

* fix(app): focus a searched page only after its switch committed

A page switch the user starts while the link search's own switch is pending can keep that switch from committing. Check that the search's switch was the only one and landed on its page before focusing; otherwise report the search as superseded.

* fix(pen): keep empty text without a width at zero

* fix(cli): remove only the design images a Storybook export generated

Replacing a story removed its whole .design folder, including files someone else put there. Read the images each owned story references, remove just those, and remove a .design folder only once it is empty.

Refs #727

* test(app): cover a page switch still pending during a link search

The previous test committed the overtaking switch, so the page check alone caught it. Advance the switch count without committing, so the test fails without the count check.

* fix(cli): stage Storybook exports and refuse linked design folders

- Write every file to a staging folder inside the output before removing the previous export, then move them into place, so a failed write no longer leaves the export half replaced.
- Refuse a .design path that is not a real folder, such as a symbolic link, before removing or writing images through it, so an export cannot reach outside the output directory.

Refs #727

* refactor(dom-css): print Storybook stories from a parsed template

Story modules were assembled from string fragments, so quoting and
layout were an implicit contract: the CLI found design images with a
regex that only matched double-quoted `new URL("…")` paths.

A story module is now one TypeScript template, parsed once with acorn
and its TypeScript plugin. Data is filled into `$placeholder` nodes and
the module is printed with esrap, which owns quoting and escaping. The
CLI reads referenced design images back through `storyImagePaths()`
instead of matching text. Tests import generated modules and assert
values rather than formatting.

* refactor(storybook): track generated files in a manifest

The export recovered which files it owned by parsing its own output: a
header regex over JSON-quoted strings, line-separator escaping, CRLF
handling, an AST walk for design images, and a path regex in the CLI.

A `.openpencil-stories.json` manifest now records the document and page
behind each generated file. The CLI validates it with Valibot, including
that every listed path stays inside the output folder, and the story
header is a plain note. Story ids use a copy of Storybook's `sanitize`,
tested against the installed Storybook; the previous rule treated `A§B`
and `A-B` as the same story. Export names use es-toolkit's `pascalCase`.

The CLI export command moves into `commands/export/`, dom-css splits
grouping and naming out of the Storybook exporter, and the CLI takes the
framework list from dom-css.

* fix(pen): keep explicit narrow text widths

A post-import pass widened every multi-character text narrower than two
font sizes, including widths the `.pen` file set on purpose, such as
`width: 0`. Omitted widths are now estimated when the text node is
created, so the pass only overrode explicit widths and is removed.

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-30 03:16:47 +04:00
Danila Poyarkov a029e267c2
feat(updater): show download progress while installing an update
Report desktop update downloads through persistent determinate or indeterminate toasts while retaining native confirmation. Use a spinner during progress and cancel pending expiry when progress resumes.

Standardize substantial Storybook fixtures as colocated example SFCs, preserve shared SDK documentation examples, and enforce semantic anatomy instead of shared-layer test IDs.
2026-09-25 23:15:14 +04:00
Marc Went 511bb481ac
feat: open documents and layers from openpencil:// and web links (#708)
* feat(desktop): register openpencil:// deep link scheme

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

* feat(desktop): parse openpencil://open?file&node links

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

* feat(desktop): queue openpencil:// links as pending opens

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

* fix(desktop): read cold-start deep links on windows/linux

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

* feat(app): resolve openpencil:// links and select the target node

A link's file is repo-relative, so it is resolved against the paths of the
open tabs and otherwise located once by the user through the dialog picker.
Nothing else is read from disk and no fs scope is widened. The node is matched
by exact name on the current page, selected and zoomed to; a missing node
raises a notice instead of failing silently.

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

* docs: document the openpencil:// URL scheme

Describe the link format, the relative-path rule, how the file is resolved
against open tabs or a one-time picker, and that the scheme can only open a
document and select a layer.

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

* test(app): cover cancelled deep-link picks

Inject the file picker and open entry points into openDeepLink so the test can
drive the branch where the picked file is not the requested one. The repository
lint forbids module registry mocking, and the existing file batch helper takes
its opener the same way.

Reword the module comment: the opened file joins the recent-files list like any
other opened file, and a one-segment file matches the first open tab whose path
ends with it.

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

* docs: sharpen the URL scheme notes

Selecting by name selects every layer with that name on the current page and
zooms to the whole selection. Record that the first matching open tab wins,
that path separators may stay literal in the query, and how the scheme reaches
the app on each platform.

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

* refactor(app): keep the deep-link io type internal

Nothing outside the module names the injected io type, so contextual typing at
the call site is enough. Drop the redundant recording array from the cancelled
pick test.

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

* fix(desktop): tag pending opens by producer

The frontend classified a pending entry by the shape of its path, which
called a canonicalized Windows path (`\\?\C:\…`) relative and sent a
double-clicked document into the deep-link resolver. Rust now says which
producer queued the entry, and the tail both producers shared moves into
`queue_pending`.

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

* test(app): assert the opener receives the resolved path

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

* test(desktop): refuse a percent-encoded parent segment

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

* chore(desktop): relax the deep-link plugin pin

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

* chore(desktop): drop the unused deep-link capability

Draining links is Rust-side, so the webview never calls
`deep-link:allow-get-current`.

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

* fix(app): clamp link values in notices

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

* fix(desktop): pass deep links through the linux desktop entry

The bundler's default desktop template writes `Exec={{exec}}` with no
field code, so a Linux cold start from a deb, rpm or AppImage never
receives the `openpencil://` link as an argument and `get_current()`
has nothing to recover. Ship a custom template that is the bundler
default plus `%U`, wired to both the deb and rpm bundlers (AppImage
reuses the deb data dir). MIME types still come from `{{mime_type}}`,
so the file associations are unchanged.

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

* feat(app): open documents from ?file= links in the browser

The desktop build takes openpencil:// links; the web app had no equivalent.
It now reads file and node off its own address bar on boot, fetches the
document from an absolute https URL without credentials and without
following redirects, selects the named layer through the same path the
deep link uses, and strips both params so a reload does not re-open.

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

* fix(app): keep router state coherent when stripping web link params

Rewriting history directly left the router's own record of the current URL
pointing at the un-stripped one, so the next router.push wrote file and node
back into the history entry. The strip is now an injected action that goes
through router.replace, preserving the route, hash and every other query key.

Also clamp the failure detail, take the last value of a repeated key like the
desktop parser does, share deep-link's clamp instead of copying it, and report
a failed fetch through toast.error.

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

* fix(app): resolve deep links by filesystem case and bound remote fetches

Deep links resolved their file by comparing path segments in JavaScript,
which is case-sensitive: on macOS and Windows `Web/Design/hikyo.pen` and
`web/design/hikyo.pen` name the same file, yet both the open-tab lookup and
the picker check refused it and the link was cancelled. The comparison now
goes through a `path_matches_suffix` Tauri command that canonicalizes the
candidate and folds ASCII case on macOS and Windows while staying exact on
Linux. `resolveDeepLinkFile` takes the comparator as an argument, so it
stays testable without Tauri, and the rule is tested in `deep_link.rs`.

An already open document is focused through `activateTabForPath` instead of
`openFileFromPath`, which re-read the file from disk first and rejected the
whole link when it had moved or lost its permissions since the tab opened
it. The picker branch still opens the file, and a tab that closed between
the snapshot and the activate falls back to opening it.

A web link's `file` URL drops its fragment. The tab identity compares source
URLs exactly, so two links to one document differing only in fragment opened
two tabs.

A document fetched from a URL is capped at 64 MiB, counted off the streamed
body rather than the sender's `Content-Length`, with the request aborted the
moment it goes over instead of buffering whatever the host decides to send.

Draining the pending-open queue goes through `openDesignFileBatch`, the
per-item catch every other open path already uses, so one failing entry no
longer skips the rest of the batch.

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

* fix(desktop): match a deep-link suffix against the literal path too

Canonicalizing the candidate resolves a symlink that sits inside the trailing
segments, so a monorepo checkout where `packages/web` links to `../apps/web`
would stop matching a link that spells the path the way the tab does. Compare
both spellings: the canonical path keeps `..` and prefix symlinks working, the
literal one keeps the path the user actually sees. Both inputs are already-open
or user-picked paths, so trying the literal one grants nothing new.

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

* fix(app): cap the automation fetch and chain a caller's abort signal

`openBrowserFileFromURL` replaced a caller-supplied `signal` with the one the
size cap needs, so a caller could no longer cancel its own request. The two
are chained instead: the caller's abort aborts the cap's controller, and an
already-aborted signal is honoured before the fetch goes out.

`handleOpenFile` in the automation bridge was the last fetch buffering an
unbounded body. It reads a document the same way, so it gets the same 64 MiB
ceiling, counted off the stream and aborted on overflow. Its relative-path
resolution and its lack of a format assert are unchanged.

`path_matches_suffix` runs `async`, so `canonicalize` cannot block the main
thread on a stale network mount, and it now refuses an absolute or
`..`-bearing suffix: `parse_open_url` already does, but this is the comparison
every caller funnels through and an absolute suffix would otherwise match on
its segments alone. The command itself gained tests over a real temp tree —
exact match, the platform case rule, the symlinked trailing directory that
motivated the literal fallback, a missing file, and the refusals.

The docs and the module header claimed an opened file always joins the recent
files list, in the same breath as saying an already open tab is focused
without re-reading it. Only the former opens anything, so only the former
touches the list.

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

* docs(changelog): note the 64 MiB ceiling on the automation bridge openFile

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

* fix(app): deliver cold-start deep links through the deep-link path

macOS hands a launch `openpencil://` link to the app as `RunEvent::Opened`
before the app's `setup` closure runs. Traced on a cold `open`:
`RunEvent::Opened` at T+0.085 s, `setup` at T+0.342 s, and `on_open_url` never
fired. The plugin's `deep-link://new-url` emit therefore reached no listener
and the URL survived only in the plugin's `current`, which was drained under
`#[cfg(any(windows, target_os = "linux"))]` on the assumption that macOS was
unaffected. It is not: a cold link launched the app to an empty tab with no
picker, no toast and no log line, while the same link fired at a running app
worked. The drain now runs on every desktop platform; `register_all` stays
gated, macOS does not support it.

Nothing is queued twice. `RunEvent::Opened` is dispatched on the thread that
runs `setup`, so a link cannot arrive between registering `on_open_url` and
reading `current`, and anything later is no longer in `current`. A cold
double-clicked document is unaffected: `current` now also yields its `file://`
URL, and the `scheme == "openpencil"` filter in `queue_deep_links` drops it,
leaving `queue_open_paths` the only producer for that path.

The pending-open routing moves out of `WorkspaceView.vue` into
`app/document/io/pending-open.ts`, so which entry reaches the deep-link
resolver and which reaches the plain opener is unit-testable without mounting
the view. A drain that fails wholesale — the `take_pending_open` invoke, the
event binding — now raises a toast instead of only a console line; per-entry
failures were already toasted.

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

* refactor(app): share one bounded body reader

readBodyWithLimit reimplemented the chunked cap that vectorize's
readBoundedResponse already applied, and it lived in the menu module while
the automation bridge imported it from there.

Move the reader to the browser document-io owner as readBoundedBody,
returning bytes with an optional overflow hook and error message, and have
both the document fetch and the vectorize providers use it. The automation
bridge now opens a browser file through openBrowserFileFromURL instead of
re-inlining fetch, cap and tab creation, so it also gets the same format
check as the Tauri path, and the caller's abort signal is combined with the
cap's controller through AbortSignal.any.

* fix(app): report a failed tab activation

activateTabForPath returned true after calling switchTab, but switchTab
silently does nothing when the tab is gone. A tab that closed while the
identity lookup awaited therefore looked focused, and the caller skipped
opening the file, so the link did nothing at all.

Return whether a tab was actually activated.

* fix(app): translate the document link notices

The four notices added for document links existed only in the English
defaults, so a localized build showed English toasts. check:i18n does not
cover the app-level notification catalog, which is why nothing caught it.

Also correct the docs: a `.` segment is refused along with `..`, matching
the matcher.

* fix(desktop): refuse a dot segment in deep links

The parser accepted `web/./design.pen` while path_ends_with_segments
refuses `.`, so such a link was queued and could then never match an open
tab or a picked file — it failed silently after asking the user to locate
the file.

Refuse `.` alongside `..` in the parser and drop the whitespace-only line
left in the capability file.

* refactor(app): tidy the document link plumbing

Four smaller things from review:

- A dismissed file picker is not a wrong file, so it no longer reports
  "expected a file ending in …", which named a file the user never chose.
- Reuse es-toolkit's omit for stripping the link params, as the MCP
  settings form already does.
- Drop the openDesignFileBatch re-export from menu/use.ts; nothing
  imports it from there.
- Move the exact-name lookup out of the view: selectNodesByName lives with
  the other selection helpers and walks the graph directly, instead of
  building a whole FigmaAPI facade from the automation bridge to answer
  one query.

* refactor(app): centralize focusing nodes

The name lookup was a link-shaped helper in the selection domain, and it
baked one strategy into the action. Split it into the two things a caller
actually needs: focusNodes(ids) is the select-and-zoom primitive that
share and collaboration references want, and focusNodesByName resolves an
exact name on the current page first.

The store dependency is a narrow interface, as with the viewport actions,
so the action is unit-testable and stale ids can be ignored instead of
selected.

---------

Signed-off-by: Marc Went <marc@went.io>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-18 14:45:49 +03:00
Danila Poyarkov 81a07c0c9a
feat(demo): rebuild the first page as a component library with staged loading (#717)
* feat(demo): rebuild the first page as a component library with staged loading

The demo generated its document behind an empty canvas with no progress,
and the first page still carried a legacy library in a dark section that
matched neither the current showcase nor the rest of the demo.

Drive the existing editor preparation from demo generation so the canvas
overlay and tab indicator report the real phases and progress, and rebuild
the first page around a component library authored in the current style:
a Button component set with two variants, linked instances, text and
boolean component properties, and the variable collections. The deleted
effects, typography, and app-preview examples are already covered by pages
02 and 03.

* fix(demo): record demo generation failures as preparation failures

Demo generation reported a thrown error as a cancellation, because the catch
only warned and the trailing finally always cancelled the handle. Report the
failure through the preparation handle so diagnostics distinguish a broken
demo build from an abandoned one.

The finally needs no failure guard: failing clears the handle, so the
following cancel already returns early, as the preparation controller test
asserts.

* fix(demo): roll back an abandoned demo build

A preparation that superseded the demo's handle — a page switch, a font
retry, or a closed tab — left the document half-built: the first page stayed
renamed, the extra pages and sections remained, and the one-page precondition
then blocked any later attempt in that session.

Track what the build creates and remove it whenever the build does not
finish, restoring the original page name and clearing the variables it added.
The cleanup is guarded by graph identity because node and page IDs are only
unique within one graph, so a document that replaced the demo is never touched.

Splitting the build into its own function keeps the entry point to
orchestration and stays within the complexity budget.
2026-09-18 08:18:21 +03:00
Danila Poyarkov 048a8fbbc1
feat(settings): configure tool access, MCP failures, and step limits
* feat(settings): configure tool access and agent step limits

Built-in AI exposed only a hardcoded subset of the tool registry, and the
maximum agent steps was a constant, so users could neither enable
extended tools such as create_component nor adjust long-running tasks.

Built-in AI and the local MCP server now keep independent, locally saved
tool permissions over one shared catalog, with searchable read-only and
side-effect groups and per-target defaults. Chat settings gain a validated
maximum-steps field whose captured value drives the stop condition,
remaining-step warnings, and limit detection for each message.

Tool access, the local server, browser access, and MCP connections are
grouped under a single Automation settings page.

Closes #573
Closes #584

* refactor(settings): split automation into MCP and Tool access pages

The Automation page mixed a permission matrix with server endpoints behind
a Tools/Connections switch, and the view switch was indistinguishable from
the provider switch. The nested scroll region showed three of 110 tools.

Rename the MCP-facing page to MCP and give tool permissions their own Tool
access page. The page owns a fixed toolbar for the target, count, defaults,
and search, so the list uses the full dialog body and no row is clipped.

* fix(automation): explain MCP startup failures with localized guidance

Every startup failure collapsed into "MCP server did not become healthy":
the spawn layer recorded the real error but the runtime discarded it, and
health probes could not distinguish a rejected token from a missing server.
The message also surfaced raw English text as the alert heading.

Classify failures by reason (not installed, denied command, early exit,
startup timeout, rejected token, unexpected response, unreachable) and
render translated heading and guidance from the catalog, keeping captured
stderr or HTTP status as labeled diagnostic detail.

* refactor(ui): share one collapsible disclosure primitive

Six features each wired Reka's collapsible with their own motion classes and
one settings-only theme token, so the same interaction drifted in spacing,
icon size, and reduced-motion handling.

Add AppCollapsible with a family theme and move the settings disclosure and
the model editor's advanced settings onto it. Chat and frame-preset call
sites keep their distinct visuals for a follow-up.

* fix(automation): explain MCP failures with localized details

The failure alert carried raw English error text as its heading, and the
diagnostic payload sat in a sibling block outside the alert with no
relationship to it.

Classify failures by reason, render translated heading and guidance from
the catalog, and keep the payload in a collapsible inside the alert, which
unmounts while collapsed so the live region announces only the summary.
Add a copy action for issue reports.

Find the executable where a graphical launch can: extend PATH with the
common global bin directories before the lookup and report the searched
directories as diagnostic detail.

* fix(automation): keep MCP failure details out of reasons already explained

An unreachable address and a rejected token already name their cause in the
translated guidance, so repeating it under Details added noise. Details now
carry only output the summary cannot: stderr, HTTP status, or an unknown
error message.

* test(settings): browse every MCP failure reason in Storybook

The failure copy lived inside the settings panel, so reviewing the eight
reasons meant reproducing each failure and the mapping could only be
checked through the panel's dependencies.

Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail
visibility, copy action, and restart action, and add a story covering
every reason plus the collapsed-details behavior.

* fix(ui): order alert details above the recovery actions

The alert rendered its action buttons before the details slot, so the
collapsible explanation of a failure appeared under the controls it
explains. Details now render directly after the description.

* fix(automation): correct MCP failure classification and detail

Review follow-ups on the failure diagnostics.

Only 401 and 403 mean the server refused our token; any other status now
reports an unexpected response instead of telling the user to replace a
token that was never the problem.

The install hint rendered the whole diagnostic detail as its package
argument, so searched directories appeared inside the install command.
The install target is now a domain constant and the searched directories
stay as detail, which not-installed failures surface again since they are
the actionable desktop diagnostic.

Exited failures also record the process exit code and signal so copied
diagnostics stay conclusive when stderr is empty. The bundled PATH test
now covers the append branch instead of only the unchanged path.

* feat(settings): accept custom values for presets and retention

Retention was a closed set of three counts while the AI step limit was a
free number, so two bounded numeric preferences looked and behaved
differently for no product reason.

Add a shared preset-or-custom field: presets stay one click, the escape
hatch reveals a validated numeric field, and the model carries only the
resolved number. Diagnostics retention becomes a bounded number (50 to
20,000) with the presets as shortcuts, and the hardcoded revalidation in
the panel is replaced by one domain resolver.

* fix(settings): label the preset and custom fields

Replacing the labeled provider field with the shared control left the AI
step limit as a bare select with a detached hint paragraph, outside the
settings group, so nothing on screen said what the number meant. The
accessibility name came from aria-label, which is why behavior tests
passed while the panel was unreadable.

Move both controls into labeled settings rows with their descriptions, and
give the revealed field its own accessible name so the two controls in one
row differ. The specs now assert the control lives inside the row that
names it, which is the check that would have caught this.

* fix(mcp): allow the desktop app origin by default

A server started manually bound the port and answered curl but the app
webview could not use it: no CORS origin was configured, so the browser
blocked every fetch and the app reported the server as unhealthy. The
workaround required an undocumented environment variable.

Allow the desktop app origins by default, accept a comma-separated
override, and document the default in the CLI help and the security notes.
Authenticated requests still need the bearer token, and browsers set Origin
themselves, so only the app webview can present these origins.

* fix(settings): address review findings on the new controls

Copy details awaited nothing and confirmed the copy before the write
finished. VueUse never rejects and falls back to a legacy write, so the
await is what makes the confirmation honest rather than an error branch.

The preset field only left custom mode when a preset arrived; a non-preset
value assigned from the owner left the select showing a value absent from
its options with the field still hidden. The watcher now follows the model
in both directions.

The story play functions queried the revealed field by the row label, which
Testing Library matches as a whole string, so those interactions could not
find it. The Storybook smoke assertion also assumed a button or tab, which
skipped every story built from other primitives.
2026-09-17 23:55:58 +03:00
Danila Poyarkov 9d2b97e679
fix: protect unsaved documents and defer credential access (#713)
* 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.
2026-09-17 15:25:15 +03:00
Danila Poyarkov 92977234cf
ci: shard unit tests by owner and cut the quick suite from 100 s to 13 s (#715) 2026-09-17 10:18:16 +03:00
Danila Poyarkov 2e66792c59 chore: merge master into live-editor-regressions 2026-09-16 00:14:31 +03:00
Danila Poyarkov 38e80a4567
Merge branch 'master' into mcp-v2-webmcp 2026-09-15 19:55:49 +03:00
Danila Poyarkov b0dfde74f5 fix(settings): standardize save feedback and deletion guards
Share persistent alerts, distinguish partial persistence from validation errors, preserve model identity on retries, and apply the store's deletion eligibility before clearing credentials. Document toast, field-error, alert, and Storybook ownership.
2026-09-15 17:53:30 +03:00
Danila Poyarkov 453b27f15f perf(properties): retain inactive panels safely
Retain one selection-property subtree while frame presets are shown. Suspend opted-in descendant scopes after cleanup, cancel drafts before detachment can blur inputs, close portals, and release previews and pending resource work.

Track focused-element removal with VueUse so WebKit shortcuts resume after input teardown. Cover retained identity, hidden inactivity, binding rollback, async disposal, undo, popup reactivation and browser focus behavior.
2026-09-15 17:01:44 +03:00
Danila Poyarkov ea4b8e4b97 feat(settings): unify preferences and integration editors
Unify Settings navigation, credential drafts, translated form validation, and appearance controls. Add explicit WebMCP access modes and searchable MCP tool permissions while preserving execution and credential safeguards.
2026-09-15 16:34:05 +03:00
Danila Poyarkov 4a9bad5cec refactor(tools)!: centralize schemas and execution contracts
Define native Valibot inputs and execution/exposure metadata on each tool. Derive effects and default capabilities, consume upstream Standard Schema conversion, and validate finite numeric inputs consistently across adapters.

Move atomic execution to Core and restore failures from Scene Graph checkpoints without relying on a property diff. Preserve topology, collections, indexes and surviving object identities during rollback.

BREAKING CHANGE: custom tools use input schemas and execution metadata instead of params, ParamDef and independently declared mutation flags. Direct tool execution validates inputs before invoking the handler.
2026-09-15 10:55:27 +03:00
Danila Poyarkov 892550a287 feat(automation): share atomic property-edit transactions
Commit audited synchronous node and variable edits with property-level rollback and undo tied to the original document. Load fonts after commit and retain legacy paths for asynchronous and structural tools.
2026-09-14 00:54:13 +03:00
Danila Poyarkov 21ecce1f38 feat: assemble an editable demo document
Compose announcement, typography, and paint workbenches with native linked components and editable effects. Preserve the original examples on a reference page.

Wait for the canvas and existing fonts before layout, fit demo pages on first visits, and preserve user viewports thereafter.
2026-09-14 00:36:53 +03:00
Danila Poyarkov 4b47ffefbb fix(clipboard): preserve component dependency references 2026-09-13 12:46:31 +03:00
Danila Poyarkov 89b60b9870 fix(clipboard): preserve typed selection data across copy and paste 2026-09-11 02:59:31 +03:00
Danila Poyarkov b6f015a9f6 fix: address Select forwarding and MCP readiness review 2026-09-10 23:54:03 +03:00
Danila Poyarkov 70341c33a1 feat(settings): centralize motion policy and add animation preference 2026-09-10 23:10:24 +03:00
Danila Poyarkov 93a407e45f test: isolate managed browser and MCP runtimes 2026-09-10 21:41:10 +03:00
Danila Poyarkov 6cec1b2c58
fix: prevent repeated Keychain prompts and isolate native tests (#664)
* fix: prevent repeated Keychain prompts and isolate native tests

* fix: make credential settings contextual and actionable

* fix: refresh credential recovery state in open Settings

* fix: allow retrying credential status checks

* fix: recover credential access after backend panics

* test: centralize native Tauri invocation

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

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

* test: group chat history tests by domain

* feat: simplify chat history navigation and diagnostics

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

* fix: address chat history review findings

* test: cover harness shutdown and restored chat scrolling

* fix: preserve Portless proxy port in MCP routes

* docs: clarify UI animation conventions

* feat: configure reasoning display and animate disclosure

* fix: separate transcript following from reasoning disclosure

* fix: restore selected chat after document recovery

* refactor: separate chat history persistence and sessions

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

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

* test: remove stale Tauri runtime markers during cleanup

* fix(collab): deduplicate pending page switches

* test: simplify pending page switch regression setup
2026-09-06 23:29:45 +03:00
Danila Poyarkov cc4dc933e8 fix(settings): revalidate queued credentials and handle status failures 2026-09-06 16:41:50 +03:00
Danila Poyarkov 95fbd06dc3 fix(settings): share MCP mutation queues across editors 2026-09-06 15:41:15 +03:00
Danila Poyarkov b5391f7305 fix(settings): persist credentials before enabling connections 2026-09-06 15:18:18 +03:00
Danila Poyarkov 24df07ac41 fix(settings): guard asynchronous credential workflows 2026-09-06 14:34:32 +03:00
Danila Poyarkov 61c1e9839b refactor(settings): separate credential services from chat 2026-09-06 14:00:09 +03:00
Danila Poyarkov a338f57f4b refactor(settings): extract domain workflows 2026-09-05 21:49:30 +03:00
Danila Poyarkov 904c90c329 refactor(settings): extract model profile workflows 2026-09-05 21:25:46 +03:00
Danila Poyarkov f674f8c99d feat(ai): modernize model picker 2026-09-04 18:45:03 +03:00
Danila Poyarkov 6be82c115e fix(automation): avoid redundant MCP restarts
- Keep the healthy Portless child when browser configuration is unchanged\n- Allow genuine configuration restarts enough time to register their replacement service\n- Cover configuration equality and delayed health readiness
2026-09-04 17:23:53 +03:00
Danila Poyarkov 61f11e7d24 fix(ai): surface actionable provider failures
- Classify authentication, access, model, rate-limit, network, and credit errors\n- Preserve the original provider error across the no-output stream consequence\n- Add optional toast actions for opening model settings\n- Consume handled chat send rejections to avoid global unhandled errors\n- Localize provider failure guidance and cover the authentication flow
2026-09-04 16:41:03 +03:00
Danila Poyarkov 5951f45d64 fix(automation): wait for Portless MCP readiness
- Hold Vite startup and MCP restarts until the sibling service health endpoint responds\n- Tolerate transient Portless 404 responses during service registration\n- Cover worktree-origin CORS preflight and restart health polling
2026-09-03 20:33:06 +03:00