- Move SceneGraph-to-NodeChange conversion, text payload assembly, and blob encoding into @open-pencil/fig\n- Inject core glyph outline lookup through an explicit runtime service\n- Centralize neutral font naming in scene-graph and retain core IO/runtime orchestration
35 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: Pencil.dev.pendocument model, parser, and SceneGraph import adapter.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:.figarchive/parser package owning Figma-specific SceneGraph conversion, raw metadata policy, and component/instance interpretation. Core keeps format-neutral IO registration and runtime rendering/font integration.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. Do not create cross-package re-export shim files whose only purpose is forwarding another package's API. Import the owning package directly at call sites; public compatibility barrels may re-export the owner directly when preserving an established package API.
@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/.
Headless SDK fields compose variable/token binding through BindingProvider and the BindableValue primitives in packages/vue/src/controls/binding-provider/ and packages/vue/src/primitives/BindableValue/. Keep numeric interaction in NumberField; providers own binding lookup, mutation, and undo batching.
Property-panel anatomy in packages/vue/src/primitives/PropertySection/, SegmentedControl/, and PropertyList/ is controlled and editor-agnostic. Connect PropertyList events to OpenPencil selection and undo through useEditorPropertyList() or an app adapter; never call useEditor() from these primitives.
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,import,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.
- The production web app/docs deploy workflows (
app.yml,docs.yml) also trigger onv*tags. They do not deploy on ordinarymasterpushes. - 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.
Production Cloudflare Pages deploys are intentionally release/manual only: app.yml and docs.yml run on v* tags and workflow_dispatch, not on master pushes. To deploy manually, run the relevant workflow from GitHub Actions (Deploy app or Deploy docs) on the desired ref; the workflow deploys to the configured production branch (master).
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. Do not create English placeholder copies under locale directories; until a real translation exists, localized navigation should link to the canonical English page.
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 uses domain namespaces rather than full Feature-Sliced Design ceremony:
- App services/state/integration live under
src/app/**; route/layout views live undersrc/views/**; app UI lives undersrc/components/**. src/components/ui/**is the shared app design-system layer.packages/vue/src/primitives/**is the headless SDK primitive layer. App wrappers around SDK primitives should stay in app component domains and only move toui/**when genuinely generic.- Root-level
src/components/*.vueis reserved for broad editor panels/surfaces assembled by views or shell layout. Do not add new root-level base controls; create a domain namespace or usesrc/components/ui/**for reusable primitives. - App component domain folders should be lowercase or kebab-case (
chat/,properties/,fill-picker/,color-picker-panel/,canvas/,inputs/). Avoid adding newPascalCase/Component.vueapp folders; migrate existing ones gradually when touched. - Vue component files stay 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 asindex.ts,types.ts,context.ts, oruse.ts. - Multi-file root components live inside their component namespace folder, not beside it. When a reusable picker/input/control grows beyond one file, create a namespace instead of leaving related files at
src/components/root. - 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
- Editing a Hug/Fill width or height switches only that axis to Fixed on the first value mutation; focus stays non-destructive, and mode plus value changes belong to one undo transaction
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. -
Treat existing root-level picker/input/control components as migration candidates when touched; do not expand that pattern.
-
Property-panel composition uses
PanelGrid,PanelFieldGroup,PanelItemRow, andPropertyItemRow; do not reintroduce generic row wrappers such as the removedPanelRow. Variable-capable fields composeBindableValueproviders, and fill UIs composeFillRoot/FillSwatchwith a consumer-owned popover rather than rebuilding a combined picker wrapper. -
Test locators follow Playwright's user-facing priority: role/name, label, and text first. Multi-part components expose scoped
data-slotanatomy; app concepts use semantic attributes such asdata-property,data-command, anddata-node-idwhen accessible identity is insufficient. Reservedata-test-idfor rare integration boundaries such as the canvas/editor host, never addtestId/testHookprops, and do not manufacture globally unique compound IDs inside shared components. -
Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
-
Vue UI styling APIs follow the Nuxt UI architecture: static Tailwind Variants themes live under
src/theme/**withslots,variants,compoundVariants, anddefaultVariants; components resolve the theme withtv()and merge per-instanceuioverrides at each rendered slot. Single-root components exposeclassrather than a one-slotuiobject. Do not add one-offfooClass,barClass,emptyActionClass, etc. props. UseUIcasing in type names (SelectUI, notSelectUi). -
Steiger parses Vue templates and rejects visual-state Tailwind utility branches, template-time
use*UI()calls, and raw SVG app icons. Bind semantic state throughdata-*attributes and resolve typed theme variants in script instead of bypassing the rule. -
Storybook is the internal component-state workshop (
bun run storybook,bun run build-storybook), while VitePress is the canonical public SDK documentation. Colocate*.stories.tswith app UI components and use toolbar themes for light/dark states instead of adding test-only routes or showcase pages to the app. -
Reuse colocated Vue demo components between Storybook and VitePress rather than maintaining separate examples. Style shared demos with Tailwind; the docs theme scans Vue SDK primitive demos through its dedicated Tailwind source.
-
Public component API tables are generated from Vue source and JSDoc with
vue-component-meta; do not manually duplicate props, events, slots, or exposed APIs in Markdown. SDK examples are processed by VitePress Twoslash and must resolve against the public@open-pencil/vueAPI. -
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/canvas/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 -
Binding-aware fields must not mutate or detach on focus. Start detach/edit-variable transactions only on the first actual value mutation; opening the variable picker is also non-destructive.
-
Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, NumberField 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 helpers live inpackages/kiwi/src/fig/**andpackages/kiwi/src/schema-runtime/**; complete.figarchive parsing lives inpackages/fig.@open-pencil/figowns SceneGraph ⇄ NodeChange conversion inpackages/fig/src/node-change/**, component/instance interpretation inpackages/fig/src/instance-overrides/**, and effective raw metadata policy inpackages/fig/src/source-metadata.ts.- Core owns
.figIO orchestration inpackages/core/src/io/formats/fig/**, runtime font/glyph integration, workers, and CanvasKit thumbnails. Keep Fig behavior covered by 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/)