Remove 14 src/engine/ shims and entire src/kiwi/ directory.
Only src/engine/fonts.ts remains (has real Tauri font logic).
Replace isTauri() with IS_TAURI constant.
- 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
Load a system CJK font (PingFang SC, Microsoft YaHei, Noto Sans CJK, etc.)
at startup and pass it as a fallback in CanvasKit's fontFamilies array.
Falls back to Google Fonts (Noto Sans SC) when no system font is available.
Also fixes loadFont to only cache fonts that successfully register with
CanvasKit's TypefaceFontProvider, preventing invalid font data from being
treated as loaded.
Closes#48
- Scope hover hit-test to current page (was searching all pages including
internal component page, causing ghost hover outlines)
- Frames/sections without visible fills or strokes are click-through
- Groups are always click-through (only children are hittable)
- Clipping parents reject hits outside their bounds
- Instances/components check children before falling back to fill check
- Switch test fixtures from material3.fig (55MB) / nuxtui.fig (82MB) to
gold-preview.fig (537KB) for fast dev runs, gate heavy fixtures behind
BUN_HEAVY_TESTS env var (enabled in CI)
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
- Add destroyed flag to SkiaRenderer.destroy() to prevent double-free
- Check destroyed flag in renderNow() to skip rendering after unmount
(loadFonts promise could resolve after component unmounts)
Replace all 'Inter' string literals with DEFAULT_FONT_FAMILY from
constants. Add loadFontsForNodes() that collects font families from
text nodes and loads them into CanvasKit, called after clipboard
paste and .fig file import.
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.
Eliminates per-frame WASM allocations for shadows and blurs:
- Reusable effectLayerPaint instead of new Paint() per effect
- ImageFilter cache keyed by params (drop shadow, blur, decal blur)
- Zero allocations during SkPicture recording for cached effects
- 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
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
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.
- AppToast component: top-center blue pill (info), red pill (errors)
- Global error handler catches window.error + unhandledrejection
- Link copied toast on share and copy link button
- use-toast composable: toast.show(message, variant)
- Cloudflare Worker + Durable Object server (packages/collab/)
- One DO per room with WebSocket Hibernation for near-zero idle cost
- Yjs sync protocol for document state (CRDT merge)
- Yjs awareness protocol for cursors, selection, presence
- Persists doc state to DO storage
- CORS headers for cross-origin WebSocket upgrade
- Client composable (src/composables/use-collab.ts)
- Y.Doc ↔ SceneGraph bidirectional binding
- Awareness: broadcasts cursor position, selection, name, color
- Auto-join from /share/:roomId URL
- Suppresses echo loops between Yjs and graph updates
- CollabPanel component (src/components/CollabPanel.vue)
- Share button generates room link, copies to clipboard
- Join flow: paste link or room ID
- Avatar stack with colored initials and tooltips
- Name input persisted to localStorage
- Remote cursor rendering in CanvasKit
- Colored arrow cursor + name label pill per remote peer
- Remote selection outlines in peer's color
- Only shows cursors on the same page
- SPA redirect for /share/* routes (Cloudflare Pages _redirects)
- Track click count with timing/proximity in onMouseDown
- Double-click in text: select word (was only handled in dblclick)
- Triple-click in text: select all
- Double-click to enter editing: select word at click position
- Add selectLine/selectLineAt to TextEditor
Use markRaw to prevent Vue from deep-proxying the Chat class
instance, which broke its internal Vue refs. Add dedent for
multiline prompts.
Tests use mock transport by default. Set TEST_REAL_LLM=1 and
OPENROUTER_API_KEY to run against real OpenRouter.
StyleRun model: array of {start, length, style} with
CharacterStyleOverride (fontWeight, italic, textDecoration, etc.).
Renderer uses ParagraphBuilder.pushStyle/pop for mixed-style text.
TextEditor delegates paragraph building to renderer.buildParagraph().
⌘B/I/U apply to selection range when text is selected, or toggle
whole-node style when no selection. Style runs adjust on
insert/delete to preserve formatting across edits.
Fig roundtrip: import characterStyleIDs + styleOverrideTable from
TextData, export back with deduped style table.
B/I/U/S toggle buttons in TypographySection.
TextEditor class in core — cursor positioning, selection, word
boundaries, line navigation via CanvasKit Paragraph API
(getGlyphPositionAtCoordinate, getRectsForRange).
Renderer draws selection highlights and blinking caret directly
on the canvas. Blue outline around text node during editing.
Phantom textarea captures input, IME composition, clipboard,
arrow keys, Home/End, word/line movement (⌥/⌘ modifiers).
Replaces the old visible <textarea> overlay.
Extract renderThumbnail and renderNodesToImage into render-image.ts,
reusing the same offscreen CanvasKit surface approach that fig-export
used for thumbnails.
- ExportSection in properties panel with scale/format selectors,
multi-export support, and live preview with checkerboard background
- Export via context menu and ⇧⌘E shortcut
- Tauri save dialog, File System Access API, and download fallback
- JPG renders with white background, PNG/WEBP with transparency
Add @/ → src/ alias in vite.config.ts and tsconfig.json.
Rewrite all ../ imports across 32 files to use @/ paths.
Same-directory ./ imports kept as-is.