# 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 three 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. Uses `citty` + `agentfmt`. - `packages/docs` — `@open-pencil/docs`: VitePress documentation site. Run with `cd packages/docs && bun run dev`. - `packages/mcp` — `@open-pencil/mcp`: MCP server for AI coding tools. Stdio + HTTP (Hono). Reuses `createServer()` factory with all core tools. - `packages/vue` — `@open-pencil/vue`: headless Vue 3 SDK (Reka UI-style) for building custom OpenPencil-powered editor shells and embedded editing surfaces. Renderless components and composables. The app is one consumer of the SDK. The root app (`src/`) is the Tauri/Vite desktop editor. App-specific editor, document, AI, collaboration, shell, tabs, demo, and automation code lives under `src/app/*`. The app consumes `@open-pencil/core` through targeted core subpath exports and `@open-pencil/vue` through the public Vue SDK entrypoint. ### Core subpath exports `@open-pencil/core` exposes domain-specific subpath exports for targeted imports. The main `"."` entry re-exports everything for backward compatibility. | Subpath | What | Heavy dep isolated | |---|---|---| | `@open-pencil/core` | everything (barrel) | all | | `@open-pencil/core/scene-graph` | SceneGraph, node types, hit-test, copy, snap, undo | — | | `@open-pencil/core/color` | parseColor, colorToHex, color management, OkHCL | culori | | `@open-pencil/core/text` | fonts, text editor, style runs, direction | — | | `@open-pencil/core/vector` | vector network encode/decode, bezier math | — | | `@open-pencil/core/figma-api` | FigmaAPI, FigmaNodeProxy | — | | `@open-pencil/core/icons` | Iconify API client, icon rendering | @iconify/utils | | `@open-pencil/core/canvas` | SkiaRenderer (Skia/CanvasKit painting engine) | — | | `@open-pencil/core/design-jsx` | JSX-to-design renderer | sucrase | | `@open-pencil/core/editor` | createEditor, Editor, EditorState | — | | `@open-pencil/core/tools` | ToolDef, ALL_TOOLS, AI adapter | diff | | `@open-pencil/core/kiwi` | .fig parse/serialize, codec, protocol | fflate, fzstd | | `@open-pencil/core/rpc` | RPC commands for CLI | — | | `@open-pencil/core/lint` | design linter rules and presets | — | | `@open-pencil/core/profiler` | render profiling | — | | `@open-pencil/core/canvaskit` | getCanvasKit loader | canvaskit-wasm | | `@open-pencil/core/layout` | computeLayout | yoga-layout | Runtime `canvaskit-wasm` import exists only in `canvaskit.ts` — all other files use `import type`. CanvasKit instance is passed as a parameter everywhere. ### Editor architecture `packages/core/src/editor/` is the framework-agnostic editor core — 13 modules sharing an `EditorContext` interface: | Module | What | |---|---| | `types.ts` | EditorState, EditorOptions, Tool, EditorToolDef, EditorContext | | `create.ts` | `createEditor()` assembler — wires context + all modules | | `viewport.ts` | screenToCanvas, applyZoom, pan, zoomToFit/100/Selection | | `selection.ts` | select, clearSelection, marquee, snap, hover, entered container | | `pages.ts` | switchPage, addPage, deletePage, renamePage | | `shapes.ts` | createShape, pen tool, adoptNodesIntoSection | | `structure.ts` | group, ungroup, wrapInAutoLayout, reorder, reparent, z-order | | `components.ts` | component/instance/detach/componentSet | | `clipboard.ts` | duplicate, copy, paste, delete, storeImage | | `undo.ts` | commitMove/Resize/Rotation, snapshot/restore | | `text.ts` | startTextEditing, commitTextEdit | | `nodes.ts` | updateNode, updateNodeWithUndo, setLayoutMode | Each module exports a factory: `createXxxActions(ctx: EditorContext) => { ... }`. `create.ts` assembles context + all modules, spreads into a flat return object. `Editor` type = `ReturnType`. The app editor session (`src/app/editor/session/create.ts`) is a thin Vue wrapper: creates `shallowReactive` state, calls `createEditor()`, and assembles app-specific modules for document I/O, autosave, export, vector edit, pen resume, flashes, profiler, and mobile clipboard. Tabs live in `src/app/tabs/`; active editor access lives in `src/app/editor/active-store/`. ## Commands - `bun run check` — type-aware lint + typecheck via oxlint + tsgo (run before committing) - `bun run check:vue` — vue-tsc type-check for .vue files (has pre-existing errors, fix progressively) - `bun run test:dupes` — jscpd copy-paste detection across all TS sources - `bun run format` — oxfmt with import sorting - `bun test ./tests/engine` — unit tests - `bun run test` — Playwright visual regression - `bun run tauri dev` — desktop app with hot reload - `bun open-pencil info ` — document stats - `bun open-pencil tree ` — node tree - `bun open-pencil find ` — search nodes - `bun open-pencil node --id ` — detailed node properties - `bun open-pencil pages ` — list pages - `bun open-pencil variables ` — list design variables - `bun open-pencil export ` — headless render to PNG/JPG/WEBP - `bun open-pencil analyze colors ` — color palette usage - `bun open-pencil analyze typography ` — font/size/weight stats - `bun open-pencil analyze spacing ` — gap/padding values - `bun open-pencil analyze clusters ` — repeated patterns - `bun open-pencil eval --code ''` — execute JS with Figma Plugin API ## Releases & CI ### How to release 1. Update version in `package.json`, `packages/core/package.json`, `packages/cli/package.json`, `desktop/tauri.conf.json` 2. Update `CHANGELOG.md` — move "Unreleased" items under new version heading with date 3. Commit: `Release v0.x.y` 4. Tag: `git tag v0.x.y && git push --tags` 5. The `build.yml` workflow triggers on `v*` tags and: - Builds Tauri binaries for macOS (arm64 + x64), Windows (x64 + arm64), Linux (x64) - Creates a draft GitHub Release with all platform binaries - Publishes `@open-pencil/core`, `@open-pencil/cli`, `@open-pencil/mcp`, and `@open-pencil/vue` to npm with provenance 6. Go to GitHub Releases → edit the draft → paste changelog section → publish ### CI workflows | Workflow | Trigger | What it does | |----------|---------|--------------| | `build.yml` | `v*` tag push or manual | Build Tauri desktop apps (5 targets), create GitHub Release, publish `@open-pencil/core`, `@open-pencil/cli`, `@open-pencil/mcp`, and `@open-pencil/vue` | | `homebrew.yml` | Release published | Update `open-pencil/homebrew-tap` cask with new version + SHA256 hashes | | `app.yml` | Push to `master` (non-docs) | Build web app, deploy to Cloudflare Pages (`app.openpencil.dev`) | | `docs.yml` | Push to `master` (`packages/docs/**`) | Build VitePress docs, deploy to Cloudflare Pages (`openpencil.dev`) | ### Before committing Run all quality gates (see [Code quality](#code-quality) for the self-review checklist): ```sh bun run check # oxlint + tsgo type-aware lint & typecheck bun run format # oxfmt bun run test:dupes # jscpd < 3% bun run test:unit # bun:test bun run test # Playwright E2E ``` ## Documentation - `CHANGELOG.md` — all user-facing changes, grouped by version. "Unreleased" section at top for in-progress work. - `README.md` — user-facing: features, getting started, CLI, project structure. No implementation details. - `AGENTS.md` (this file) — contributor/agent reference: architecture, conventions, how to release. - `packages/docs/` — VitePress site deployed at `openpencil.dev`. User guide, SDK, automation, reference, and development docs. When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Update `AGENTS.md` when architecture or conventions change. ## Commit messages Use Conventional Commits for regular development commits: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci`, `chore`. - Keep the first line short, imperative, and scoped when helpful - Put rationale and implementation details in the commit body - Keep the commit type lowercase (`fix:`, `feat:`, `docs:`), but start each body line/bullet with an uppercase word - Prefer scopes that match the project structure: `app`, `tauri`, `core`, `cli`, `mcp`, `vue`, `docs`, or focused domains like `editor`, `scene-graph`, `canvas`, `tools`, `kiwi`, `io`, `text`, `vector`, `color`, `acp`, `ai`, `collab`, `automation`, `i18n` - Use the narrowest honest scope, or omit it if the change spans multiple unrelated areas Example: ```text fix(editor): preserve text edit undo state - Snapshot both text and styleRuns when editing starts - Restore both on undo instead of comparing against the live node ``` Release commits are the exception: keep using `Release v0.x.y`. ## CLI - All CLI output must use `agentfmt` formatters — `fmtList`, `fmtHistogram`, `fmtSummary`, `fmtNode`, `fmtTree`, `kv`, `entity`, `bold`, `dim`, etc. - Don't hand-roll `console.log` formatting — use the helpers from `packages/cli/src/format.ts` which re-exports agentfmt with project-specific adapters (`nodeToData`, `nodeDetails`, `nodeToTreeNode`, `nodeToListItem`) - Every command supports `--json` for machine-readable output ## Tools (AI / MCP / CLI) - Tool operations live in `packages/core/src/tools/` as framework-agnostic `ToolDef` objects, split by domain: - `schema.ts` — `ToolDef` type, `defineTool()`, shared helpers (`nodeSummary`, `nodeToResult`) - `read.ts` — query tools: selection, find, pages, fonts, components - `create.ts` — shape/component/page creation, JSX render - `modify.ts` — property setters: fills, strokes, effects, text, layout - `structure.ts` — tree ops: delete, clone, reparent, group, arrange - `variables.ts` — variable/collection CRUD and binding - `vector.ts` — boolean ops, paths, viewport, SVG/image export - `analyze.ts` — analyze (colors, typography, spacing, clusters), diff, eval - `registry.ts` — assembles all tools into the `ALL_TOOLS` array - Each tool has: name, description, typed params, and an `execute(figma: FigmaAPI, args)` function - `defineTool()` gives type-safe params in the execute body; the array `ALL_TOOLS` erases the generics for adapters - AI adapter (`packages/core/src/tools/ai-adapter.ts`): `toolsToAI()` converts ToolDefs → valibot schemas + Vercel AI `tool()` wrappers - `src/app/ai/tools/index.ts` is just a thin wire: creates FigmaAPI from editor store, calls `toolsToAI()` - CLI commands (`packages/cli/src/commands/`) are **not** generated from ToolDefs — they have custom agentfmt formatting, tree walking, pagination. The `eval` command is the CLI's access to all ToolDef operations via FigmaAPI. - MCP adapter (`packages/mcp/src/server.ts`): `startServer()` creates unified HTTP + WebSocket server. Registers all ToolDefs as MCP tools (zod schemas). Single entry point: `index.ts` (Hono + Streamable HTTP with sessions). Browser connects via WebSocket, tool calls proxied through. - MCP-only tools (`open_file`, `new_document`, `save_file`, `get_codegen_prompt`) are registered directly in `server.ts`, not as ToolDefs — they need Node.js fs access or don't operate on the scene graph - `open_file` and `new_document` are only registered when `OPENPENCIL_MCP_ROOT` is set (path scoping for security) - Export tools (`export_image`, `export_svg`, `get_jsx`) accept an optional `path` param — when provided and `OPENPENCIL_MCP_ROOT` is set, the MCP server writes output to disk and returns `{ written, byteLength }` instead of the raw data - Core prompts (`CODEGEN_PROMPT`, `JSX_REFERENCE`) live as markdown files in `packages/core/src/tools/prompts/`, loaded via raw-md bundler plugin; app chat/ACP prompts live under `src/app/ai/**` markdown files. - To add a new tool: add a `defineTool()` in the appropriate domain file, add to `ALL_TOOLS` in `registry.ts` — it's instantly available in AI chat, MCP, and via `eval` in CLI - `FigmaAPI` (`packages/core/src/figma-api/`) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals ## ACP (Agent Client Protocol) - ACP transport (`src/app/ai/acp/transport.ts`) spawns agents via dynamic import of `@tauri-apps/plugin-shell` - Pure mapping logic in `src/app/ai/acp/map-update.ts` — converts `SessionUpdate` → `UIMessageChunk` - ACP design context prompt (`ACP_DESIGN_CONTEXT`) is authored in `src/app/ai/acp/design-context.md` and re-exported from `src/constants.ts` - Agent definitions (`ACP_AGENTS`) in `packages/core/src/constants.ts` - MCP server: Vite plugin in dev, `openpencil-mcp` via shell plugin in production Tauri (requires `npm i -g @open-pencil/mcp`; follow-up: bundle as Tauri sidecar) - Architecture: browser ↔ WebSocket :7601 ↔ MCP server :7600 ↔ HTTP ↔ agent subprocess - Shell permissions scoped per-command in `desktop/capabilities/default.json` (`args: true` — agents need dynamic SDK flags) - ACP providers visible only in Tauri desktop when MCP server is reachable - Permission requests shown in AlertDialog — user must approve/reject each request (60s auto-reject timeout) ## Collaboration - P2P via Trystero (WebRTC) — no server relay. Signaling over MQTT public brokers. - Yjs CRDT for document state sync. Awareness protocol for cursors/selections/presence. - y-indexeddb for local persistence — room survives page refresh. - Constants in `src/constants.ts`: `TRYSTERO_APP_ID`, `PEER_COLORS`, `ROOM_ID_LENGTH`, `ROOM_ID_CHARS`, `YJS_JSON_FIELDS` - `src/app/collab/use.ts` — composable: connect/disconnect, cursor/selection broadcasting, follow mode, Yjs ↔ SceneGraph sync - Provided via `COLLAB_KEY` injection — `useCollabInjected()` in child components - ICE servers: Google STUN + Cloudflare STUN + Open Relay TURN (TCP + UDP) - Room IDs use `crypto.getRandomValues()` — no `Math.random()` anywhere in codebase - Stale cursors cleaned on peer disconnect via `removeAwarenessStates()` ## Code conventions ### File and folder naming OpenPencil follows a Reka UI-inspired component namespace structure: - Vue component namespace folders use PascalCase: `ColorPicker/`, `Toolbar/`, `ProviderSettings/`. - Vue component files use PascalCase: `ColorPickerRoot.vue`, `ToolbarItem.vue`. - Component-scoped composables use camelCase: `useToolbarState.ts`, `usePageList.ts`. - Non-component domain folders use lowercase or kebab-case: `scene-graph/`, `figma-api/`, `node-edit/`. - Non-component TypeScript files use lowercase or kebab-case unless they are conventional entrypoints such as `index.ts`, `types.ts`, `context.ts`, or `use.ts`. - Multi-file root components live inside their component namespace folder, not beside it. - Use subfolders for multi-file domains instead of many sibling files with repeated prefixes. Prefer `selection/container.ts`, `selection/hit-test.ts`, `selection/overlays.ts` over `selection-container.ts`, `selection-hit-test.ts`, `selection-overlays.ts`; prefer `tools/schema.ts`, `tools/registration.ts` over `tool-schema.ts`, `tool-registration.ts`. A short prefix is acceptable only when there are one or two files, for conventional test names, or when splitting would create empty wrapper directories. - `@/` import alias for app cross-directory imports; app feature code lives under `src/app/*` - Use package-local aliases inside workspace packages: `#vue/*` in `packages/vue`, `#cli/*` in `packages/cli`, `#mcp/*` in `packages/mcp`, and `#core/*` when core code needs an alias. Prefer relative imports within nearby core modules when that is clearer than an alias. - No `any` — use proper types, generics, declaration merging - No `!` non-null assertions — use guards, `?.`, `??` - No `Math.random()` — use `crypto.getRandomValues()` everywhere - No inline type definitions when a named type exists — use `Color` not `{ r: number; g: number; b: number; a: number }`, use `Vector` not `{ x: number; y: number }`, use `SceneNode` / `Effect` / `Fill` / `Stroke` from `@open-pencil/core/scene-graph` instead of re-spelling their shapes inline - Shared types (GUID, Color, Vector, Matrix, Rect) live in `packages/core/src/types.ts` - Domain types (SceneNode, Fill, Stroke, Effect, BlendMode, etc.) live in `packages/core/src/scene-graph/` and are exported from `@open-pencil/core/scene-graph` - Window API extensions (showOpenFilePicker, queryLocalFonts) live in `src/global.d.ts` and `packages/core/src/global.d.ts` - Use `culori` for color conversions — don't reimplement parseColor/colorToRgba - Use `@vueuse/core` hooks — prefer higher-level composables (`useBreakpoints`, `useEventListener`, `onClickOutside`, etc.) over raw APIs (`useMediaQuery`, manual `addEventListener`) - Prefer VueUse utilities for simple browser/timer state: `refAutoReset` for temporary copied/saved flags, `promiseTimeout` for async sleeps/retry backoff, `useClipboard`/`useFileDialog`/`useLocalStorage` where they fit the local state model. Don't force VueUse when direct APIs are clearer: one-shot `requestAnimationFrame` focus/defer calls, explicit service-owned reconnect/permission timers, or nanostores-backed state can stay hand-rolled. - No module-level mutable state in components — use the editor store - Prefer `tw-animate-css` for animations — don't hand-write `