.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 (
)
}
```
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 (
)
}
```
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 `