31 KiB
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. This file keeps agent-facing architecture, conventions, and commands; detailed public docs live under packages/docs/**.
Monorepo
Bun workspace packages:
packages/scene-graph—@open-pencil/scene-graph: SceneGraph, node types, copy/snap/undo helpers, variables, instances, hit testing. Framework-agnostic.packages/pen—@open-pencil/pen: Pen/vector editing helpers shared by core/editor surfaces.packages/kiwi—@open-pencil/kiwi: pure Kiwi schema/runtime/protocol package. Owns low-level Figma Kiwi codec/container/parse helpers and stays SceneGraph-agnostic.packages/fig—@open-pencil/fig: publishable.figpackage shell and low-level smoke/test boundary. Production SceneGraph.figpolicy still lives mostly in core while this package grows.packages/core—@open-pencil/core: renderer, layout, editor core, Figma API, tools, clipboard, vector conversion, and app/CLI-facing document I/O. Depends on scene-graph/pen/kiwi but keeps browser DOM out of core.packages/dom-css—@open-pencil/dom-css: DOM/CSS projection layer for HTML/CSS/JSX/Tailwind compatibility. Owns DesignDOM types and browser/headless CSS runtime adapters; keeps DOM/CSS parser dependencies out of core.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.packages/cli—@open-pencil/cli: headless CLI for .fig inspection, export, linting. Usescitty+agentfmt.packages/mcp—@open-pencil/mcp: MCP server for AI coding tools. Stdio + HTTP (Hono). Reuses core tools.packages/docs—@open-pencil/docs: published VitePress documentation site. Run withbun run docs:dev.
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 scene graph primitives from @open-pencil/scene-graph, editor/rendering services through targeted @open-pencil/core subpath exports, and @open-pencil/vue through the public Vue SDK entrypoint.
Public package exports
Use public package exports across package/app boundaries. Do not import workspace package internals from app code.
@open-pencil/scene-graph— SceneGraph, node types, primitives, copy/snap/undo, instance helpers, variable helpers, vector-network types.@open-pencil/core— broad compatibility barrel for editor/rendering/tooling APIs.- Common targeted core subpaths keep imports smaller and dependency intent clearer:
@open-pencil/core/color,/text,/vector,/figma-api,/icons,/canvas,/design-jsx,/editor,/tools,/kiwi,/clipboard,/rpc,/lint,/profiler,/io,/canvaskit,/layout. - Use
@open-pencil/kiwifor low-level Kiwi/FIG schema-runtime, codec, container, GUID, and parse helpers.
CanvasKit runtime loading is centralized in @open-pencil/core/canvaskit for app/browser use. Headless raster export may dynamically load canvaskit-wasm/full; elsewhere prefer import type and pass the CanvasKit instance in.
Editor architecture
packages/core/src/editor/ is the framework-agnostic editor core. createEditor() in create.ts assembles an EditorContext plus domain action modules for viewport, selection, pages, shapes, structure, components, clipboard, undo/history, text, variables, layout, color space, graph reads, tool registry, and related helpers. Check the folder before adding editor behavior; keep new actions in the nearest domain module/folder instead of growing unrelated files.
Editor type = ReturnType<typeof createEditor>. Core modules should share state through EditorContext rather than importing app code or Vue.
Editor event bus
The editor exposes a typed nanoevents emitter. Event names/payloads live in EditorEvents in packages/core/src/editor/types.ts; graph events are bridged from SceneGraph by graph-events.ts. Subscribe with editor.onEditorEvent(event, handler), or in Vue use useEditorEvent(event, handler) from packages/vue/src/editor/events/use.ts.
Important invariant: all selection mutations in core go through ctx.setSelectedIds() and all tool changes go through ctx.setActiveTool() so events fire consistently. App-layer code should use editor actions such as clearSelection(), select(), or setTool() — never direct state.selectedIds = or state.activeTool = assignments.
The app editor session (src/app/editor/session/create.ts) is a Vue wrapper around core: it creates reactive state, calls createEditor(), and assembles app-specific 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 app and Vue SDK .vue filesbun 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 run test:unit— engine/unit testsbun run test— Playwright E2E and visual regression testsbun run tauri dev— desktop app with hot reloadbun open-pencil --help— list CLI commands. Common commands includeinfo,tree,find,node,pages,variables,export,convert,lint,query,selection,formats,analyze ..., andevalfor Figma Plugin API scripting.
Releases & CI
How to release
- Update version in the root
package.json, publishablepackages/*/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 public workspace packages to npm with provenance. Keep the exact package list in sync with
.github/workflows/build.yml.
- Go to GitHub Releases → edit the draft → paste changelog section → publish
CI workflows
Key workflows live in .github/workflows/. Use build.yml as the source of truth for release packaging and npm publishing, ci.yml / heavy-tests.yml for validation gates, and app.yml / docs.yml for Cloudflare Pages deploys.
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. Do not put speculative/internal implementation plans in packages/docs/**; VitePress docs are published. Keep temporary plans in ignored scratch/ or distill durable public direction into the canonical roadmap.
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 - Preserve product/domain casing in subjects and bodies:
DOM/CSS,CSS,HTML,JSX,Tailwind,Kiwi,.fig,MCP,CLI,AI,ACP,i18n. Do not flatten acronyms to lowercase prose such asdom css documents. - Prefer scopes that match the project structure:
app,tauri,core,cli,dom-css,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) - CLI data/inspection commands should support
--jsonfor machine-readable output
Tools (AI / MCP / CLI)
- Framework-agnostic tool operations live under
packages/core/src/tools/**asToolDefobjects. Domains include read, create, modify, structure, variables, vector, analyze, describe, codegen, stock-photo, and helpers. Check the existing domain folder before adding a new file. schema.tsdefinesToolDef,defineTool(), and shared result helpers. Each tool has a name, description, typed params, and anexecute(figma: FigmaAPI, args)function.- Registries (
registry*.ts) assemble tool sets. Add new tools to the appropriate registry so AI chat, MCP, and CLI eval paths can see them. - AI adapter (
packages/core/src/tools/ai-adapter.ts) converts ToolDefs to Vercel AI tools with valibot schemas.src/app/ai/tools/index.tsis a thin app wire that createsFigmaAPIfrom the active editor. - CLI commands in
packages/cli/src/commands/**are not generated from ToolDefs; they own CLI UX, pagination, and agentfmt formatting. Theevalcommand exposes ToolDef operations throughFigmaAPI. - MCP server code lives in
packages/mcp/src/server.ts. MCP-only tools such asopen_file,new_document,save_file, andget_codegen_promptare registered there because they need server filesystem access or are not scene-graph tools. open_fileandnew_documentare only registered whenOPENPENCIL_MCP_ROOTis set. Export tools can write files under that root when given apath.- Core codegen prompts live as markdown under
packages/core/src/tools/prompts/; app chat/ACP prompts live undersrc/app/ai/**markdown files. FigmaAPI(packages/core/src/figma-api/) is the execution target for tools and CLI eval. It is Figma Plugin API compatible and uses Symbols for hidden internals.
ACP and collaboration
Keep this section light; implementation details move often.
- ACP UI/transport lives under
src/app/ai/acp/**; provider definitions live inpackages/core/src/constants.ts; app prompts live undersrc/app/ai/**. Public docs:packages/docs/programmable/ai-chat.mdandpackages/docs/programmable/mcp-server.md. - ACP transport uses Tauri shell permissions, so check
desktop/capabilities/**when changing agent launch behavior. - Collaboration lives under
src/app/collab/**and is documented inpackages/docs/programmable/collaboration.md. It uses Trystero + Yjs + awareness; preserve crypto-safe room IDs and peer cleanup semantics when changing it.
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
bun run check:archand related lint rules; keep app/package boundaries clean instead of relying on review to catch private imports. In practice: use public workspace exports across boundaries, keep core framework-agnostic, keep app services separate from component/view layers, keep shared UI free of app stores/services, and keep property-panel internals inside the property panel. - Test placement is strict: app E2E in
tests/e2e/**/*.spec.ts, Figma automation intests/figma/**/*.spec.ts, engine/unit tests intests/engine/**/*.test.ts, shared test utilities intests/helpers/**, and standalone package tests in their packagetests/**when established. UI-visible behavior belongs in E2E; graph/internal-state assertions belong in engine/unit tests. Do not commit temporary/profile specs.
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.
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 undersrc/app/*- Use package-local aliases inside workspace packages:
#vue/*inpackages/vue,#cli/*inpackages/cli,#dom-css/*inpackages/dom-css,#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 }, and importSceneNode/Effect/Fill/Strokefrom@open-pencil/scene-graphinstead of re-spelling their shapes. - Shared geometry/color primitives live in
packages/scene-graph/src/primitives.ts; scene/node domain types live inpackages/scene-graph/src/types.tsand are exported from@open-pencil/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/kiwi/src/schema-runtime/contains the Kiwi codec runtime; keep runtime changes minimal and prefer wrappers/helpers for project-specific validation- Core code must guard browser APIs with explicit runtime checks such as
typeof window !== 'undefined'/typeof document !== 'undefined'before using them. - Name repeated or cross-feature constants; use
src/constants.tsfor app-wide constants rather than feature-local values.
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
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. - Before custom UI/control/composable work, read upstream docs for the relevant dependency instead of guessing from local usage. Prefer their
llms.txtentrypoints when available:- Reka UI (
https://reka-ui.com/llms.txt) before building dialogs, popovers, dropdowns, menus, selects, tooltips, toasts, trees, splitters, or other primitives. - VueUse (
https://vueuse.org/llms.txt) before hand-rolling DOM events, browser APIs, refs/focus, media queries, timers, clipboard, storage, async state, or observers. - Tailwind / tailwind-variants docs before inventing one-off styling prop APIs or variant composition.
- Reka UI (
- If upstream docs contradict local patterns, prefer current upstream APIs and update local wrappers deliberately.
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.
Rendering
- Canvas is CanvasKit (Skia WASM) on a WebGL surface, not DOM
renderVersionvssceneVersion:renderVersion= canvas repaint (pan/zoom/hover);sceneVersion= scene graph mutations. UI that only cares about graph data should avoid watching repaint-only state; use editor events for incremental surfaces such as the layer tree.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.
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 propagate to instances through the editor/component sync path; do not hand-copy instance fields in app UI code.
- Instance property copying lives in
@open-pencil/scene-graphhelpers and uses structured copies for nested values.
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
Component structure
-
src/components/ui/**is the app design-system layer: reusable visual primitives, wrappers around Reka UI primitives, low-level styled controls, and UI class helpers. These files must not import app services/stores or feature panels. -
src/components/Shell/**is for app shell chrome and global app services rendered as components (menu bar, toast viewport, update/status chrome). Shell components may use app shell/editor stores. -
src/components/properties/**,src/components/chat/**,src/components/LayerTree/**,src/components/Toolbar/**, and similar folders are feature/domain component namespaces. Keep feature-specific controls there unless they are genuinely reusable UI primitives. -
Root-level
src/components/*.vueis for broad editor panels/surfaces that are assembled by views or shell layout. Do not add new root-level base controls; create a domain folder or move reusable primitives tosrc/components/ui/**. -
Test hooks should be
data-test-idattributes owned by the rendered markup or generated internally from semantic component state. Do not addtestId,visibilityTestId,triggerTestId, or other test-id props to component APIs. -
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/Shell/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 -
Use
Tip/ tooltip components for hover help; do not add nativetitleattributes in Vue UI. -
Mac keyboards: use
e.codenote.keyfor shortcuts with modifiers (Option transforms characters) -
Icons: use unplugin-icons with Iconify/Lucide (
<icon-lucide-*>) — don't use raw SVG or Unicode symbols -
App menu (
src/components/Shell/AppMenu.vue) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden whenIS_TAURIis true -
Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, ScrubInput pointer ownership, section drag targets, side-panel containment, and global number-spinner styling.
File format
.figfiles use Figma's Kiwi schema andNodeChange[]records. Low-level schema/runtime/codec/container/parse helpers live inpackages/kiwi/src/fig/**andpackages/kiwi/src/schema-runtime/**.- Core still owns SceneGraph
.figpolicy: import/export orchestration inpackages/core/src/io/formats/fig/**, SceneGraph ⇄ NodeChange conversion inpackages/core/src/kiwi/fig/node-change/**, and component/instance override interpretation inpackages/core/src/kiwi/fig/instance-overrides/**. packages/figis the publishable boundary for future.figpolicy extraction; do not move behavior there without package-local tests and dist smoke.- Vector data uses reverse-engineered
vectorNetworkBlobbinary format — encoder/decoder inpackages/core/src/vector/and scene-graph vector-network types in@open-pencil/scene-graph. showOpenFilePicker/showSaveFilePickerare File System Access API (Chrome/Edge), not Tauri-only; code must keep browser fallbacks.- Safari save: no File System Access API → use an
<a>download fallback with deferredrevokeObjectURL. SafariBanner warns users about limitations. - Tauri detection: use
IS_TAURIfrom@open-pencil/core/constants/src/constants.ts; don't inline__TAURI_INTERNALS__checks. .figexport compression uses fflate in browser paths and Tauri Rust commands where available.- Test
.figround-trip by exporting and reimporting in Figma when changing file-format behavior. - Test fixtures (
tests/fixtures/*.fig) are Git LFS. If no.figfixtures changed,git push --no-verifycan skip the slow LFS pre-push hook; use regulargit pushwhen fixtures changed.
Tauri
- Tauri v2 desktop app lives under
desktop/; checkdesktop/Cargo.toml,desktop/capabilities/**, anddesktop/tauri.conf.jsonbefore adding desktop capabilities. - File system and shell permissions must be configured explicitly; vague "Internal error" save failures often mean missing permissions.
- Dev tools: add or use a menu item to toggle, don't rely on keyboard shortcuts.
Publishing
bun publishfrom package dirs — resolvesworkspace:*→ actual versions- Public packages publish built
dist/output, not runtime TypeScript entrypoints - Public workspace packages build before publishing; most use tsdown, and split packages may also run
tsc --emitDeclarationOnlyplus dist smoke checks. Keep release tooling package lists in sync with.github/workflows/build.yml. - CLI publishes a Node-compatible
bin/openpencil.jswrapper; do not point packagebinentries at TypeScript source
Reference
figma-use — historical Figma toolkit reference. Verify current paths/types in that repo before copying assumptions. Useful areas:
- 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/)