30 KiB
OpenPencil
Vue 3 + CanvasKit (Skia WASM) + Yoga WASM design editor. Tauri v2 desktop, also runs in browser.
Roadmap: packages/docs/development/variables-ui-roadmap.md tracks remaining variable-system UI work. 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. 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.
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, 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 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 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
Releases & CI
How to release
- 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
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: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 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.
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 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.
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 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 (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— 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)
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_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()
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 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.
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, 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. -
@/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/kiwi-schema/is vendored — don't modify -
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: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
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 - 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
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
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.)
- 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. - 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
File format
- .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
- 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