Skip to content

Latest commit

 

History

History
414 lines (330 loc) · 33 KB

File metadata and controls

414 lines (330 loc) · 33 KB

OpenPencil

Vue 3 + CanvasKit (Skia WASM) + Yoga WASM design editor. Tauri v2 desktop, also runs in browser.

Roadmap: packages/docs/development/roadmap.md tracks product direction, Figma compatibility gaps, and raw metadata coverage. Current architecture and commands live in this file.

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/clipboard Figma/OpenPencil clipboard parsing and import helpers
@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, EditorEvents, Tool, EditorToolDef, EditorContext
create.ts createEditor() assembler — wires context, event bus + 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<typeof createEditor>.

Editor event bus

The editor exposes a typed nanoevents emitter for lifecycle events. Defined in EditorEvents (types.ts), emitted via emitEditorEvent() on the context, subscribed via editor.onEditorEvent(event, handler) which returns an unbind function.

Event Payload Emitted by
render:requested { renderVersion, sceneVersion } requestRender()
repaint:requested { renderVersion, sceneVersion } requestRepaint()
graph:replaced SceneGraph replaceGraph()
node:created SceneNode SceneGraph emitter → graph-events.ts
node:updated id, changes SceneGraph emitter → graph-events.ts
node:deleted id SceneGraph emitter → graph-events.ts
node:reparented nodeId, oldParentId, newParentId SceneGraph emitter → graph-events.ts
node:reordered nodeId, parentId, index SceneGraph emitter → graph-events.ts
selection:changed selectedIds[], previousIds[] setSelectedIds()
tool:changed tool, previousTool setActiveTool()
page:changed pageId, previousPageId switchPage(), replaceGraph()
viewport:changed { panX, panY, zoom }, previous viewport actions

All selection mutations in core use ctx.setSelectedIds() and all tool changes use ctx.setActiveTool() so the event bus fires consistently. App-layer code uses editor.clearSelection(), editor.select(), or editor.setTool() — never direct state.selectedIds = or state.activeTool = assignments.

Vue SDK provides useEditorEvent(event, handler) composable (packages/vue/src/editor/events/use.ts) that auto-disposes on scope cleanup.

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 + architecture checks (run before committing)
  • bun run check:arch — Steiger architecture lint for project-specific import boundaries
  • 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 product TS sources
  • bun run test:tools — tests for private repo tooling under tools/*
  • 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 <file> — document stats
  • bun open-pencil tree <file> — node tree
  • bun open-pencil find <file> — search nodes
  • bun open-pencil node <file> --id <id> — detailed node properties
  • bun open-pencil pages <file> — list pages
  • bun open-pencil variables <file> — list design variables
  • bun open-pencil export <file> — headless render to PNG/JPG/WEBP
  • bun open-pencil analyze colors <file> — color palette usage
  • bun open-pencil analyze typography <file> — font/size/weight stats
  • bun open-pencil analyze spacing <file> — gap/padding values
  • bun open-pencil analyze clusters <file> — repeated patterns
  • bun open-pencil eval <file> --code '<js>' — 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, packages/mcp/package.json, packages/vue/package.json, desktop/tauri.conf.json, and desktop/Cargo.toml
  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. Ensure GitHub release secrets include TAURI_SIGNING_PRIVATE_KEY (and TAURI_SIGNING_PRIVATE_KEY_PASSWORD if the updater key is password-protected); the public updater key is configured in desktop/tauri.conf.json.
  6. 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
  7. 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 for the self-review checklist):

bun run check          # oxlint + tsgo type-aware lint & typecheck
bun run format         # oxfmt
bun run test:dupes     # jscpd — zero clones
bun run test:tools     # private repo tooling tests
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:

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.tsToolDef 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 SessionUpdateUIMessageChunk
  • 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

  • Do not place code or tests ad hoc. Before adding or moving files, inspect the existing folder structure and nearby patterns, then put changes in the established domain-specific location. If no proper location exists, create one deliberately and update docs/conventions as needed.
  • Architecture boundaries are enforced by Steiger (bun run check:arch). App code must use public workspace package exports, workspace packages must not import app src/ code, package-local aliases (#core, #vue, #cli, #mcp) are only for their owning package, core must stay framework-agnostic, app service/domain code (src/app/**) must not import app component/view layers, components must not import views, shared UI (src/components/ui/**) must not import app services/stores, property-panel internals must stay inside the property panel, canvas/editor overlay code must not import property-panel internals, Vue components must not use <style> blocks, code outside core editor internals must not assign editor.state.selectedIds or editor.state.activeTool directly, committed code must not import scratch/generated/vendor internals, and durable docs belong under packages/docs/** unless the root Markdown allowlist is deliberately updated.
  • Test placement is strict and enforced by Steiger: app E2E tests live under tests/e2e/** and use *.spec.ts; Figma automation tests live under tests/figma/** and use *.spec.ts; engine/unit tests live under tests/engine/** and use *.test.ts (with helpers.ts, *.bench.ts, and visual-* support scripts allowed); shared test utilities live under tests/helpers/**. Do not commit temporary/profile specs (*.tmp.*, *.profile.*). Do not put store-only/internal-state assertions in E2E. If a test drives the UI like a user and verifies visible behavior, it can be E2E; if it creates nodes through internals and asserts graph state, it belongs in engine/unit coverage.

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 sibling files with repeated prefixes. Prefer selection/container.ts, selection/hit-test.ts over selection-container.ts, selection-hit-test.ts. When adding a second file for a domain (e.g. eval-wrap.ts next to eval.ts), create the folder immediately (eval/index.ts + eval/wrap.ts) instead of prefixing. Oxlint catches sibling prefix files when a sibling folder exists; Steiger catches 3+ sibling files with the same prefix. The convention applies even before either rule triggers.

Repo tools and scripts

Private repository tooling lives under tools/<domain>/, not as ad-hoc root scripts. Use kebab-case domain folders and split by capability inside src/:

tools/<domain>/
  package.json
  src/index.ts
  src/<capability>.ts
  tests/<capability>.test.ts

Use scripts/ only for tiny compatibility entrypoint shims that import ../tools/<domain>/src/...; do not put implementation logic there. Workflow helpers, release packaging helpers, architecture rules, package checks, visual-oracle utilities, and other maintainable programs belong in tools/ with focused tests when they contain logic. Steiger enforces tool layout and script shims. bun run check includes bun run test:tools, and lint/format cover tools/.

  • @/ 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 <style> transition keyframes
  • No duplicated component logic — if two components share data (icon maps, util functions, constants), export from one place and import in both
  • packages/core/src/kiwi/schema-runtime/ contains the vendored Kiwi codec runtime; keep runtime changes minimal and prefer wrappers/helpers for project-specific validation
  • Core code must guard browser APIs: typeof window !== 'undefined', typeof document === 'undefined'
  • Constants in src/constants.ts — no magic numbers in components or composables

Code quality

Before submitting a PR, run the full quality gate and do a self-review:

bun run check          # oxlint + tsgo type-aware lint & typecheck — zero errors required
bun run format         # oxfmt with import sorting
bun run test:dupes     # jscpd — zero clones required
bun run test:tools     # private repo tooling tests
bun run test:unit      # bun:test
bun run test           # Playwright E2E

Self-review checklist:

  • Run bun run test:dupes — if duplication rises, extract shared helpers or use existing types
  • No inline type definitions that duplicate named types (Color, Vector, SceneNode, Effect, Fill, Stroke, etc.)
  • No copy-pasted logic — extract into functions. If two components share a util, icon map, or data structure, export from one place. If jscpd flags it, fix it.
  • Use precise union types — 'closed' | 'half' | 'full' not number | string | null
  • Files should stay under ~600 lines — split by domain when they grow (see packages/core/src/tools/ for the pattern)
  • structuredClone for deep copies, never shallow spread when mutating nested objects
  • Don't hand-roll what a dependency already does. Check existing deps first (package.json, packages/*/package.json). If none covers it, find a quality library instead of inlining an implementation — e.g. use diff for unified diffs, not a custom line-by-line loop; use culori for color math, not manual RGB parsing
  • es-toolkit is available in core for small, focused utility helpers when it clearly improves readability. Prefer subpath imports such as es-toolkit/object, es-toolkit/array, and es-toolkit/predicate; good fits include omit / pick for object key selection, uniq for dedupe, and isNotNil for typed nullish filtering. Do not replace clear native JavaScript just for consistency, and avoid es-toolkit/compat unless deliberately migrating lodash-compatible behavior.
  • Check Reka UI for existing components (Dialog, Popover, DropdownMenu, Select, Tooltip, Toast, etc.) before building custom ones — especially dropdowns, popovers, and modals

Rendering

  • Canvas is CanvasKit (Skia WASM) on a WebGL surface, not DOM
  • renderVersion vs sceneVersion: renderVersion = canvas repaint (pan/zoom/hover); sceneVersion = scene graph mutations. UI panels watch sceneVersion only.
  • requestRender() bumps both counters; requestRepaint() bumps only renderVersion
  • renderNow() 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
  • Remote cursors: Figma-style colored arrows with white border + name pill, rendered in screen space
  • Pixel-affecting renderer features need committed visual coverage, not just mock/geometry assertions. Add or update a Playwright canvas snapshot for changes to fills, gradients, images, blend modes, masks, boolean geometry, corners, strokes, shadows, blur, text rendering, or demo showcase scenes. Use targeted snapshot updates such as bunx playwright test tests/e2e/canvas/renderer-visuals.spec.ts --project=openpencil --update-snapshots and then rerun the same test without --update-snapshots.

Scene graph

  • Nodes live in flat Map<string, SceneNode>, tree via parentIndex references
  • 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 componentId for 1:1 sync
  • Override key format: "childId:propName" in instance's overrides record
  • Editing a component must call syncIfInsideComponent() to propagate to instances
  • SceneGraph.copyProp<K>() typed helper — uses structuredClone for arrays

Layout

  • computeAllLayouts() must be called after demo creation and after opening .fig files
  • Yoga WASM handles flexbox; CSS Grid blocked on upstream (react/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.)
  • Vue UI styling APIs must follow the existing :ui / tailwind-variants slot pattern. Do not add one-off fooClass, barClass, emptyActionClass, etc. props to components; define a typed Ui object with named slots and merge through the local use*UI() helper or a ui prop.
  • Do not pass imperative setters/actions through slots as :set-*, :update-*, :request-*, :toggle-*, etc. unless the component is explicitly a renderless primitive whose whole contract is slot actions. Prefer v-model, emitted events, normal component props, or owned default UI. For DOM refs/focus, use VueUse (templateRef, unrefElement, useFocus, etc.) instead of ref callback plumbing through slots.
  • App wrappers around SDK primitives should compose a single ui object from shared UI helpers (useSelectUI, usePopoverUI, etc.) rather than bypassing the design system with raw Tailwind strings spread across multiple props.
  • Editor commands share packages/vue/src/editor/commands/registry.ts as the canonical source for shortcut display tokens, keyboard bindings, and context-menu test IDs. Store portable shortcuts such as MOD+D, MOD+SHIFT+H, and MOD+ALT+K; format them with formatShortcut() at render time so macOS shows / and Windows/Linux show Ctrl/Alt.
  • Labels and translations must not contain shortcut text. Keep labels semantic (Add auto layout, Show/Hide) and render shortcuts from command metadata. Steiger enforces this for packages/vue/src/i18n/messages.ts and locale JSON files.
  • Canvas context-menu structure lives in packages/vue/src/editor/menu-model/canvas.ts. Do not hand-build command grouping in src/components/CanvasMenu.vue; the component should render menu entries and provide app-specific actions only when unavoidable.
  • Browser and Tauri menus share src/app/shell/menu/schema.ts as the canonical menu model. Do not add menu items directly in src/components/AppMenu.vue or desktop/src/menu.rs.
  • Regenerate the native menu with bun run generate:tauri-menu after editing the shared menu schema; desktop/generated/menu.json is consumed by the Tauri menu builder. Tauri also runs this generator from desktop/tauri.conf.json via beforeDevCommand and beforeBuildCommand.
  • Every shared menu item with an id must be handled by src/app/shell/menu/use.ts, an editor command, or explicitly marked browser/native-only in the schema.
  • Tailwind 4 for styling — no inline CSS, no component-level <style> blocks
  • Mac keyboards: use e.code not e.key for shortcuts with modifiers (Option transforms characters)
  • Splitter resize handles need inner div with pointer-events-none for 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 when IS_TAURI is true
  • Sections are draggable by title pill, not by the area to the right of the title
  • CSS contain: paint layout style on side panels to isolate repaints from WebGL canvas

File format

  • .fig files use Kiwi binary codec — schema in packages/core/src/kiwi/binary/codec.ts
  • NodeChange is the central type for Kiwi encode/decode
  • Vector data uses reverse-engineered vectorNetworkBlob binary format — encoder/decoder in packages/core/src/vector/
  • 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 deferred revokeObjectURL. SafariBanner warns users about limitations.
  • Tauri detection: IS_TAURI constant from packages/core/src/constants.ts — don't use '__TAURI_INTERNALS__' in window inline
  • .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 — use git push --no-verify to skip the slow LFS pre-push hook. Use regular git push only when .fig fixtures 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 publish from package dirs — resolves workspace:* → actual versions
  • Public packages publish built dist/ output, not runtime TypeScript entrypoints
  • Core, Vue, MCP, and CLI build with tsdown before publishing
  • CLI publishes a Node-compatible bin/openpencil.js wrapper; do not point package bin entries at TypeScript source

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