9.2 KiB
9.2 KiB
OpenPencil
Vue 3 + CanvasKit (Skia WASM) + Yoga WASM design editor. Tauri v2 desktop, also runs in browser.
Roadmap: plan.md — phases, tech stack, CLI architecture, test strategy, keyboard shortcuts.
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.packages/docs—@open-pencil/docs: VitePress documentation site. Run withcd packages/docs && bun run dev.
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 test:dupes— jscpd copy-paste detection across all TS sourcesbun 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 node <file> --id <id>— detailed node propertiesbun open-pencil pages <file>— list pagesbun open-pencil variables <file>— list design variablesbun open-pencil export <file>— headless render to PNG/JPG/WEBPbun open-pencil analyze colors <file>— color palette usagebun open-pencil analyze typography <file>— font/size/weight statsbun open-pencil analyze spacing <file>— gap/padding valuesbun open-pencil analyze clusters <file>— repeated patternsbun open-pencil eval <file> --code '<js>'— execute JS with Figma Plugin API
CLI
- All CLI output must use
agentfmtformatters —fmtList,fmtHistogram,fmtSummary,fmtNode,fmtTree,kv,entity,bold,dim, etc. - Don't hand-roll
console.logformatting — use the helpers frompackages/cli/src/format.tswhich re-exports agentfmt with project-specific adapters (nodeToData,nodeDetails,nodeToTreeNode,nodeToListItem) - Every command supports
--jsonfor machine-readable output
Tools (AI / MCP / CLI)
- Tool operations are defined once in
packages/core/src/tools/schema.tsas framework-agnosticToolDefobjects - Each tool has: name, description, typed params, and an
execute(figma: FigmaAPI, args)function defineTool()gives type-safe params in the execute body; the arrayALL_TOOLSerases the generics for adapters- AI adapter (
packages/core/src/tools/ai-adapter.ts):toolsToAI()converts ToolDefs → valibot schemas + Vercel AItool()wrappers src/ai/tools.tsis just a thin wire: creates FigmaAPI from editor store, callstoolsToAI()- CLI commands (
packages/cli/src/commands/) are not generated from ToolDefs — they have custom agentfmt formatting, tree walking, pagination. Theevalcommand is the CLI's access to all ToolDef operations via FigmaAPI. - To add a new tool: add a
defineTool()inschema.ts, add toALL_TOOLSarray — it's instantly available in AI chat, and viaevalin CLI FigmaAPI(packages/core/src/figma-api.ts) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals
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 - App menu (
src/components/AppMenu.vue) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden whenIS_TAURIis true - 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
- Safari save: no File System Access API → uses
<a>download link with deferredrevokeObjectURL. SafariBanner warns users about limitations. - 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
- Test fixtures (
tests/fixtures/*.fig) are Git LFS — usegit push --no-verifyto skip the slow LFS pre-push hook. Use regulargit pushonly when.figfixtures changed.
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