The selection size pill was drawn in local node space after
canvas.concat(worldMatrix * viewMatrix), causing it to scale
with the canvas zoom. Now computed in screen coordinates like
the multi-selection badge.
Non-Figma clipboard content (plain text, images) matched the figmeta
regex but failed at decodeMessage, flooding the console with unhandled
promise rejections on every paste.
- Export getAbsolutePositionFull from @open-pencil/core/canvas subpath
- Update vue import from @open-pencil/core/canvas/coordinate to @open-pencil/core/canvas
- Formatting fixes from oxfmt in pen/convert.ts, pen/read.ts, figma-factory.ts
- Move CODEGEN_PROMPT to codegen.md, loaded via raw-md bundler plugin
- Add JSX_REFERENCE as jsx-reference.md with full prop/tag/example docs
- Copy JSX Reference button (book icon) in Code panel header
- Multi-root JSX: try parsing as-is, wrap in fragment on failure
- Component and Instance tag aliases in JSX renderer
- renderJSX returns RenderResult[] to support fragments
- raw-md plugin for both tsdown and Vite
Co-authored-by: sld0Ant <sld0Ant@users.noreply.github.com>
- open_file: opens a .fig/.pen from disk into a new tab via MCP
- new_document: creates an empty doc with optional save path
- export_image, export_svg, get_jsx: optional path param writes output
to disk instead of returning base64/string (avoids flooding AI context)
- OPENPENCIL_MCP_ROOT env var scopes all file paths (defaults to cwd)
- setPlannedFilePath + startWatchingCurrentFile on editor store
- Extract openFileFromPath helper in use-menu.ts
- Add fs:allow-mkdir and scoped fs:allow-watch Tauri capabilities
- Better 'app not connected' error in stdio — instructs agent to stop
Co-authored-by: Jais Pedersen <jais@pedersens.net>
- Use applyStyleToRange from text/style-runs.ts instead of naively
appending runs (which created overlaps and invalid state)
- Apply fontWeight from style name (e.g. 'Bold' → 700)
- Apply color as fills on the style override
- Previously the color param was accepted but silently ignored
Fixes#214
JSX renderer: support position="absolute", top, and left props for
placing children inside auto-layout containers without breaking flow.
MCP server: send notifications/tools/list_changed when the desktop
app connects or disconnects, so MCP clients can refresh their tool
list instead of discovering disconnection per-call.
Addresses #208 (findings 2 and 5)
MCP clients often serialize numeric arguments as JSON strings.
Previously this caused validation errors on tools like node_move.
Now both the MCP server (zod) and AI adapter (valibot) coerce
string-encoded numbers to numbers, rejecting only genuinely
non-numeric values like 'abc'.
Fixes#207
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
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>
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(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>
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
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
- 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
The layout section was gated on node.type === 'FRAME', hiding it for
COMPONENT, COMPONENT_SET, and INSTANCE nodes that also support
auto-layout. Also use copyStyleRuns instead of structuredClone per
project lint rules.
styleRuns were updated on the graph during editing (via syncText) but
commitTextEdit only captured text changes. Now both text and styleRuns
are snapshotted at startTextEditing and restored on undo.
commitTextEdit compared prevText from the scene graph node, but
syncText already updates the node on every keystroke during editing.
This made prevText === newText, so the undo entry was never pushed.
Fix: capture the original text when startTextEditing is called and
use that snapshot for the undo comparison.