openpencil/PLAN.md

1272 lines
60 KiB
Markdown
Raw Normal View History

# OpenPencil
Open-source, AI-native design editor. Think Figma, but you can self-host it, extend it, and talk to it.
## Why
- Figma is proprietary, expensive, and actively fights programmatic access
- Penpot is SVG-based (slow for complex documents)
- No existing tool combines open source + Skia rendering + AI-native design + .fig compatibility
## Positioning
| | OpenPencil | Figma | Penpot |
|---|---|---|---|
| Open source | ✅ MIT | ❌ | ✅ |
| Rendering | Skia (WASM) | Skia (WASM) | SVG |
| AI-native | ✅ 26 tools | ❌ Plugins only | ❌ |
| .fig import/export | ✅ | N/A | ❌ |
| Desktop | Tauri (~7 MB) | Electron | Browser |
| Collaboration | P2P (WebRTC + CRDT) | Proprietary | WebSocket |
| Self-hosted | ✅ | ❌ | ✅ |
| Cost | Free forever | $15/editor/mo | Free (self-host) |
## Reusable assets from figma-use
We've built a substantial toolkit in figma-use that transfers directly:
### Figma binary format (Kiwi)
- Full Kiwi encoder/decoder for .fig files
- 533-definition schema (NodeChange with 538 fields)
- Zstd compression/decompression
- Direct WebSocket multiplayer protocol (3000-6000x faster than plugin API)
- Variable binding encoding
- All node types, fills, strokes, effects, transforms
### JSX renderer
- `packages/render` — declarative JSX-to-design-nodes renderer
- Frame, Text, Rect, Ellipse, Line, Star, Polygon, Vector, Icon primitives
- Layout props (flex, gap, padding, justify, items)
- Variable references in colors (`var:Name`, `$Name`)
### Design linter (17 rules)
- `packages/linter` — standalone, reusable as-is
- Design tokens, layout, typography, accessibility, structure rules
- Presets: recommended, strict, accessibility, design-system
### MCP server
- `packages/mcp` — full MCP implementation for AI agents
- All design operations as MCP tools
- Battle-tested with Claude, works with any MCP client
### XPath query engine
- `packages/plugin/src/query.ts` — query nodes by type, attributes, structure
- `//FRAME[@width > 100]`, `//TEXT[contains(@name, 'Button')]`
### Export pipeline
- JSX export with icon matching (Iconify)
- Storybook generation (React/Vue)
- Font extraction (Google Fonts CSS)
- Screenshot/PNG/SVG/PDF export
### Analyze tools
- Cluster detection (repeated patterns → potential components)
- Color palette analysis with similarity merging
- Typography audit (font combinations, sizes, weights)
- Spacing analysis (grid compliance check)
- Accessibility tree snapshot
---
## Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ Tauri Shell │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Editor (Web) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────────────────────┐ │ │
│ │ │ Vue 3 UI │ │ Skia CanvasKit (WASM, 7MB) │ │ │
│ │ │ │ │ - Vector rendering │ │ │
│ │ │ - Toolbar │ │ - Text shaping │ │ │
│ │ │ - Panels │ │ - Image processing │ │ │
│ │ │ - Properties │ │ - Effects (blur, shadow) │ │ │
│ │ │ - Layers │ │ - Export (PNG, SVG, PDF) │ │ │
│ │ │ - Variables │ │ │ │ │
│ │ │ - AI Chat │ └─────────────────────────────────┘ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Core Engine (TS) │ │ │
│ │ │ │ │ │
│ │ │ SceneGraph ─── Layout (Yoga) ─── Selection │ │ │
│ │ │ │ │ │ │ │
│ │ │ Undo/Redo ─── Constraints ─── Hit Testing │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ File Format Layer │ │ │
│ │ │ │ │ │
│ │ │ .openpencil (Kiwi binary, same codec as .fig) │ │ │
│ │ │ .fig import ── .pen import ── .svg/.png export │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ AI Tools (26 ToolDefs) │ │
│ │ │ │
│ │ create_shape ── set_fill ── render (JSX) ── get_node │ │
│ │ set_layout ── find_nodes ── eval_code ── ... │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Collaboration (P2P, no server) │ │
│ │ │ │
│ │ Trystero (WebRTC) ── Yjs (CRDT) ── y-indexeddb │ │
│ │ Cursors ── Presence ── Follow mode │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
## Tools (complete list)
Everything a designer needs, organized by what you're doing.
### Editor layout
Follows Figma's UI3 layout (introduced Config 2024) — toolbar at the bottom, navigation on the left, properties on the right. This is the modern standard designers expect.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Navigation panel (left) │ Canvas │ Properties panel (right) │
│ │ │ │
│ ┌──────────────────────┐ │ │ ┌──────────────────────┐ │
│ │ 📄 File name ▾ │ │ │ │ Appearance │ │
│ ├──────────────────────┤ │ │ │ ┌──────────────────┐ │ │
│ │ Layers │ Assets │ Pages│ │ ┌─────────────────┐ │ │ │ W: 400 H: 300 │ │ │
│ ├──────────────────────┤ │ │ │ │ │ │ X: 100 Y: 200 │ │ │
│ │ ▾ 🔲 Header │ │ │ Selected Frame │ │ │ │ R: 0° │ │ │
│ │ ├── T Logo │ │ │ │ │ │ └──────────────────┘ │ │
│ │ ├── 🔲 Nav │ │ └─────────────────┘ │ ├──────────────────────┤ │
│ │ │ ├── T Home │ │ │ │ Layout │ │
│ │ │ └── T About │ │ ┌──────────┐ │ │ Auto layout → ↓ ↩ │ │
│ │ └── ◆ CTA Button │ │ │ Card │ │ │ Gap: 16 Pad: 20 │ │
│ │ ▾ 🔲 Hero │ │ │ │ │ ├──────────────────────┤ │
│ │ ├── T Heading │ │ └──────────┘ │ │ Position │ │
│ │ └── ○ Avatar │ │ │ │ Constraints: ◫ │ │
│ │ ▸ 🔲 Footer │ │ │ ├──────────────────────┤ │
│ │ │ │ │ │ Fill │ │
│ │ │ │ │ │ ■ #3B82F6 100% + │ │
│ │ │ │ │ ├──────────────────────┤ │
│ │ │ │ │ │ Stroke │ │
│ │ │ │ │ │ (none) + │ │
│ │ │ │ │ ├──────────────────────┤ │
│ │ │ │ │ │ Effects │ │
│ │ │ │ │ │ (none) + │ │
│ │ │ │ │ ├──────────────────────┤ │
│ │ │ │ │ │ Export │ │
│ │ │ │ │ │ (none) + │ │
│ └──────────────────────┘ │ │ └──────────────────────┘ │
│ │ │ │
├──────────────────────────┴────────────────────────────────────┴──────────────────────────┤
│ Toolbar (bottom) │
│ ▶ Select │ # Frame ▾ │ □ Shape ▾ │ ✎ Pen ▾ │ T Text │ 🤚 Hand │ 💬 Comment │ ⚡Actions │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
```
**Three panels:**
- **Navigation panel (left)** — tabs for Layers, Assets (component library), Pages. File name + actions dropdown at top. Resizable, collapsible.
- **Canvas (center)** — infinite canvas with zoom/pan. All design work happens here.
- **Properties panel (right)** — context-sensitive. Sections: Appearance (size, position, rotation), Layout (auto-layout / grid), Position (constraints), Fill, Stroke, Effects, Export. Resizable.
- **Toolbar (bottom)** — design tools in a horizontal strip. Frees up vertical space on canvas. Includes the Actions menu (AI, plugins, productivity shortcuts).
**Why bottom toolbar (Figma UI3 style):**
- More vertical canvas space (design is primarily vertical — phone screens, web pages)
- Tools are closer to where your cursor naturally rests
- Actions menu at the end provides AI/plugin access without cluttering the toolbar
- Consistent with what Figma designers already know (as of April 2025, UI3 is the only Figma UI)
### Toolbar (bottom bar)
Horizontal strip at the bottom of the canvas. Tools grouped with subtle dividers. Some tools have a ▾ dropdown for nested tools.
```
┌─────┬─────────┬──────────┬────────┬───────┬───────┬─────────┬──────────┐
│ ▶ │ # ▾ │ □ ▾ │ ✎ ▾ │ T │ 🤚 │ 💬 │ ⚡Actions │
│Select│ Frame │ Shapes │ Draw │ Text │ Hand │ Comment │ │
│ V │ Section │ Rect R │ Pen P │ T │ H │ C │ │
│ K │ Slice │ Ellipse O│Pencil │ │ │ │ │
│ │ │ Line L │ │ │ │ │ │
│ │ │ Arrow │ │ │ │ │ │
│ │ │ Polygon │ │ │ │ │ │
│ │ │ Star │ │ │ │ │ │
└─────┴─────────┴──────────┴────────┴───────┴───────┴─────────┴──────────┘
```
**Actions menu (⚡)** — the AI entry point:
- 🤖 AI Chat — open sidebar for conversational design with AI
- Quick actions: create component, auto-layout, tidy up, etc.
- Plugins
- Run MCP tool manually
### Tool options bar
A horizontal bar above the canvas changes based on the active tool. Shows contextual controls the designer needs right now.
| Active tool | Options bar contents |
|---|---|
| **Select** (nothing selected) | *empty* |
| **Select** (node selected) | X, Y, W, H inputs · Rotation · Corner radius · Constraints dropdown |
| **Select** (multiple selected) | Align buttons (6) · Distribute H/V · Tidy up · Boolean ops dropdown |
| **Frame** | Preset sizes dropdown (iPhone 16, Desktop 1440, Custom…) · Fill color · Layout mode toggle |
| **Rectangle** | W, H inputs · Fill color · Corner radius · Stroke toggle |
| **Ellipse** | W, H inputs · Fill color · Arc start/end/ratio |
| **Line** | Length · Stroke color · Stroke weight · Cap style (butt/round/square) · Arrow toggles |
| **Polygon** | Sides (3-12 slider) · Radius · Fill color |
| **Star** | Points (3-20) · Inner radius ratio (0-1) · Fill color |
| **Pen** | Path close toggle · Fill/Stroke toggles · Bend tool |
| **Pencil** | Stroke weight · Smoothing (0-100) · Stroke color |
| **Text** | Font family · Weight · Size · Line height · Letter spacing · Align (L/C/R/J) · Color |
| **Hand** | Zoom level indicator |
| **Comment** | *empty* (click canvas to place) |
### Canvas navigation
| Action | Input | Behavior |
|--------|-------|----------|
| Pan | Space+drag / Middle mouse drag / Two-finger trackpad | Move viewport |
| Zoom in | Cmd+= / Scroll up / Pinch out | Zoom toward cursor position |
| Zoom out | Cmd+- / Scroll down / Pinch in | Zoom toward cursor position |
| Zoom to fit all | Shift+1 | Fit all content in view with padding |
| Zoom to selection | Shift+2 | Fit selected nodes in view |
| Zoom to 100% | Cmd+0 | Reset to actual pixels |
| Zoom to 50%/200% | Cmd+1 / Cmd+2 | Preset zoom levels |
| Pixel preview | — | Render at 1x showing actual pixels |
| Rulers | Shift+R | Toggle rulers on canvas edges |
| Grid | Cmd+' | Toggle layout grid overlay |
| Guides | Drag from ruler | Create horizontal/vertical guide line |
| Minimap | — | Small overview in bottom-right corner (toggle) |
### Selection & manipulation
| Action | Input | Behavior |
|--------|-------|----------|
| Select | Click | Select topmost node under cursor |
| Deep select | Double-click | Enter group/frame, select child |
| Multi-select | Shift+click | Toggle node in selection |
| Marquee select | Drag on empty canvas | Select all nodes intersecting rectangle |
| Move | Drag selected | Move by delta. Shift constrains to axis. Smart guides snap to edges/centers of siblings |
| Resize | Drag handle | 8 handles around selection. Shift constrains proportions. Alt resizes from center |
| Rotate | Hover just outside corner handle → rotate cursor → drag | Rotation. Shift snaps to 15° increments |
| Scale | K then drag | Scale tool, resizes content including text size and stroke weight |
| Nudge | Arrow keys | Move 1px. Shift+arrow moves 10px |
| Duplicate | Alt+drag / Cmd+D | Duplicate in place or at drag position |
| Copy/Paste | Cmd+C / Cmd+V | Clipboard. Paste positions at cursor or center of viewport |
| Copy as CSS | Cmd+Shift+C | Copy selected node's styles as CSS to clipboard |
| Delete | Backspace / Delete | Remove selected nodes |
### Smart guides & snapping
| Feature | Behavior |
|---------|----------|
| Edge snapping | Red lines appear when edges align with siblings (±1px threshold) |
| Center snapping | Vertical/horizontal center lines shown |
| Spacing guides | Pink dimension labels when equal spacing detected between 3+ objects |
| Parent padding | Snap to parent's padding boundaries |
| Grid snapping | Snap to pixel grid (configurable: 1px, 8px, custom) |
| Distance labels | Shows distance (px) between selected node and hovered node |
### Shapes
| Tool | Shortcut | Draw behavior | Modifier keys |
|------|----------|---------------|---------------|
| Frame | F | Click+drag to create sized frame. Click to create default (100×100) | Shift: square. Alt: from center |
| Section | Shift+S | Click+drag to create section region | — |
| Rectangle | R | Click+drag | Shift: square. Alt: from center. Both: square from center |
| Ellipse | O | Click+drag | Shift: circle. Alt: from center |
| Line | L | Click+drag | Shift: constrain to 0°/45°/90° |
| Arrow | Shift+L | Click+drag, auto-adds arrow endpoint | Shift: constrain angle |
| Polygon | — | Click+drag for size, then adjust sides in options bar | Shift: constrain proportions |
| Star | — | Click+drag for outer radius, options bar for points/inner ratio | Shift: constrain proportions |
### Pen & Pencil
| Action | Input | Behavior |
|--------|-------|----------|
| Place point | Click | Straight corner point |
| Place curve point | Click+drag | Bezier curve, drag sets handle length/angle |
| Close path | Click first point | Closes the path |
| Cancel | Escape | Finish open path |
| Continue path | Select endpoint + P | Resume drawing from an existing path end |
| Edit point | Double-click node with Select tool | Enter vector edit mode |
| Move point | Drag point | Move anchor point |
| Adjust handle | Drag handle | Change curve shape. Alt+drag breaks handle symmetry |
| Add point | Click on segment | Insert new anchor point on path |
| Delete point | Select point + Delete | Remove point, path reconnects |
| Toggle straight/curve | Double-click point | Convert between corner and smooth point |
| Bend | Click segment + drag | Convert straight segment to curve |
| Pencil freehand | Shift+P then drag | Freehand stroke, auto-simplified to bezier path |
### Text
| Action | Input | Behavior |
|--------|-------|----------|
| Create text box | T then click | Auto-width text, grows horizontally |
| Create fixed-width text | T then click+drag | Fixed width, wraps and grows vertically |
| Edit text | Double-click text node | Enter text editing mode |
| Select word | Double-click word | Select word |
| Select paragraph | Triple-click | Select paragraph |
| Select all | Cmd+A (in text edit mode) | Select all text in this text node |
| Bold range | Select text + Cmd+B | Toggle bold on selection |
| Italic range | Select text + Cmd+I | Toggle italic on selection |
| Underline | Select text + Cmd+U | Toggle underline |
| Strikethrough | Select text + Cmd+Shift+X | Toggle strikethrough |
| Change font/size | Select text + use options bar | Mixed styles within one text box |
| Bulleted list | Select + options bar | Toggle unordered list |
| Numbered list | Select + options bar | Toggle ordered list |
| Link | Select text + Cmd+K | Add hyperlink |
| Exit text edit | Escape / Click outside | Return to Select tool |
**Text auto-resize modes:**
- **None** — fixed width and height, clips overflow
- **Height** — fixed width, grows vertically to fit content
- **Width and height** — grows both directions to fit content
- **Truncate** — fixed size, shows ellipsis (…) on overflow
### Properties panel (right side)
Context-sensitive. Sections are grouped to match Figma UI3's modern layout. Sections collapse/expand. The panel is resizable.
**Header row** — shows selection name, component status, and quick actions (mask, create component, boolean ops, more ⋯ menu).
**Appearance section:**
- W, H inputs (width, height)
- Rotation input (degrees)
- Corner radius: uniform input, click 🔓 for independent corners (TL, TR, BR, BL)
- Corner smoothing slider (0-100%, squircle)
- Opacity slider 0-100%
- Blend mode dropdown (18 modes)
- Clip content checkbox
**Layout section:**
- Shows "Use auto layout" button when no layout set
- When auto-layout active: direction, gap, padding, justify, align, child sizing (see Layout panel details below)
- When grid active: template columns/rows, gaps (see Grid layout details below)
**Position section:**
- X, Y inputs (absolute position on canvas)
- Constraints visual picker (box with pin toggles for each edge + center)
- Horizontal: left / right / left+right / center / scale
- Vertical: top / bottom / top+bottom / center / scale
- "Ignore auto layout" toggle (absolute position within auto-layout parent)
**Fill section:**
- Add/remove fills (multiple fills supported, stacked with blend modes)
- Types: solid, linear gradient, radial gradient, angular gradient, diamond gradient, image
- Color picker: HSB/RGB/Hex input, opacity slider, eyedropper tool (I)
- Gradient editor: add/remove stops, drag stop positions, edit stop colors
- Image fill: tile/fill/fit/crop modes, exposure/contrast/saturation filters
- Variable binding: click 🔗 icon → pick variable from collection
**Stroke section:**
- Add/remove strokes (multiple supported)
- Color (same as fill)
- Weight: number input (supports independent per-side: top/right/bottom/left)
- Alignment: inside / center / outside
- Dashes: gap and dash length inputs (or preset patterns)
- Cap: butt / round / square
- Join: miter / bevel / round
- Variable binding for color
**Effects section:**
- Add/remove effects (stackable)
- Drop shadow: color, X offset, Y offset, blur, spread, show behind node toggle
- Inner shadow: color, X offset, Y offset, blur, spread
- Layer blur: radius
- Background blur: radius
**Export section:**
- Add export presets: format (PNG/SVG/PDF/JPG) + scale (0.5x-4x) + suffix
- Multiple presets per node
- Export selected: Cmd+Shift+E
### Layout panel (auto-layout frames)
Appears in properties panel when a frame with layout is selected.
```
┌─────────────────────────────────────┐
│ Auto Layout [X] │ ← click X to remove layout
├─────────────────────────────────────┤
│ Direction: [→] [↓] [↩] │ ← horizontal / vertical / wrap
│ Gap: [16] Padding: [20] [🔓] │ ← unlock for per-side padding
│ │
│ Justify: [≡] start/center/end/between/around/evenly
│ Align: [≡] start/center/end/stretch/baseline
│ │
│ ─── Children sizing ─── │
│ Primary: Fixed / Fill / Hug │
│ Counter: Fixed / Fill / Hug │
│ Min W: [__] Max W: [__] │
│ Min H: [__] Max H: [__] │
└─────────────────────────────────────┘
```
**CSS Grid mode** (when Yoga supports it):
```
┌─────────────────────────────────────┐
│ Grid Layout [X] │
├─────────────────────────────────────┤
│ Columns: [1fr] [1fr] [1fr] [+] │ ← add/remove/edit tracks
│ Rows: [auto] [auto] [+] │
│ Col gap: [16] Row gap: [12] │
└─────────────────────────────────────┘
```
### Layers panel
Left side panel. Tree view of the document.
```
┌──────────────────────────────────────┐
│ 📄 Page 1 ▾ │ ← page selector dropdown
├──────────────────────────────────────┤
│ ▾ 🔲 Header 👁 🔒│
│ ├── T Logo text 👁 │
│ ├── 🔲 Nav 👁 │
│ │ ├── T Home 👁 │
│ │ ├── T About 👁 │
│ │ └── T Contact 👁 │
│ └── ◆ CTA Button (instance) 👁 │
│ ▾ 🔲 Hero Section 👁 │
│ ├── T Heading 👁 │
│ ├── T Subheading 👁 │
│ └── ○ Avatar 👁 │
│ ▸ 🔲 Footer (collapsed) 👁 │
└──────────────────────────────────────┘
```
| Action | Input | Behavior |
|--------|-------|----------|
| Select layer | Click | Select corresponding node on canvas |
| Multi-select | Cmd+click | Add/remove from selection |
| Range select | Shift+click | Select range between last selected and clicked |
| Expand/collapse | Click ▸/▾ | Toggle children visibility |
| Rename | Double-click name | Inline rename |
| Reorder | Drag layer | Move in tree (changes z-order and parent) |
| Toggle visibility | Click 👁 | Show/hide node |
| Toggle lock | Click 🔒 | Lock/unlock node |
| Drag into frame | Drag layer onto another layer | Reparent node |
| Search | Cmd+F in panel | Filter layers by name |
### Components
| Action | How |
|--------|-----|
| Create component | Select frame → Right-click → "Create component" or Cmd+Alt+K |
| Create instance | Drag component from assets panel or Alt+drag existing instance |
| Detach instance | Right-click instance → "Detach instance" |
| Reset overrides | Right-click instance → "Reset all overrides" |
| Edit main component | Double-click instance → "Go to main component" |
| Create variant | Select component → "+" button in properties → set variant property values |
| Swap variant | Select instance → variant dropdown in properties panel |
| Add component prop | Select main component → properties panel → "+" next to properties section |
| Component prop types | Text (overridable string), Boolean (show/hide layer), Instance swap (replace nested instance), Variant (select variant) |
### Variables panel
Separate panel (tab alongside properties), or modal.
```
┌──────────────────────────────────────┐
│ Variables │
├──────────────────────────────────────┤
│ 📁 Colors [+] │ ← collection
│ Modes: [Light] [Dark] [+] │
│ ───────────────────────────────── │
│ Primary 🟦 #3B82F6 🟦 #60A5FA│
│ Secondary 🟪 #8B5CF6 🟪 #A78BFA│
│ Background ⬜ #FFFFFF ⬛ #0F172A│
│ Text ⬛ #1E293B ⬜ #F8FAFC│
│ │
│ 📁 Spacing [+] │
│ SM 4 │
│ MD 8 │
│ LG 16 │
│ XL 32 │
│ │
│ 📁 Typography [+] │
│ Body Size 16 │
│ Heading Size 32 │
└──────────────────────────────────────┘
```
| Action | How |
|--------|-----|
| Create collection | "+" button at top → name collection → add modes |
| Create variable | "+" inside collection → type (color/number/string/boolean) → name → values per mode |
| Bind to node | Select node → click 🔗 in fill/stroke/text/etc. → pick variable |
| Edit variable | Click value cell → edit inline |
| Switch mode | Top-right mode switcher in canvas → all bound properties update live |
| Alias variable | Set variable value to reference another variable |
### Boolean Operations
| Operation | Shortcut | Description |
|-----------|----------|-------------|
| Union | Cmd+Shift+U | Combine shapes |
| Subtract | Cmd+Shift+S | Cut one shape from another |
| Intersect | Cmd+Shift+I | Keep overlapping area |
| Exclude | Cmd+Shift+E | Keep non-overlapping areas |
| Flatten | Cmd+E | Merge into single vector |
### Alignment & distribution
Available when 2+ nodes selected. Shown in options bar and right-click menu.
| Action | Shortcut | Description |
|--------|----------|-------------|
| Align left | Alt+A | Align left edges |
| Align horizontal center | Alt+H | Align horizontal centers |
| Align right | Alt+D | Align right edges |
| Align top | Alt+W | Align top edges |
| Align vertical center | Alt+V | Align vertical centers |
| Align bottom | Alt+S | Align bottom edges |
| Distribute horizontally | Ctrl+Alt+H | Equal horizontal spacing |
| Distribute vertically | Ctrl+Alt+V | Equal vertical spacing |
| Tidy up | Ctrl+Alt+T | Auto-arrange into grid with equal spacing |
| Match width | — | Set all selected to same width |
| Match height | — | Set all selected to same height |
### Pages
| Action | How |
|--------|-----|
| Switch page | Click page name in layers panel dropdown |
| Create page | "+" next to page dropdown |
| Rename page | Double-click page name |
| Delete page | Right-click → Delete (cannot delete last page) |
| Reorder pages | Drag in dropdown list |
| Duplicate page | Right-click → Duplicate |
### Import & Export
| Format | Import | Export |
|--------|--------|--------|
| .fig (Figma) | ✅ Kiwi binary codec | ✅ Kiwi + Zstd + thumbnail |
| .png | ✅ (image fill) | ✅ (1x, 2x, 3x) via CLI and editor |
| .jpg | ✅ (image fill) | ✅ |
| .webp | — | ✅ |
| .svg | planned | planned |
| .pdf | — | planned |
| Figma clipboard | ✅ copy/paste between apps | ✅ |
### AI tools — 26 tools
Tools are defined once in `packages/core/src/tools/schema.ts` as framework-agnostic `ToolDef` objects. Available in the in-app AI chat (via Vercel AI SDK adapter) and in the CLI via the `eval` command.
#### Read (7 tools)
| Tool | Description |
|------|-------------|
| `get_selection` | Get selected nodes |
| `get_page_tree` | Get page node tree |
| `get_node` | Get node by ID |
| `find_nodes` | Find nodes by name or type |
| `list_pages` | List all pages |
| `list_variables` | List design variables |
| `list_collections` | List variable collections |
#### Create (4 tools)
| Tool | Description |
|------|-------------|
| `create_shape` | Create a shape (frame, rect, ellipse, line, polygon, star, text) |
| `render` | Render JSX to design nodes |
| `create_component` | Convert frame to component |
| `create_instance` | Create a component instance |
#### Modify (10 tools)
| Tool | Description |
|------|-------------|
| `set_fill` | Set fill color |
| `set_stroke` | Set stroke color and weight |
| `set_effects` | Set effects (shadow, blur) |
| `update_node` | Update node properties (position, size, text, etc.) |
| `set_layout` | Set auto-layout properties |
| `set_constraints` | Set resize constraints |
| `rename_node` | Rename a node |
| `reparent_node` | Move node to a different parent |
| `clone_node` | Duplicate a node |
| `delete_node` | Delete a node |
#### Organize (4 tools)
| Tool | Description |
|------|-------------|
| `select_nodes` | Set selection |
| `group_nodes` | Group nodes |
| `ungroup_node` | Ungroup |
| `switch_page` | Switch to a different page |
#### Escape hatch (1 tool)
| Tool | Description |
|------|-------------|
| `eval_code` | Execute code with Figma-compatible plugin API |
### Prototyping (planned)
| Feature | Description |
|---------|-------------|
| Connections | Link frames with interaction triggers |
| Triggers | Click, hover, press, mouse enter/leave, after delay, drag |
| Actions | Navigate to, overlay, swap, back, scroll to, open URL |
| Transitions | Instant, dissolve, smart animate, move in/out, push, slide |
| Easing | Linear, ease-in, ease-out, ease-in-out, spring, custom bezier |
| Preview | Play prototype in browser |
| Device frames | iPhone, Android, Desktop, custom sizes |
---
---
## Technical Deep Dive
### Scene Graph
The scene graph is a tree of nodes. Every node is identified by a GUID (`sessionID:localID`) and has a parent reference via `ParentIndex` (parent GUID + position string for z-ordering).
We reuse Figma's proven schema — 194 message/enum/struct definitions, with `NodeChange` as the central type (~390 fields after removing deprecated tag fields).
#### Node hierarchy
```
Document
└── Canvas (page)
├── Frame
│ ├── Rectangle
│ ├── Text
│ └── Frame (nested)
│ ├── Ellipse
│ └── Instance (→ references Component)
├── Component
│ └── ...children (the main component definition)
├── Section
│ └── Frame
├── Group
│ └── ...children
└── BooleanOperation
└── ...operand shapes
```
#### Node types (29, from Figma Kiwi schema)
| Type | ID | Description |
|------|----|-------------|
| DOCUMENT | 1 | Root, one per file |
| CANVAS | 2 | Page |
| GROUP | 3 | Group container |
| FRAME | 4 | Primary container (artboard), supports auto-layout |
| BOOLEAN_OPERATION | 5 | Union/subtract/intersect/exclude result |
| VECTOR | 6 | Freeform vector path |
| STAR | 7 | Star shape |
| LINE | 8 | Line |
| ELLIPSE | 9 | Ellipse/circle, supports arc data |
| RECTANGLE | 10 | Rectangle |
| REGULAR_POLYGON | 11 | Regular polygon (3-12 sides) |
| ROUNDED_RECTANGLE | 12 | Rectangle with smooth corners |
| TEXT | 13 | Text with rich formatting |
| SLICE | 14 | Export region |
| SYMBOL | 15 | Component (main) |
| INSTANCE | 16 | Component instance |
| STICKY | 17 | FigJam sticky note |
| SHAPE_WITH_TEXT | 18 | FigJam shape |
| CONNECTOR | 19 | Connector line between nodes |
| CODE_BLOCK | 20 | FigJam code block |
| WIDGET | 21 | Plugin widget |
| STAMP | 22 | FigJam stamp |
| MEDIA | 23 | Video/GIF |
| HIGHLIGHT | 24 | FigJam highlight |
| SECTION | 25 | Canvas section (organizational) |
| SECTION_OVERLAY | 26 | Section overlay |
| WASHI_TAPE | 27 | FigJam washi tape |
| VARIABLE | 28 | Variable definition node |
#### Core node properties
Every node carries these fields (subset of NodeChange):
```
Identity: guid, type, name, phase (CREATED/REMOVED)
Tree: parentIndex (parent GUID + position string)
Transform: size (Vector), transform (2x3 Matrix), rotation
Appearance: fillPaints[], strokePaints[], effects[], opacity, blendMode
Stroke: strokeWeight, strokeAlign, strokeCap, strokeJoin, dashPattern[]
borderTopWeight, borderBottomWeight, borderLeftWeight, borderRightWeight
borderStrokeWeightsIndependent
Corners: cornerRadius, cornerSmoothing
rectangleTopLeftCornerRadius, rectangleTopRightCornerRadius
rectangleBottomLeftCornerRadius, rectangleBottomRightCornerRadius
rectangleCornerRadiiIndependent
Visibility: visible, locked
Constraints: horizontalConstraint, verticalConstraint
```
Type-specific fields:
```
Text: textData (characters, styleOverrides, baselines, glyphs)
fontSize, fontName, lineHeight, letterSpacing, paragraphSpacing
textAlignHorizontal, textAlignVertical, textAutoResize, textTruncation
textCase, textDecoration, textListData
fontVariant* (ligatures, numeric, caps, position)
fontVariations[], hyperlink
Vector: vectorData (vectorNetworkBlob, normalizedSize)
fillGeometry[], strokeGeometry[]
handleMirroring, arcData (for ellipse arcs)
Star: starInnerScale, count (point count)
Component: symbolData, componentKey, symbolDescription
componentPropDefs[], isSymbolPublishable
sharedComponentMasterData, sharedSymbolMappings[]
Instance: overriddenSymbolID, symbolData.symbolOverrides[]
componentPropRefs[], componentPropAssignments[]
overrideStash[], propsAreBubbled
Layout: stackMode (NONE/HORIZONTAL/VERTICAL)
stackSpacing, stackPadding, stackHorizontalPadding, stackVerticalPadding
stackPaddingRight, stackPaddingBottom
stackJustify, stackCounterAlign, stackCounterAlignItems
stackPrimaryAlignItems, stackPositioning, stackReverseZIndex
stackPrimarySizing, stackCounterSizing, stackChildPrimaryGrow
stackChildAlignSelf
bordersTakeSpace, resizeToFit
Grid: gridRowCount, gridColumnCount
gridRowGap, gridColumnGap
gridColumnSizes[], gridRowSizes[] (GridTrackSize: type + value)
Styles: inheritFillStyleID, inheritStrokeStyleID, inheritTextStyleID
inheritEffectStyleID, inheritGridStyleID
styleType, styleDescription
Prototype: prototypeInteractions[] (event + actions[])
transitionNodeID, transitionType, transitionDuration, easingType
overlayPositionType, overlayRelativePosition
prototypeStartingPoint, prototypeStartNodeID
Variables: variableData (value + dataType for BOOLEAN/FLOAT/STRING)
Paint.variableBinding (binds fill/stroke color to variable GUID)
Export: exportSettings[], exportBackgroundDisabled
Plugin: pluginData[], pluginRelaunchData[]
Accessibility: ariaRole, accessibleLabel
Connectors: connectorStart, connectorEnd, connectorLineStyle
connectorStartCap, connectorEndCap, connectorControlPoints[]
```
#### Paint (fill/stroke)
```
Paint {
type: SOLID | GRADIENT_LINEAR | GRADIENT_RADIAL | GRADIENT_ANGULAR
| GRADIENT_DIAMOND | IMAGE | EMOJI | VIDEO
color: {r, g, b, a} (0-1 floats)
opacity: 0-1
visible: bool
blendMode: NORMAL | MULTIPLY | SCREEN | ... (18 modes)
stops: ColorStop[] (for gradients: color + position)
transform: Matrix (for gradient/image positioning)
image: Image{hash, name, dataBlob}
imageScaleMode: TILE | FILL | FIT | CROP
paintFilter: tint, shadows, highlights, exposure, temperature, vibrance, contrast
variableBinding: PaintVariableBinding (binds color to variable GUID)
}
```
#### Effect
```
Effect {
type: INNER_SHADOW | DROP_SHADOW | FOREGROUND_BLUR | BACKGROUND_BLUR
color: {r, g, b, a}
offset: {x, y}
radius: float (blur radius)
spread: float (shadow spread)
visible: bool
blendMode: BlendMode
showShadowBehindNode: bool
}
```
#### In-memory representation
Nodes live in a flat `Map<string, Node>` keyed by GUID string. The tree structure is maintained via `parentIndex` references. This gives O(1) lookup by ID and efficient traversal.
```typescript
interface SceneGraph {
nodes: Map<string, Node>
root: string // Document GUID
getNode(id: string): Node
getChildren(id: string): Node[] // Sorted by position string
getParent(id: string): Node | null
createNode(type: NodeType, parent: string, props: Partial<NodeChange>): Node
updateNode(id: string, changes: Partial<NodeChange>): void
deleteNode(id: string): void
moveNode(id: string, newParent: string, position: string): void
// Queries
findByType(type: NodeType): Node[]
findByName(pattern: string): Node[]
hitTest(point: Vector, canvas: string): Node | null
getNodesInRect(rect: Rect, canvas: string): Node[]
}
```
### Undo/Redo
Figma's own approach (visible in the Message schema): `Message.localUndoStack` and `Message.localRedoStack` — each undo entry is a full `Message` containing the inverse `NodeChange[]`.
We use the same **inverse command** pattern:
```typescript
interface UndoEntry {
label: string // "Create Rectangle", "Change fill", etc.
forward: NodeChange[] // Changes to apply
inverse: NodeChange[] // Changes to revert (auto-computed)
timestamp: number
}
interface UndoManager {
undoStack: UndoEntry[]
redoStack: UndoEntry[]
apply(changes: NodeChange[], label: string): void // Pushes inverse onto undoStack
undo(): void // Pops undoStack, pushes to redoStack
redo(): void // Pops redoStack, pushes to undoStack
beginBatch(label: string): void // Group multiple changes into one undo step
commitBatch(): void
}
```
How inverse computation works:
| Operation | Forward | Inverse |
|-----------|---------|---------|
| Create node | `{guid, phase: CREATED, ...props}` | `{guid, phase: REMOVED}` |
| Delete node | `{guid, phase: REMOVED}` | `{guid, phase: CREATED, ...allProps}` (snapshot) |
| Change prop | `{guid, fill: "#F00"}` | `{guid, fill: "#00F"}` (previous value) |
| Move node | `{guid, parentIndex: newParent}` | `{guid, parentIndex: oldParent}` |
| Reparent | `{guid, parentIndex: newParent}` | `{guid, parentIndex: oldParent}` |
Before applying any change, we snapshot the affected fields. The snapshot becomes the inverse. This is simple, correct, and the exact pattern Figma uses.
**Batching:** operations like "drag to move" produce hundreds of position changes per second. We debounce into a single undo entry. `beginBatch`/`commitBatch` wraps multi-step operations (e.g., "create component" = create frame + set symbolData + create children).
### Figma Compatibility & Pixel-Perfect Testing
The goal: open any `.fig` file and render it identically to Figma.
#### .fig file format
```
┌─────────────────────────────────┐
│ Magic header: "fig-kiwi" (8B) │
│ Version (4B uint32 LE) │
│ Schema length (4B uint32 LE) │
│ Compressed Kiwi schema │
│ Message length (4B uint32 LE) │
│ Compressed Kiwi message │ ← NodeChange[] (the entire document)
│ Blob data │ ← Images, vector networks, fonts
└─────────────────────────────────┘
```
We already have the full pipeline from figma-use:
- **Kiwi schema**: 194 definitions, 2178 lines (in git: `9216dcc^:packages/cli/src/multiplayer/schema.ts`)
- **Codec**: encode/decode Messages with Kiwi, Zstd compress/decompress (`codec.ts`, 546 lines)
- **Protocol**: wire format parsing, message type detection (`protocol.ts`, 238 lines)
- **Client**: WebSocket multiplayer connection (`client.ts`, 351 lines)
#### Import pipeline
```
.fig file
→ parse header (magic + version)
→ decompress Zstd
→ decode Kiwi schema
→ decode Message → NodeChange[]
→ build SceneGraph (flat map of nodes)
→ resolve blob references (images, vector networks)
→ apply to OpenPencil scene graph
```
#### Rendering compatibility
Both OpenPencil and Figma use Skia CanvasKit for rendering. This means identical rendering primitives. The pixel-perfect challenge is in:
1. **Layout computation** — auto-layout (flexbox) results must match exactly. Using Yoga helps since Figma's layout is also CSS-flexbox-based, but we need to verify edge cases.
2. **Text shaping** — same font + same Skia text shaper = same glyphs. We must use the same fonts (embedded in .fig blobs or loaded from the same sources).
3. **Effect rendering** — shadows, blurs, blend modes are Skia-native, should match.
4. **Vector path rendering** — vector networks are stored as blobs, need exact reproduction of fill/stroke geometry.
5. **Corner smoothing** — Figma's "smooth corners" (squircle) uses `cornerSmoothing` (0-1). Skia doesn't have native squircle — needs a custom path approximation matching Figma's implementation.
6. **Subpixel positioning** — rounding differences at fractional coordinates.
#### Fuzzy pixel-perfect test suite
```
Crawl Figma files → for each:
1. Export from Figma (via REST API or screenshot):
figma-use export node <id> --scale 2 --output expected.png
2. Import .fig into OpenPencil
3. Render same node in OpenPencil:
openpencil render <id> --scale 2 --output actual.png
4. Compare with pixelmatch:
- Threshold: 0.1 (allow minor subpixel differences)
- Report: diff percentage, diff image, failing regions
- Pass if < 0.5% pixels differ
```
**Test corpus structure:**
```
tests/figma-compat/
├── corpus/
│ ├── basic-shapes.fig # Rectangles, ellipses, lines, stars, polygons
│ ├── auto-layout.fig # Flexbox: horizontal, vertical, nested, wrap
│ ├── grid-layout.fig # CSS Grid layouts
│ ├── text-styles.fig # Fonts, sizes, line heights, letter spacing, mixed styles
│ ├── effects.fig # Shadows, blurs, blend modes
│ ├── gradients.fig # Linear, radial, angular, diamond, image fills
│ ├── components.fig # Components, instances, overrides, variants
│ ├── constraints.fig # Pin constraints, scale, fill container
│ ├── vectors.fig # Pen tool paths, boolean operations
│ ├── corner-smoothing.fig # Squircle / smooth corners at various values
│ ├── masks.fig # Mask layers, outline masks
│ ├── variables.fig # Variable-bound fills, strokes, text
│ └── real-world/
│ ├── landing-page.fig
│ ├── mobile-app.fig
│ └── design-system.fig
├── expected/ # Screenshots from Figma (ground truth)
│ ├── basic-shapes/
│ │ ├── node-1-2.png
│ │ └── ...
├── actual/ # Screenshots from OpenPencil (test run)
├── diffs/ # Visual diffs
└── report.html # Test report with side-by-side comparison
```
**CI pipeline:**
```bash
# 1. Crawl: download .fig files and export screenshots from Figma
bun run test:figma:crawl
# 2. Render: import .fig into OpenPencil, render same nodes
bun run test:figma:render
# 3. Compare: pixel diff with threshold
bun run test:figma:compare
# 4. Report: generate HTML report with failures
bun run test:figma:report
```
**Compatibility tiers:**
| Tier | Requirement | What it covers |
|------|-------------|----------------|
| T0 | Exact pixels | Basic shapes, solid fills, positioning, sizing |
| T1 | < 0.1% diff | Auto-layout, text (minor font hinting differences) |
| T2 | < 0.5% diff | Effects, gradients, masks |
| T3 | < 1% diff | Complex components, real-world files |
| T4 | Visual match | Corner smoothing, subpixel rendering edge cases |
### Layout Engine: Yoga
Based on our research, **Yoga** is the right choice now:
- **CSS Grid support is landing** — [facebook/yoga#1893](https://github.com/facebook/yoga/pull/1893)–#1902 (9 PRs by @intergalacticspacehighway from Expo)
- PR 1/9 (style types & public API) under active review by NickGerleman (Meta), last activity Feb 28, 2026
- Supported: `grid-template-columns/rows`, `grid-column/row-start/end`, `grid-auto-columns/rows`, `minmax()`, `auto`, `%`, `px`, `fr`
- Not yet: `repeat()`, `auto-fill`/`auto-fit`, `grid-template-areas`, `grid-auto-flow`, subgrid
- Battle-tested in React Native (billions of devices)
- ~45KB WASM, well-maintained by Meta
- Flexbox + Grid covers everything a design tool needs
- No need to maintain a custom layout engine
We'll wrap Yoga with a thin adapter that speaks our property names (`fill_container` → `flex-grow:1`, `fit_content` → `auto`).
Mapping Figma layout fields to Yoga:
| Figma (NodeChange field) | Yoga equivalent |
|---|---|
| `stackMode: HORIZONTAL` | `flexDirection: row` |
| `stackMode: VERTICAL` | `flexDirection: column` |
| `stackSpacing` | `gap` |
| `stackPadding` / `stackHorizontalPadding` / `stackVerticalPadding` / `stackPaddingRight` / `stackPaddingBottom` | `padding*` |
| `stackJustify` (MIN/CENTER/MAX/SPACE_BETWEEN) | `justifyContent` |
| `stackCounterAlign` / `stackCounterAlignItems` | `alignItems` |
| `stackPrimarySizing: FIXED/HUG/FILL` | `width/height: fixed/auto/flex-grow` |
| `stackCounterSizing: FIXED/HUG/FILL` | Cross-axis sizing |
| `stackChildPrimaryGrow` | `flexGrow` |
| `stackChildAlignSelf` | `alignSelf` |
| `stackPositioning: ABSOLUTE` | `position: absolute` |
| `gridRowCount` / `gridColumnCount` | CSS Grid `grid-template-rows/columns` count |
| `gridRowGap` / `gridColumnGap` | `row-gap` / `column-gap` |
| `gridColumnSizes[]` / `gridRowSizes[]` | `grid-template-columns/rows` (track sizes) |
### File Format: Kiwi binary
We already have the full Kiwi codec from figma-use. The `.openpencil` format will use the same encoding:
- Kiwi binary schema (compact, fast parsing)
- Zstd compression
- Same NodeChange-based structure (proven at Figma scale)
- Superset of .fig — we add our own fields but can read Figma files
Migration from .fig: decode with our Kiwi codec → re-encode as .openpencil.
### Collaboration (P2P CRDT)
Fully peer-to-peer via Trystero (WebRTC). No relay server — signaling via MQTT public brokers, data direct between peers.
- Yjs CRDT for document state sync
- Awareness protocol for cursors, selections, presence, follow mode
- y-indexeddb for local persistence — room survives page refresh
- ICE servers: Google STUN + Cloudflare STUN + Open Relay TURN (TCP + UDP)
- Secure room IDs via `crypto.getRandomValues()`
- Stale cursors cleaned on peer disconnect
## Tech Stack
| Layer | Choice | Why |
|-------|--------|-----|
| Rendering | Skia CanvasKit WASM | Same as Figma/Pencil, proven performance |
| UI | Vue 3 + TypeScript | Reactivity, composables, SFC ecosystem |
| Styling | Tailwind CSS 4 | Fast iteration |
| State | Vue reactivity + composables | Native to the framework, zero boilerplate |
| Layout | Yoga WASM | Flexbox, battle-tested (CSS Grid blocked on upstream) |
| Desktop | Tauri v2 | ~7MB vs Electron's ~100MB, Rust backend |
| AI | Vercel AI SDK + OpenRouter | Client-side tool use, no backend |
| Collaboration | Trystero (WebRTC) + Yjs (CRDT) | P2P sync, no server required |
| File format | Kiwi binary + Zstd | Compact, fast, .fig compatible |
| Build | Bun + Vite 7 | Fast bundling, native TS |
## Phases
### Phase 1: Core engine ✅
SceneGraph, Skia rendering, basic shapes, selection, zoom/pan, undo/redo.
### Phase 2: Editor UI + Layout ✅
Properties panel, layers panel, toolbar, Yoga layout integration, text editing, pen tool with vector networks.
### Phase 3: File format + Import/Export ✅
.fig import and export via Kiwi binary codec, Figma clipboard interop, PNG/JPG/WEBP export.
### Phase 4: Components + Variables ✅
Components, instances, component sets with live sync and override preservation. Variables with collections, modes, color bindings, alias chains.
### Phase 5: AI integration ✅
2026-03-09 14:43:11 +00:00
In-app AI chat (⌘J) with multi-provider support (Anthropic, OpenAI, Google AI, OpenRouter, compatible endpoints), 90+ tools, streaming responses with tool call timeline, visual verification via export_image. JSX renderer with sucrase. Code panel with JSX/Tailwind export.
### Phase 6: Collaboration ✅
P2P real-time collaboration via Trystero (WebRTC) + Yjs CRDT. Cursors, presence, follow mode. No server required.
### Phase 7: Distribution ✅
Tauri v2 desktop app (macOS, Windows, Linux). Web app at app.openpencil.dev. CLI published on npm. Documentation site at openpencil.dev. GitHub Actions CI for builds and deploys.
### What's next
2026-03-09 14:43:11 +00:00
- **Prototyping** — frame transitions, interaction triggers (click, hover, drag), overlay management, fullscreen preview mode
- **Shader effects (SkSL)** — custom visual effects via GPU shaders
- **Raster tile caching** — instant zoom/pan for complex documents
- **Component libraries** — publish, share, and consume design systems across files
- **CI tools** — design linting, code export, visual regression in pipelines via the headless CLI
2026-03-09 14:43:11 +00:00
- **Windows code signing** — Azure Authenticode certificates for properly signed binaries
- **Skewing and OkHCL color** — affine transforms and perceptually uniform color space
- **WebGPU/Graphite rendering** — experimental next-gen rendering backend
- **.fig compatibility** — improving import/export fidelity across a larger set of real-world files
- **Port remaining figma-use tools** — bring the full 118-tool set from [figma-use](https://github.com/dannote/figma-use) into the editor (currently 90+/118)
- **Grid child positioning UI** — column/row span controls in properties panel, grid overlay visualization on canvas
---
## CLI & Headless Mode
The CLI (`@open-pencil/cli`) runs headless in Bun — loads the engine directly, no window, no Tauri, no WebGL. CanvasKit WASM CPU software rasterizer enables PNG export without a display server.
### Monorepo structure (implemented)
Bun workspace with three packages:
```
package.json — workspace root
packages/
core/ — @open-pencil/core: scene graph, renderer, layout, codec, tools
cli/ — @open-pencil/cli: headless CLI (info, tree, find, export, analyze)
docs/ — VitePress documentation site
src/ — Vue 3 + Tauri desktop editor (imports from @open-pencil/core)
desktop/ — Tauri v2 Rust backend
tests/
e2e/ — Playwright visual regression
engine/ — bun:test unit tests
```
### Kiwi decoder performance — investigated
We benchmarked the Rust `kiwi-schema` crate (by Evan Wallace, 1.5M downloads) against our JS decoder:
| File | JS (Bun) | Rust (native) |
|------|----------|---------------|
| material3 (87K nodes, 44.7 MB) | **168 ms** | 595 ms |
| nuxtui (315K nodes, 229 MB) | **785 ms** | 4,215 ms |
**JS is 3.5–5.4x faster.** The Rust crate uses dynamic `Value` enum with `HashMap<&str, Value>` per object — heap allocations for every field of every node. Our JS decoder uses `compileSchema()` which generates specialized decode functions via `new Function()` — the JIT optimizes these into near-native code with zero boxing overhead.
A faster Rust/WASM decoder would require code generation (like protobuf's `prost` with derive macros) to emit typed structs instead of dynamic `Value`. Not worth the effort given our JS decoder already handles 315K nodes in under a second.
**The real bottleneck was O(n²) `getChildren()` in `importNodeChanges`** (scanning all parent entries for each node). Fixed by building a `childrenMap` index upfront → **69x speedup** (37s → 535ms for material3).
### Risks — all validated
- **CanvasKit WASM in Bun** ✅ — CPU surface works headless for CLI export
- **Font loading in headless** ✅ — system fonts loaded via `readFileSync` + `FontMgr.FromData()`
- **Core extraction** ✅ — engine is `@open-pencil/core` with zero DOM deps, used by both app and CLI
- **Kiwi codec performance** ✅ — JS decoder is 3.5–5.4x faster than Rust kiwi-schema crate (JIT-compiled `new Function()` vs dynamic `Value` with HashMap)
- **P2P collaboration** ✅ — Trystero + Yjs works without a relay server
---
## Keyboard Shortcuts Reference
Full Figma-compatible shortcut map. Implemented shortcuts marked with ✅.
### Tools (single key, no modifier)
| Key | Tool | Status |
|-----|------|--------|
| V | Move/Select | ✅ |
| K | Scale | |
| H | Hand | ✅ |
| F | Frame | ✅ |
| S | Section / Slice | |
| R | Rectangle | ✅ |
| O | Ellipse | ✅ |
| L | Line | ✅ |
| ⇧L | Arrow | |
| P | Pen | |
| ⇧P | Pencil | |
| T | Text | ✅ |
| C | Comment | |
| I | Eyedropper | |
### File
| Shortcut | Action | Status |
|----------|--------|--------|
| ⌘N | New Window | |
| ⌘O | Open File | ✅ |
| ⌘W | Close Tab | |
| ⌘S | Save | |
| ⇧⌘E | Export… | |
### Edit
| Shortcut | Action | Status |
|----------|--------|--------|
| ⌘Z | Undo | ✅ |
| ⇧⌘Z | Redo | ✅ |
| ⌘X | Cut | |
| ⌘C | Copy | |
| ⌘V | Paste | |
| ⇧⌘V | Paste Over Selection | |
| ⌘D | Duplicate | ✅ |
| ⌫ | Delete | ✅ |
| ⌘A | Select All | ✅ |
| ⇧⌘A | Select Inverse | |
| ⌥⌘C | Copy Properties | |
| ⌥⌘V | Paste Properties | |
| ⌃C | Pick Color (Eyedropper) | |
### View
| Shortcut | Action | Status |
|----------|--------|--------|
| ⌘' | Pixel Grid | |
| ⌃G | Layout Guides | |
| ⇧R | Rulers | |
| ⌘\ | Show/Hide UI | |
| ⌘= | Zoom In | ✅ |
| ⌘- | Zoom Out | ✅ |
| ⌘0 | Zoom to 100% | ✅ |
| ⌘1 | Zoom to Fit | |
| ⌘2 | Zoom to Selection | |
| N / ⇧N | Next/Previous Frame | |
### Object
| Shortcut | Action | Status |
|----------|--------|--------|
| ⌥⌘G | Frame Selection | |
| ⌘G | Group Selection | |
| ⇧⌘G | Ungroup | |
| ⇧A | Add Auto Layout | |
| ⌥⌘K | Create Component | |
| ⌥⌘B | Detach Instance | |
| ⌘] | Bring Forward | |
| ⌥⌘] | Bring to Front | |
| ⌘[ | Send Backward | |
| ⌥⌘[ | Send to Back | |
| ⇧H | Flip Horizontal | |
| ⇧V | Flip Vertical | |
| ⌘E | Flatten | |
| ⇧⌘H | Show/Hide Selection | |
| ⇧⌘L | Lock/Unlock Selection | |
| ⌥/ | Remove Fill | |
| ⇧X | Swap Fill and Stroke | |
### Text
| Shortcut | Action | Status |
|----------|--------|--------|
| ⌘B | Bold | |
| ⌘I | Italic | |
| ⌘U | Underline | |
| ⇧⌘X | Strikethrough | |
| ⇧⌘U | Create Link | |
### Arrange
| Shortcut | Action | Status |
|----------|--------|--------|
| ⌥A | Align Left | |
| ⌥H | Align Horizontal Centers | |
| ⌥D | Align Right | |
| ⌥W | Align Top | |
| ⌥V | Align Vertical Centers | |
| ⌥S | Align Bottom | |
| ⌥⇧H | Distribute Horizontal Spacing | |
| ⌥⇧V | Distribute Vertical Spacing | |
### Canvas Interaction
| Input | Action | Status |
|-------|--------|--------|
| Click | Select node | ✅ |
| Shift+Click | Add/remove from selection | ✅ |
| Alt+Drag | Duplicate and move | ✅ |
| Shift+Drag (draw) | Constrain to square/circle | ✅ |
| Shift+Drag (resize) | Maintain aspect ratio | ✅ |
| Shift+Drag (rotate) | Snap to 15° | ✅ |
| Middle mouse drag | Pan | ✅ |
| Scroll | Pan | ✅ |
| Ctrl+Scroll / Pinch | Zoom | ✅ |
| Double-click text | Edit text inline | ✅ |
| Drag onto frame | Reparent into frame | ✅ |
| Escape | Deselect / Cancel | ✅ |
---
*Created: 2026-02-26 · Updated: 2026-03-02*