Full Figma-compatible vector network: vertices, segments (cubic
bezier with tangent offsets), and regions (filled closed paths).
Pen tool interaction:
- Click to place vertices, click+drag to set bezier tangent handles
- Click first vertex to close path (with visual highlight)
- Enter to commit open path, Escape to cancel
- Preview line from last vertex to cursor
- Tangent handle visualization (lines + control points)
Rendering:
- Vector nodes rendered via CanvasKit Path API
- Converts vector network to path: regions as closed fills,
segments as open strokes
- Proper cubic bezier from tangentStart/tangentEnd offsets
Data model:
- VectorNetwork, VectorVertex, VectorSegment, VectorRegion types
- vectorNetwork field on SceneNode (nullable)
- computeVectorBounds for node sizing
- HandleMirroring enum for future handle behavior
Snap targets are now siblings of the moving node (not root children),
converted to absolute coordinates. This fixes guides not reflecting
resized objects.
Un-rotate mouse position around node center before testing against
axis-aligned handle positions. Fixes resize handles being unclickable
after rotating a shape.
- Blue frame name above top-level frames (root children only)
- Blue pill with W × H below selection bounding box
- Pre-create labelFont/sizeFont once on font load to avoid
CanvasKit raw-to-smart pointer error
When selecting a node inside a frame, the parent frame gets a
dashed blue outline (50% opacity, 4px dash) showing hierarchy
context — matching Figma's behavior.
- TreeRoot/TreeItem from reka-ui for proper tree structure
- Click arrow to expand/collapse frames with children
- Leaf nodes (no children) show no arrow
- Drag reorder preserved with drop indicator
- Bigger arrow icons (text-xs) with hover highlight
Drag layers to reorder within parent or reparent into frames.
Blue indicator line shows insertion point, ring highlight on
drop-into containers. 4px dead zone before drag starts.
Prevents dropping into self or descendants.
Figma-style scrubby inputs: hover icon shows ew-resize cursor,
drag left/right to change value. Click the number to type.
Replaces all raw number inputs in PropertiesPanel.
Uses VueUse useEventListener for automatic cleanup.
Vue's computed caching compares array contents by reference. Since
graph.getNode() returns the same mutable object, the computed saw
no change even when properties were mutated. Spreading creates new
objects so Vue detects the difference.
selectedNodes computed didn't depend on renderVersion, so mutations
to node objects via graph.updateNode() weren't triggering reactivity.
Adding void state.renderVersion ensures the computed recomputes
whenever requestRender() is called.
- 9-dot alignment grid for simultaneous primary + counter axis
- Direction buttons with SVG icons (vertical/horizontal/wrap)
- W/H sizing mode dropdowns (Fixed/Hug/Fill) with click-outside
- Uniform padding input with expand toggle for per-side padding
- Gap input with visual icon
- + / − to add/remove auto layout instead of triple toggle
- Sizing mode awareness: shows Fill option when inside auto-layout
Dragging inside auto-layout frames:
- 8px dead zone before breaking out of auto-layout flow
- Blue insertion indicator line between children, computed from
cursor position relative to child midpoints
- Drop reorders children in the parent's childIds array and
recomputes layout via Yoga
- Dragging from outside into an auto-layout frame shows the
indicator and inserts at the correct index
SceneGraph.reorderChild() added for index-based child insertion.
Renderer draws the indicator as a 2px blue line (horizontal for
vertical layout, vertical for horizontal layout).
Single frame selected: toggles auto-layout on/off.
Any other selection: creates a new frame around the selected nodes,
reparents them into it, and enables vertical auto-layout with HUG
sizing.
navigator.clipboard.write() inside .then() loses user gesture context
and gets blocked by the browser. The schema is prefetched on app init
and cached, so encoding is done synchronously in clipboardData.setData
within the native copy event.
Copy now produces actual fig-kiwi binary (Kiwi schema + encoded
Message with NodeChanges) matching Figma's clipboard format. Schema
is bundled as public/figma-schema.bin (28KB deflated, 540 defs)
and prefetched on app init.
Dual clipboard: synchronous OpenPencil internal format written via
clipboardData.setData, then navigator.clipboard.write overwrites
with combined HTML containing both (figma) and (openpencil) markers.
Paste checks our format first, falls back to Figma decode.
navigator.clipboard.read() requires clipboard-read permission and
silently fails on localhost. Use copy/cut/paste DOM events instead —
the browser provides clipboardData synchronously, no permissions needed.
Shortcut table covers all Figma shortcuts organized by category
(tools, file, edit, view, object, text, arrange, canvas interaction)
with implementation status tracking.
Zoom: use continuous exponential scaling (0.99^deltaY) instead of
fixed 1.1/0.9 factor per event.
File parser:
- .fig files are ZIP archives with Kiwi-encoded canvas data
- Detects canvas blob by name (canvas.fig) or largest binary entry
- Decodes Kiwi Message → NodeChange[] → SceneGraph
Node conversion:
- Maps Figma types to OpenPencil types (COMPONENT → FRAME, etc.)
- Extracts position from transform matrix (m02, m12)
- Extracts rotation from transform matrix (atan2)
- Converts fillPaints/strokePaints to Fill[]/Stroke[]
- Converts effects (shadows, blurs)
- Handles independent corner radii
- Preserves text properties (font, size, alignment, spacing)
- Builds parent-child tree from parentIndex GUIDs
- Sorts children by parentIndex.position
Browser compatibility:
- fzstd for Zstd decompression (replaces Bun.zstd*)
- fflate for ZIP extraction
- Cmd+O opens file dialog for .fig files
- UndoManager.clear() for resetting on file open
Font loading:
- Inter Regular font bundled (342KB TTF)
- TypefaceFontProvider for Paragraph API
- MakeFreeTypeFaceFromData for Font object
- Fonts load async, fallback to drawText before ready
Text rendering:
- ParagraphBuilder with font family, size, letter spacing,
line height, text alignment
- Multi-line text wraps to node width
- Text nodes render at correct position in local space
Inline editing:
- Double-click text node → HTML textarea overlay
- Positioned/sized to match node on canvas (zoom-aware)
- Live updates to scene graph on input
- Escape or blur commits edit
- Keyboard events stop propagation during editing
- Text cursor for TEXT tool
Scene graph:
- Nodes use relative coordinates (x,y relative to parent)
- getAbsolutePosition() walks up the parent chain
- reparentNode() preserves visual position when moving between frames
- Deep hit testing traverses into container children
- hitTestFrame() finds deepest frame under cursor (for drop targeting)
- flattenTree() returns indented layer list for the panel
- isDescendant() prevents circular reparenting
Renderer:
- canvas.translate(node.x, node.y) per node — children render relative
- All shapes draw at (0,0) in their local space
- Drop target highlight (blue outline on frame during drag-over)
- Selection UI uses getAbsolutePosition() for screen-space handles
Input:
- Drag onto frame → blue highlight → drop reparents nodes
- Handle hit testing uses absolute positions
- Snap guides work with absolute coordinates
Layers panel:
- Tree view with depth-based indentation (16px per level)
- Expand arrow for containers with children
- Shift+click for additive selection
Rotation:
- Rotation handle above selection (line + circle)
- Drag to rotate, Shift snaps to 15° increments
- Live preview during drag, applied on release
- Rotated nodes render correctly with canvas.rotate()
Multi-select bounding box:
- Individual outlines per node + unified group bbox
- Group resize handles on the unified bounds
- Handles rotated AABB for rotated nodes
Snap guides:
- Magenta guide lines when moving nodes near edges/centers
- Snaps to left/right/top/bottom/center of other nodes
- 5px threshold, guides clear on mouse up
Text tool:
- T shortcut or toolbar to activate
- Click to place text node (200×24, default 'Text')
- CanvasKit drawText with default font (Inter to come)
Color picker (Reka UI Popover):
- HSV saturation/brightness area with pointer drag
- Hue slider (rainbow gradient)
- Alpha slider
- Hex input + alpha percentage
- Live color updates to fill/stroke
Properties panel:
- Add/remove fills and strokes with +/× buttons
- Toggle fill visibility
- Stroke weight editing
- Multi-select shows layer count + shared opacity
- ColorPicker integrated for fill and stroke colors
Frame clipping:
- Frame children now clip to parent bounds
The WebGL surface is bound to the framebuffer size at creation time.
Resizing the canvas element without recreating the surface left dark
empty space. Now createSurface() is called on every resize.
- Resize by dragging selection handles (8 positions: corners + edges)
- Shift+resize maintains aspect ratio
- Marquee selection: drag on empty canvas to select nodes in rectangle
- Alt+drag to duplicate and move selected nodes
- Cmd+D to duplicate in place (+20px offset)
- Cmd+A to select all
- Shift+draw constrains to square/circle
- Cursor changes to resize arrows on handle hover
- Marquee drawn as blue transparent rect with selection stroke
Draw selection outlines and resize handles in screen space (CSS pixels)
instead of canvas coordinates. Skia renders via dpr-scaled transform
so strokes stay 1px regardless of zoom level.