6.4 KiB
6.4 KiB
OpenPencil
Vue 3 + CanvasKit (Skia WASM) + Yoga WASM design editor. Tauri v2 desktop, also runs in browser.
Monorepo
Bun workspace with two packages:
packages/core—@open-pencil/core: scene graph, renderer, layout, codec, kiwi, clipboard, vector, snap, undo. Zero DOM deps, runs headless in Bun.packages/cli—@open-pencil/cli: headless CLI for .fig inspection, export, linting. Usescitty+agentfmt.
The root app (src/) is the Tauri/Vite desktop editor. Its src/engine/ files are thin re-export shims from @open-pencil/core.
Commands
bun run check— lint + typecheck (run before committing)bun run format— oxfmt with import sortingbun test ./tests/engine— unit testsbun run test— Playwright visual regressionbun run tauri dev— desktop app with hot reloadbun open-pencil info <file>— document statsbun open-pencil tree <file>— node treebun open-pencil find <file>— search nodesbun open-pencil export <file>— headless render to PNG/JPG/WEBP
Code conventions
@/import alias for app cross-directory imports, relative imports within core- No
any— use proper types, generics, declaration merging - No
!non-null assertions — use guards,?.,?? - Shared types (GUID, Color, Vector, Matrix, Rect) live in
packages/core/src/types.ts - Window API extensions (showOpenFilePicker, queryLocalFonts) live in
src/global.d.tsandpackages/core/src/global.d.ts - Use
culorifor color conversions — don't reimplement parseColor/colorToRgba - Use
@vueuse/corehooks (useEventListener, etc.) — don't do manual addEventListener/removeEventListener packages/core/src/kiwi/kiwi-schema/is vendored — don't modify- Core code must guard browser APIs:
typeof window !== 'undefined',typeof document === 'undefined'
Rendering
- Canvas is CanvasKit (Skia WASM) on a WebGL surface, not DOM
renderVersionvssceneVersion:renderVersion= canvas repaint (pan/zoom/hover);sceneVersion= scene graph mutations. UI panels watchsceneVersiononly.requestRender()bumps both counters;requestRepaint()bumps onlyrenderVersionrenderNow()is only for surface recreation and font loading (need immediate draw)- Resize observer uses rAF throttle, not debounce — debounce causes canvas skew
- Viewport culling skips off-screen nodes; unclipped parents are NOT culled (children may extend beyond bounds)
- Selection border width must be constant regardless of zoom — divide by scale
- Section/frame title text never scales — render at fixed font size, ellipsize to fit
- Rulers are rendered on the canvas (not DOM), with selection range badges that don't overlap tick numbers
Scene graph
- Nodes live in flat
Map<string, SceneNode>, tree viaparentIndexreferences - Frames clip content by default is OFF (unlike what you'd assume)
- When creating auto-layout, sort children by geometric position first
- Dragging a child outside a frame should reparent it, not clip it
- Layer panel tree must react to reparenting — watch for stale children refs
- Groups: creating a group must preserve children's visual positions
Components & instances
- Purple (#9747ff) for COMPONENT, COMPONENT_SET, INSTANCE — matches Figma
- Instance children map to component children via
componentIdfor 1:1 sync - Override key format:
"childId:propName"in instance'soverridesrecord - Editing a component must call
syncIfInsideComponent()to propagate to instances SceneGraph.copyProp<K>()typed helper — usesstructuredClonefor arrays
Layout
computeAllLayouts()must be called after demo creation and after opening .fig files- Yoga WASM handles flexbox; CSS Grid blocked on upstream (facebook/yoga#1893)
- Auto-layout creation (Shift+A) must recompute layout immediately to update selection bounds
UI
- Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
- Tailwind 4 for styling — no inline CSS, no component-level
<style>blocks - Mac keyboards: use
e.codenote.keyfor shortcuts with modifiers (Option transforms characters) - Splitter resize handles need inner div with
pointer-events-nonefor sizing (zero-width handle collapses without it) - Number input spinner hiding is global CSS in
app.css, not per-component - ScrubInput (drag-to-change number) — cursor and pointerdown on outer container, not inner spans
- Icons: use unplugin-icons with Iconify/Lucide (
<icon-lucide-*>) — don't use raw SVG or Unicode symbols - Sections are draggable by title pill, not by the area to the right of the title
- CSS
contain: paint layout styleon side panels to isolate repaints from WebGL canvas
File format
- .fig files use Kiwi binary codec — schema in
packages/core/src/kiwi/codec.ts NodeChangeis the central type for Kiwi encode/decode- Vector data uses reverse-engineered
vectorNetworkBlobbinary format — encoder/decoder inpackages/core/src/vector.ts - showOpenFilePicker/showSaveFilePicker are File System Access API (Chrome/Edge), not Tauri-only — code has fallbacks
- Tauri detection:
IS_TAURIconstant frompackages/core/src/constants.ts— don't use'__TAURI_INTERNALS__' in windowinline - .fig export: compression with fflate (browser) or Tauri Rust commands
- Test .fig round-trip by exporting and reimporting in Figma
Tauri
- Tauri v2 with plugin-dialog, plugin-fs, plugin-opener
- File system permissions must be configured in
desktop/tauri.conf.json— "Internal error" on save means missing permissions - Dev tools: add a menu item to toggle, don't rely on keyboard shortcut
Publishing
bun publishfrom package dirs — resolvesworkspace:*→ actual versions- Core:
prepublishOnlyrunstscto builddist/for Node.js consumers - CLI requires Bun runtime (
#!/usr/bin/env bun)
Reference
figma-use — our Figma toolkit. Use as reference for:
- Kiwi binary format, schema, encode/decode (
packages/shared/src/kiwi/) - Figma WebSocket multiplayer protocol (
packages/plugin/src/ws/) - Vector network blob format (
packages/shared/src/vector/) - Node types, paints, effects, layout fields (
packages/shared/src/types/) - MCP tools / design operations (
packages/mcp/) - JSX-to-design renderer (
packages/render/) - Design linter rules (
packages/linter/)
Known issues
- Safari ew-resize/col-resize/ns-resize cursor bug (WebKit #303845) — fixed in Safari 26.3 Beta