- 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)
Replace BFS (which skipped DSD-modified nodes in visited set, breaking
the chain for deeper clones) with iterative convergence loop. Track
dsdSizeSet separately from dsdModified so nodes with only position/
geometry changes inherit size from their source.
Fix transitive override propagation: re-clone from source (not component)
to preserve componentId chain for DSD. Add syncChildrenDeep for deep
property propagation (stroke colors on nested vectors).
Reduces vector overflow in gold-preview.fig from 25 to 0 (visible,
unclipped).
- Resolve symbolOverride guidPaths by overrideKey instead of guid
- Handle overriddenSymbolID for instance component swaps
- Propagate overrides transitively through the componentId clone chain
- Preserve internalOnly flag on canvas nodes during export
- Scope computeAllLayouts to current page for paste/undo/font-load
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
During .fig import, instance nodes have no children — Figma expects
them to be cloned from their component. The componentId references
used original Figma GUIDs that didn't match our generated node IDs.
Fix:
- Track original GUID → new node ID mapping during import
- Remap all instance componentId references after node creation
- Iteratively populate empty instances from their components
(multiple passes needed since cloning creates new instances)
- Extract font key collection from editor store into
collectFontKeys() in packages/core/src/fonts.ts
- 18 tests: styleToWeight, weightToStyle, markFontLoaded/isFontLoaded,
collectFontKeys (nested nodes, style runs, deduplication, default
font filtering, invalid IDs)
MCP test: find_nodes searches currentPage only — switch to
FOUNDATIONS page where Button components live.
eval-cli test: material3.fig first page has 1 TEXT node, not 3 —
assert >0 instead of ==3.
Tauri's loadFont registered fonts with the CanvasKit provider but
not in core's loadedFamilies cache, so isFontLoaded returned false
and measureTextNode returned null during layout recomputation.
Add markFontLoaded() to core and call it from Tauri font loader.
Add tests verifying text measurement integrates with auto-layout
centering.
Each instance in Figma's clipboard carries symbolData.symbolOverrides
with per-child property changes (text, fills, visibility). The override
targets a child via guidPath which matches the overrideKey field on the
component's child node.
After cloning component children into instances, overrides are resolved
through the overrideKey→figmaId→internalId→clonedChild chain and applied
to the instance's children.
FRAME nodes with VARIANT componentPropDefs are now promoted to
COMPONENT_SET on import (both .fig files and clipboard paste).
Clipboard paste now respects the Internal Only Canvas: components
on it are used to populate instance children but are not pasted
as visible nodes, matching Figma's behavior.
When pasting from Figma, INSTANCE nodes reference their component via
symbolData.symbolID but have no children in the clipboard. If the
component was also pasted, clone its children into the instance using
the componentId mapping (cloneChildrenWithMapping).
Extract shared kiwi→SceneNode conversion into kiwi-convert.ts, used by
both fig-import.ts (.fig file opening) and clipboard.ts (Figma paste).
The clipboard import was a simplified copy that diverged over time:
- Missing SYMBOL→COMPONENT type mapping (Figma's kiwi name for components)
- textAutoResize hardcoded to NONE instead of using clipboard value
- No gradient/image fill support, no effects, no style runs
- Missing flipX/flipY, constraints, text decorations, arc data, etc.
- Percent letter spacing not converted to pixels
Now both paths use nodeChangeToProps() with identical conversion logic.
* Replace saveLayer with MaskFilter for drop shadows
Drop shadows now draw directly with MaskFilter.MakeBlur instead of
saveLayer + ImageFilter. Eliminates per-shadow offscreen buffer
allocation, significantly improving render throughput for scenes
with many shadowed nodes.
Text shadows still use saveLayer (MaskFilter can't be applied to
text glyphs individually).
Benchmark: 50 shadow nodes render at 1.55ms/frame (scene change).
* Cache per-node SkPictures for effect rendering
Nodes with visible effects (shadows, blurs) are recorded into
individual SkPictures and replayed on subsequent scene redraws.
Only the modified node re-records; unchanged shadow nodes replay
from cache.
- MaskFilter.MakeBlur for drop shadows instead of saveLayer
- Reusable effectLayerPaint and cached ImageFilters/MaskFilters
- invalidateNodePicture() called on node update
- invalidateAllPictures() on font load; invalidateScenePicture()
preserves node cache for scene-only invalidation
- Shadow benchmark: 50 nodes at 1.47ms/frame (scene change)
* Update changelog with shadow rendering performance improvements
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
* 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.
Covers a few gaps in the existing MCP test suite:
- find_nodes filtering by type (wasn't exercised at all)
- create_shape with an invalid type enum (Zod enforcement)
- create_shape with a missing required param (Zod enforcement)
13 tests using InMemoryTransport from @modelcontextprotocol/sdk:
- Tool registration (all 76 tools listed with descriptions)
- new_document, open_file, save_file lifecycle
- Tool execution through MCP protocol (create, fill, delete, query)
- Error paths: no document loaded, nonexistent node, invalid file path
- Full workflow: new → create → nest → query → delete
Fix @open-pencil/core dep in MCP package: workspace:* for local dev
(pnpm publish resolves this to the actual version at publish time).
- 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)
Redo was creating duplicate childIds because snapshots captured after
tree construction had populated childIds, and createNode appends to
parent.childIds again. Fix: clear childIds in redo snapshots.
New tests (7):
- layoutAlignSelf from stackChildAlignSelf
- clipsContent from frameMaskDisabled
- fontWeight from fontName.style via styleToWeight
- letterSpacing object → pixels conversion
- undo removes full subtree
- redo recreates correct parent-child tree
- redo without childIds:[] demonstrates the duplicate bug
Figma clipboard data includes VARIABLE_SET, VARIABLE, and other non-visual
types that were falling through to the default RECTANGLE case in mapNodeType.
These now join DOCUMENT and CANVAS in the skipTypes set.
The first render records an SkPicture before fonts are loaded (fallback
drawText path). When fonts finish loading asynchronously, the cached
picture was never invalidated — subsequent renders replayed stale picture
without paragraph text. Hovering a node forced a full re-render (volatile
overlay path), making text reappear. Un-hovering replayed the stale cache.
Fix: call invalidateScenePicture() at the end of loadFonts() so the next
render records a fresh picture with proper paragraph text.
29 tools: open_file, save_file, new_document + all 26 from @open-pencil/core.
Stdio for MCP clients (Claude Code, Cursor), HTTP (Hono + Streamable HTTP
with sessions) for scripts, browser extensions, CI.
Runs on both Bun and Node.js (via tsx).
9 app-menu tests: menu visibility, all 6 submenus content, Undo/Duplicate/
Zoom to fit via menu actions.
2 autosave tests: write triggers after scene change with mock file handle,
no write without file handle.
- Replace _id/_graph/_api with Symbol-keyed properties so eval scripts
can't access SceneGraph internals directly
- fills/strokes/effects return frozen structuredClone copies
- Fix currentPage getter to define selection only once per proxy
- Fix layoutSizingHorizontal/Vertical: respect parent layout direction
even when the node itself is an auto-layout frame
- Add 6 new tests: layoutSizing, frozen arrays, internals not exposed
- sceneNodeToJsx() in @open-pencil/core: converts SceneNode subtree to JSX
- CodePanel.vue with Prism.js highlighting, line numbers, copy button
- Third tab in properties panel: Design | Code | AI
- 14 new tests covering shapes, text, layout, effects, multi-selection
Tool part type is 'tool-create_shape' (name embedded in type field),
not a separate toolName property. Extract via part.type.replace('tool-', '').
Mock transport now emits proper UI message stream protocol:
tool-input-start → tool-input-delta → tool-input-available → tool-output-available.