Commit graph

133 commits

Author SHA1 Message Date
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
Danila Poyarkov 21b1d25bff fix(chat): derive Markdown image origin at runtime
- Restrict assistant-rendered images to the active deployment origin\n- Cover local and self-hosted origins without hardcoded production hosts\n- Verify cross-origin, insecure, and data image rejection in browser tests\n\nCo-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
2026-09-03 14:12:14 +03:00
Danila Poyarkov dd53d08474 refactor(chat): adopt upstream Markdown v2
- Replace the fork-era Markdown integration with Comark and an explicit Shiki extension\n- Centralize OpenPencil theming, parser lifecycle, controls, and URL hardening\n- Remove Mermaid stubs and bundled math or diagram dependencies\n- Address contextual composer review findings and extend browser coverage\n\nCo-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
2026-09-03 13:34:55 +03:00
Danila Poyarkov f0afe3c4d7 feat(chat): adopt contextual composer improvements
- Pin selected layers as bounded context without exposing metadata in transcript bubbles
- Show collapsible reasoning and copy individual assistant responses
- Autosize multiline prompts and cover the new chat states in browser tests

Co-authored-by: Jason Kneen <jason.kneen@bouncingfish.com>
2026-09-03 11:40:50 +03:00
Danila Poyarkov 9bb9478833
refactor: replace complex conditional object spreads
- Add a typed, modular custom Oxlint rule package with direct regression coverage
- Replace complex conditional object spreads with explicit construction across the repository
- Preserve all existing custom rule registrations and diagnostic behavior
2026-09-01 19:49:57 +03:00
Danila Poyarkov 379ef8b62d
perf(canvas): rebuild navigation rendering (#591)
* perf(canvas): add traced navigation benchmarks

- Record and replay timestamped pan and zoom gestures through DOM and CDP input paths\n- Correlate input, viewport, render, long-task, and retained-backing events in Chromium traces\n- Report frame pacing, latency, jump, anchor drift, and crisp-settlement metrics

* perf(canvas): stabilize navigation comparisons

- Separate low-overhead metric runs from optional CPU-profile traces\n- Warm scenarios before recording and use a consistent SwiftShader browser configuration\n- Add a canonical momentum-pan reversal gesture alongside pinch reversal

* fix(canvas): require hardware GPU navigation benchmarks

- Run macOS performance captures through Metal-backed ANGLE and reject accidental SwiftShader fallback\n- Record the GL renderer and reserve software GPU mode for portable correctness smoke runs

* perf(canvas): cache shadow rasters for crisp backing

- Rasterize local drop and inner shadows only while constructing retained scene backing\n- Bound native image memory and invalidate cached entries with node and renderer lifecycle changes\n- Quantize zoom-aware raster resolution and reuse nearby scales without lowering normal scene quality

* test(canvas): verify retained shadow raster fidelity

- Compare settled retained-backing shadow output with direct CanvasKit rendering\n- Keep backdrop blur on the picture fallback and exercise graph-driven cache invalidation\n- Cover updates, deletion, and reparenting through actual SceneGraph events

* perf(canvas): benchmark real FIG fixtures

- Serve exact local fixture bytes through an isolated Playwright route for production preview runs\n- Wait for document loading and page population before zooming to fit and recording navigation\n- Record the resolved fixture path in benchmark environment artifacts

* fix(canvas): preserve nested effect subtree pictures

- Keep deeply nested shadow documents on one retained subtree picture instead of exploding them into per-node image draws\n- Restrict shadow raster acceleration to effect-bearing page children\n- Cover nested shadow fallback and restore gold-preview FIG pinch performance to master levels

* refactor(canvas): share recorded wheel sample type

* perf(canvas): defer backing settlement across zoom reversals

- Track explicit navigation phases and gesture generations instead of inferring idle from viewport timing\n- Cancel or defer retained backing construction while pan, zoom, momentum, or tentative settlement is active\n- Add a repeated short-pause pinch reversal fixture based on the user trace

* perf(canvas): index bounded render chunks

- Split oversized painter subtrees into self-paint and bounded descendant chunks without dropping container visuals\n- Bulk-load chunk visual bounds into RBush for selective world-space queries\n- Cover bounded updates and gold-preview build/query complexity before tile rendering consumes the index

* refactor(canvas): namespace render chunk coverage

* perf(canvas): model chunk paint context

- Preserve ancestor transform and clip dependencies for independently renderable chunks\n- Keep opacity, blend, blur, and mask isolation subtrees atomic until command-level splitting exists\n- Report oversized atomic chunks and lock gold-preview to bounded painter units

* perf(canvas): record pixel-correct render chunks

- Record interruptible chunks in world coordinates with ancestor transforms, clips, and chunk-local culling bounds\n- Draw opacity, blend, blur, and mask isolation chunks directly into destination surfaces in painter order\n- Compare composited chunk output with direct CanvasKit rendering instead of relaxing visual thresholds

* perf(canvas): render selective world tiles

- Map world regions to fixed 256-device-pixel tile targets and quantized sharpness levels\n- Query only intersecting render chunks and preserve atomic destination compositing\n- Match multi-tile CanvasKit output against direct rendering and measure gold-preview tile cost

* perf(canvas): cache chunk pictures across tiles

- Reuse world-space chunk command pictures for every intersecting tile\n- Pool 256-pixel tile surfaces and expose allocation, draw, flush, and snapshot timings\n- Keep expensive atomic foreground blur visible as an over-budget scheduler constraint

* perf(canvas): schedule cached tile rendering

- Bound tile images with an LRU cache and reuse pooled CanvasKit surfaces\n- Plan mandatory holes, stale visible refreshes, and overscan by navigation and content generation\n- Stop jobs at a strict deadline while reporting fallbacks, stale work, overruns, and over-budget effects

* perf(canvas): integrate progressive tiled rendering

- Keep retained scene output as the interaction fallback while exact tiles refine only after navigation becomes idle
- Centralize runtime URL flags and pass renderer selection through the typed Vue canvas API
- Replace benchmark sleeps with explicit mode-aware renderer settlement and report exact tiled coverage
- Preserve bounded scheduler metrics, generation cancellation, native resource cleanup, and shared visual-bounds logic

* refactor(app): centralize runtime query configuration

- Parse collaboration, recent-files, benchmark, presentation, and renderer flags in one typed app module
- Remove ad hoc URL parsing from workspace and collaboration runtime consumers
- Cover supported values and production-safe defaults without adding a repository lint rule

* fix(canvas): replace fallback pixels with exact tiles

- Render opaque page-background tile cells and install them with source replacement instead of double-compositing translucent scene content
- Exercise the live progressive controller against direct rendering across masks, effects, blend isolation, images, fallback text, transforms, and clipping
- Preserve the bounded reversal path with zero Long Tasks and exact settlement near 128 ms on gold-preview.fig

* perf(canvas): invalidate tiled content selectively

- Index chunk dependencies across contained nodes and transform or clipping ancestors
- Re-record affected chunk pictures and invalidate tiles intersecting old or new visual bounds
- Advance unaffected cached tiles to the new scene generation instead of rebuilding the full chunk index and tile cache
- Keep structural graph mutations on the safe full-rebuild path and cover selective refresh end to end

* perf(canvas): bound atomic blur tile refresh

- Render atomic blur chunks with tile-local isolation bounds and blur halos instead of replaying full-subtree layers
- Keep content refresh behind the retained fallback, cap GPU submissions to four tile jobs per frame, and adapt estimates from measured work
- Preserve large-radius CPU over-budget visibility while preventing Metal-backed refresh bursts and deferred GPU overload
- Add deterministic node-mutation benchmarks and summarize scheduler throughput, job duration, overruns, and exact content settlement

* perf(canvas): cancel obsolete tile refresh generations

- Count and report queued jobs removed by content or navigation generation changes
- Add deterministic mutation-then-reversal benchmark support without sleeps
- Assert exact tile work remains suspended during navigation and resumes for the final viewport
- Summarize cancellation alongside scheduler throughput, overruns, and settlement metrics

* test(canvas): cover live tiled blur settlement

- Load gold-preview.fig through the real tiled canvas surface and wait on explicit renderer settlement
- Commit the settled radius-210 large-blur browser snapshot
- Replay the canonical zoom reversal during refresh and require byte-identical final canvas convergence

* fix(canvas): harden renderer resource lifecycle

- Release tiled surfaces, images, pictures, and queued work across surface, font, graph, page, structure, and renderer lifecycle boundaries
- Restore pooled canvas, viewport, and backing state through exception-safe native recording and raster paths
- Rebuild tiled chunk topology only when isolation requirements actually change, preserving selective blur mutation performance
- Document deterministic active-renderer settlement and add lifecycle, graph replacement, cache failure, and surface replacement regressions

* test(canvas): remove source-matching renderer claims

- Delete the autopsy suite that inferred runtime correctness from source text, regexes, line placement, and symbol counts
- Keep renderer ordering, cache cleanup, effect behavior, and pixel fidelity covered by executable behavioral and lifecycle tests

* perf(canvas): present retained backing during tiled navigation

- Profile production reversal traces and attribute tiled p95 cost to GPU command-buffer flushes from full-scene fallback replay and tile presentation
- Use the retained backing as the moving fallback while tile scheduling and cached lookup remain allocation-free
- Defer tile image presentation until idle and expose visible versus presented tile counts in navigation telemetry
- Reduce tiled reversal render p95 from about 8ms to 0.3ms while preserving exact idle replacement and visual parity

* perf(canvas): prioritize visible tile settlement

- Profile per-tile allocation, draw, flush, snapshot, and chunk costs through scheduler telemetry
- Defer overscan until all visible exact tiles are covered
- Replace the four-job idle cap with a higher safety ceiling while the measured five-millisecond deadline controls cheap work
- Reduce mutation-plus-reversal exact settlement from about 272ms to 160ms without Long Tasks, overruns, or over-budget jobs

* refactor(canvas): clarify renderer lifecycle boundaries

- Extract retained backing state types and navigation preview timing\n- Isolate tiled scheduler telemetry from frame orchestration\n- Document settlement and CanvasKit ownership invariants\n- Preserve hot drawing loops, budgets, cache limits, and rendering decisions

* fix(canvas): preserve current label rendering

Retain the merged paragraph-label cache lifecycle and substituted-font readiness while reconstructing the renderer stack on current master.

* test(canvas): keep tile benchmark assertions deterministic

Keep performance timing in benchmark telemetry while asserting structural tile selectivity and cache behavior in CI.

* feat(canvas): expose experimental tiled rendering

- Persist retained or tiled canvas mode in General settings\n- Keep retained rendering as the default and apply changes after reload\n- Preserve URL overrides for deterministic benchmarks and support reproduction

* refactor(app): centralize renderer preference state

Expose renderer override provenance from runtime configuration and keep the settings control's derived state separate from its explicit persistence action.

* refactor(app): share settings layout anatomy

Reuse slot-based section headers and bordered groups while keeping each settings control row explicit.

* fix(canvas): harden tiled renderer boundaries

- Bound low-zoom tile planning and handle failed tile surface allocation\n- Preserve effect raster dependencies, runtime-safe clocks, and navigation timing contracts\n- Keep benchmarks deterministic, backward compatible, and accurately localized

* fix(canvas): invalidate dependent node pictures

Track first-child shadow dependencies for retained node pictures so child geometry updates cannot leave stale parent shadows.
2026-08-31 13:39:40 +03:00
Danila Poyarkov 0f64cecc49
fix(text): finalize font readiness and label shaping (#593)
* fix(text): prepare browser fonts atomically

- Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures
- Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation
- Shape frame, section, and component labels through a bounded native Paragraph cache
- Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior

* test(canvas): cover substitution and label shaping

- Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output
- Assert text-picture and derived-glyph invalidation with finalized substituted readiness
- Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation

* fix(text): preserve same-origin fetches during font resolution

- Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests
- Keep the external provider HTTPS allowlist enforced for cross-origin font resources
- Cover the production race discovered while loading gold-preview.fig during provider initialization

* fix(text): preserve substituted path glyphs

- Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted
- Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle
- Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection
- Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage

* fix(text): use browser font transport without desktop warning

- Keep the browser provider implementation aligned with current settings behavior after splitting from preparation

* test(text): inject same-origin browser font context

- Keep the transport test deterministic outside a Window global

* fix(text): preserve final browser font transport hardening

- Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot

* fix(text): initialize font transport outside Window contexts

- Keep unit and headless module imports safe while browsers use their current origin

* test(text): satisfy async visual fixture contract

- Return from the resolver setup continuation after requesting render

* test(canvas): include paragraph cache lifecycle

* fix(text): cancel queued browser font loads

- Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing

* fix(text): close font queue cancellation races

- Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels

* fix(text): abort active provider resolution

- Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
2026-08-30 14:33:32 +03:00
Danila Poyarkov 6f5638380a
feat(app): prepare documents atomically per tab (#592)
* feat(app): show atomic document loading progress

- Preserve the existing full-canvas pencil loader while adding phase, detail, accessible status, and honest determinate progress
- Keep one generation-safe load owner across FIG decoding, graph preparation, page population, fonts, fallbacks, layout, viewport fitting, and first-render fade
- Prevent nested page setup and viewport cleanup from revealing partially prepared documents
- Cover obsolete sessions, font-resolution ownership, and staged loader UI

* refactor(app): scope editor preparation per tab

- Replace the shared loading boolean with one reactive preparation snapshot and one imperative controller per editor store
- Keep Core page work progress-only and inject canvas suspension from the app boundary
- Route FIG, storage, recovery, DOM import, and page switching through reusable tab-local preparation handles
- Abort only the closing tab's operation and cover generation safety, multi-tab isolation, progress UI, and disposal

* fix(editor): commit prepared pages atomically

- Prepare population, fonts, fallbacks, and layout without changing the visible page
- Reject cancelled and stale prepared pages before committing viewport, selection, and page events
- Keep the preparation overlay until the committed scene version is presented
- Cover call order, cancellation, stale generations, and presentation acknowledgement

* fix(app): stage imported documents before commit

- Prepare imported graphs in an isolated Core editor before replacing the live document
- Share font loading while keeping live selection, graph, renderers, and history untouched during staging
- Preserve the previous graph when staging is cancelled or fails and remove the duplicate pre-font layout pass

* refactor(app): namespace preparation UI

- Move canvas and tab preparation presentations into focused subfolders with concise component names
- Share progress and phase presentation helpers across preparation surfaces
- Show tab-local preparation status without covering the active canvas for background work

* fix(app): cancel preparation work at source

- Publish typed per-store preparation lifecycle events with explicit completion, cancellation, and failure outcomes
- Propagate tab-local AbortSignals through FIG parsing, population workers, and browser font downloads
- Keep cancellable font requests outside shared in-flight caches while retaining globally completed font registrations
- Stop FIG manifest previews from replacing the live graph before atomic document commit

* fix(app): cancel storage and DOM preparation

- Propagate preparation signals through S3 downloads, byte progress, local-cache boundaries, and DOM/CSS conversion checkpoints
- Reuse merged diagnostics and localized toasts for document, storage, and presentation failures
- Replace manual font concurrency and presentation timers with es-toolkit limitAsync and withTimeout
- Guard stalled first presentation and fix the merged recovery dialog title bindings

* fix(app): stage reload and font retry

- Prepare reload graphs in isolation and preserve the current document on read, decode, font, or layout failure
- Restore page and viewport state only after atomic graph commit with cancellable reload reads
- Run font Retry as a tab-local preparation with cache reset, final layout, picture invalidation, and presentation acknowledgement
- Keep completed document pixels visible while Retry reports activity in the tab

* fix(app): enforce exclusive preparation outcomes

- Complete document, storage, recovery, and DOM preparations only after successful commit
- Keep failed and cancelled handles terminal so lifecycle events cannot report contradictory outcomes
- Preserve external AbortError identity across storage timeouts and cancel streamed readers without returning partial bytes
- Cover credential-free pre-abort, mid-stream cancellation, progress cutoff, and terminal outcome exclusivity

* feat(diagnostics): record preparation outcomes

- Persist completed, cancelled, and failed preparation lifecycles through the validated diagnostics recorder
- Store only operation kind, outcome, cancellation or failure category, terminal phase, and coarse duration bucket
- Exclude document subjects, font families, storage identities, URLs, raw durations, messages, and stack traces

* chore(app): keep browser font tests with typography split

- Remove the browser font transport test inherited from a mixed cancellation commit; the source and coverage remain on the typography branch and safety snapshot

* test(vue): assert injected render suspension

- Exercise shouldSuspendRender instead of removed Core loading state\n- Preserve the contract that rendering resumes without a version change

* test(app): complete atomic preparation contracts

- Acknowledge first presentation in headless file-open tests\n- Assert the cancellable font-loading signature at the Tauri fallback boundary

* fix(app): preserve preparation cancellation

- Stage imported graphs before mutating live tabs and propagate aborts through page, DOM, font, and storage work\n- Use the accessible progress primitive and clamp determinate values\n- Cover fallback-font cancellation and yield pending-open test polling to the task queue

* test(text): await fallback font request cancellation

Start the mocked remote font request before aborting so the test proves that the active request receives the preparation signal.
2026-08-30 12:21:49 +03:00
Danila Poyarkov 8408b56721
refactor(app): modernize browser clipboard adapter (#601)
* refactor(app): isolate system clipboard adapters

- Split browser and Tauri clipboard behavior into focused adapters\n- Inject browser clipboard capabilities into unit-testable operations\n- Remove navigator and document mutation from headless clipboard tests

* refactor(app): delegate browser clipboard fallbacks

- Use copy-to-clipboard for modern rich MIME writes and execCommand fallback\n- Keep OpenPencil-specific HTML and plain-text payload construction at the adapter boundary\n- Remove hand-rolled browser capability and selection handling

* test(clipboard): verify rich browser menu round-trip

- Exercise copy from the browser Edit menu under a user gesture\n- Verify text/html and text/plain ClipboardItem formats\n- Paste the system clipboard payload back into the canvas

* refactor(clipboard): reduce adapter surface

- Expose only command dispatch and the injectable system clipboard contract\n- Keep browser and Tauri copy/paste operations private to their adapters\n- Rename the in-memory DataTransfer fallback and share design HTML recognition

* fix(clipboard): harden format and fallback handling

- Require complete OpenPencil or Figma clipboard markers\n- Await browser writes so adapter failures resolve false\n- Write plain-only Tauri payloads as text and reject unrelated clipboard text

* fix(clipboard): reject unrelated current browser data

* fix(clipboard): bind fallbacks to copied selection

- Match cached rich HTML to the current Tauri plain-text fallback\n- Preserve nodes when selection changes during an asynchronous cut\n- Reject unrelated current browser HTML before consulting memory

* fix(clipboard): reuse the shared memory payload type

* fix(clipboard): scan design markers linearly

* fix(clipboard): distinguish unavailable and empty reads

* style(clipboard): use includes for marker closure

* test(clipboard): avoid wall-clock marker assertions
2026-08-28 14:37:42 +03:00
Mikel Cabezas 7a311677cc
fix(editor): harden clipboard fallbacks
Merges the contributor clipboard fallback fix with maintainer follow-ups for browser cut safety and isolated fallback tests. Selections are preserved when clipboard serialization fails, and clipboard fallback tests no longer depend on host APIs.
2026-08-24 19:16:16 +03:00
Sergio Bernal b65b1bd481
fix(chat): treat MCP results with optional isError correctly
Merges the contributor fix with maintainer follow-up coverage. MCP results now treat omitted isError as success, scope detection to mcp__ tools, and preserve generic tool error handling.
2026-08-24 18:18:02 +03:00