create_vector now validates the path JSON before accepting it:
checks vertices have numeric coordinates and segment indices are
in range. Returns a clear error message instead of silently
creating a malformed node that crashes on save.
The scene graph also normalizes vectorNetwork on updateNode as
a safety net for other code paths.
Vectors created via MCP with straight-line segments (no tangent
handles) crashed the .fig serializer on save. Segments without
tangentStart/tangentEnd are now normalized to zero vectors at
creation time, producing straight line segments as intended.
Fixes#206
When right-clicking a component, the context menu now shows
'Create Instance' instead of 'Create Component'. Previously both
were shown simultaneously.
The disabled flag was inverted — the manually constructed menu item
used `!commandMenuItem(...).disabled` which made the item disabled
when the command was enabled, and vice versa. Replaced with a direct
commandMenuItem() call which handles label, disabled, and action
correctly.
Fixes#223
estimateTextSize() now tries opentype.js per-glyph advance widths
before falling back to the rough 0.6 × fontSize × charCount heuristic.
Since font data is already cached via loadFont(), this gives accurate
variable-width measurement for any loaded font — no CanvasKit needed.
Parsed opentype.js Font objects are cached per family+style to avoid
reparsing on every layout pass.
The estimateTextSize() fallback (fontSize × 0.6 × charCount) produced
wildly inaccurate results, causing 26K node mismatches on material3.fig.
Nodes imported from .fig files already carry Figma's computed dimensions —
use those instead. Falls back to the estimate only for newly-created
nodes with the 100×100 default size.
Reduces headless layout mismatches by ~56% (26,299 → ~11,669), with
TEXT node mismatches dropping 92%.
Fixes#212
Co-authored-by: Alexander Bonin <3462438+alex4o@users.noreply.github.com>
The canvas had no tabindex, so clicking on it didn't move focus away
from previously focused input elements. When document.activeElement
was still an input/textarea (e.g. from a panel interaction), the
keyboard shortcut guard treated Backspace as blocked by an active
input — making it impossible to delete selected nodes via the canvas.
- Add tabindex="-1" + outline-none to the canvas element
- Explicitly focus canvas on mousedown (except during text editing)
Fixes#220
Previously only Shift+click toggled additive selection in the layers
panel. Cmd+click (macOS) and Ctrl+click (Windows/Linux) had no effect
beyond replacing the selection with a single layer.
Treat Shift, Cmd, and Ctrl modifiers equally as additive toggle, matching
common design tool conventions (Figma, Sketch).
Co-authored-by: TKman <102001532+greekr4@users.noreply.github.com>
A user on a dark OS who launches OpenPencil currently gets a pure-white
canvas flash into their dark desktop every time — visually jarring and
eye-straining in low light. Upstream has no theme system at all, so
this change defers to the OS `prefers-color-scheme` for newly-created
pages only.
Changes:
- Add `CANVAS_BG_COLOR_DARK` (#2c2c2c, matching Figma's dark-mode
canvas) next to the existing light `CANVAS_BG_COLOR`.
- Add `getDefaultCanvasBgColor()` helper that reads
`matchMedia('(prefers-color-scheme: dark)')` once per call.
- Use the helper in `editor/create.ts` (initial editor state) and
`editor/pages.ts` (page-switch reset). Both are new-page
initializers.
Deliberately untouched:
- `io/formats/fig/export.ts` still writes the static light
`CANVAS_BG_COLOR` as the page `backgroundColor`. If a dark-theme
user's exports forced darkness on recipients, files wouldn't be
portable. The serialization path stays neutral.
- `canvas/renderer.ts` default — only a fallback when state is
missing `pageColor`; effectively dead once `create.ts` runs.
- Per-page ColorInput in `PageSection.vue` remains the manual
override for any user preference.
Companion colors (rulers, selection, snap) were already dark-
friendly values and need no adjustment — rulers become slightly more
legible on dark canvas than they were on light.
Tested on Arch + KDE Plasma (dark) + WebKit2GTK 2.52 + Mesa 26:
new pages render ~#2c2c2c, switching to light OS theme flips back
to #f5f5f5 on next launch, saved-and-reopened files keep the light
export value (confirming the serialization boundary).
* fix(a11y): tab order in inspector panel follows visual layout
- ScrubInput gets tabindex=0 so Tab reaches it; on focus it enters
edit mode and transfers focus to the inner <input>
- All inspector buttons get explicit tabindex=0 to follow DOM order
- Tab flow: align buttons → X → Y → W → H → rotation → flip/rotate →
opacity → corner radius → ...
Closes#180
* fix(inspector): apply ScrubInput value live while typing
* fix(keyboard): block shortcuts while ScrubInput is focused
- Detect scrub input focus via closest() in isEditing() guard
- Add isScrubInputFocused() check in plain() so tool shortcuts,
Backspace, Enter etc. don't fire while typing in inspector fields
* fix(inspector): use reactive state for ScrubInput focus, remove redundant tabindex on buttons
- Surface editing state from ScrubInputRoot via editing-change emit
- Track scrubInputFocused in editor store instead of querying DOM
- Remove no-op tabindex="0" from all <button> elements
- Keep tabindex on ScrubInput wrapper <div> (actual tab order fix)
---------
Co-authored-by: Danila Poyarkov <dev@dannote.net>
* fix(layout): resolve design token variables before passing to yoga-layout
applyPadding() and applyAutoLayout() passed variable references like
$--spacing-lg directly to yoga-layout's setPadding()/setGap() which
expects numeric values, causing a crash.
Now resolves variable references via ctx.resolveNumber() before
assignment, following the same pattern already used by parseSize()
and applyCornerRadius() in the same file.
Fixes open-pencil/open-pencil#201
* test(layout): add regression tests for variable padding/gap resolution (#201)
* feat(mcp): add list_available_fonts tool for font discovery
The existing list_fonts tool reports only fonts currently used in the
current page (it walks every TEXT node and groups by fontFamily).
Agents authoring new text have no way to discover what fonts the host
can render — they have to guess and hope set_font succeeds.
This adds list_available_fonts, which returns the set of font
families the host advertises. On desktop this is the system fonts
font-kit enumerates plus any bundled fonts the host registers; in
non-desktop runtimes that don't expose enumeration, it returns an
empty list with an explanatory note rather than throwing.
The optional `family` filter mirrors list_fonts for a substring
search, so agents can do `list_available_fonts({family: "mono"})` to
narrow before set_font.
Wired through:
- packages/core/src/figma-api/index.ts: adds optional
listAvailableFontFamilies hook on FigmaAPI (same pattern as
exportImage). Optional so non-desktop hosts aren't forced to
implement it.
- packages/core/src/tools/read.ts: defines the tool.
- packages/core/src/tools/registry.ts: registers it in CORE_TOOLS so
the MCP server advertises it.
- src/automation/figma-factory.ts: provides the desktop
implementation by delegating to engine/fonts.ts#listFamilies which
already wraps the Tauri list_system_fonts sidecar.
* refactor(mcp): reshape to listAvailableFontsAsync matching Figma API
Per review feedback on #210: FigmaAPI mirrors the Figma Plugin API, and
Figma already exposes figma.listAvailableFontsAsync(): Promise<Font[]>
where Font = { fontName: { family, style } }. Replacing the custom
listAvailableFontFamilies hook with the Figma-shaped method means eval'd
plugin code and any future plugin-compat layer get font enumeration for
free. Follows the same pattern as loadFontAsync on FigmaAPI.
- packages/core/src/figma-api/proxy.ts: export FigmaFont type
({ fontName: FigmaFontName }) alongside FigmaFontName.
- packages/core/src/figma-api/index.ts: drop optional
listAvailableFontFamilies; add listAvailableFontsAsync() as a real
method (non-optional) with a safe [] default, mirroring loadFontAsync.
- packages/core/src/tools/read.ts: list_available_fonts execute() now
consumes Font[] and dedupes families via a Set. Drops the
host-capability fallback note — the new method always returns an
array, so a non-desktop host just yields {count: 0, fonts: []}.
- src/engine/fonts.ts: add listFonts() returning TauriFontFamily[]
({family, styles[]}) so the factory can emit one Font per (family,
style) pair.
- src/automation/figma-factory.ts: override listAvailableFontsAsync,
flattening {family, styles[]}[] to Font[].
- oxlint.json: add listFonts to the engine/fonts allowlist.
Tool public shape unchanged (still returns {count, fonts: string[]})
so agents calling list_available_fonts see no change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
useMagicKeys tracks key state globally, so plain/shift/space shortcuts
fired even when an <input> or <textarea> was focused. This caused
Backspace to delete selected nodes while editing values in the
property panel.
Track active element reactively via useActiveElement and gate plain(),
shift(), and spaceHeld computeds on !inputFocused.
Closes#200
Add disabled prop to Tip — forces tooltip closed when the parent popover
is open. Fixes the settings gear and variable-bind popovers not opening
on Windows WebView2 due to TooltipTrigger intercepting pointer events.
Closes#171
Co-authored-by: Anton Soldatov <sld0Ant@users.noreply.github.com>
Allow insecure localhost connections via NSAllowsLocalNetworking so the
WebSocket bridge works in release builds on macOS.
Also add debug logging for WebSocket and MCP health check failures.
Closes#190
Co-authored-by: Anton Soldatov <sld0Ant@users.noreply.github.com>
- Move use-external-link.ts → utils/external-link.ts (plain function, not a composable)
- Remove non-null assertions on providerDef.keyURL (v-if already guards)
- Remove test scaffolding from production global.d.ts
External links (<a target="_blank">) in the AI panel triggered
"Command plugin:shell|open not allowed by ACL" on Tauri desktop.
Root cause: Tauri v2 intercepts <a target="_blank"> via the shell plugin,
but only the opener plugin had ACL permissions. Replace all external links
with programmatic openUrl() from @tauri-apps/plugin-opener, which is already
whitelisted via "opener:default" in capabilities.
- Extract openExternalLink() composable for Tauri/browser switch
- Replace <a target="_blank"> with <button @click> in ProviderSetup/Settings
- Add E2E test verifying window.open is called in browser context
Fixes#193
Co-authored-by: Danila Poyarkov <dev@dannote.net>
Bumping the id re-keys the ToastRoot component, so reka-ui remounts it
and restarts the auto-dismiss countdown. Without this, a duplicate error
arriving at t=9s would vanish 1s later despite the fresh ×N badge.
Error toasts had `duration: 0` which in reka-ui means "never auto-
dismiss." Combined with the global `window.error` /
`unhandledrejection` handler in `setupGlobalErrorHandler`, any error-
emitting source — even a transient one called repeatedly per frame
(wheel events, render-loop callbacks) — could pile up over the
canvas with no way to recover except a hard-restart.
Three small changes that compose:
- Errors now use ERROR_TOAST_DURATION (10s) instead of 0. Long
enough to read; short enough to self-clean.
- Dedupe by (message, variant): repeating the same error increments
a count badge ("×3") on the existing toast instead of stacking
duplicates. Prevents the cascade-on-every-frame failure mode.
- Hard cap at 5 stacked toasts; oldest drop off when a 6th would
arrive. Belt-and-suspenders against any source we missed.
The Toast interface gains a non-optional `count: number` (defaults
to 1). External callers (info/warning/error) are unaffected — they
go through the new internal `push()` which sets it.
Add nodeToXPath() to core — builds a unique XPath selector for any node
by walking up the tree and using name predicates (or positional index
when names collide among same-type siblings). Properly escapes names
containing quotes using XPath concat() or double-quote delimiters.
UI: "Copy node ID" and "Copy XPath" items in the canvas right-click
"Copy/Paste as" submenu. Also i18n the entire submenu — all labels now
use menuMessages with translations for de, es, fr, it, pl, ru, zh-CN.
CLI: `open-pencil selection` command (app-mode only) returns selected
nodes with id, name, type, size, and xpath. Connects to the running
app via the automation WebSocket.
Automation: new `selection` RPC handler that returns selected nodes
with computed XPath selectors.
- Upgrade @open-pencil/yoga-layout to include free() and freeRecursive()
- freeYogaTree() in layout.ts now properly releases WASM memory
- FinalizationRegistry remains as a safety net for missed cleanup
Fixes#183
- Add packages/mcp/src/stdio.ts — proper stdio MCP server that
connects to the running app via WebSocket
- openpencil-mcp bin now points to stdio entry (was HTTP)
- openpencil-mcp-http bin added for the HTTP server
- Extract registerTools() from server.ts to share between transports
- Move index.ts banner output to stderr (prevents stdout corruption)
- Update Tauri shell spawn to use openpencil-mcp-http
- Update all docs (EN + 6 translations): correct binary names, source
paths, port number
Fixes#194
PropertyListRoot's update() and patch() pushed a new undo entry on
every call, so dragging a color slider or color area in the fill,
stroke, or effect pickers filled the undo stack with dozens of
intermediate entries per interaction. Cmd+Z had to be pressed many
times to revert a single conceptual color change.
Wrap update() and patch() in a debounced begin/commit batch keyed by
(op, propKey, index, nodeIds). Rapid events collapse into one undo
entry, and the batch commits after 300ms of idle. Changing prop key,
index, or selection flushes the previous batch immediately, and
add/remove/toggleVisibility flush any pending batch before running so
button actions never mix with drag history. The existing explicit
batch in toggleVisibility for multi-node is preserved.
Only raw handler left is nudge (needs keydown repeat events).
Tool shortcuts now use whenever(plain(code)) loop over TOOL_SHORTCUTS.
Space hold uses watch(spaceHeld) on a computed reactive boolean.
Extract inline callbacks into named functions (smartDelete,
confirmOrEnterText, escapeOrDeselect, toggleAutoLayout, toggleUI,
toggleAI, exportSelectionPng) so the shortcut mapping section is
a clean key→action table with no branching logic.
Nudge: Arrow keys move selection by 1px, Shift+Arrow by 10px.
Consecutive nudges within 300ms are batched into a single undo
entry so Cmd+Z undoes the entire sequence at once.
Fix duplicate redo creating nodes with new IDs, breaking
subsequent undo entries that reference the original IDs. Now
snapshotSubtree/restoreSubtree preserve node IDs across redo.
Fix delete undo not restoring children — deleteSelected only
snapshotted top-level nodes, so undoing a frame delete lost all
its children. Now snapshots the full subtree.
Fix draw tool not committing resize to undo — shapes created via
draw had their final dimensions lost on redo because handleDrawUp
never recorded the resize. Now uses beginBatch/commitBatch to
combine create+resize into a single undo entry.
Clean up use-keyboard.ts — remove shouldPreventDefault and opaque
PREVENT_* sets, move tool switch and nudge to dedicated keydown
listener, add shift() helper.
Closes#182
- Replace backslashes with forward slashes in noHandRolledColor and
noTypeofWindowCheck rules so file-path exclusions work on Windows
- Fixes Windows CI build failures where color.ts was incorrectly
flagged by no-hand-rolled-color
- Add save_file MCP tool that proxies to the browser app via RPC
- Add save_file RPC handler in the automation server
- Rebuild dist/ to fix stale @open-pencil/core/* self-referencing imports
from before the domain module restructuring
Fixes#184Closes#185 (save_file added; open_file requires File System Access API
which is browser-interactive; new_document needs a store reset method)
- Move clipboard-text-outlines.ts → text/opentype.ts
- Move kiwi/serialize-clipboard-v4.ts → text/clipboard-derived-text.ts
- Delete types-opentype.d.ts in favor of @types/opentype.js
- Add opentype.js to core package dependencies
- Add Vite alias for opentype.js ESM resolution
- Update all imports across core, kiwi, clipboard, and tests
* chore: remove unused pi context file
- Delete .pi/context.md
- Remove an unused local context artifact from the repository
* Add clipboard text v4 export
* fix(clipboard): rebase v4 export branch onto master
- Resolve conflicts after the core text module reorganization
- Fix clipboard text export imports and bundled font asset loading
- Update raw binary expectations for textUserLayoutVersion 4
- Re-run check and unit tests after the rebase
- Document semantic/conventional commit format in AGENTS.md with short subjects and detailed bodies
- Keep commit types lowercase, but start each commit body line with an uppercase word
- Add missing unreleased changelog entries from recent editor and UI fixes
- Tighten unreleased changelog wording to stay concise and user-facing
- Convert button, input, icon-button, toast, surface from flat-string
functions to slot-based { base: string } return pattern
- Convert section from bare TV exports to useSectionUI() with
{ wrapper, label } slots
- Remove standalone select helpers (selectTrigger, selectContent,
selectItem) in favor of useSelectUI()
- Delete use-component-ui.ts — no longer needed
- Update all 20+ call sites across property panels and chat components
Long localized labels like 'По содержимому' overflow the input.
Add sizingHugShort/sizingFillShort i18n keys with abbreviated
translations for all locales. Dropdown items keep full labels.
Replace the separate ScrubInput + AppSelect pair with a single
combined control: the sizing mode (Hug/Fill) shows as an inline
label inside the scrub input, swapping to a chevron on hover.
- ScrubInput: add group class and #suffix slot (stays generic)
- LayoutSection: embed reka-ui Select in the suffix slot with
group-hover toggle between label and chevron