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.
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. Usescitty+agentfmt. -
packages/docs—@open-pencil/docs: VitePress documentation site. Run withcd packages/docs && bun run dev. -
packages/mcp—@open-pencil/mcp: MCP server for AI coding tools. Stdio + HTTP (Hono). ReusescreateServer()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.
@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.
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>.
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/.
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 boundariesbun 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 sourcesbun run test:tools— tests for private repo tooling undertools/*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 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
- 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, anddesktop/Cargo.toml - Update
CHANGELOG.md— move "Unreleased" items under new version heading with date - Commit:
Release v0.x.y - Tag:
git tag v0.x.y && git push --tags - Ensure GitHub release secrets include
TAURI_SIGNING_PRIVATE_KEY(andTAURI_SIGNING_PRIVATE_KEY_PASSWORDif the updater key is password-protected); the public updater key is configured indesktop/tauri.conf.json. - The
build.ymlworkflow triggers onv*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/vueto npm with provenance
- Go to GitHub Releases → edit the draft → paste changelog section → publish
| 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) |
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 E2ECHANGELOG.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 atopenpencil.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.
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 likeeditor,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.
- 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
- Tool operations live in
packages/core/src/tools/as framework-agnosticToolDefobjects, split by domain:schema.ts—ToolDeftype,defineTool(), shared helpers (nodeSummary,nodeToResult)read.ts— query tools: selection, find, pages, fonts, componentscreate.ts— shape/component/page creation, JSX rendermodify.ts— property setters: fills, strokes, effects, text, layoutstructure.ts— tree ops: delete, clone, reparent, group, arrangevariables.ts— variable/collection CRUD and bindingvector.ts— boolean ops, paths, viewport, SVG/image exportanalyze.ts— analyze (colors, typography, spacing, clusters), diff, evalregistry.ts— assembles all tools into theALL_TOOLSarray
- 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/app/ai/tools/index.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. - 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 inserver.ts, not as ToolDefs — they need Node.js fs access or don't operate on the scene graph open_fileandnew_documentare only registered whenOPENPENCIL_MCP_ROOTis set (path scoping for security)- Export tools (
export_image,export_svg,get_jsx) accept an optionalpathparam — when provided andOPENPENCIL_MCP_ROOTis 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 inpackages/core/src/tools/prompts/, loaded via raw-md bundler plugin; app chat/ACP prompts live undersrc/app/ai/**markdown files. - To add a new tool: add a
defineTool()in the appropriate domain file, add toALL_TOOLSinregistry.ts— it's instantly available in AI chat, MCP, and viaevalin CLI FigmaAPI(packages/core/src/figma-api/) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals
- 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— convertsSessionUpdate→UIMessageChunk - ACP design context prompt (
ACP_DESIGN_CONTEXT) is authored insrc/app/ai/acp/design-context.mdand re-exported fromsrc/constants.ts - Agent definitions (
ACP_AGENTS) inpackages/core/src/constants.ts - MCP server: Vite plugin in dev,
openpencil-mcpvia shell plugin in production Tauri (requiresnpm 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)
- 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_KEYinjection —useCollabInjected()in child components - ICE servers: Google STUN + Cloudflare STUN + Open Relay TURN (TCP + UDP)
- Room IDs use
crypto.getRandomValues()— noMath.random()anywhere in codebase - Stale cursors cleaned on peer disconnect via
removeAwarenessStates()
- 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 appsrc/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 assigneditor.state.selectedIdsoreditor.state.activeTooldirectly, committed code must not import scratch/generated/vendor internals, and durable docs belong underpackages/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 undertests/figma/**and use*.spec.ts; engine/unit tests live undertests/engine/**and use*.test.ts(withhelpers.ts,*.bench.ts, andvisual-*support scripts allowed); shared test utilities live undertests/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.
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, oruse.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.tsoverselection-container.ts,selection-hit-test.ts. When adding a second file for a domain (e.g.eval-wrap.tsnext toeval.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.
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 undersrc/app/*- Use package-local aliases inside workspace packages:
#vue/*inpackages/vue,#cli/*inpackages/cli,#mcp/*inpackages/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()— usecrypto.getRandomValues()everywhere - No inline type definitions when a named type exists — use
Colornot{ r: number; g: number; b: number; a: number }, useVectornot{ x: number; y: number }, useSceneNode/Effect/Fill/Strokefrom@open-pencil/core/scene-graphinstead 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.tsandpackages/core/src/global.d.ts - Use
culorifor color conversions — don't reimplement parseColor/colorToRgba - Use
@vueuse/corehooks — prefer higher-level composables (useBreakpoints,useEventListener,onClickOutside, etc.) over raw APIs (useMediaQuery, manualaddEventListener) - Prefer VueUse utilities for simple browser/timer state:
refAutoResetfor temporary copied/saved flags,promiseTimeoutfor async sleeps/retry backoff,useClipboard/useFileDialog/useLocalStoragewhere they fit the local state model. Don't force VueUse when direct APIs are clearer: one-shotrequestAnimationFramefocus/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-cssfor 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
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 E2ESelf-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
jscpdflags it, fix it. - Use precise union types —
'closed' | 'half' | 'full'notnumber | string | null - Files should stay under ~600 lines — split by domain when they grow (see
packages/core/src/tools/for the pattern) structuredClonefor 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. usedifffor unified diffs, not a custom line-by-line loop; useculorifor color math, not manual RGB parsing es-toolkitis available in core for small, focused utility helpers when it clearly improves readability. Prefer subpath imports such ases-toolkit/object,es-toolkit/array, andes-toolkit/predicate; good fits includeomit/pickfor object key selection,uniqfor dedupe, andisNotNilfor typed nullish filtering. Do not replace clear native JavaScript just for consistency, and avoides-toolkit/compatunless 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
- 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
- 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-snapshotsand then rerun the same test without--update-snapshots.
- 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
- 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
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
- Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
- Vue UI styling APIs must follow the existing
:ui/tailwind-variantsslot pattern. Do not add one-offfooClass,barClass,emptyActionClass, etc. props to components; define a typedUiobject with named slots and merge through the localuse*UI()helper or auiprop. - 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. Preferv-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
uiobject 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.tsas the canonical source for shortcut display tokens, keyboard bindings, and context-menu test IDs. Store portable shortcuts such asMOD+D,MOD+SHIFT+H, andMOD+ALT+K; format them withformatShortcut()at render time so macOS shows⌘/⌥and Windows/Linux showCtrl/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 forpackages/vue/src/i18n/messages.tsand locale JSON files. - Canvas context-menu structure lives in
packages/vue/src/editor/menu-model/canvas.ts. Do not hand-build command grouping insrc/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.tsas the canonical menu model. Do not add menu items directly insrc/components/AppMenu.vueordesktop/src/menu.rs. - Regenerate the native menu with
bun run generate:tauri-menuafter editing the shared menu schema;desktop/generated/menu.jsonis consumed by the Tauri menu builder. Tauri also runs this generator fromdesktop/tauri.conf.jsonviabeforeDevCommandandbeforeBuildCommand. - Every shared menu item with an
idmust be handled bysrc/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.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
- .fig files use Kiwi binary codec — schema in
packages/core/src/kiwi/binary/codec.ts NodeChangeis the central type for Kiwi encode/decode- Vector data uses reverse-engineered
vectorNetworkBlobbinary format — encoder/decoder inpackages/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 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 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
bun publishfrom package dirs — resolvesworkspace:*→ 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.jswrapper; do not point packagebinentries at TypeScript source
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/)
- Safari ew-resize/col-resize/ns-resize cursor bug (WebKit #303845) — fixed in Safari 26.3 Beta