Drop the emitter-to-shallowRef bridge (rAF batched, 60 lines).
state.sceneVersion on shallowReactive IS already Vue-reactive.
useSceneComputed is now 15 lines. Zero latency during scrub.
- CanvasContext hit test return types: unknown → SceneNode | null
- Move toast.ts from SDK to app (src/utils/toast.ts) — singleton
with unguarded window access doesn't belong in headless SDK
- ExportSection: store.graph.getNode → store.getNode
- ImageFillPicker: store.graph.images.get → store.getImage (new method)
- Add getImage(hash) to core editor create.ts
Zero store.graph access from components. Zero toast in SDK.
- duplicateSelected: recursive subtree cloning via graph.cloneTree()
Filters to top-level selection to avoid double-cloning. Children
get new IDs wired to new parent. Single undo entry.
- PropertyListRoot isMixed: compare array lengths first (fast path),
only JSON.stringify when lengths match (rare).
- glContext in use-canvas.ts: ownership verified correct, no change.
Critical:
- restorePageFromSnapshot: emit node:reordered after bulk restore
so component sync and useSceneComputed bridge detect changes
- subscribeToGraph: capture and cleanup unbind functions on
replaceGraph() — prevents listener leak on old graph emitter
Important:
- useNodeProps: node/nodes use useSceneComputed instead of plain
computed — re-evaluate on any scene mutation, not just selection
- Undo forward closures: explicit id: containerId/frameId/groupId
in createNode spreads — no more implicit ID preservation contract
Moderate:
- Extract usePropScrub composable — deduplicate previousValues +
updateProp + commitProp from AppearanceControls + PositionControls
Replace the sceneVersion counter hack with proper Vue pattern:
- shallowRef + triggerRef bridged from SceneGraph's nanoevents emitter
- Ref-counted listeners — subscribe on first useSceneComputed, unsubscribe
when last scope is disposed. WeakMap per editor instance.
- Emitter fires on node:updated/created/deleted/reparented/reordered
sceneVersion counter stays in EditorState for:
- Renderer cache invalidation (label-cache, scene picture recording)
- watch() dependencies in LayerTreeRoot, ExportSection, autosave
These are legitimate uses of the counter as a cache key.
Add useSceneComputed() to SDK — wraps a function in a computed()
that depends on sceneVersion internally. Eliminates the anti-pattern
of 'void editor.state.sceneVersion' at the top of computed blocks.
Updated 8 files: EditorMenuRoot, ExportControlsRoot, PageListRoot,
useSelectionState, PagesPanel, CodePanel, VariablesDialog,
VariablesSection.
Zero void-sceneVersion hacks remaining in components.
Extracted shared helpers to eliminate copy-pasted code:
Core:
- computeAbsoluteBounds() in geometry.ts — replaces 4 bounding box loops
- makeDocumentNodeChange/makeCanvasNodeChange in kiwi-serialize.ts
- parseFigBuffer/parseFigKiwiContainer in kiwi/fig-parse-core.ts
(fig-parse-worker.ts: 103 → 11 lines)
- createDefaultEditorState() — shared between core + app store
- calcClusterConfidence() in tools/analyze.ts
- retryWithNormalizedFamily() in fonts.ts
- getVectorNode() in tools/vector.ts
- renderFills() helper in renderer/scene.ts
- withNodeBounds() helper in renderer/overlays.ts
- drawNodeHighlightRect() in renderer/highlight-rect.ts
- isInViewport() in renderer/label-cache.ts
App:
- decodeTauriStderr() in utils/tauri.ts
- printNodeResults() in cli format.ts
Remaining 28 clones are: text-editor cursor movement (inherent),
xpath DOM traversal (inherent), small 5-line patterns too
context-specific to extract.
LayerTree SDK components:
- LayerTreeRoot — tree data from editor graph, expand/collapse with
auto-expand on selection, scroll-into-view, scope sync
- LayerTreeItem — per-node slot with isSelected, padLeft, select,
toggleExpand, toggleVisibility, toggleLock, rename
App LayerTree.vue now composes LayerTreeRoot + LayerTreeItem + TreeRoot/
TreeItem from Reka UI. Deleted LayerRow.vue — its logic is in the SDK.
EditorMenu SDK component:
- EditorMenuRoot — selection-aware state (hasSelection, isInstance,
isComponent, isGroup, canCreateComponentSet, otherPages, visibility,
lock) + copyAsText/SVG/JSX via slots
App CanvasMenu.vue now composes EditorMenuRoot — pure template with
Reka UI ContextMenu + Tailwind styling, zero business logic.
Removed useSelectionState composable — EditorMenuRoot replaces it.
New headless components:
- CanvasRoot — provides canvas context, initializes surface + render loop
- CanvasSurface — bare <canvas> element, receives ref from CanvasRoot
SkiaRenderer.renderFromEditorState() — single method replaces 30-line
state sync in use-canvas.ts. Takes EditorState + graph + textEditor
directly, sets all renderer properties and calls render().
Example app updated to use CanvasRoot/CanvasSurface, ToolbarRoot/
ToolbarItem, PageListRoot instead of old monolithic components.
New Reka UI-style headless components following the pattern:
ComponentName/context.ts — InjectionKey, provide/inject
ComponentName/Root.vue — state management, slot props
ComponentName/Item.vue — per-item logic, slot props
ComponentName/index.ts — barrel export
ScrubInput (new headless):
ScrubInputRoot — scrub state, pointer capture, edit/commit logic
ScrubInputField — input element for direct editing
ScrubInputDisplay — read-only display with slot for value/suffix
App ScrubInput.vue composes these with Tailwind styling
Toolbar (new headless):
ToolbarRoot — active tool, tool list, flyout state
ToolbarItem — per-tool active state and select handler
PageList (refactored):
PageListRoot — pages, currentPageId, CRUD actions via slots
Old components kept as backward-compatible aliases.
Add hitTestAtPoint() and selectAtPoint() to core editor/selection.ts.
CanvasContextMenu is now a pure Reka UI wrapper — receives onContextMenu
callback from EditorCanvas which owns the canvas ref and coordinates.
- VariablesDialog: replace 7 manual undo.push + graph mutation + requestRender
patterns with editor.renameCollection/addVariable/removeVariable etc.
- PositionSection: replace inline alignment/flip/rotate geometry with
editor.alignNodes/flipNodes/rotateNodes
- Move kiwi-serialize.ts into kiwi/ where it belongs
- Remove dead createPropertyChange export from undo.ts
- Un-export internal queryFonts/FontInfo from fonts.ts
- Deduplicate font weight maps: single FONT_WEIGHT_NAMES in fonts.ts,
figma-api-proxy.ts imports from there
- Add interaction constants to core barrel export (were in constants.ts
but not reachable via '@open-pencil/core')
- Fix variable shadowing in use-text-edit.ts: rename inner 'editor'
(textEditor) to 'textEd' to avoid shadowing the Editor parameter
- Fix unused params: prefix with _ in scene.ts, describe-issues.ts, xpath.ts
- Remove unused getCoords param from setupPanZoom
- Move cursorCanvasX/Y assignment to app callback (not on EditorState)
- Simplify EditorStore return: spread editor instead of listing 80+ methods
individually — new Editor methods auto-forward, no more manual sync
- Add tsgo to check script, add check:vue script for vue-tsc
- Add vue-tsc as dev dependency
- Reduces vue-tsc errors from 172 (pre-existing) to 135 (37 fixed)
Renderless components (Reka UI-style) exposing editor logic via slots:
- OpenPencilProvider — injects editor via Vue provide/inject
- OpenPencilCanvas — CanvasKit/Skia surface with render loop
- PageList, LayerTree, ToolSelector, NodeProperties — slot-based
Composables:
- useEditor() — access editor from any descendant
- useCanvas() — low-level surface lifecycle (moved from app)
The app's use-canvas.ts is now a 2-line re-export from the SDK.
Includes example app in packages/vue/example/.
Reuse safeColor() (which defaults a ?? 1) in the variable export
path. The Kiwi schema requires all Color struct fields — variables
created without explicit alpha caused 'Missing required field a'.
Fixes#128
New module packages/core/src/random.ts with randomHex, randomInt,
randomIndex — centralizes all crypto-based randomness.
Replaces:
- Date.now() IDs in VariablesDialog (not unique, not random)
- Hand-rolled hex token in automation/server.ts
- Inline crypto.getRandomValues in clipboard.ts, use-collab.ts
Also adds --color-component Tailwind theme color (#9747ff) and
replaces all 6 hardcoded occurrences across DesignPanel, LayerTree,
NodeContextMenuContent, and menu.ts.
- Add IS_BROWSER constant to core/constants.ts, rewrite IS_TAURI to use it
- Replace 9 raw 'typeof window' checks across canvaskit, fig-export,
figma-api, fig-file, fonts, SafariBanner, use-chat
- Promote no-typeof-window-check lint rule from warn to error
- Extract array mutation helpers (updateArrayItem, removeArrayItem,
toggleArrayVisibility) into useMultiProps — DRY up FillSection and
StrokeSection (removes 20+ duplicated for-loops)
- Merge loadLocalFont + tryLoadLocalFont into findLocalFont with
optional style param — eliminates duplicated queryLocalFonts logic
and adds consistent variable font filtering for CJK fallback
- Replace nested ternary font matching with a simple loop over
family name candidates
- Extract fetchBundledFont helper for browser/Node font resolution
- Ship Inter-Regular.ttf in packages/core/assets so headless CLI
can resolve the bundled fallback via import.meta.url instead of
fetch('/') which fails in Node/Bun (fixes#121 scenario)
- Use IS_BROWSER constant instead of repeating typeof window checks