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.
The loader div from index.html was never dismissed after the SDK
migration because use-canvas.ts moved to the SDK (which correctly
doesn't touch app-specific DOM). Added onReady callback in
EditorCanvas.vue to fade out and remove the loader.
Browser validation found 2 runtime issues:
1. TooltipProvider only wrapped EditorView, but <Tip> components in
AppToast rendered outside it → moved TooltipProvider to App.vue
to wrap everything including toasts.
2. SDK components call useEditor() but the app never provided the
editor via the SDK's injection system → added provideEditor(store)
in EditorView.vue so SDK primitives can access the editor.
Validated: zero console errors, canvas renders, demo shapes visible.
- 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.
The wrapper component existed only to do hit-test + select on right-click,
which is now editor.selectAtPoint(). The 5-line Reka UI ContextMenu
composition belongs directly in EditorCanvas where the canvas ref lives.
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.
FillPicker.vue (504 → 121 lines): dumb tab switcher, no gradient
internals. Delegates entirely to sub-components.
New GradientEditor.vue (227 lines): owns gradient stops, stop bar
with pointer drag, subtype select, and HsvColorArea for active stop.
Uses AppSelect instead of hand-rolled Reka UI Select.
New ImageFillPicker.vue (79 lines): image preview, file picker,
scale mode select via AppSelect.
Each component owns its full concern — no data leaking between them.
Replaced inline SVGs with Lucide icons, use twMerge for tab classes.
Delete 9 shim files from src/composables/ that just re-exported
SDK composables. All 18 consumers now import from '@open-pencil/vue'.
Merge dual useNodeProps()/useMultiProps() calls into single
useNodeProps() in 5 property section components.
src/composables/ now has only 4 app-specific files:
use-chat, use-collab, use-keyboard, use-menu.
- 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)