Commit graph

2818 commits

Author SHA1 Message Date
Danila Poyarkov bb5f68880d
fix: escape string attributes in JSX export (#754)
Layer names and other string props were written into JSX attributes
verbatim. A `"` closed the attribute and let the rest of the name add
props or expression containers, which `render` and `replace` then
evaluate. Sucrase also decodes `&` entities in attribute strings, so
names containing entities changed on a round trip.

Strings containing `"` or `&` are now written as expression containers
holding a JavaScript string literal. Tailwind JSX export uses the same
helper for `data-name` and `className`.
2026-09-25 15:50:36 +04:00
Danila Poyarkov 2d9e83d58a
refactor(export): derive export format lists from the IO registry (#755)
The Export panel, SDK helpers, app menus, and CLI each kept their own
hand-written format lists, so new formats such as PPTX reached some
surfaces and not others.

Scene Graph now owns the persisted export-setting format ids, Core IO
adapters carry literal ids so that list is checked against real adapters,
and the panel labels, scale handling, app format types, and CLI format
validation/help are derived from the registry. PPTX joins the Export
panel as a result.
2026-09-25 15:42:42 +04:00
Danila Poyarkov 99925e4c25
fix(tools): evaluate calc expressions without expr-eval (#753)
* fix(tools): evaluate calc expressions without expr-eval

expr-eval has an unpatched critical advisory for code execution through
toJSFunction(), which compiles expressions with new Function
(GHSA-q9v2-7m5w-4693). The advisory covers every published version, so
`bun run check:audit` fails on every branch and `bun audit fix` has nothing
to upgrade to. The calc tool only ever called evaluate(), so the advisory's
own vector was not reachable, but the dependency stays flagged and the
evaluator's surface was far wider than the tool documents: random(), factorials,
trigonometry, constants, strings, array indexing, property access and
statement sequences all evaluated, while the documented ** operator did not
parse at all, since expr-eval spells power as ^.

Replace it with a recursive-descent evaluator for exactly the documented
grammar. It compiles nothing, reaches no host object, and fixes **, which is
right-associative and binds tighter than a leading sign, so -2 ** 2 is -4 as
in ordinary notation. Non-finite results are still reported by the tool rather
than the evaluator, so 1 / 0 keeps its "Produced Infinity" message.

The tool had no tests; both the evaluator and the tool's JSON-array and
error-reporting paths are covered now.

* refactor(tools): parse calc expressions with jsep

Replace the hand-written tokenizer and recursive-descent parser with jsep,
a maintained zero-dependency expression parser with no advisory history, and
keep only the tree walk: an allowlist of node types, arithmetic operators and
the documented functions. Parsing, where the vulnerabilities in this class of
library live, is no longer ours to maintain.

Behaviour follows the parser rather than the previous hand-written precedence,
so a leading sign now binds tighter than '**' and '-2 ** 2' is 4; the tests
pin that alongside right-associativity. Parse errors keep jsep's own wording
and character positions.

* fix(tools): reject inherited names and fold long calc argument lists

Two findings from review of this branch. The function lookup used `in`, so
an inherited key such as `constructor(1)` passed the guard and then failed
while destructuring a missing arity, reporting a TypeError instead of an
unknown function; it now uses Object.hasOwn. min and max spread their
arguments, which overflows the call stack on V8 at roughly 125k arguments,
so they fold instead. Both paths are covered by tests.
2026-09-25 01:44:46 +04:00
Danila Poyarkov 8131401ead
fix: explain unsupported browsers instead of a blank window (#745)
* fix: explain unsupported browsers instead of a blank window

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

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

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

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

* build: enforce the browser baseline from compatibility data

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

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

* fix: recognise production error codes in the boot observer

Vue passes the error reference URL as the errorHandler info argument in
production builds instead of the development string, so the observer never
classified a setup or render failure as fatal in the shipped app and the
boot-failure notice only appeared on the dev server. Match Vue's exported
ErrorCodes in both forms, and cover the component-setup path in the E2E
spec; the scenario was also verified against a production build.
2026-09-22 14:40:59 +04:00
Danila Poyarkov 1204b677cc docs: remove GitHub Discussions link from docs header
The docs site header now links only GitHub and Discord.
2026-09-18 23:58:37 +03:00
Danila Poyarkov 6cf1748e31 Release v0.15.1 2026-09-18 16:33:46 +03:00
Danila Poyarkov 0092feff7d
docs: link the Discord server and GitHub Discussions (#731) 2026-09-18 15:54:27 +03:00
Danila Poyarkov 152d0d4b74
docs: correct README claims against the current release (#730) 2026-09-18 15:54:16 +03: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 e2de2471b9
fix(core): store picker colours in the document's colour profile (#724)
The OKHCL picker baked every colour to sRGB coordinates, so editing a fill in
a Display-P3 document stored sRGB numbers in a document that declares P3:
rendering stayed correct because the perceptual payload drives it, but the
stored value and any export disagreed with the profile.

`okhclToRGBA` and `rgbaToOkHCL` now take the colour space to read or write,
and both storage paths — the Figma API proxy and the picker actions — pass the
document's profile. sRGB documents are unchanged, since that is the default.
2026-09-18 13:08:49 +03:00
Victor Wads 0f6420d9cd
fix(MCP): add read-only file lifecycle tools (#722)
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-18 11:55:06 +03:00
Danila Poyarkov b198e88136
test(native): cover the desktop MCP server lifecycle (#723)
* test(native): cover the desktop MCP server lifecycle

`tests/e2e/native/**` had no MCP coverage, so nothing verified that the
real Tauri shell finds and spawns a server. Browser E2E cannot: the spawn
path short-circuits to the Vite-managed server in development.

Add a native spec that asserts what the app actually resolved: a missing
install reports its own reason, a resolvable binary either becomes healthy
with a discovery file and answering port or is refused with a version
explanation, and hiding the resolved binary then reports the missing
install without relaunching.

Also generate the desktop icons in `build:native-test`, since they are
ignored and the build failed on a clean checkout, and let the snapping
spec select its own section instead of assuming the dialog state.

* test(native): resolve the home directory portably

process.env.HOME is unset on Windows, which put the discovery override
outside the home directory. The app accepts a server-reported discovery
path only inside it, so it fell back to its own path and could not read
the test server's token.
2026-09-18 11:42:55 +03:00
Danila Poyarkov 3ba79e6c0d
refactor(ui): share one collapsible content treatment (#721)
* refactor(ui): share one collapsible content treatment

Three disclosures drove Reka directly with three different motion
treatments, and the chat tool-call one named utilities that do not exist:
`collapsible-up`/`collapsible-down` without the `animate-` prefix, so that
collapse never animated and ignored reduced motion.

Export the shared content treatment from the collapsible theme and use it
in the chat tool call, reasoning, and frame preset disclosures. Their
headers keep their own shape — a status icon, a thought icon, a text
chevron — because they are structurally different from the labeled row
disclosure.

* docs: note the chat disclosure animation fix
2026-09-18 11:42:50 +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 899fecf845
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-18 00:43:10 +03:00
Danila Poyarkov 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 d7971ff03b
fix(ai): reach the custom model option and update stale chat specs (#719)
* fix(ai): keep the custom model option reachable

The model picker appends its custom entry last, but the combobox renders
only the first hundred rows until the user types. Providers with large
catalogs, such as OpenRouter, pushed the entry past that cap, so a custom
model ID was only discoverable by guessing that a search for "custom"
would reveal it.

Place the custom entry after the curated recommendations, ahead of the
long tail, and give its group a label without the ellipsis that belongs to
the option text.

* fix(ui): stop pinning combobox group labels

Pinned labels sit inside the scroll container's padding, so a scrolled row
stayed visible as a clipped sliver above the label. Group labels now
scroll with their rows.

* test(chat): update specs for the current settings UI

The credential persistence assertions pointed at test ids that moved to
General settings and are covered by the credentials spec, the model editor
returns through Cancel rather than Back, and the API-key link is an anchor
whose href can be asserted directly instead of stubbing window.open.

* docs: note the custom model picker fix

* test(chat): assert the provider key page URL
2026-09-17 16:34:08 +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 904f3ca390
fix: use official Homebrew cask installation guidance (#712)
* fix: use official Homebrew cask installation guidance

* docs: guide localized pages through the old Homebrew tap

The translated getting-started pages documented the official cask but not
the migration from the archived tap, so readers of those pages had no
uninstall step for the old formula.
2026-09-17 13:36:17 +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 c4a3dae2a4
ci: unify verified release builds (#711)
* ci: fix desktop build cache ownership

* build: centralize native release artifact validation

Reuse package command execution and npm artifact paths. Share release identity and target metadata, consume explicit Tauri artifact outputs, and reject incomplete or mixed-run artifact sets before draft publication.

* refactor: use release package aliases across directories

* ci: coordinate verified native and npm releases

Build shared frontend inputs once and keep native targets parallel. Bind their complete artifact set to immutable source and workflow revisions, verify updater signatures and attestations, and replace draft assets only after preflight and verified npm publication.

* test: group native release tests by domain
2026-09-16 21:58:38 +03:00
Danila Poyarkov 7bc5c1fe2f Release v0.15.0 2026-09-16 17:57:35 +03:00
Danila Poyarkov b70a4a2c52 docs: add official dependency documentation for agents
Direct agents to verified documentation indexes and version-matched APIs before implementing dependency integrations. Remove instructions for disabled Copilot.
2026-09-16 17:25:15 +03:00
Danila Poyarkov 6358fd103c
fix(editor): stabilize live edits, gestures, and retained rendering (#699) 2026-09-16 17:19:17 +03:00
Danila Poyarkov 4c1e97240f test: align label cache contracts with component members 2026-09-16 16:14:12 +03:00
Danila Poyarkov 8fdfb7b648 docs(testing): define source-owned test architecture
Make package-local coverage the canonical destination, with explicit app, cross-owner integration, E2E and native exceptions. Document support ownership, bounded browser adapters and domain-by-domain migration without changing runner discovery.

Keep fixture reuse assertions inside the native input scenario instead of counting fixture housekeeping as separate platform acceptance.
2026-09-16 15:28:47 +03:00
Danila Poyarkov f88a66fced chore: merge master into live-editor-regressions
Preserve brand-asset preparation and the worktree-aware Vite server configuration.
2026-09-16 15:06:30 +03:00
Danila Poyarkov 964d25215b fix(effects): keep undo snapshots free of Vue proxies
Store scrub snapshots in a shallow ref so undo restores plain graph data and subsequent effect edits remain cloneable.
2026-09-16 14:47:18 +03:00
Danila Poyarkov 5b151ffc1e fix(canvas): include component-set members in label catalog 2026-09-16 14:13:34 +03:00
Danila Poyarkov ba519c04c0 test(clipboard): open a document in the Tauri fixture
Desktop mode starts on Home. Create a document before waiting for the canvas so routing assertions exercise clipboard behavior instead of timing out during setup. Keep this simulated-browser coverage distinct from native clipboard acceptance.
2026-09-16 14:00:30 +03:00
Danila Poyarkov 73fc732d20 test: honor the configured browser test origin
Use the configured baseURL for manually created pages and same-origin DOM/CSS imports. Repair the hover helper serialized argument uncovered once its startup reached the intended server. Preserve all existing pixel and behavior assertions.
2026-09-16 12:46:13 +03:00
Danila Poyarkov b9e435909d test(canvas): guard text against backing render history
Attribute the historical 84-pixel residual to fractional overscan backing history with controlled old/new origin and direct-first probes. Preserve the original paint sequence in an exact-pixel regression with glyph and ink guards for both emulated color gamuts. No runtime or tolerance changes.
2026-09-16 12:30:19 +03:00
Danila Poyarkov 42225c604a
feat: refresh branding with generated platform icons (#707)
* feat: refresh branding with generated platform icons

Keep the approved mark and optical micro master as the source of truth. Generate web, documentation, and native assets at their owning build boundaries instead of storing raster variants or linking web icons to desktop output.

* fix: refine small brand marks and loading artwork

* feat: adopt blue editing-handle brand mark

* feat: refine teal branding for light and dark themes

* feat: apply ivory tiles across brand surfaces

Use edge-to-edge web tiles with a larger mark and optical micro favicons. Preserve native spacing and platform-owned maskable/touch cropping. Address review feedback on story props, native dimensions, and brand test type checking.

* test: allow cold npm startup in packaging fixture

CI hit Bun's five-second default while running npm pack --dry-run. Give this integration test a scoped 30-second budget without changing production timeouts or other tests.
2026-09-16 11:54:07 +03:00
Danila Poyarkov 9eea1beb2a fix(vue): detach nested selection projection values
Top-level selection copies were safe, but nested fill and child arrays still aliased graph data. Clone initial node values and only the changed preview fields so mutable consumers cannot bypass graph tracking or cancellation. Preserve field-granular updates and paused subscriptions.
2026-09-16 10:05:44 +03:00
Danila Poyarkov ae5b8c6c04 test(canvas): align visual oracles with ruler-free fixtures
The stored arrow and blur images still included rulers despite explicit no-rulers fixtures. Inspect and update only these two oracles, preserving the arrow budget and exact settled reversal comparison. Check reversal parity before comparing the stored blur image so a stale oracle cannot hide that contract.
2026-09-16 03:45:34 +03:00
Danila Poyarkov c59879105c fix(editor): close preview and history lifecycle gaps
Discard provisional undo batches on graph replacement without replaying old document edits or clearing committed history. Reset failed preview controllers and refresh selected projections for plain-state SDK consumers while pausing inactive subscriptions.

Cover the verified review findings with negative controls, fix static Vue host insertion, and preserve the shared exact renderer oracle with its independently verified sRGB selection color.
2026-09-16 03:36:44 +03:00
Danila Poyarkov e1206848b1 test(canvas): load painted-text font fixtures explicitly
Use a test-owned family with bundled Regular and SemiBold faces so the nonblank raster oracle does not depend on earlier tests loading Inter 600. Keep exact pixel equality and assert font readiness before painting.
2026-09-16 03:27:10 +03:00
Danila Poyarkov 3220fd1798 fix(canvas): preserve paint geometry and color fidelity
Use native paragraph foreground paints instead of independent outline layout, keeping mutable shader paragraphs transient. Render transformed diamond gradients with a retained, owned runtime program and align Skia surface encoding with the browser drawing buffer.

Add independent pixel and ownership regressions and correct only the reviewed text, gradient and FIT-image visual oracles. Existing arrow/blur snapshot failures and the separate 84-pixel comparison remain unresolved; no tolerances are relaxed.
2026-09-16 02:57:56 +03:00
Danila Poyarkov c361ec3c87 fix(canvas): preserve path text with loaded fonts 2026-09-16 00:59:26 +03:00
Danila Poyarkov 2e66792c59 chore: merge master into live-editor-regressions 2026-09-16 00:14:31 +03:00
Danila Poyarkov 11f9f1b57e fix(types): align Vue fallback declarations
The app and SDK ambient SFC declarations disagreed on indexed props. SDK-first declaration order reproduced all twelve Storybook default-argument errors. Use the same opaque fallback and compile existing stories under both declaration orders as a regression check.
2026-09-15 23:59:28 +03:00
Danila Poyarkov 9d21b3e17a
docs: refresh SDK and workflow guides 2026-09-15 23:43:01 +03:00
Danila Poyarkov 12282fbc84 chore: merge master into docs/current-workflows 2026-09-15 23:37:12 +03:00
Danila Poyarkov a4c3e803f6
docs: curate unreleased changelog 2026-09-15 23:37:08 +03:00
Danila Poyarkov 6a4781c7ab chore: merge master into docs/changelog-release-audit 2026-09-15 23:29:15 +03:00
Danila Poyarkov 9dd5359a0a
ci: keep AI disclosure out of co-author credits (#706) 2026-09-15 23:29:10 +03:00
Danila Poyarkov 9d89b6a7f5 docs: describe the imported dash rendering fix 2026-09-15 23:26:18 +03:00
Danila Poyarkov 53400d1089 chore: merge master into ci/ai-attribution-policy 2026-09-15 23:23:26 +03:00
Danila Poyarkov 27d1d9ca33
refactor: consolidate Playwright projects (#702) 2026-09-15 23:23:22 +03:00
Danila Poyarkov 3b4fdeb623 fix(ci): recognize bare AI co-author addresses 2026-09-15 23:22:21 +03:00