# Developer Guide Task-oriented guide for working in this codebase. For the why-and-how-it's-structured, see [`ARCHITECTURE.md`](./ARCHITECTURE.md). **For SDK consumers** (writing adapters, canonicals, or panels for the editor without modifying internals), see [`SDK_GUIDE.md`](./SDK_GUIDE.md) + the three tutorials. This guide covers in-tree contribution. --- ## Getting started ```sh # Install dependencies npm install # Start the dev server (http://localhost:5173) npm run dev # Type-check + production build npm run build # Type-check only npx tsc -b # Lint npm run lint ``` The dev server uses Vite. HMR is on; most edits show up in the browser without a reload. --- ## Project layout (high-level) ``` src/ registry/ Canonical components — the abstract palette adapters/ Per-library renderers — shadcn, mui themes/ CSS-variable token packs scoped by [data-theme] state/ Zustand store for editor-side state style/ Class-string parser/serializer (tw-classes.ts) craft/ Craft.js bridge (CanonicalNode, resolver) editor/ Editor UI shell (Toolbox, Inspector, SaveLoadBar, …) persistence/ Zod envelope + localStorage I/O lib/utils.ts shadcn-managed cn (tailwind-merge) components/ui/ shadcn primitives (managed by `npx shadcn add`) App.tsx Boot: side-effect imports → main.tsx ReactDOM root index.css Tailwind v4 + token blocks + safelist ``` Full architectural breakdown in [`ARCHITECTURE.md`](./ARCHITECTURE.md). --- ## Recipes ### Adding a canonical component (Pattern A — single slot) Canonicals are the abstract palette. Each one has a stable id, a Zod prop schema, and defaults. Adapters provide the actual rendering. 1. Create `src/registry/components/.ts`: ```ts import { z } from 'zod' import { registerCanonical } from '../registry' // or 'registerComponent' — they're aliases export const tooltipPropsSchema = z.object({ label: z.string(), placement: z.enum(['top', 'right', 'bottom', 'left']), }) export type TooltipProps = z.infer registerCanonical({ id: 'tooltip', // stable — persisted in documents category: 'feedback', displayName: 'Tooltip', // shown in Toolbox; persisted as Craft resolver key tags: ['hint', 'popover'], isCanvas: false, styleSlots: ['root'], // Pattern A — one slot; see Pattern B recipe below for multi-slot propsSchema: tooltipPropsSchema, defaults: { props: { label: 'Tooltip', placement: 'top' }, style: { classes: { root: 'px-2 py-1 rounded-md bg-popover text-popover-foreground' } }, }, }) ``` 2. Add one line to `src/registry/components/index.ts`: ```ts import './tooltip' ``` 3. Provide an adapter impl for it — see "Adding an adapter impl for an existing canonical" below. The toolbox picks it up automatically (iterates `listComponents()` and groups by `category`). **Heads-up:** the canonical's default `style.classes.root` is what new instances start with. If the inspector's panels can later set classes outside this default's vocabulary, make sure those utilities are in the Tailwind safelist (see [§ Tailwind safelist](#tailwind-safelist)). ### Adding a Pattern B canonical (multiple style slots) Use this pattern when a canonical has visually-distinct regions the user should style independently — Card has `header`/`body`/`footer`, Tabs has `tabs`/`content`. Declare multiple `styleSlots`; the inspector's `SlotPicker` exposes them as pills above the class-editing panels. For canonicals where each region should *also* be its own drop zone (Card's header / body / footer all accept dropped children independently), declare a matching `canvasSlots` — see "Adding a multi-canvas Pattern B canonical" below. For canonicals where regions are styling-only (no per-region drops), stop here. 1. Declare the slots in the canonical: ```ts registerCanonical({ id: 'dialog', // ... styleSlots: ['root', 'header', 'body', 'actions'], // 'root' must be first defaults: { props: { /* ... */ }, style: { // Provide an entry per slot, even if empty — keeps Inspector reads from // returning undefined for newly-added slots. classes: { root: '', header: '', body: '', actions: '' }, }, }, }) ``` 2. Write the adapter impl. Consume `composedClasses[slot]` and `composedInlineStyles[slot]` per region. The root slot is duplicated to the legacy `className` / `inlineStyle` fields for Pattern A compat, so you can also read those for the root region if you prefer. ```tsx export function ShadcnDialog({ children, rootRef, composedClasses = {}, composedInlineStyles = {}, }: AdapterRenderProps) { return (
{/* header content */}
{children}
{/* action buttons */}
) } ``` 3. No changes needed in Inspector or panels — `SlotPicker` shows automatically when `styleSlots.length > 1`, and every class-editing panel already accepts a `slot` prop. ### Adding a multi-canvas Pattern B canonical When each named region needs to be its own independently-droppable canvas (Card with header / body / footer drop zones), add `canvasSlots`: 1. Declare both `styleSlots` and `canvasSlots`. The outer canonical's `isCanvas` MUST be `false` — declaring both `isCanvas: true` AND `canvasSlots` would create competing drop targets and break hit-testing. ```ts registerCanonical({ id: 'splitter', category: 'layout', // ... isCanvas: false, // outer is just a wrapper styleSlots: ['root', 'left', 'right'], canvasSlots: ['left', 'right'], // both panels accept drops defaults: { props: {}, style: { classes: { root: '', left: '', right: '' } }, }, }) ``` 2. The adapter impl receives `slotChildren: Record` — each entry is a `` wrapper that becomes its own linked Craft child node: ```tsx export function ShadcnSplitter({ rootRef, composedClasses = {}, composedInlineStyles = {}, slotChildren = {}, }: AdapterRenderProps) { return (
{slotChildren.left}
{slotChildren.right}
) } ``` 3. Each `slotChildren[slot]` renders as a `
`. The `.canvas-slot` class in `src/index.css` gives empty slots a min-height + a dashed outline + a "Drop here" hint via `:empty` — disappears the moment the slot has children. 4. **Document migrations.** Changing a canonical from props-driven to multi-canvas (or back) is a persisted-shape change. Existing saved documents have the old shape baked in. Add a migration step in `src/persistence/migrations.ts` that walks the Craft tree and rewrites stale Card / Splitter / etc. nodes. The Card migration is the reference example — strip the dropped string props AND flip persisted `isCanvas: true` to `false`. ### Adding a dynamic-canvas canonical (one canvas per data item) When the **number** of canvases depends on a prop (Tabs → one per tab, Carousel → one per slide, Stepper → one per step), `canvasSlots` is a **function** instead of a static list: 1. Give each item a stable id and derive slot keys from it. Use a `z.string().default(() => genId())` field named `id` — the inspector hides `id`-named ZodDefault fields automatically, so the designer never edits the slot key (editing it would orphan the dropped content). Export a `slotKeys(items)` helper next to the canonical: ```ts export function slideSlotKeys(slides: readonly { id: string }[]) { return slides.map((s) => `slide-${s.id}`) } registerCanonical({ id: 'carousel', isCanvas: false, styleSlots: ['root', 'slide', /* … */], canvasSlots: (props) => slideSlotKeys((props as CarouselProps).slides), // … }) ``` 2. The adapter reads `slotChildren[key]` for each item via the same helper. Export the helper from `src/sdk/canonical.ts` so third-party adapters can match the keys (`tabSlotKeys` / `slideSlotKeys` are the precedents). 3. Branch on `useIsEditing()` if the runtime view differs from the authoring view (Carousel pins to the authored `currentSlide` in the editor but owns its own next/prev index at runtime). ### Authoring an overlay canonical (inline in editor, real overlay at runtime) Modal / Drawer / Toast / Tooltip / Popover follow one contract — copy it for any custom overlay: 1. **Branch the adapter on `useIsEditing()`** (`@crafted-design/editor/sdk`): ```tsx import { useIsEditing } from '@crafted-design/editor/sdk' import { createPortal } from 'react-dom' export function ShadcnMyOverlay({ props, children, rootRef }: AdapterRenderProps) { const editing = useIsEditing() if (editing) { // Inline, always-open preview so the content is a normal drop target. // Built-ins portal this into the Overlay Stage: const stage = document.getElementById('craftjs-overlay-stage') return stage ? createPortal({children}, stage) : null } // Runtime: the library's real overlay with its own open / dismiss behavior. return {children} } ``` 2. **Hide it from the toolbox** (`hidden: true` on the canonical) if it should only be reachable by attaching to a trigger. The built-in overlays do this; they're created via the right-click **Attach overlay** menu, which also wires the trigger's `triggers: string[]` to the overlay's `name`. 3. **Open state lives in the overlay runtime store** (keyed by the `name` prop), not in the canonical's own state — so a Button elsewhere in the document can toggle it. Tooltip / Popover instead register their content into the store and let the trigger wrap itself in the library primitive (native hover / click), rather than toggling open. 4. **Test the `useIsEditing` branch both ways** — the top-bar **Preview** toggle flips `state.options.enabled`, so you can confirm the inline preview and the real overlay in the same session. ### Adding an adapter A new adapter wraps a UI library and provides impls for some or all canonicals. Missing impls render a labeled placeholder — the user can swap to a covering adapter or remove the node. 1. Create `src/adapters//components/.tsx` for each canonical you want to support. Match `AdapterRenderProps`: ```tsx import type { AdapterRenderProps } from '../../types' export function MyButton({ props, className, sx, rootRef }: AdapterRenderProps) { const { label, intent, disabled } = props as { label: string; intent: string; disabled: boolean } return ( ) } ``` Pick which output prop matches your library: `className` (Tailwind-style), `sx` (MUI-style), or `inlineStyle` (raw CSS). Each is populated by `CanonicalNode` from `adapter.classMap` or the default passthrough. 2. (Optional) Provide capability hooks. The `Adapter` interface accepts five optional fields: - **`Wrapper`** — a React component rendered around the canvas. Use for global providers (theme, locale, library reset). **Must be a pure context provider**: no `document` listeners, no global CSS injection, no browser API mutation. See § Wrappers must be pure context providers below. - **`themeTokens`** — CSS variable declarations the adapter wants injected when active. - **`classMap`** — `(canonicalClasses, canonicalId) => { className?, sx?, inlineStyle? }`. Rewrites canonical Tailwind classes into adapter-native render props. - **`mount` / `unmount`** — imperative side effects on adapter swap. Use these for global state your library needs. 3. Create `src/adapters//index.ts`: ```ts import { registerAdapter } from '@design/sdk' import { MyButton } from './components/Button' registerAdapter({ id: 'mylib', displayName: 'My Library', components: { button: MyButton }, // Optional: Wrapper, themeTokens, classMap, mount, unmount // Declare any external npm packages this adapter needs, mapped to the // tested range. Surfaced in the compatibility matrix; // omit if the adapter uses no external library. peerDependencies: { 'my-ui-lib': '^3' }, }) ``` `registerAdapter` validates the manifest via Zod (`AdapterManifestSchema.ts`). Missing required fields throw at boot with a readable error. > **Wrapper adapters must register before `` mounts.** If your > adapter declares a `Wrapper` (a global provider like MUI's > `ThemeProvider`), register it via a side-effect import in your entry > module — never lazily after the editor is on screen. `AdapterProvider` > composes every adapter's Wrapper to keep the React tree stable across > adapter swaps; a Wrapper added post-mount reshapes that tree and remounts > Craft's ``, wiping the canvas. `registerAdapter` emits a dev > warning if you break this. Adapters *without* a Wrapper can register any > time (e.g. hot reload). 4. Add a side-effect import to `src/App.tsx`: ```ts import './adapters/mylib' ``` `AdapterSwitcher` picks the new adapter up automatically (iterates `listAdapters()`). #### Shipping an adapter as a subpath entry The three built-in adapters double as opt-in package entries (`@crafted-design/editor/adapters/{shadcn,mui,html}`) so a host bundles only the UI libraries it renders. To add a built-in adapter the same way: 1. **`vite.config.dist.ts`** — add the adapter index to `lib.entry` (e.g. `'adapters/mylib': resolve(__dirname, 'src/adapters/mylib/index.ts')`). Externalize any heavy peer library there too (so it isn't bundled). 2. **`package.json` `exports`** — add the subpath, pointing `import` at `./dist-lib/adapters/mylib.js` and `types` at the per-file `./dist-lib/adapters/mylib/index.d.ts` (vite-plugin-dts emits a file tree, not a single bundled `.d.ts`). 3. **`package.json` `sideEffects`** — list `**/adapters/mylib/**`. This is mandatory: the registration is a side-effect import, and an unlisted module gets tree-shaken out of the published bundle (the bug that shipped an adapter-less `dist-lib` in earlier versions). Add the same to `peerDependencies` + `peerDependenciesMeta` (optional) if it needs an external library. 4. **`src/core.tsx`** (and/or the full `src/main-app.tsx`) — add the side-effect import so the chosen batteries-included entry registers it. 5. Export a non-type value from the index (e.g. `export const adapterId = 'mylib'`) so vite-plugin-dts emits a `.d.ts` for the subpath; a bare side-effect import still registers the adapter. 6. **`src/adapters/adapters-register.test.ts`** — extend the coverage-parity guard. `npm run docs:matrix` regenerates the matrix; CI's `--check` fails if a built-in adapter isn't 48/48. ### Adding an adapter impl for an existing canonical If the canonical already exists and you just need to fill a coverage gap in an adapter: 1. Create `src/adapters//components/.tsx` matching `AdapterRenderProps`. 2. Add it to the adapter's `components` map in `src/adapters//index.ts`. The next render of any node with that canonical id picks up the new impl. No further wiring. ### Adding a theme 1. Append a CSS block to `src/index.css`, scoped to `[data-theme=""]`. Only override tokens that *differ* from `:root` — the cascade handles the rest: ```css [data-theme="forest"] { --primary: oklch(0.55 0.18 145); --primary-foreground: oklch(0.98 0.02 145); --ring: oklch(0.55 0.18 145); } ``` 2. Create `src/themes/.ts`: ```ts import { registerTheme } from './registry' registerTheme({ id: 'forest', displayName: 'Forest', dataThemeValue: 'forest' }) ``` 3. Add one line to `src/themes/index.ts`: ```ts import './forest' ``` `ThemeSwitcher` picks it up automatically. ### Adding an inspector panel The Inspector reads panels from a pluggable registry — built-ins and custom panels register the same way. Seven panels ship today (Layout, Size, Spacing, Typography, Appearance, Effects, Properties); they register themselves at module load via `src/editor/inspector/built-in-panels.ts`. To add an eighth — say, a custom "Animation" panel — follow this template, copying from `TypographyPanel.tsx` as the canonical example. **Note on array props:** if your panel surfaces an array prop via PropsPanel, the built-in `ArrayField` editor ships with HTML5 drag-and-drop reorder. The drag handle is a `GripVertical` icon on each item card; drop indicator shows whether the dropped item will land before or after the target. ↑/↓ buttons are retained as a keyboard-accessibility fallback. No work required on your end — `ArrayField` handles it. 1. **Add a slice to `src/style/tw-classes.ts`** if your panel edits Tailwind classes. Each slice is a self-contained block: const arrays + slice interface + regex patterns + `parse*` / `serialize*` / `merge*` trio. Slices must be independent — `parseX` should pass through every class that's not in X's prefix family as `unknownClasses`. See the typography block as a template. ```ts export const ANIMATIONS = ['none', 'spin', 'pulse', 'bounce'] as const export type Animation = typeof ANIMATIONS[number] export interface AnimationSlice { animate?: Animation } const ANIMATE_RE = new RegExp(`^animate-(${ANIMATIONS.join('|')})$`) export function parseAnimation(classString: string): { slice: AnimationSlice; unknownClasses: string[] } { /* … */ } export function serializeAnimation(slice: AnimationSlice): string[] { /* … */ } export function mergeAnimation(original: string, updates: Partial): string { /* … */ } ``` 2. **Add a test block** in `src/style/tw-classes.test.ts`. Five tests per slice is typical: extract all fields, unknown passthrough, disambiguation (if applicable), merge patch preserves other slices, round-trip stability. 3. **Add a `PanelId` to `src/registry/types.ts`** and extend `getApplicablePanels` defaults if useful. If the panel applies only to specific canonicals, leave the default rule alone and let canonicals opt in via explicit `applicablePanels`. 4. **Build the panel** in `src/editor/inspector/Panel.tsx`. Use the shared building blocks. The Inspector wraps each panel in a `CollapsibleSection`, so don't render your own title: ```tsx import { mergeAnimation, parseAnimation, ANIMATIONS } from '@/style/tw-classes' import type { Animation, AnimationSlice } from '@/style/tw-classes' import { PanelRow } from './shared/PanelRow' import { ValueSelect } from './shared/ValueSelect' import { useNodeClasses } from './shared/useNodeClasses' // `slot` defaults to 'root' so Pattern A canonicals can pass nothing. // Pattern B canonicals' Inspector passes the active slot from SlotPicker. export function AnimationPanel({ nodeId, slot = 'root' }: { nodeId: string; slot?: string }) { const { classString, writeClasses } = useNodeClasses(nodeId, slot) const { slice } = parseAnimation(classString) const update = (patch: Partial) => { writeClasses(mergeAnimation(classString, patch)) } return (
update({ animate: v as Animation | undefined })} />
) } ``` **Shared controls available**: `ValueSelect` (enum dropdown with optional icons via `renderOption`), `ColorPicker` (token swatches + react-colorful visual picker + hex), `NumericInput` (token + arbitrary CSS value + step buttons), `BoxSidesEditor` (linked/unlinked 4-side editor), `PanelRow` (label-left layout). `useNodeClasses` is the single I/O funnel — returns `{ classString, inlineStyle, writeClasses, writeInline, activeBreakpoint }`. Reads/writes target the *active breakpoint's* class slice; inline reads/writes target the base `style.inline[slot]`. Your panel gets responsive support for free. **Read the live class string at write time** by passing the current `classString` into `merge*` — that's the closure-captured value, refreshed on every render via `useNodeClasses`. Don't call `parseAnimation` separately just before writing; the merge function already does it. 5. **If the panel supports arbitrary values via ColorPicker/NumericInput**, follow the token-vs-arbitrary mutual-exclusion pattern (see Conventions). Arbitrary values work at every breakpoint via `style.responsiveInline`. `useNodeClasses` routes the writes automatically based on `activeBreakpoint`; no panel-side gating needed. 6. **Register the panel via `registerPanel`.** The Inspector resolves panels through a registry. Add a side-effect import for your panel's registration in `App.tsx` (or in `src/editor/inspector/built-in-panels.ts` if it's a built-in): ```ts import { registerPanel } from '@design/sdk' // or '../inspector/panel-registry' internally import { AnimationPanel } from './AnimationPanel' registerPanel({ id: 'animation', displayName: 'Animation', order: 80, // after every built-in (10–70) applicableTo: () => true, // or narrow by category / isCanvas component: AnimationPanel, }) ``` Resolution rules: if a canonical declares `applicablePanels`, that list is a whitelist — only panels with those ids render. Otherwise each panel's `applicableTo(def)` predicate decides. Canonicals with explicit `applicablePanels` (Button, the 5 form canonicals) won't show your panel unless they add `'animation'` to their list. 7. **Add the slice's utilities to `scripts/gen-safelist.ts`** so Tailwind compiles them. The script reads slice arrays from `tw-classes.ts` — add `expand('animate-', ANIMATIONS)` and Tailwind will see every breakpoint-prefixed combination. Without the safelist entry, your classes will land in the DOM but Tailwind won't generate CSS for them. Silent failure. ### Adding a `shadcn` primitive ```sh npx shadcn add ``` This writes to `src/components/ui/.tsx`. The adapter impl wraps it. **Watch out:** if the shadcn CLI writes to `./@/components/ui/.tsx` instead, your `tsconfig.json` is missing the `@/*` path aliases (the CLI reads root tsconfig). Fix per [§ tsconfig path aliases](#tsconfig-path-aliases). --- ### Authoring a canonical that supports inline text editing Any canonical whose adapter impl renders user-editable text can opt into double-click-to-edit with two SDK exports — `EditableText` and `useStartTextEdit`. No canonical-schema change is needed; it's purely an adapter-impl concern. ```tsx import { EditableText, useStartTextEdit, type AdapterRenderProps, } from '@design/sdk' // '@crafted-design/editor/sdk' for published consumers export function MyText({ props, rootRef, className }: AdapterRenderProps) { const { content } = props as { content: string } const startEdit = useStartTextEdit() return (

{ e.stopPropagation() // don't let the canvas handle the dblclick startEdit() // sets editorStore.editingTextNode = this id }} > {/* propPath is the key under data.props.nodeProps to write on commit. multiline → Enter inserts a newline; otherwise Enter commits. */}

) } ``` Notes: - `EditableText` renders a Fragment in display mode (no DOM wrapper — the parent's typography applies directly) and a `contenteditable="plaintext-only"` span in edit mode. It writes to `data.props.nodeProps[propPath]` — **not** `data.props[propPath]` (the canonical props live one level down, under `nodeProps`). - Commit fires on Enter (single-line), blur, or click-outside; Escape reverts. The whole edit is one undo step. - `useStartTextEdit()` must be called from inside the adapter impl (it uses `useNode()` to resolve the node id). It's the only supported way to enter edit mode — adapter authors never touch `editorStore` directly. ### Writing an `EditorImageProvider` To route image uploads to your own backend instead of the default base64-inline provider, wrap the editor: ```tsx import { EditorImageProvider } from '@crafted-design/editor/sdk' import { Editor } from '@crafted-design/editor' const backend = { async upload(file: File) { const { url } = await myApi.upload(file) return { url } // optionally { url, thumbnail } }, async list() { return (await myApi.listImages()).map((url) => ({ url })) }, async delete(url: string) { // optional — enables a delete button await myApi.deleteImage(url) }, // canList defaults to true when you pass a provider; set false to // hide the Library grid + Assets inspector panel. } function App() { return ( ) } ``` The `src` field of the Image canonical automatically uses the active provider for its Upload button + Library modal. Read the provider from a custom panel/component with `useEditorImageProvider()`. Full contract table in `docs/INTEGRATION_GUIDE.md` § Asset backends. --- ### Authoring a token theme Define a theme from a handful of base colors — `deriveTokens` fills the rest and the CSS is generated + injected. Add `darkTokens` for a dark variant. Built-ins live in `src/themes/.ts`; SDK consumers call the same `registerTheme`. ```ts // src/themes/forest.ts import { registerTheme } from './registry' registerTheme({ id: 'forest', displayName: 'Forest', tokens: { primary: 'oklch(0.55 0.18 145)', // primaryForeground/secondary/accent/background/… optional — derived }, darkTokens: { primary: 'oklch(0.7 0.16 145)' }, }) ``` Then side-effect-import it (`src/themes/index.ts`). Only restate tokens that differ from the scheme neutral defaults; everything else derives. The visual theme editor (top bar → "Edit theme") authors the same shape visually with an OKLCH slider + live preview, and can export the CSS. ### Adding a style panel for a new utility family A panel reads/writes the active (breakpoint × state) quadrant through `useNodeClassesMulti` — never poke Craft state or the `NodeStyle` quadrants directly. ```tsx function MyPanel({ nodeIds, slot = 'root' }: { nodeId: string; nodeIds: readonly string[]; slot?: string }) { const { classStrings, writeClassesAll, writeInlineAll } = useNodeClassesMulti(nodeIds, slot) // Tailwind utility family → writeClassesAll((cur) => mergeMine(cur, patch)) // Arbitrary CSS value → writeInlineAll('cssProperty', value) (auto-safelisted/injected) // ...render controls; show "— Mixed" when classStrings differ across nodes } registerPanel({ id: 'myFamily', displayName: 'My Family', order: 55, applicableTo: () => true, component: MyPanel }) ``` Writes coalesce into one undo step via the hook's history throttle. Reads already reflect `activeBreakpoint` + `activeState`, so hover/focus/active and per-breakpoint editing work for free. If your control emits a literal Tailwind class from a fixed set, add that family to `scripts/gen-safelist.ts`; arbitrary values route to inline CSS and need no safelist entry. --- ## Conventions ### Class-string editing Anything that writes to a node's `style.classes.root` **must go through a merge function in `src/style/tw-classes.ts`** (`mergeTypography`, `mergeLayout`, `mergeSpacing`, `mergeSize`, `mergeAppearance`, `mergeEffects`). Direct string concatenation drops classes the parser doesn't recognize on the next round-trip. Inspector panels go through `useNodeClasses` rather than calling `actions.setProp` directly — see [§ Adding an inspector panel](#adding-an-inspector-panel). ```ts // ✅ Right — funnel through the slice's merge function import { mergeTypography } from '@/style/tw-classes' const { classString, writeClasses } = useNodeClasses(nodeId) writeClasses(mergeTypography(classString, { fontSize: 'lg' })) // ❌ Wrong — drops classes from other slices silently actions.setProp(nodeId, (props) => { props.style.classes.root = 'text-lg ' + props.style.classes.root }) ``` ### Token + arbitrary mutual exclusion When a panel sets a token (via classes) for a CSS property, it must clear the matching inline arbitrary value — and vice versa. Otherwise both end up on the node and inline silently wins via CSS specificity, leading to confused state. ```ts // ✅ Right — token pick clears the corresponding inline property const setFill = (v: ColorPickerValue) => { if (v.kind === 'token') { update({ bg: v.token }) writeInline('backgroundColor', undefined) // <-- clear inline } else if (v.kind === 'hex') { update({ bg: undefined }) // <-- clear token writeInline('backgroundColor', v.hex) } else { update({ bg: undefined }) writeInline('backgroundColor', undefined) } } ``` This pattern repeats across every panel that supports both tokens and arbitrary values (TypographyPanel for color, AppearancePanel for fill + border-color + radius, SpacingPanel for padding/margin shorthands, SizePanel for every dimension). Don't shortcut it. ### Adapter impls consume composed render props — never `style.classes.root` directly `AdapterRenderProps` carries `style` (raw `NodeStyle`), plus the composed render-side fields. Reading `style.classes.root` directly bypasses both `composeResponsive` and `composeInlineStyle`. **Pattern A (single slot)** — read `className` / `inlineStyle`: ```tsx // ✅ Right export function MyBox({ children, rootRef, className, inlineStyle }: AdapterRenderProps) { return
{children}
} // ❌ Wrong — drops md:* utilities AND the user's arbitrary hex / px picks export function MyBox({ children, rootRef, style }: AdapterRenderProps) { return
{children}
} ``` **Pattern B (multiple slots)** — read `composedClasses[slot]` / `composedInlineStyles[slot]` per region: ```tsx // ✅ Right — each slot gets its own composed classes + inline styles export function MyCard({ composedClasses = {}, composedInlineStyles = {}, children, rootRef }: AdapterRenderProps) { return (
{children}
) } ``` The root entries of `composedClasses` / `composedInlineStyles` always mirror `className` / `inlineStyle`, so Pattern A impls don't need to care about the maps. The `style` prop is still on `AdapterRenderProps` for impls that need raw access (rare). ### `cn` from `@/lib/utils` Use shadcn's `cn` for class composition. It handles tailwind-merge conflict resolution. ```ts import { cn } from '@/lib/utils' className={cn('base classes', conditional && 'more classes', incomingClassName)} ``` ### `rootRef` on adapter impls Adapter impls must attach the `rootRef` callback to their outermost real DOM element. Without it, Craft's `connect` / `drag` can't attach to a real DOM node — selection and dragging silently break. ```tsx // ✅ Right — ref on the visible element
{children}
// ❌ Wrong — Craft can't find a DOM node to attach to
{children}
``` ### `useEditorStore` — subscribe vs. snapshot | Where you read | Use | |---|---| | In render, displaying or reacting to the value | `useEditorStore((s) => s.activeThemeId)` (subscribes; re-renders on change) | | In an event handler / `useEffect` that just needs the latest value | `useEditorStore.getState().activeThemeId` (no subscription, no re-render) | Click handlers that read state but don't display it should use `getState()` to avoid unnecessary re-renders. ### Side-effect imports for registration Canonicals, adapters, and themes all register themselves at module load. They're imported for *side effects* in `App.tsx`: ```ts import './registry/components' // canonicals import './adapters/shadcn' // shadcn adapter import './adapters/mui' // mui adapter import './themes' // themes ``` **Order matters once:** side-effect imports MUST run before `` renders, otherwise the registries are empty when `getResolver()` walks them. `App.tsx` is the only place that boot-orders these. ### Toolbox preferences live in their own localStorage key Favorites + recently-used canonicals persist to `localStorage['craftjs-design.toolbox']` — a **separate** namespace from the document envelope (`craftjs-design:doc:v1`). They're *user-level*, not *document-level*: they survive document switches and aren't part of saved documents. When wiping local state during development, decide which you want to clear: ```js // In the browser DevTools console localStorage.removeItem('craftjs-design:doc:v1') // clear the current document localStorage.removeItem('craftjs-design.toolbox') // clear toolbox prefs (favorites, recents) ``` If you're adding a new piece of user-level UI state, follow the same pattern — its own localStorage key, read/written outside the document envelope. Don't accidentally stuff user preferences into the document. ### Adding a starter template Templates seed new documents with pre-arranged canvas content. Three ship today (Empty, Landing page, Sign-up form); add more by registering at module load. 1. Build the template via `buildTemplate(NodeSpec)`. The builder consults the canonical registry — so it must be imported after `./registry/components`. ```ts // src/persistence/templates/dashboard.ts import { buildTemplate } from './builder' import { registerTemplate } from './registry' registerTemplate({ id: 'dashboard', name: 'Dashboard', description: 'A header, sidebar, and main content area.', envelope: buildTemplate({ root: { canonical: 'stack', nodeProps: { direction: 'vertical', gap: '4' }, style: { classes: { root: 'h-screen' } }, children: [ { canonical: 'heading', nodeProps: { level: '2', content: 'Dashboard' } }, // ... more children ], }, }), }) ``` 2. Add a side-effect import to `src/persistence/templates/index.ts`: ```ts import './dashboard' ``` 3. The template appears in the editor's "New from template…" popover automatically. **NodeSpec shape**: - `canonical: string` — required, the canonical id. - `nodeProps?: Record` — shallow-merged over the canonical's defaults. - `style?: Partial` — classes merged per-slot; other fields shallow-merged. - `children?: NodeSpec[]` — only honored when the canonical is a Pattern A canvas (`isCanvas: true`). Ignored for leaves. Pattern B multi-canvas templates (Card with header/body/footer children, Tabs with per-tab content) aren't supported by the current builder. Workaround: ship a Pattern-A-only template; users can drop Card/Tabs and populate the slots manually. ### Adding a schema migration step When a canonical's persisted shape changes incompatibly (renamed a prop, dropped a field, changed a type), existing saved documents need a one-shot transformation at load time. Migrations live in `src/persistence/migrations.ts` and run through the versioned pipeline in `migrateDocument()`: each step declares the `version` it upgrades a document **to**, and `migrateDocument` runs every step whose `version` exceeds the document's stamped version, then re-stamps to `CURRENT_DOCUMENT_VERSION`. To add one: 1. Bump `CURRENT_DOCUMENT_VERSION` in `src/persistence/schema.ts`. 2. Add a step to `MIGRATION_STEPS` whose `up(tree)` mutates the parsed Craft tree in place: ```ts // src/persistence/migrations.ts const MIGRATION_STEPS: MigrationStep[] = [ { version: 2, up: (tree) => { migrateCardPropsV6(tree); /* … */ } }, { version: 3, // ← new up: (tree) => { for (const id of Object.keys(tree)) { const node = tree[id] if (node.displayName !== 'MyCanonical') continue // …transform node.props.nodeProps in place… } }, }, ] ``` Migration rules: - **Idempotent.** Running a step twice must equal running it once — a document hand-stamped at the new version won't re-run it, but keep steps idempotent anyway. Tests assert this. - **One-way.** There are no `down` steps (newer canonicals can't round-trip to an older schema; the policy is export-before-downgrade). - **Walks the tree directly.** No Craft.js APIs at migration time — operate on the raw serialized node map. - **Drops, don't transform** for changes that can't be losslessly converted (synthesizing fresh node ids + linked-parent wiring is a different complexity class). - **Add test cases** in `migrations.test.ts`: happy path, isolation, idempotency, and version-gating (a doc already at the new version is untouched). ### Writing a StorageAdapter The editor persists through a `StorageAdapter` (default: IndexedDB → localStorage fallback). To back persistence with your own store (a server, a different local DB), implement the interface and register it before `` mounts: ```ts import { setStorageAdapter } from '@design/sdk' import type { StorageAdapter } from '@design/sdk' const adapter: StorageAdapter = { async readIndex() { /* → { documents, activeId } */ }, async writeIndex(index) { /* persist */ return { ok: true } }, async readDocument(id) { /* → EditorDocument | null */ }, async writeDocument(id, doc) { /* persist */ return { ok: true } }, async deleteDocument(id) { /* … */ }, async estimateUsage() { return { usedBytes: 0, totalBytes: Infinity, percent: 0 } }, // Optional: init() (one-time setup, awaited before first read), // and listVersions / readVersion / writeVersion to enable version history. } setStorageAdapter(adapter) ``` Adapter rules: - **All methods async.** The document store awaits blob I/O; the index is held in synchronous Zustand state after bootstrap so the UI is unchanged. - **Return typed `WriteResult`.** `{ ok: true }` or `{ ok: false, kind: 'quota' | 'schema' | 'unknown', error }`. `'quota'` triggers the storage-full UI. - **Validate + migrate on read.** `readDocument` should parse with `documentSchema` and run `migrateDocument` (the built-in adapters do) so older envelopes upgrade on load. - **Versioning is opt-in.** Omit the `*Version` methods and the version-history UI hides itself; implement them (ring-buffer your autos) to enable snapshots. - **Cross-tab.** The store posts BroadcastChannel messages on write; you don't need to — but if another process writes your backend, broadcast an `index-changed` / `doc-changed` yourself to keep open tabs in sync. - **Contract test.** Run your adapter through `runStorageAdapterContract` (`src/persistence/adapters/adapterContract.ts`) the way the localStorage + IndexedDB adapters do. ### Adding a UI control that mutates a node directly Most node-state mutations go through `useNodeClasses` (for slot classes / inline) or `actions.setProp` (for canonical props). But some controls need to bypass React's render loop for performance — for example, the canvas-overlay drag-resize writes `dom.style.width/height` directly during the drag, then commits the final value via `setProp` on release. Pattern (see `src/editor/canvas/ResizeOverlay.tsx` for the reference example): 1. Identify the selected node's DOM via `query.node(id).get().dom`. 2. During the gesture, mutate `dom.style.` directly. React doesn't track these writes — no re-render per mousemove, smooth 60fps. 3. On gesture end, commit via `actions.setProp((props) => { ... })`. The next render passes the same value through React's style-prop pipeline; no visible jump. Things to watch for: - If unrelated state changes trigger a Craft re-render mid-gesture (theme change, etc.), React's reconcile may wipe the direct DOM mutation. Designers don't typically operate multiple controls during a single gesture, so acceptable. - Stop event propagation on the gesture's mousedown if you're rendering the handles outside the Craft node tree — `e.stopPropagation()` is belt-and-suspenders against any document-level Craft listener. ### Adding a font token A font-token registry drives the Typography panel's Font dropdown. Built-ins (`sans`, `heading`, `mono`) seed at module load; add more by calling `registerFontToken` at app boot. 1. **Decide on an id.** Lowercase, digits, hyphens only. Used as both the class suffix (`font-`) and — for URL-backed fonts — the `@font-face` family name. 2. **Register:** ```ts // src/your-fonts.ts import { registerFontToken } from '@design/sdk' registerFontToken({ id: 'inter', name: 'Inter', // appears in the dropdown family: '"Inter Variable", sans-serif', // CSS font-family value url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap', }) ``` 3. **Side-effect import:** ```ts // src/App.tsx — alongside the other side-effects import './your-fonts' ``` 4. The dropdown re-captures the registry on selection change — pick a node and "Inter" appears in the Font dropdown. **URL vs no-url:** with `url`, the runtime injects an `@font-face` declaration loading the font + a class rule using the font. Without `url`, only the class rule is injected — your font has to already be available (via host-provided CSS, system fallback, etc.). **Built-ins overlap with Tailwind utilities.** `font-sans` and `font-heading` are already Tailwind utilities via `@theme inline` in `index.css`; the registry injects them anyway for consistency (same lookup path for all tokens). Redundant but harmless. **Hot reload caveat:** the dropdown captures `listFontTokens()` keyed by `[nodeId]`. Post-mount registrations appear when the user selects a different node. ### Adding an error boundary fallback The editor ships four error-boundary layers; integration consumers (or this project's contributors adding new editor regions) plug new ones the same way. 1. **Author a fallback component** that matches `ErrorFallbackProps`: ```tsx import type { ErrorFallbackProps } from '@/editor/errors/ErrorBoundary' import { AlertTriangle, RefreshCcw } from 'lucide-react' export function MyToolFallback({ error, reset }: ErrorFallbackProps) { return (
My tool failed

{error.message}

) } ``` 2. **Wrap your subtree:** ```tsx import { ErrorBoundary } from '@/editor/errors/ErrorBoundary' import { MyToolFallback } from './MyToolFallback' myTelemetry(err, info)}> ``` 3. **`reset()` clears `state.error` and re-mounts children.** If the underlying bug is still there, the fallback re-renders — same outcome, no infinite loop. The user gets a path out of transient failures. **Caveat:** error boundaries don't catch async errors. A component that throws in a `useEffect` won't trigger `componentDidCatch`. Document the async error path separately (e.g., via `window.onerror` listener) if your tool can throw async. ### The `@design/sdk` boundary There is a public boundary at `src/sdk/`. Files under `src/sdk/` are the contract for external SDK consumers (adapters / canonicals / panels authored outside the editor's core). Internal code can import either way; new code outside `src/adapters/`, `src/registry/`, `src/editor/inspector/`, and `src/style/` should prefer the SDK path. ```ts // ✅ Right — SDK consumers see clear, documented boundary import { registerAdapter, useNodeClasses } from '@design/sdk' import type { AdapterRenderProps } from '@design/sdk' // ❌ Wrong (for SDK consumers) — reaches into internals import { registerAdapter } from '../../src/adapters/AdapterContext' ``` Anything under `examples/` MUST import only from `@design/sdk`. That's the proof-of-boundary subtree — the Chakra example at `examples/adapter-chakra/` demonstrates the pattern. When adding a new public name (a new type or function intended for SDK consumers): 1. Add the implementation in its natural internal location. 2. Re-export it from the appropriate `src/sdk/*.ts` file. 3. Add the name to `src/sdk/boundary.test.ts`'s `EXPECTED_FUNCTIONS` list (catches accidental future removal). 4. Add JSDoc with a runnable usage example. ### Responsive arbitrary inline works at every breakpoint Arbitrary inline values are not restricted to the base breakpoint. The data shape: - **Base** — `style.inline[slot][cssProp]` (unchanged). - **Non-base** — `style.responsiveInline[bp][slot][cssProp]` (new). `useNodeClasses` routes reads / writes between the two automatically based on `activeBreakpoint`. Panel code doesn't gate by breakpoint anymore — calling `writeInline(cssProperty, hexValue)` at the `md` breakpoint writes to `style.responsiveInline.md[slot][cssProperty]`. CanonicalNode generates a hash-keyed CSS class with `@media` rules covering all breakpoints + the base entry; the class is appended to the slot's composed className and the CSS is rendered inside an inline `