# 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. 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 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` and `@open-pencil/cli` 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 npm | | `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 + 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, reference, development docs. When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Update `AGENTS.md` when architecture or conventions change. ## 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/ai/tools.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`): `createServer()` converts ToolDefs → zod schemas + MCP `registerTool()`. Adds `open_file`, `save_file`, `new_document` for headless file ops. Two entry points: `index.ts` (stdio), `http.ts` (Hono + Streamable HTTP with sessions). - 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.ts`) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals ## 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/composables/use-collab.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 - `@/` 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, `?.`, `??` - 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 `scene-graph.ts` 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.ts` - 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`) - No module-level mutable state in components — use the editor store - Prefer `tw-animate-css` for animations — don't hand-write `