- New useInlineRename composable: shared editing state, commit logic,
and keyboard handling for inline rename across panels
- LayerTree: double-click a layer name to rename inline
- PagesPanel: refactored to use useInlineRename, removing duplicate code
Closes#57
Use one constant spring (stiffness 800, damping 50) for both drag
tracking and snap animation. Eliminates the transition switch that
caused motion-v to reinitialize mid-animation. Extract spring
constants to constants.ts. Fix selectTab not updating targetHeight.
* Add SVG export
Scene graph → SVG serializer with support for rectangles, ellipses,
lines, stars, polygons, vectors (fill/stroke geometry and vector
networks), text with style runs, gradients (linear, radial, angular),
image fills, effects (drop shadow, inner shadow, blur), opacity,
rotation, flips, blend modes, clip paths, and nested groups.
- packages/core: svg-node.ts (minimal XML builder), svg-export.ts
(serializer), ExportFormat now includes 'SVG', computeContentBounds
extracted for reuse
- packages/cli: export command accepts --format svg
- UI: SVG added to ExportSection format picker (scale hidden for SVG),
context menu 'Export as SVG' option
- 47 new tests covering SVGNode builder, geometry blob → path, vector
network → path, and full node export for all shape types
* Update changelog
* Add export_svg tool to ToolDefs
Available via MCP, AI chat, and CLI eval command.
* Replace standalone export items with Copy/Paste as submenu
Figma-style submenu: Copy as text, Copy as SVG, Copy as PNG (⇧⌘C),
Copy as JSX. Keeps Export as PNG (⇧⌘E) as a separate item.
* Remove standalone Export as PNG from context menu
* Update changelog
Upgrade reka-ui 2.8.2 → 2.9.0 and replace the hand-rolled HSV color
area, hue slider, alpha slider, and hex input with reka-ui's new
ColorArea, ColorSlider, and ColorField primitives.
- ColorAreaRoot + ColorAreaArea handle the 2D saturation/brightness
picker with proper gradient backgrounds and keyboard navigation
- ColorSliderRoot + ColorSliderTrack for hue and alpha channels with
automatic gradient rendering
- ColorFieldRoot + ColorFieldInput for hex color text input with
arrow key increment/decrement support
- Bridge between our Color type (0–1 range) and reka-ui's Color
(0–255 RGB) via hex string serialization
Removes ~110 lines of custom HSV↔RGB conversion math, pointer capture
handling, and CSS gradient computation.
Profiler modules in packages/core/src/profiler/:
- FrameStats: rolling 120-frame buffer tracking FPS, CPU/GPU times, node counts
- GpuTimer: EXT_disjoint_timer_query_webgl2 wrapper for GPU-side timing
- DrawCallCounter: WebGL context proxy counting draw calls per frame
- PhaseTimer: Chrome DevTools Performance panel custom tracks via User Timing API
- HudRenderer: in-canvas overlay with stats text and frame time bar graph
- CaptureStack: per-node profiling with speedscope JSON export
- RenderProfiler: orchestrates all layers with zero cost when disabled
Integration:
- SkiaRenderer.render() instrumented with phase markers for scene, picture
recording/replay, section titles, component labels, selection, rulers, flush
- Node counting and viewport culling tracking in renderNode()
- WebGL2 context passed through for GPU timer and draw call counter
- Shift+P keyboard shortcut to toggle HUD overlay
- toggleProfiler() added to editor store
Checkbox item in File > Auto-save to local file. Enabled by default.
When disabled, the 3s debounced auto-save to the opened .fig file
is skipped — user can still save manually with ⌘S.
- Remove opacity ScrubInput from color row (matches Figma layout)
- Constrain ColorInput with min-w-0 flex-1 so it doesn't push buttons off
- Add shrink-0 to eye/remove buttons to prevent squishing
- Move side selector from header to details row (right of weight input)
- Use 4-dot grid icon for the side selector button (matches Figma)
- Highlight button border with accent color when independent weights active
- Use 3-line stroke weight icon instead of filled rect
- Header now only has section label + add button
Global * scrollbar styles caused scrollbars to appear on the
menu bar and other elements. Replace with scrollbar-thin utility
applied only to panel scroll containers.
Decode internalOnly from kiwi field 142, filter internal pages
in SceneGraph.getPages() by default (pass true to include them).
Pages with names of only dashes/asterisks/spaces and no children
render as horizontal divider lines.
Panel resizing now creates a new surface from the existing
GrDirectContext instead of destroying and recreating the entire
renderer. All caches (paints, fonts, node pictures, scene picture,
filters) are preserved across resizes.
* Fix shadow rendering, implement blur effects, add spread support
- Fix drop shadow rendering order: draw shadows before fills so they
appear behind opaque shapes instead of on top (#23.1)
- Implement shadow spread: expand/contract shadow shape by spread value
for both drop shadow and inner shadow (#23.2)
- Implement background blur: clip to node shape and apply blur filter
to content behind the node (#23.3)
- Fix text shadows: use saveLayer with ColorFilter.MakeBlend to apply
shadows to text glyphs instead of the bounding box (#23.4)
- Implement layer blur with actual saveLayer + blur filter instead of
the no-op comment (#23.3)
- Implement foreground blur: clip to node shape and blur content in
front (#23.7)
- Add inner shadow spread support with rounded rect handling
- Add makeRRectWithSpread and makeRRectWithOffset helpers
Closes#23
* Add Effects section to demo showcasing shadow and blur fixes
Drop shadows (subtle, medium, heavy, with spread), inner shadows
(inset, with spread, on ellipse, combined), text shadows on glyphs,
layer blur, and glassmorphism background blur card.
* Fix drop shadow, layer blur, and background blur rendering
Drop shadow: use MakeDropShadowOnly to produce shadow-only output
that doesn't bleed through opaque fills. The sigma is radius/2 to
match Figma's blur convention.
Layer blur: wrap entire node content in a saveLayer with blur filter
at the renderNode level instead of trying to blur after-the-fact
in renderEffects.
Background/foreground blur: extract clipNodeShape helper to reduce
duplication in blur effect rendering.
Update tests to match new rendering approach.
* Add visual regression tests for effects rendering
9 snapshot tests covering drop shadow, inner shadow, spread,
ellipse shapes, text glyphs, layer blur, and combined effects.
* Make Effects demo section background white
* Clean up renderer code and tests
- Extract applyClippedBlur to deduplicate background/foreground blur
- Remove comments that repeat what code does
- Format with oxfmt
* Add undo/redo support for effect property changes
ScrubInput controls (offset, radius, spread, opacity) now use
scrubEffect for live preview and commitEffect on release to create
a single undo entry per interaction.
- Switch editor state from reactive() to shallowReactive() to avoid deep
Proxy wrapping of Set, arrays, and nested objects
- Replace Vue watch on renderVersion with useRafFn dirty-flag loop that
polls for changes without going through Vue's watcher scheduler
- Coalesce wheel events: accumulate deltaX/deltaY/zoomDelta between
frames and flush once per rAF instead of per event
- Coalesce Safari gesturechange events: buffer the latest scale/position
and apply once per rAF
- Fix Set.delete() on shallowReactive state to use replace pattern
- New AppSelect.vue: generic wrapper around reka-ui Select primitives
- Replace all 4 native <select> elements (font weight, effect type,
export scale, export format)
- Fix font picker dropdown truncating names (min-w-56)
- Show explanation when local fonts unavailable (Safari/Firefox)
Extract menu items into shared NodeContextMenuContent component,
used by both the canvas and layers panel context menus.
Right-clicking a layer selects it and shows the same menu.
On copy: serialize each text node's paragraph into an SkPicture and
embed the bytes (base64) in the OpenPencil clipboard payload.
On paste: if the font isn't available, render from the cached SkPicture
instead of the broken fallback. Text looks pixel-perfect without the
font installed — same behavior as Figma.
Missing font indicator:
- Orange ⚠ badge on canvas over text nodes with unavailable fonts
- Amber warning banner in the Typography panel listing missing families
- isFontLoaded() check in @open-pencil/core for querying font status
- Use Int32Array instead of Uint32Array for Kiwi pasteID (signed int field)
- Error toasts: don't auto-dismiss, show copy button, text is selectable
- Add clipboard roundtrip tests (encode → decode → verify)
useEditorStore() now returns a Proxy that delegates to the current
active store at access time. No more store() wrapper functions,
try/catch guards, or passing stores through function parameters.
Open multiple documents in tabs within a single window, like Figma.
- Tab bar appears when 2+ documents are open, with close (×) and new (+) buttons
- ⌘N / ⌘T create a new empty tab
- ⌘W closes the active tab (last tab closes and reopens as Untitled)
- ⌘O opens .fig files in a new tab (reuses current tab if Untitled with no changes)
- Middle-click closes a tab
- Each tab has its own SceneGraph, UndoManager, viewport, and selection state
- Switching tabs remounts the canvas to rebind the Skia renderer to the active store
- Keyboard shortcuts, menu actions, and AI tools all operate on the active tab
Header row with app icon, double-click-to-rename document name, and
sidebar toggle button. Shows in both Tauri and browser.
When panels are hidden, a floating bar on the canvas provides the same
controls to restore the UI.