v1.10.1 · documentation

Pick your design system.
Drop the editor in.

A pluggable, AI-native drag-and-drop website builder for React — pick your design system (shadcn, MUI, plain HTML), drop the editor in, and let humans or AI agents (via the built-in MCP server) build designs.

$npm install @crafted-design/editor react@19 react-dom@19 @craftjs/core@^0.2.12
Getting Started

Minimal host app

The smallest real integration: install → render <Editor /> → it persists, saves, loads, and shares on its own. Uses the lean /core entry, so no MUI in the bundle and nothing extra to install.

This is a runnable project — the files live next to this README (package.json, index.html, src/main.tsx, src/App.tsx). Run it in place, open it in StackBlitz, or copy the files into a fresh Vite + React 19 + TS app.

Its source is typechecked against the built package in CI (npm run check:example), so this minimal integration can't drift from the real API. For the in-tree authoring examples see examples/adapter-chakra and examples/sdk-smoke.

Run it

git clone https://github.com/anhba817/craftjs-design
cd craftjs-design/examples/minimal-host
npm install
npm run dev

Or start fresh

npm create vite@latest my-editor -- --template react-ts
cd my-editor
npm install @crafted-design/editor react@19 react-dom@19 @craftjs/core@^0.2.12

shadcn + plain-HTML need no other peers. (Want MUI too? npm install @mui/material @emotion/react @emotion/styled and import @crafted-design/editor instead of /core.)

package.json (relevant bits)

{
  "type": "module",
  "dependencies": {
    "@crafted-design/editor": "^1.0.0",
    "@craftjs/core": "^0.2.12",
    "react": "^19",
    "react-dom": "^19"
  }
}

src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

src/App.tsx

// Lean entry: editor + shadcn + plain-HTML adapters, no MUI.
import { Editor } from '@crafted-design/editor/core'
import '@crafted-design/editor/index.css'

export default function App() {
  return (
    <div style={{ height: '100vh' }}>
      <Editor />
    </div>
  )
}

That's the whole integration. <Editor /> ships its own toolbar — save, load, download (.json), and share-by-URL — and persists automatically through the built-in storage adapter (IndexedDB, falling back to localStorage), so a refresh restores the last document. No host wiring required.

Pin your design system (recommended) so end users can't switch adapters:

<Editor adapter="shadcn" />   // or "html"; hides the adapter switcher

Pinning adapter="mui" additionally requires the full entry (or /adapters/mui) and the MUI peers: npm install @mui/material @emotion/react @emotion/styled.

Persisting to your own backend

Don't reach for the exportDocument helpers to bolt on persistence — point the editor's storage seam at your backend instead. Implement the StorageAdapter interface and register it before rendering <Editor />:

import { setStorageAdapter } from '@crafted-design/editor/core'
import type { StorageAdapter } from '@crafted-design/editor/sdk'

setStorageAdapter(myServerAdapter) // load/save/list against your API

Full walkthrough in docs/COOKBOOK.md "Server-backed storage" and docs/SDK_GUIDE.md "Persistence backend". The exportDocument / downloadDocument / importDocumentFromFile helpers exist for one-off file export/import, not as the persistence path.

Going further

Getting Started

Integration Guide

How to embed the editor in your own React app.

The shipped artifact is a Vite library-mode bundle: an ES module exporting <Editor /> plus the full SDK. React, React DOM, and Craft.js are externalized as peer dependencies — your host app provides them.

This guide assumes a Vite + React 19 host (the editor's own development target). Older React versions are not supported — the React 19 ref-as-prop semantics + the unified Fragment are load-bearing.

Install

npm install @crafted-design/editor react@19 react-dom@19 @craftjs/core@^0.2.12

@mui/material, @emotion/react, and @emotion/styled are optional peer dependencies — they are NOT bundled. Install them only if you use the full @crafted-design/editor entry (which registers the MUI adapter) or import /adapters/mui; the lean /core entry (shadcn + plain-HTML) needs no extra peers. See Subpath exports below.

Subpath exports

Since 0.7.0 the package is modular — pick the entry that matches the adapters you want, so you don't bundle a UI library you never render:

Import path What you get External peers
@crafted-design/editor Full <Editor /> — registers editor + shadcn + plain-HTML + MUI. The batteries-included default. requires @mui/material, @emotion/react, @emotion/styled
@crafted-design/editor/core Lean <Editor /> — registers editor + shadcn + plain-HTML, no MUI. Same full export surface (Editor, SDK, stores, doc helpers). none
@crafted-design/editor/adapters/shadcn Side-effect import that registers just the shadcn adapter. none
@crafted-design/editor/adapters/html Registers just the plain-HTML adapter (no UI library). none
@crafted-design/editor/adapters/mui Registers just the MUI adapter. @mui/material, @emotion/react, @emotion/styled
@crafted-design/editor/sdk SDK-only surface (registerAdapter, registerCanonical, registerPanel, registerTheme, registerTemplate, registerFontToken, useNodeClasses, all the matching types). No editor UI — use when authoring a canonical / adapter / panel without pulling in <Editor />. none
@crafted-design/editor/index.css Tailwind CSS bundle (global preflight + :root tokens). Import once per page; no JS overhead. none
@crafted-design/editor/index.scoped.css Same stylesheet, every rule scoped under .crafted-design-scope — for embedding inline in a Tailwind-v4 host without a double preflight / token clobbering. Use instead of index.css. See Inline embedding. none

Typical setups:

// shadcn-only host — no MUI in the bundle, nothing extra to install
import { Editor } from '@crafted-design/editor/core'
import '@crafted-design/editor/index.css'

// want MUI too — install the peers, use the full entry
//   npm install @mui/material @emotion/react @emotion/styled
import { Editor } from '@crafted-design/editor'

// lean core + opt into one extra adapter explicitly
import { Editor } from '@crafted-design/editor/core'
import '@crafted-design/editor/adapters/mui' // side-effect: registers MUI

Opt-in is at the import boundary, not at runtime: importing an adapter registers it before <Editor /> mounts. (Registering an adapter after mount would reshape the provider tree and remount the canvas, so it isn't supported.) .d.ts files ship alongside every JS entry, so TypeScript hosts resolve types without configuration. See ADAPTER_VERSIONING.md for the peer-dependency policy and ADAPTER_MATRIX.md for per-adapter coverage.

Bundle format

The package ships ESM only, unminified, with source maps. There is no CommonJS/UMD build and no separate *.min.js — both are deliberate:

  • ESM-only avoids the dual-package hazard; modern bundlers and Node ≥ 20 consume ESM directly.
  • Unminified because you consume the editor through your own bundler, which minifies the final app. Shipping a parallel minified entry would double the published surface and the exports map for no real benefit, and the source maps give you readable stack traces in development.

The SDK subpath (/sdk) is side-effect-free, so a bundler tree-shakes any authoring symbol you don't import. (Importing /sdk registers nothing beyond the editor's three baseline font tokens — sans/heading/mono.)

Minimal embed

import { Editor } from '@crafted-design/editor'
import '@crafted-design/editor/index.css'

function App() {
  return <Editor />
}

export default App

The editor takes 100% of its parent's height (it uses h-screen internally). Wrap in a container if you want it to share screen real estate:

function App() {
  return (
    <div className="grid grid-cols-[1fr_300px]">
      <Editor />
      <YourHostSidebar />
    </div>
  )
}

Embedding as a controlled component (1.6.0)

By default <Editor> is a self-contained app: it owns its document, persists to IndexedDB, and shows its own Save/Load chrome. To embed it inside your own UI — a step in a multi-step form, a drawer, a tab — drive it as a controlled component instead. All of these props are additive and optional; with none passed, behavior is identical to the minimal embed above.

import { Editor, type EditorHandle } from '@crafted-design/editor/core'
import type { EditorDocument } from '@crafted-design/editor/core'

function CardEditor() {
  // The host owns the document.
  const [doc, setDoc] = useState<EditorDocument>(seedFromYourBackend)

  return (
    <Editor
      adapter="shadcn"
      value={doc}                       // controlled: single source of truth
      onChange={(next) => setDoc(next)} // debounced; persist with JSON.stringify(next)
      persistence={false}               // never touch the built-in IndexedDB store
      hideChrome                        // drop the Save/Load bar — render your own
    />
  )
}
Prop Type Effect
value EditorDocument | string Controlled. The document the editor renders; re-seeds whenever its identity changes. Persistence is forced off.
defaultValue EditorDocument | string Uncontrolled one-time seed on mount; edits stay internal, surfaced via onChange. Ignored when value is set.
onChange (doc: EditorDocument) => void Fired (debounced) on every change — structural edits and prop/style edits. The same envelope Export produces.
onChangeDebounceMs number Debounce window for onChange. Default 150.
persistence boolean Whether the editor manages its own IndexedDB store/autosave. Default true. value implies false.
hideChrome boolean Hide document-management chrome (Save/Load/Import/Export/Share bar, onboarding tour, quota banners, cross-tab watcher). Keeps toolbox + canvas + inspector.

Both value and defaultValue accept an EditorDocument envelope or its JSON string — each is validated + migrated on the way in, exactly like an Import. Build a seed without an editor using the headless buildDocument, or feed a string straight from your backend.

No feedback loop. The natural controlled wiring — edit → onChange → setState → new value → re-apply — does not loop: the editor tracks the last serialized tree and skips re-applying a value it already produced. Re-applying an identical envelope is a no-op.

Reading on demand (imperative ref). Redundant with onChange but convenient for a "serialize on click" button without holding the doc in state:

const ref = useRef<EditorHandle>(null)
// …
<Editor ref={ref} />
<button onClick={() => save(ref.current!.getDocument())}>Save</button>
<button onClick={() => ref.current!.setDocument(loaded)}>Load</button>

A runnable end-to-end example (controlled value + onChange + ref + a live <DocumentRenderer> preview) lives in examples/controlled-host.

CSS isolation. This controlled API removes the persistence/chrome/seed machinery. To embed inline in an app already running Tailwind v4 (no iframe), import the scoped stylesheet — see Inline embedding into a Tailwind-v4 app below.

Inline embedding into a Tailwind-v4 app (1.7.0)

The default stylesheet @crafted-design/editor/index.css is a full Tailwind v4 build — a global preflight (the * reset) + the editor's design tokens.

Tokens don't clobber yours (1.8.2+). The editor's document tokens (--primary, --background, .dark, [data-theme]) ship in a cascade layer (@layer crafted-design), so your app's unlayered :root / .dark tokens always win — importing index.css no longer overrides your brand colors app-wide. (The editor's --ed-* chrome tokens stay unlayered, but a host has no --ed-* to collide with.) The trade-off: because your :root wins everywhere, the editor canvas also inherits your brand tokens, and a preflight is still global. For full subtree isolation — host tokens never reach the canvas, no second preflight — use the scoped stylesheet instead:

// in a Tailwind-v4 host — INSTEAD of index.css:
import '@crafted-design/editor/index.scoped.css'

<Editor value={doc} onChange={setDoc} persistence={false} hideChrome />

Every rule in it is prefixed with .crafted-design-scope (and the editor's :root tokens are rehomed onto that class), which <Editor> and <DocumentRenderer> put on their root. So:

  • The editor's preflight resets only inside the editor subtree — your page isn't double-reset.
  • The editor's tokens live only inside .crafted-design-scope — your host's :root / --color-* tokens are untouched, and vice-versa.
  • Runtime overlays (Modal/Drawer/Toast) portal into a scope-classed container, so they're styled correctly even though they're DOM-detached.

When to use which:

Host Stylesheet
Want the editor canvas fully isolated from host tokens (and no double preflight) index.scoped.css
Fine with the canvas inheriting your brand tokens; just don't want your :root clobbered index.css (global — tokens are layered)
No CSS framework / standalone / its own route / iframe index.css (global)

Notes & limits:

  • The scoped sheet omits a global preflight — it assumes the host already has one (Tailwind v4). A host with no reset at all should use index.css.
  • The editor owns its look: scoping makes the editor's utilities un-overridable by host CSS (intended). Theme the canvas via registerTheme and the chrome via editorTheme, not by overriding editor utilities. (editorTheme works under the scoped sheet too — the --ed-* chrome tokens stay global so the prop's inline values still apply; 1.8.3+.)
  • The MUI adapter renders overlays via MUI's own portals (emotion-styled), outside the Tailwind scoped sheet; the scoped sheet targets the shadcn / html (Tailwind) stacks.

examples/controlled-host embeds inline with the scoped sheet.

Pinning the adapter (host-chosen design system)

The product model is that you — the host — choose the design system; the people using your editor don't. Pin it with the adapter prop:

import { Editor } from '@crafted-design/editor'   // full entry registers MUI
import '@crafted-design/editor/index.css'

function App() {
  return <Editor adapter="mui" />
}

What pinning does:

  • The active adapter is set to mui before first paint.
  • The AdapterSwitcher disappears from the toolbar — end users can't change the design system.
  • Loading a document does not override it. A document saved under shadcn still opens — documents store canonical ids, not library components, so it simply renders through MUI. The envelope's adapterId is a preference, not a command, while pinned.

MUI requires its peers. The MUI adapter (whether via the full entry or /adapters/mui) needs the optional peer dependencies installed:

npm install @mui/material @emotion/react @emotion/styled

Pinning adapter="mui" without registering the MUI adapter (or without the peers, which makes its import fail) logs a console warning and falls back to the default shadcn.

Want to pin a starting adapter but still let users switch? Both knobs are independent:

<Editor adapter="html" allowUserToSwitchAdapter />   // starts on plain HTML, switcher stays
<Editor allowUserToSwitchAdapter={false} />          // default adapter (shadcn), no switcher
<Editor />                                           // legacy behavior: switcher shows all registered adapters

allowUserToSwitchAdapter defaults to false when adapter is set, true otherwise.

Customizing the registry

The editor pre-registers 48 canonicals, the built-in adapters (shadcn + MUI + plain-HTML on the full entry; shadcn + plain-HTML on /core), 7 themes, inspector panels, and starter templates. Override any of these by calling the SDK BEFORE rendering <Editor />:

Adapter coverage policy. The three built-in adapters (shadcn, MUI, plain-HTML) implement every canonical — see ADAPTER_MATRIX.md. The in-repo Chakra adapter is an example (a third-party-adapter demo covering a 20-canonical subset) and is NOT part of the published package. When a document uses a canonical the active adapter doesn't implement, the node renders a labeled placeholder (<Name> — no impl in adapter "<adapter>") instead of crashing, so you can swap adapters or remove the node.

Remove a built-in canonical

import { Editor, unregisterCanonical } from '@crafted-design/editor'

unregisterCanonical('alert')  // drops Alert from the toolbox

function App() {
  return <Editor />
}

Add a custom canonical

import { z } from 'zod'
import { Editor, registerCanonical } from '@crafted-design/editor'

registerCanonical({
  id: 'callout',
  category: 'feedback',
  displayName: 'Callout',
  tags: ['alert', 'banner'],
  isCanvas: true,
  styleSlots: ['root'],
  propsSchema: z.object({
    intent: z.enum(['info', 'warning', 'success']),
  }),
  defaults: {
    props: { intent: 'info' },
    style: { classes: { root: 'p-4 rounded-md border' } },
  },
})

// (Add adapter impls for your supported adapters too.)

Add a custom adapter

import { Editor, registerAdapter, type AdapterRenderProps } from '@crafted-design/editor'

function MyBox({ children, rootRef, className }: AdapterRenderProps) {
  return <div ref={rootRef} className={className}>{children}</div>
}

registerAdapter({
  id: 'mylib',
  displayName: 'My Library',
  components: { box: MyBox },
})

Add a custom inspector panel

import { Editor, registerPanel, useNodeClasses } from '@crafted-design/editor'

function NotesPanel({ nodeId }: { nodeId: string }) {
  const { classString, writeClasses } = useNodeClasses(nodeId)
  return (
    <textarea
      value={classString}
      onChange={(e) => writeClasses(e.target.value)}
      placeholder="Designer notes…"
    />
  )
}

registerPanel({
  id: 'notes',
  displayName: 'Notes',
  order: 100,                     // after every built-in (10–70)
  applicableTo: () => true,
  component: NotesPanel,
})

Add a custom theme

import { Editor, registerTheme } from '@crafted-design/editor'

// Add the CSS block to your host's global stylesheet:
// [data-theme="forest"] { --primary: oklch(...); }

registerTheme({
  id: 'forest',
  displayName: 'Forest',
  dataThemeValue: 'forest',
})

Asset backends

The Image canonical's src field is edited through an <ImagePicker> (Upload / Library / URL). Where uploaded images actually live is the host's decision, wired through <EditorImageProvider>.

Without a provider, the editor uses a default base64 provider: uploads are encoded to inline data: URLs and embedded directly in the document. This keeps the editor self-contained for demos and local use, but inline bytes bloat the saved envelope and can blow the localStorage quota — the provider warns in the console above 500 KB. The default provider can't enumerate inline URLs, so the Inspector's Assets panel is hidden and the picker's Library tab falls back to scanning the current document's existing Image nodes.

To route uploads to a real backend, wrap the editor:

import { Editor } from '@crafted-design/editor'
import { EditorImageProvider } from '@crafted-design/editor/sdk'

const backend = {
  // Persist a file, return its canonical URL.
  async upload(file: File) {
    const { url } = await myApi.upload(file)
    return { url }                       // optionally { url, thumbnail }
  },
  // Previously-uploaded assets for the Library grid + Assets panel.
  async list() {
    return (await myApi.listImages()).map((url) => ({ url }))
  },
  // Optional — enables a delete affordance.
  async delete(url: string) {
    await myApi.deleteImage(url)
  },
  // Defaults to true when `list` is supplied; pass false to opt out
  // of the Library grid + Assets panel.
  // canList: true,
}

function App() {
  return (
    <EditorImageProvider value={backend}>
      <Editor />
    </EditorImageProvider>
  )
}

The EditorImageProviderValue contract:

Field Type Notes
upload (file: File) => Promise<{ url, thumbnail? }> Required. Resolves to the URL written into the node's src.
list () => Promise<{ url, thumbnail? }[]> Required. Powers the Library grid + Assets inspector panel.
delete (url: string) => Promise<void> Optional. Surfaces a delete button when present.
canList boolean Defaults to true when you pass a custom provider. Set false to hide the Library grid / Assets panel.

Read the active provider from a custom panel or component with useEditorImageProvider().

Rendering saved documents (production pages)

Display a saved document on a public route without the editor — no toolbox/inspector/toolbar, no editing interactions, a fraction of the bundle (~48 KB gz + your adapter vs ~256 KB for the editor):

import { DocumentRenderer } from '@crafted-design/editor/renderer'
import '@crafted-design/editor/adapters/shadcn' // your design system
import '@crafted-design/editor/index.css'       // the stylesheet

<DocumentRenderer document={savedEnvelope} />
  • document — the EditorDocument envelope (or its JSON string). It runs through the same validation + version migrations as an editor import, so anything the editor loads, the renderer renders.
  • adapter — optional override of the envelope's adapterId. Adapters are per instance: several renderers with different adapters can coexist on one page.
  • The envelope's themeId + colorMode apply automatically, scoped to the renderer's wrapper. Overlays behave as at runtime (modals open on their triggers, portal to <body>).
  • A malformed document or unregistered adapter renders a small inline role="alert" fallback (and logs details) instead of crashing the page.

Rendering is identical to the editor's preview mode — same canonical resolver, same adapter impls — so what designers previewed is what ships.

A runnable example (latest Vite + React 19 + TS, rendering a real exported document) lives at examples/renderer-host — the display-page counterpart to examples/minimal-host.

Template variables (1.9.0)

Let users drop {{ tokens }} into text (headings, paragraphs, buttons, …) that resolve to per-recipient values at render time — a merge-field system for emails, personalized pages, and the like. The token syntax is a safe Mustache/Jinja subset: {{ path.to.value }} interpolation only, no loops, conditionals, or expressions.

Declare the variables the editor offers. Wrap <Editor> in the provider:

import { Editor } from '@crafted-design/editor'
import { EditorTemplateVariablesProvider } from '@crafted-design/editor/core'
import type { TemplateVariable } from '@crafted-design/editor/core'

const variables: TemplateVariable[] = [
  { key: 'contact.name',  label: 'Full name', group: 'Contact', sample: 'Jane Doe' },
  { key: 'contact.email', label: 'Email',     group: 'Contact', sample: 'jane@acme.com' },
  { key: 'company.name',  label: 'Company',    group: 'Company', sample: 'Acme Inc.' },
]

<EditorTemplateVariablesProvider variables={variables} values={liveValues}>
  <Editor /* … */ />
</EditorTemplateVariablesProvider>
  • The inspector's text fields grow a {{ }} picker (searchable, grouped by group) that inserts the selected token at the caret; users can also type tokens directly.
  • key supports dot-paths (contact.name) resolved against nested values; a flat key matching the whole path wins first.
  • On the canvas each token renders its value — from the optional values prop when present, else the variable's sample, else the raw {{ token }} when neither exists (so unconfigured fields stay visible). Tokens with a variable carry a dashed underline so authors can spot them.

Render with real values on a production page — the renderer takes a flat or nested values object and substitutes the same way:

import { DocumentRenderer } from '@crafted-design/editor/renderer'

<DocumentRenderer
  document={savedEnvelope}
  variables={{ contact: { name: 'Jane Doe' }, company: { name: 'Acme Inc.' } }}
/>

A missing value falls back to the raw {{ token }} by default. For a server-side render, renderDocumentToHtml(doc, { variables, onMissingVariable }) in @crafted-design/editor/headless does the same with no React/DOM, and interpolate(text, values) / extractTemplateRefs(text) are exported for custom pipelines. The document envelope is unchanged — tokens live inside ordinary text props, so saved documents stay portable across hosts with different variable sets.

When driving the editor through the MCP server, declare the same variables via CRAFTED_DESIGN_TEMPLATE_VARIABLES (a JSON array) so the agent can discover and insert them. A full host↔renderer loop lives in examples/controlled-host.

Persistence

The editor persists documents to IndexedDB by default (0.5.0+), behind a StorageAdapter seam, with an automatic fallback to localStorage where IndexedDB is unavailable (private mode, locked-down browsers). Integration hosts that want their own backend implement the adapter and register it before <Editor /> mounts — this is the recommended path:

import { setStorageAdapter } from '@crafted-design/editor/sdk'
import type { StorageAdapter } from '@crafted-design/editor/sdk'

const apiAdapter: StorageAdapter = {
  async readIndex() {
    return myApi.getIndex() // { documents: DocumentSummary[], activeId }
  },
  async writeIndex(index) {
    await myApi.putIndex(index)
    return { ok: true } // or { ok: false, kind: 'quota' | 'schema' | 'unknown', error }
  },
  async readDocument(id) {
    return myApi.getDoc(id) // EditorDocument | null
  },
  async writeDocument(id, doc) {
    await myApi.putDoc(id, doc)
    return { ok: true }
  },
  async deleteDocument(id) {
    await myApi.deleteDoc(id)
  },
  async estimateUsage() {
    return { usedBytes: 0, totalBytes: Infinity, percent: 0 }
  },
  // Optional: init() for one-time setup (awaited before the first read);
  // listVersions / readVersion / writeVersion to enable the version-history
  // UI (omit them and it stays hidden).
}

setStorageAdapter(apiAdapter)

All methods are async. Return { ok: false, kind: 'quota' } from a write to trigger the editor's storage-full UI. The document store reads the index synchronously into memory after bootstrap (so the UI subscribes the usual way) but document blobs are loaded through the adapter on demand — useDocumentStore.getState().loadActiveDocument() returns a Promise.

For one-off blob round-trips outside the store, the lower-level helpers still exist:

import { exportDocument, importDocumentFromFile } from '@crafted-design/editor'

const blob = exportDocument(myEnvelope)        // → JSON Blob
const env = await importDocumentFromFile(file) // File → validated envelope

Note: the library does not generate framework source code from a document — it's a runtime editor whose documents are JSON rendered live by the chosen adapter. Portability is the JSON envelope (export / import / share-by-URL) + embedding <Editor />.

Error handling

The editor ships four layers of error boundaries (top shell, canvas, toolbox, per-inspector-panel). The top-shell boundary catches anything that bubbles out of the rest. You can supply your own telemetry handler:

import { Editor, ErrorBoundary, TopShellErrorFallback } from '@crafted-design/editor'

function App() {
  return (
    <ErrorBoundary
      fallback={TopShellErrorFallback}
      onError={(error, info) => {
        // Ship to your error tracker — Sentry, Bugsnag, etc.
        Sentry.captureException(error, { extra: { componentStack: info.componentStack } })
      }}
    >
      <Editor />
    </ErrorBoundary>
  )
}

The editor's internal boundaries already log to console.error by default; the top-shell onError is the integration point for app-level telemetry.

Telemetry (errors + metrics)

Wrapping onError on the top-shell boundary only catches what bubbles all the way up. To receive errors from every boundary (canvas, toolbox, layers, each inspector panel) plus opt-in perf metrics, install a TelemetryProvider — one handler pair the whole editor feeds. The editor collects nothing by default; these handlers only fire if you install them.

import { Editor } from '@crafted-design/editor'
import { TelemetryProvider } from '@crafted-design/editor/sdk'

function App() {
  return (
    <TelemetryProvider
      onError={(err, info) =>
        // info.boundary = 'canvas' | 'toolbox' | 'layers' | 'panel' | …
        Sentry.captureException(err, { extra: info })
      }
      onMetric={(m) =>
        // m.name e.g. 'document.apply' / 'document.bootstrap'; m.durationMs
        posthog.capture(m.name, m)
      }
    >
      <Editor />
    </TelemetryProvider>
  )
}

An explicit onError prop on a specific ErrorBoundary still takes precedence over the provider for that boundary. Imperative hosts (no React wrapper) can call setTelemetry({ onError, onMetric }) before mount. Emitted metrics today: document.bootstrap (first index read) and document.apply (deserialize on load / switch) — both carry durationMs.

Theming the editor chrome

The editor's own UI — toolbox, inspector, toolbar, panels, banners ("the chrome") — is themed by the host through the editorTheme prop. Pass a built-in preset or a partial token map:

<Editor editorTheme="dark" />                                   // built-in dark
<Editor editorTheme={{ surface: '#16161e', accent: '#7aa2f7' }} /> // brand tokens
<Editor editorTheme={{ preset: 'dark', accent: '#7aa2f7' }} />   // dark + override

editorTheme is 'light' (default) | 'dark' | an EditorChromeTokens map. A token map sets only the tokens you name; the rest fall back to the preset (default 'light'). Values are any CSS color — hex, oklch(…), or even var(--your-host-token).

Token Role (light default)
surface panel / toolbar background (white)
surface2 subtle inset / hover background (gray-50)
surface3 stronger inset / active, canvas viewport (gray-100)
border / border2 / borderStrong hairline → input → emphasized borders
textStrong / text / textMuted / textFaint heading → body → secondary → disabled text
accent / accentFg selection, focus rings, primary chrome buttons + their text
danger / dangerFg destructive actions, error banners + their text

Like the adapter prop, editorTheme is host policy — there's no end-user chrome-theme switcher. The chrome theme is applied as CSS variables on <html> (so chrome that portals to <body> — dropdowns, modals — is themed too) and leaves the rest of your host page untouched.

This is NOT the document theme. editorTheme styles the editor around the canvas. The canvas content your end users design is themed separately by registerTheme / the canvas theme switcher / colorMode (next section). The two are fully independent — a dark editor chrome around a light document works, Figma-style — so don't reach for editorTheme to restyle the canvas, or registerTheme to restyle the chrome.

Token themes, dark mode, color variables, safelist (0.3.0)

Token themes (no hand-written CSS). Pass a tokens map to registerTheme; the editor derives the full token set and injects the [data-theme] block. Add darkTokens for a .dark[data-theme] variant.

import { registerTheme } from '@crafted-design/editor/sdk'
registerTheme({ id: 'forest', displayName: 'Forest',
  tokens: { primary: 'oklch(0.55 0.18 145)' },
  darkTokens: { primary: 'oklch(0.7 0.16 145)' } })

Dark mode. A Light / Dark / Auto toggle ships in the top bar; Auto follows prefers-color-scheme. The chosen mode persists in the saved document (EditorDocument.colorMode). This is the document's dark mode — ThemeProvider applies .dark to the canvas wrapper only. It's independent of the editor chrome theme (editorTheme, above): the canvas can be dark while the chrome is light, or vice-versa.

Your design tokens in the color picker. Wrap the editor so designers can pick your CSS variables alongside the theme tokens:

import { EditorColorVariablesProvider } from '@crafted-design/editor/sdk'
<EditorColorVariablesProvider variables={[{ name: 'brand-blue' }]}>
  <Editor />
</EditorColorVariablesProvider>

Define the variables in your CSS (:root { --brand-blue: … }) so the swatches and applied colors resolve.

Fonts. Designers upload fonts in the built-in "Fonts" panel (storage routes through your EditorImageProvider). To also offer popular fonts without uploading, call registerSystemFonts() and/or registerGoogleFonts() at startup.

Optional production-CSS trim (safelist plugin). By default the editor injects arbitrary inline-value CSS at runtime — zero config. For production pages you can trim Tailwind's output to exactly what your saved documents use:

// vite.config.ts
import { craftedDocumentSafelist } from '@crafted-design/editor/vite-plugin'
export default defineConfig({
  plugins: [craftedDocumentSafelist({
    documents: ['./content/*.json'],
    outFile: './src/safelist.docs.css',
  })],
})

Then @import "./safelist.docs.css"; from your stylesheet. Purely an upgrade — skipping it changes nothing.

Icons (1.10.0)

The Icon canonical (and NavItem's icon) accept any icon name — the inspector shows a searchable picker over the full lucide set (~1800 glyphs). Glyphs are lazy-loaded per icon, so the editor bundle carries the name list, not all the SVGs. Documents store the kebab name (shopping-cart); an unknown name renders a neutral fallback glyph and keeps the stored name. Documents authored before 1.10.0 (the old 16-name set) are unchanged — those names are all valid lucide names.

Bring your own icons. Replace the entire icon set — your design system's icons, Iconify, a curated subset — with a resolver registered before the editor / renderer mounts:

import { registerIconResolver } from '@crafted-design/editor/sdk'
import { MyIcon } from './icons'

registerIconResolver((name, sizePx) => <MyIcon name={name} size={sizePx} />)
// Pass no argument to restore the built-in lucide resolver.

The resolver maps an icon name + pixel size to a ReactNode. The same names flow through the editor, the browser <DocumentRenderer />, and the headless renderDocumentToHtml. One caveat for server-side rendering: the built-in lucide resolver is handled for renderDocumentToHtml automatically, but a custom resolver must be synchronous (return the glyph directly, no React.lazy/Suspense) to appear in the static HTML — effects don't run under renderToStaticMarkup.

When driving the editor via MCP, icon.name accepts any lucide kebab name — no enum to consult.

Responsive & supported viewports (1.8.0)

The editor chrome reflows to the viewport:

Width Layout
≥ 1280 (xl) Docked side columns + the full toolbar inline.
1024–1280 (lg–xl) Docked side columns; the toolbar's secondary controls collapse into a overflow menu.
< 1024 (lg) Side panels become overlay drawers toggled from the toolbar ( / inspector buttons); canvas is full-width.
< 640 (sm) Adds a dismissible "optimized for larger screens" hint.

The right panel is a single column with Properties / Overlays tabs at all sizes. Selecting a node opens the inspector (and, on narrow viewports, its drawer).

Touch / phones — what works and what doesn't. Inspecting, selecting, editing props, reordering via the Layers tab, and saving all work on a touch device. Dragging a new component from the toolbox onto the canvas does not — Craft.js uses HTML5 drag-and-drop, which doesn't fire on touch. So component authoring wants a pointer (mouse/trackpad) and a ≥ ~768px viewport; phones are supported for review and light edits, not full drag-to-build. The chrome stays usable down to ~360px.

These breakpoints are the editor's own; they're independent of the document's responsive breakpoints (the base/sm/md/lg/xl style editing on the canvas), which you design regardless of the editor window size.

Caveats

React version

The editor requires React 19. The old display: contents ref-forwarding wrappers around shadcn primitives (which were React-18-era workarounds) are gone — refs now flow directly through plain function components via React 19's ref-as-prop semantics.

Older React 18 hosts would fail at runtime; the dist's peerDependencies declare ^19.

Module format + bundle size

ESM only. The package ships ES modules (no CommonJS/UMD). React 19 and the adapter stack are ESM-first, and a dual package risks two copies of the registry singletons (the dual-package hazard). Consume it from an ESM-aware bundler (Vite, Next, Rollup, esbuild, modern webpack).

Two entry points (package.json exports):

Import Builds to Gzipped Contains
@crafted-design/editor dist-lib/index.js (+ index.css) ~414 KB JS / ~124 KB CSS the full editor + the shadcn and MUI adapters + all 48 canonicals
@crafted-design/editor/sdk dist-lib/sdk.js ~44 KB the authoring SDK only (register* helpers, hooks, types) — no editor UI, no adapter impls

So a host that only authors canonicals/adapters/panels against the SDK pays ~44 KB, not the full editor — the entries are separate chunks and the SDK surface doesn't pull the editor or MUI in.

MUI weight. The full-editor entry eagerly bundles both the shadcn and MUI adapters; MUI is roughly 290 KB gz of index.js. Shadcn-only hosts can avoid paying for MUI entirely by importing @crafted-design/editor/core (shadcn + plain-HTML, no MUI) — see Subpath exports. The Chakra adapter is an example and is not in the published bundle (only the dogfood app registers it).

Minification. The dist is intentionally not minified (easier to debug post-install, smaller diffs in sourcemaps); your bundler minifies it as part of your app build — minifying index.js roughly halves it. Run npm run analyze to emit an interactive treemap (bundle-stats.html) of what's in the bundle.

CSS size. The CSS is large because the Tailwind safelist covers every utility × breakpoint the inspector can emit (270+ @source inline() directives). Hosts running their own Tailwind build can dedupe by sharing the safelist; see the Tailwind troubleshooting section above, and the optional @crafted-design/editor/vite-plugin safelist generator.

Document storage quota

localStorage has a 5–10 MB quota per origin. Two UI layers surface storage pressure before the editor silently drops a save:

  • <StorageQuotaBanner> appears once documentRegistry.getStorageUsage() reports ≥ 80 % of a conservative 5 MB ceiling. Dismissable; the dismiss state lives in sessionStorage so it survives a reload but resets across tabs.
  • <StorageQuotaErrorModal> is blocking. It fires when writeDocument / writeDocumentIndex catches a QuotaExceededError from localStorage.setItem. The save did NOT complete; the user must delete a document via the toolbar Documents menu or accept that subsequent edits won't be persisted.

For larger designs, use the useDocumentStore API to read documents into memory and persist to your own backend (IndexedDB, server-side). The documentRegistry.writeDocument API returns a typed WriteResult so custom backends can react to per-write failures rather than relying on the built-in banner / modal.

Cross-tab edit safety

When two tabs edit the same document, useConcurrentEditWatcher listens to window.storage events from sibling tabs. Index changes auto-sync; active-doc blob changes raise <ConcurrentEditBanner> with two actions: Reload (apply the other tab's version, discarding unsaved local changes) or Overwrite (save your snapshot, blowing away the other tab's write). Tests live in src/editor/persistence/concurrentEditWatcher.test.ts and exercise the decideStorageEvent helper without a DOM.

Async error handling

window.error and window.unhandledrejection are caught by useGlobalErrorHandler and surfaced via <AsyncErrorBanner> — a toast at bottom-right with a Dismiss button. Critical async failures (Hydrator deserialize, adapter mount) still bubble through the four <ErrorBoundary> layers; the global handler only catches the long tail (event handlers, fetch promises, third-party scripts).

Keyboard navigation

The canvas region is a single tab stop; arrow keys move the selection directly. Toolbox implements the WAI-ARIA toolbar pattern with roving tabindex. See docs/ACCESSIBILITY.md for the full key map.

Custom font tokens at runtime

registerFontToken injects <style data-craftjs-fonts> into document.head. If your host CSP forbids inline <style>, this won't work — host apps need a CSP that allows style-src 'self' 'unsafe-inline' or the equivalent for inline style injection. A nonce-aware variant could be added in future.

Troubleshooting

"Invalid hook call" errors

Almost always a duplicate React. Verify your host app's React version is the SAME instance the editor's bundle expects (the externalized peer dep). Common fixes:

# Force a single React resolution in your host app's package.json
{
  "overrides": {
    "react": "^18.3.1"
  }
}

Tailwind classes don't apply

Make sure you import the editor's CSS:

import '@crafted-design/editor/index.css'

The editor's Tailwind safelist is baked into this CSS. Your host app's Tailwind config can either:

  • Coexist via separate <link> / <style> tags (default — works fine).
  • Merge by adding the editor's safelist to your config's safelist and building one shared Tailwind output.

Adapter switcher shows "no impl in adapter X" placeholders

The adapter doesn't have a component impl for that canonical. Either swap to a covering adapter (shadcn / MUI / plain-HTML cover all 48) or implement the missing canonical in your custom adapter.

Where to next

  • docs/ARCHITECTURE.md — full architecture reference.
  • docs/SDK_GUIDE.md — every public SDK function + type.
  • docs/TUTORIAL_ADAPTER.md — step-by-step adapter authoring.
  • docs/TUTORIAL_CANONICAL.md — step-by-step canonical authoring.
  • docs/TUTORIAL_PANEL.md — step-by-step inspector panel authoring.
  • examples/adapter-chakra/ — reference adapter implementation.
Guides

Cookbook

Task-oriented recipes for common integration + extension jobs. Each points at the authoritative reference rather than repeating it — start here when you know what you want to do but not which doc covers it.

I want to… Recipe
Embed the editor in my app Minimal embed
Choose what to bundle (skip MUI, etc.) Pick an entry point
Persist documents to my own backend Server-backed storage
Add a component the editor doesn't have Custom canonical
Render canonicals with my design system Custom adapter
Add a control to the inspector Custom panel
Add a theme / brand fonts Themes & fonts
Drop a built-in component Remove a canonical
Collect errors / metrics Telemetry

Embed the editor

Render <Editor />; it brings its own toolbar (save / load / download / share) and persists automatically. The full copy-pasteable host app is examples/minimal-host; the embedding walkthrough + props is INTEGRATION_GUIDE.md.

Pick an entry point

@crafted-design/editor (full: + MUI, needs the MUI/Emotion peers) vs /core (shadcn + plain-HTML, no peers) vs opting into /adapters/* explicitly. The full matrix with peer requirements is INTEGRATION_GUIDE.md → Subpath exports.

Server-backed storage

Implement the StorageAdapter interface (readIndex / writeIndex / readDocument / writeDocument / deleteDocument / estimateUsage, plus optional version methods) and register it before <Editor /> mounts via setStorageAdapter. Don't use the exportDocument file helpers for this. Full interface + a contract test: DEVELOPER_GUIDE.md → Writing a StorageAdapter and SDK_GUIDE.md → Persistence backend.

Add a custom canonical

Register the abstract component (id, props schema, defaults, applicable panels) with registerCanonical, then give each adapter a renderer. Step by step: TUTORIAL_CANONICAL.md; the quick version is INTEGRATION_GUIDE.md → Add a custom canonical.

Author an adapter

Map canonical ids to your design system's components with registerAdapter. Walkthrough: TUTORIAL_ADAPTER.md. To ship it as its own @your-scope/...adapter opt-in subpath entry (build entry, sideEffects, .d.ts), see DEVELOPER_GUIDE.md → Shipping an adapter as a subpath entry. Declare any UI-library peer in the adapter's peerDependencies (ADAPTER_VERSIONING.md).

Add an inspector panel

registerPanel adds a section to the right-hand inspector; useNodeClasses reads/writes the selected node's classes. Walkthrough: TUTORIAL_PANEL.md.

Themes and fonts

registerTheme (token-driven or CSS-driven) adds a theme to the switcher; registerFontToken / registerSystemFonts / registerGoogleFonts add fonts to the Typography dropdown. Reference: SDK_GUIDE.md → Theme tokens, color variables, fonts.

Remove a built-in canonical

unregisterCanonical('id') before <Editor /> mounts drops it from the palette. Example: INTEGRATION_GUIDE.md → Remove a built-in canonical.

Wire telemetry

setTelemetry(sink) (or wrap with <TelemetryProvider>) routes error-boundary reports + document.bootstrap / document.apply metrics to your collector. Zero collection by default. Reference: SDK_GUIDE.md + the telemetry section.

Guides

SDK Reference

Public surface exported from @crafted-design/editor/sdk (alias @design/sdk in this repo). SDK consumers MUST import only from these paths — reaching into src/adapters/types, src/registry/types, etc. is unsupported and can break across versions. ESLint's no-restricted-imports rule enforces this for examples/**.

This document is the narrative reference — what each surface is for, how it composes, and why the boundaries are drawn where they are. For authoritative function signatures + parameter tables, see the auto-generated TypeDoc reference at docs/api/. The TypeDoc reference is regenerated from JSDoc on each npm run docs; if the prose below contradicts the reference, the reference wins.

For task-oriented walkthroughs (writing an adapter, a canonical, or a panel), see TUTORIAL_ADAPTER.md, TUTORIAL_CANONICAL.md, TUTORIAL_PANEL.md.


Adapter surface

Adapters wrap a UI library and render canonical components.

Types

Adapter

Top-level adapter manifest. Three required fields, five optional.

interface Adapter {
  id: string
  displayName: string
  components: Partial<Record<CanonicalId, ComponentType<AdapterRenderProps>>>

  Wrapper?: ComponentType<{ children: ReactNode }>
  themeTokens?: Record<string, string>
  classMap?: ClassMapFn
  mount?: () => void
  unmount?: () => void
}
  • id — stable string identifier. Persisted in saved documents.
  • displayName — shown in the AdapterSwitcher.
  • components — map of canonical id → React renderer.
  • Wrapper — global provider (theme, locale). Must be a pure context provider. No document listeners, no global CSS, no browser API mutation.
  • themeTokens — CSS variable declarations to inject when active.
  • classMap — rewrites canonical Tailwind classes into adapter-native render props (mostly used by sx-style libraries like MUI).
  • mount / unmount — imperative side-effect hooks.
AdapterRenderProps

Every adapter component receives this shape.

interface AdapterRenderProps {
  canonicalId: CanonicalId
  props: Record<string, unknown>       // user-set component props
  style: NodeStyle                     // raw style data
  children?: ReactNode
  rootRef?: (el: HTMLElement | null) => void

  // Pattern A — single root slot
  className?: string
  sx?: Record<string, unknown>
  inlineStyle?: CSSProperties

  // Pattern B — per-slot maps
  composedClasses?: Record<string, string>
  composedInlineStyles?: Record<string, CSSProperties>
  slotChildren?: Record<string, ReactNode>
}

Rules of thumb:

  • Pattern A impls read className, inlineStyle. Forward rootRef to the outermost real DOM element so Craft's connectors attach.
  • Pattern B impls read composedClasses[slot], composedInlineStyles[slot], and slotChildren[slot] per named region.

Functions

Name Purpose
registerAdapter(adapter) Register an adapter at module load. Validated via Zod manifest.
listAdapters() All registered adapters in registration order.
useActiveAdapter() React hook returning the currently-active adapter.

Canonical surface

Canonicals are abstract palette entries — Box, Button, etc. — that adapters render concretely.

Types

CanonicalComponent<Props>
interface CanonicalComponent<Props = Record<string, unknown>> {
  id: CanonicalId
  category: CanonicalCategory
  displayName: string
  tags: readonly string[]
  isCanvas: boolean
  styleSlots: readonly string[]
  canvasSlots?: readonly string[] | ((props: Props) => readonly string[])
                                       // multi-canvas Pattern B
  propsSchema: z.ZodType<Props>
  defaults: { props: Props; style: NodeStyle }
  applicablePanels?: readonly PanelId[]
}
  • isCanvas — true if the outer node itself is a canvas (Pattern A). False for leaves AND for Pattern B composites where named sub-slots are the canvases.
  • styleSlots — named buckets for class strings. ['root'] for Pattern A; more for Pattern B.
  • canvasSlots — when set, CanonicalNode generates one <Element canvas> wrapper per slot and passes them via slotChildren. Outer is NOT a canvas; inner slots are. Function form: supply (props) => readonly string[] for dynamic counts — Tabs uses this to expose one canvas per props.tabs entry. Adding/removing entries via PropsPanel updates the canvas list on next render.
NodeStyle
interface NodeStyle {
  classes: Record<string, string>                  // slot → base class string
  responsive?: Record<string, Record<string, string>>  // bp → slot → classes
  inline?: Record<string, Record<string, string>>      // slot → cssProp → value (base)
  responsiveInline?: Record<string, Record<string, Record<string, string>>>
                                                       // bp → slot → cssProp → value
  // pseudo-class states, composing with breakpoints
  // (state ∈ 'hover' | 'focus' | 'active'):
  states?: Record<string, Record<string, string>>      // state → slot → classes
  stateResponsive?: Record<string, Record<string, Record<string, string>>>
                                                       // bp → state → slot → classes
  stateInline?: Record<string, Record<string, Record<string, string>>>
                                                       // state → slot → cssProp → value
  stateResponsiveInline?: Record<string, Record<string, Record<string, Record<string, string>>>>
                                                       // bp → state → slot → cssProp → value
}

A runtime Zod schema for this shape (nodeStyleSchema) backs the semantic document validation pass.

CanonicalCategory

'layout' | 'input' | 'display' | 'navigation' | 'feedback' | 'media' | 'content'

PanelId

'layout' | 'spacing' | 'size' | 'typography' | 'appearance' | 'effects' | 'componentProps'

Functions

Name Purpose
registerCanonical(def) Register a canonical. Preferred SDK name.
registerComponent(def) Identical alias kept for backwards compatibility.
unregisterCanonical(id) Remove a canonical. Useful when overriding a built-in.
listComponents() All registered canonicals.
getComponent(id) One canonical by id.
getComponentByDisplayName(name) One canonical by displayName.
getApplicablePanels(def) Legacy helper returning panel ids; prefer getPanelsFor for new code.
getCanvasSlots(def) Resolves canvas slots (explicit canvasSlots, or ['root'] if isCanvas, else []).

Hot canonical reload: registerCanonical / unregisterCanonical called AFTER the editor mounts bump an internal version counter; the Toolbox

  • Craft's internal resolver pick up the change without a reload. Existing canvas content keeps rendering — unaffected canonicals stay live; nodes referencing a removed canonical fall back to the missing-impl placeholder. Hot-replacing a canonical (unregisterregister with the same id + different propsSchema) does NOT re-validate existing node props — documents may carry stale prop shapes.

Inspector panel surface

Panels render inside the Inspector for selected nodes. Built-ins register themselves at module load; SDK consumers add custom panels the same way.

Types

PanelDefinition
interface PanelDefinition {
  id: string
  displayName: string
  order: number                                       // sort key; built-ins use 10–70
  applicableTo: (def: CanonicalComponent) => boolean
  component: ComponentType<{ nodeId: string; slot: string }>
}

Resolution: if the canonical sets applicablePanels, that list is a whitelist — only panels with those ids render. Otherwise each panel's applicableTo predicate decides.

Functions

Name Purpose
registerPanel(def) Register an inspector panel. Re-registering replaces.
unregisterPanel(id) Remove by id. Returns true if removed.
listPanels() All panels, sorted by order.
getPanelsFor(canonicalDef) Resolved list for a specific canonical.

Font tokens

The Typography panel's Font dropdown reads from a registry. Built-ins (sans, heading, mono) seed at boot; SDK consumers add more.

Types

FontToken
interface FontToken {
  id: string         // lowercase + digits + hyphens; used as `font-<id>` class
  name: string       // display name in the Typography dropdown
  family: string     // CSS font-family value
  url?: string       // optional @font-face source for hosted webfonts
}

Functions

Name Purpose
registerFontToken(token) Add a font. URL-backed tokens inject @font-face; all tokens inject .font-<id> { font-family: ... } into document.head.
unregisterFontToken(id) Remove. Returns true if a token was removed.

registerFontToken validates the id (lowercase, digits, hyphens only) — throws on invalid input. Re-registering the same id overwrites.

import { registerFontToken } from '@crafted-design/editor/sdk'

registerFontToken({
  id: 'inter',
  name: 'Inter',
  family: '"Inter Variable", sans-serif',
  url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap',
})

After registration, "Inter" appears in the Typography panel's Font dropdown on next render (the panel re-captures the registry on selection change — full hot-reload isn't supported yet).

ColorPicker gradient values

The ColorPickerValue discriminated union extended with a gradient variant. Adapters / custom panels that render the color picker can opt in to gradients via the allowGradient prop.

type ColorPickerValue =
  | { kind: 'token'; token: TokenColor }
  | { kind: 'hex'; hex: string }
  | { kind: 'gradient'; gradient: Gradient }
  | { kind: 'unset' }

interface Gradient {
  type: 'linear' | 'radial'
  angle: number                             // linear only: 0–360°
  position: { x: number; y: number }        // radial only: 0–100 (%)
  stops: GradientStop[]                     // 2–8 entries
}

interface GradientStop {
  color: string                             // hex
  position: number                          // 0–100
}

Gradient values serialize via gradientToCss(g) and persist to style.inline[slot].background (CSS longhand). The built-in AppearancePanel demonstrates the routing — Fill accepts gradients, Border Color doesn't (border-image would require a different rendering path).

Hooks

useNodeClasses(nodeId, slot = 'root')

Read/write helper for a node's slot. Returns:

{
  classString: string                  // active-breakpoint class string for the slot
  inlineStyle: Record<string, string>  // base-breakpoint inline (slot scoped)
  writeClasses(next: string): void
  writeInline(cssProperty: string, value: string | undefined): void
  activeBreakpoint: 'base' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
}

Routes reads/writes between style.classes / style.inline (base) and style.responsive / style.responsiveInline (non-base) based on the editor's current activeBreakpoint. Use this hook in custom panels instead of poking Craft state directly.


Theme tokens, color variables, fonts, safelist (0.3.0)

Theme token API

registerTheme accepts a small tokens map (and optional darkTokens); deriveTokens fills the full shadcn core set and the [data-theme] (+ .dark[data-theme]) CSS block is generated and injected for you — no hand-written CSS.

import { registerTheme } from '@crafted-design/editor/sdk'

registerTheme({
  id: 'forest',
  displayName: 'Forest',
  tokens: { primary: 'oklch(0.55 0.18 145)', primaryForeground: 'oklch(0.98 0.02 145)' },
  darkTokens: { primary: 'oklch(0.7 0.16 145)' },
})

Exports: registerTheme, unregisterTheme, getTheme, listThemes, deriveTokens, themeTokensToCss; types Theme, ThemeInput, ThemeTokens, ColorScheme. dataThemeValue defaults to the id.

Color-variable source

Surface host CSS custom properties in the ColorPicker:

import { EditorColorVariablesProvider } from '@crafted-design/editor/sdk'

<EditorColorVariablesProvider variables={[{ name: 'brand-blue', label: 'Brand Blue' }]}>
  <Editor />
</EditorColorVariablesProvider>

Picking one writes var(--brand-blue). Exports: EditorColorVariablesProvider, useColorVariables; types ColorVariable, EditorColorVariablesValue.

Curated fonts (without uploading)

registerSystemFonts() adds OS font stacks (no network); registerGoogleFonts() adds popular Google fonts via one combined CDN <link> (opt-in). Both register tokens that appear in the Font dropdown. Exports: registerSystemFonts, registerGoogleFonts, SYSTEM_FONTS, GOOGLE_FONTS, googleFontsHref; type GoogleFont. (In-editor upload is built in via the "Fonts" panel — no host code needed.)

Safelist Vite plugin (optional)

A separate, node-only subpath — @crafted-design/editor/vite-plugin:

import { craftedDocumentSafelist } from '@crafted-design/editor/vite-plugin'

export default defineConfig({
  plugins: [craftedDocumentSafelist({
    documents: ['./saved/home.json', './saved/about.json'],
    outFile: './src/safelist.docs.css', // @import this from your CSS
  })],
})

Scans saved documents for the arbitrary classes their inline values map to and emits @source inline(…). Opt-in — the runtime <style> injection path stays the zero-config default.


Overlays + dynamic canvases (0.4.0)

useIsEditing() — the overlay editor-mode contract

import { useIsEditing } from '@crafted-design/editor/sdk'

function MyOverlay({ props, children }: AdapterRenderProps) {
  const editing = useIsEditing() // true while authoring; false in preview / runtime
  if (editing) return <InlinePreview>{children}</InlinePreview>
  return <RealDialogPrimitive>{children}</RealDialogPrimitive>
}

useIsEditing() returns Craft's state.options.enabled. The built-in overlay canonicals (Modal / Drawer / Toast / Tooltip / Popover) follow a contract that custom overlay canonicals should mirror:

  • Editing mode — render an inline, always-open preview so the content is a normal drop target the designer can drop into and style. The built-ins portal this preview into the Overlay Stage (the right-side panel) via createPortal to #craftjs-overlay-stage, but a custom overlay can render inline in place if that fits better.
  • Preview / runtime — render the library's real overlay (Dialog, Drawer, Snackbar, Tooltip, Popover) with its own open / hover / dismiss behavior. Open state for click-toggle overlays lives in the overlay runtime store, keyed by the canonical's name prop; triggering components (Button, Icon, …) flip it via their triggers: string[].

The top-bar Preview toggle flips state.options.enabled, so a designer can switch between the two branches without leaving the editor.

Overlay authoring seam (0.9.0)

The pieces the built-in overlay impls use are public, so a custom overlay canonical gets the same editor-stage + runtime behavior:

import {
  useOverlayRuntime,   // the zustand open/close store (toggle/set/state/…)
  readOverlayOpen,     // resolve a named overlay's open state from store state
  useOverlayStageTarget, // the #craftjs-overlay-stage portal target (or null)
  OverlayCard,         // labeled wrapper for the editor-mode preview
} from '@crafted-design/editor/sdk'
import { createPortal } from 'react-dom'

function MyOverlay({ props }: AdapterRenderProps) {
  const editing = useIsEditing()
  const stage = useOverlayStageTarget()
  if (editing && stage) {
    return createPortal(<OverlayCard label="My overlay" name={props.name}>…</OverlayCard>, stage)
  }
  // runtime: gate on the store
  const open = readOverlayOpen(useOverlayRuntime((s) => s.state), props.name, props.defaultOpen)
  return open ? <RealDialog>…</RealDialog> : null
}

Class-merge util + per-canonical prop types

  • cn — clsx + tailwind-merge, the helper the built-in adapters use to compose a canonical's className with their own (later classes win). import { cn } from '@crafted-design/editor/sdk'.
  • Per-canonical prop types — every canonical's props type (ButtonProps, ModalProps, TableProps, …) is exported from the SDK so an adapter impl can narrow its props: props as ButtonProps. Type-only.
import { slideSlotKeys, SLIDE_SLOT_PREFIX } from '@crafted-design/editor/sdk'
import type { CarouselProps } from '@crafted-design/editor/sdk'

// In a custom Carousel adapter impl: look up each slide's canvas child.
const keys = slideSlotKeys(props.slides)
return keys.map((k, i) => <Slide key={k}>{slotChildren[k]}</Slide>)

slideSlotKeys mirrors tabSlotKeys (§ Canonical surface): it derives the per-slide canvas slot keys that CanonicalNode allocates from the Carousel canonical's canvasSlots(props) function, so a third-party adapter reads the right entries out of slotChildren. Both helpers are the pattern for any dynamic-canvas canonical — a canonical whose canvasSlots is a (props) => readonly string[] function rather than a static list.

The other two dynamic-canvas built-ins export the same kind of helpers:

  • StepperstepperSlotKey(i) / stepperSlotKeys(count).
  • TabletableCellSlotKey(r, c) / tableCellSlotKeys(rows, cols, merges), plus the merge-geometry helpers containingMerge / isCellCovered and the TableMerge type, so a custom Table adapter can render merged cells the way the built-ins do.

Persistence backend + code export (0.5.0)

setStorageAdapter — plug your own backend

Documents persist to IndexedDB by default (with a localStorage fallback). To store them in your backend instead, implement StorageAdapter and register it before <Editor /> mounts:

import { setStorageAdapter } from '@crafted-design/editor/sdk'
import type { StorageAdapter } from '@crafted-design/editor/sdk'

const myAdapter: StorageAdapter = {
  async readIndex() { return fetch('/api/docs').then((r) => r.json()) },
  async writeIndex(index) {
    await fetch('/api/docs', { method: 'PUT', body: JSON.stringify(index) })
    return { ok: true }
  },
  async readDocument(id) { /* … */ return null },
  async writeDocument(id, doc) { /* … */ return { ok: true } },
  async deleteDocument(id) { /* … */ },
  async estimateUsage() { return { usedBytes: 0, totalBytes: Infinity, percent: 0 } },
}
setStorageAdapter(myAdapter)

All methods are async. WriteResult is { ok: true } | { ok: false; kind: 'quota' | 'schema' | 'unknown'; error } — return kind: 'quota' so the editor's storage-full UI fires. Optional: init() (one-time setup, awaited before the first read), and the version trio listVersions / readVersion / writeVersion (omit them and the version-history UI hides itself). getStorageAdapter() returns the active adapter.

Export to React/JSX source code is intentionally not part of this library (it's a runtime editor + document model, not a design-to-code generator). There is no exportDocumentAsJsx. Portability is JSON export (exportDocument), import, and share-by-URL — round-tripping the document model, which the chosen adapter renders live.


What's NOT exported

The following are internal and may change without notice:

  • CanonicalNode — the Craft.js bridge component.
  • buildResolver / getResolver — internal Craft resolver plumbing.
  • The Zustand editor store (useEditorStore) — implementation detail.
  • tw-classes slice helpers (mergeTypography, etc.) — internal style funnel.

If you find yourself reaching for one of these, open a discussion — the SDK likely needs a new export.


Public API stability

The public runtime surface is frozen and enforced. src/sdk/surface.test.ts holds the exact list of exported names for both entry points and fails CI if any export is added, removed, or renamed — so the surface can't drift silently. Treat that test as the authoritative inventory.

  • @crafted-design/editor/sdk — the authoring surface (register* / unregister* / list* / get* functions, author hooks, the slot-key helpers, provider components, and their types).
  • @crafted-design/editor (and /core) — re-exports the entire SDK surface plus the editor-only runtime: Editor, ErrorBoundary / TopShellErrorFallback, the host stores (useEditorStore, useDocumentStore), and the document import/export helpers.

What the SemVer promise covers (at 1.0)

  • The existence and call signatures of every exported name in the frozen list. Removing or renaming one, or making a breaking signature change, is a major bump with a CHANGELOG entry.
  • The document envelope (EditorDocument) shape — changed only with a migration shipped in src/persistence/migrations.ts.
  • The canonical ids of built-in components (saved documents reference them).

What it does NOT cover

  • Rendered HTML / CSS classes / visual output. Adapters and styling evolve; don't assert on the editor's DOM structure or class strings.
  • Internal modules under src/ that aren't re-exported here (see What's NOT exported above) — reaching past the entry points is unsupported.
  • Bundle size / file layout / chunk namescheck:size budgets these but they aren't an API.
  • Craft.js bridge types beyond the ones explicitly re-exported.

Deprecation path

To remove or rename a public export after 1.0: ship the replacement first, mark the old one @deprecated (with the JSDoc pointer to the replacement) for at least one minor, then remove it in the next major — updating the frozen list + CHANGELOG in that commit.

Since 1.0

1.0.0 (the first stable release, on the latest dist-tag) put the surface under the full SemVer promise above. New exports may still be added in minors — every addition updates the frozen list + the CHANGELOG — but nothing is removed or renamed outside a major.

Guides

MCP server — let an AI build designs

The crafted-design MCP server (the bin's mcp subcommand) is a Model Context Protocol server that exposes the editor's component registry and document model as tools. An AI client (Claude Code, Claude Desktop, any MCP client) can use it to author and edit editor documents — producing the same EditorDocument JSON the editor loads, the <DocumentRenderer /> renders, and you ship.

It builds on the headless API (SDK_GUIDE@crafted-design/editor/headless): the agent works against an in-progress document with no browser and no editor running.

Install

The server ships as a bin on the package. Its only extra dependency is the MCP SDK, an optional peer (the editor itself doesn't need it):

npm install @crafted-design/editor @modelcontextprotocol/sdk

For the render_image tool (and the exact, in-browser check_contrast), also install Playwright + a browser — both are optional; the rest of the server works without them:

npm i -D playwright && npx playwright install chromium

Connect a client

Claude Code:

claude mcp add crafted-design -- npx -y @crafted-design/editor mcp

Claude Desktop (claude_desktop_config.jsonmcpServers):

{
  "mcpServers": {
    "crafted-design": {
      "command": "npx",
      "args": ["-y", "@crafted-design/editor", "mcp"]
    }
  }
}

(Both run the crafted-design bin's mcp subcommand over stdio. If the MCP SDK isn't installed, it prints an install hint and exits.)

Other MCP clients

Nothing here is Claude-specific — this is a standard stdio MCP server, so any MCP client registers it with the same command and args:

command: npx   args: ["-y", "@crafted-design/editor", "mcp"]

(Before the package is published, point at the built bin instead: command: node, args: ["<abs>/dist-lib/cli.js", "mcp"].) Only the config file and its key differ per client:

Client Where Key
VS Code (Copilot agent) .vscode/mcp.json servers
Cursor .cursor/mcp.json (or ~/.cursor/mcp.json) mcpServers
Windsurf ~/.codeium/windsurf/mcp_config.json mcpServers
Cline / Roo (VS Code) extension "MCP Servers" settings mcpServers
Continue.dev ~/.continue/config.yaml mcpServers:
Zed settings.json context_servers
Gemini CLI ~/.gemini/settings.json mcpServers

Most use the JSON shape:

{
  "mcpServers": {
    "crafted-design": {
      "command": "npx",
      "args": ["-y", "@crafted-design/editor", "mcp"]
    }
  }
}

Codex (OpenAI CLI) uses TOML, with a snake_case key — ~/.codex/config.toml:

[mcp_servers.crafted-design]
command = "npx"
args = ["-y", "@crafted-design/editor", "mcp"]

(or codex mcp add crafted-design -- npx -y @crafted-design/editor mcp).

Custom agents / frameworks connect programmatically — no config file. Pass the same stdio command to an MCP client SDK or an agent framework's MCP adapter:

# OpenAI Agents SDK
from agents.mcp import MCPServerStdio
server = MCPServerStdio(params={
    "command": "npx",
    "args": ["-y", "@crafted-design/editor", "mcp"],
})
// Raw MCP TypeScript SDK
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const transport = new StdioClientTransport({
  command: 'npx',
  args: ['-y', '@crafted-design/editor', 'mcp'],
})

The same works with LangChain (langchain-mcp-adapters), LlamaIndex, Pydantic-AI, and others.

Transport: the server speaks stdio (every desktop / CLI client supports it). A client that needs HTTP/SSE can't connect directly — open an issue if you need a streamable-HTTP transport.

Declaring template variables

To let the agent insert merge tokens ({{ contact.name }}), set the CRAFTED_DESIGN_TEMPLATE_VARIABLES env var to a JSON array of { key, label?, group?, sample? } — the same shape the editor's EditorTemplateVariablesProvider takes (see the Integration Guide). The agent discovers them via list_template_variables; get_capabilities nudges it to use tokens. Invalid JSON is ignored (the server starts with no variables).

{
  "mcpServers": {
    "crafted-design": {
      "command": "npx",
      "args": ["-y", "@crafted-design/editor", "mcp"],
      "env": {
        "CRAFTED_DESIGN_TEMPLATE_VARIABLES": "[{\"key\":\"contact.name\",\"label\":\"Full name\",\"group\":\"Contact\",\"sample\":\"Jane Doe\"}]"
      }
    }
  }
}

The workflow

Call get_capabilities first — it returns this in-band. The shape:

  1. Discoverlist_canonicals (every component, container vs leaf vs multi-canvas) and describe_canonical (full props JSON Schema, defaults, slots, panels).
  2. Startcreate_document (root is a Box canvas), or apply_template / load_document.
  3. Buildadd_node returns the new node's id; address later edits by it.
    • Pattern A containers (box, stack, section): pass parentId.
    • Pattern B (card, tabs, table): pass parentId and slot (see describe_canonicalcanvasSlots).
  4. Refineupdate_node_props, update_node_style, move_node, remove_node.
  5. See itrender_image (a PNG you can look at), outline_document (cheap text tree), or render_html (structure-faithful HTML).
  6. Check colorstheme_palette (the theme's pairs) + check_contrast (per text node, worst-first) so you don't ship illegible text.
  7. Finishvalidate_document, then get_document for the EditorDocument JSON.

Every mutating tool returns the validation status + a fresh outline, so the model stays oriented. Bad input (unknown canonical, schema violation, missing node) comes back as a recoverable tool error, not a crash.

Tool catalog

Tool What it does
get_capabilities The workflow + tool order (read first).
list_canonicals All components: id · category · container/leaf/slots.
describe_canonical One component: props JSON Schema, defaults, slots, panels.
list_adapters / list_themes / list_templates Registered design systems / themes / templates.
list_template_variables Host-declared merge variables; insert any as a {{ key }} token in a text prop.
create_document Fresh document (adapter / theme / colorMode / root).
apply_template Load a registered starter template.
add_node Add a canonical under a parent (or a Pattern B slot); returns its id.
update_node_props Merge a props patch (schema-checked).
update_node_style Merge Tailwind classes per style slot.
move_node Reparent (slot/index); cycle-safe.
remove_node Delete a node + subtree (ROOT / slot containers protected).
set_adapter / set_theme Set the document's design system / canvas theme.
outline_document Compact id · canonical tree.
render_html Static structural HTML preview.
render_image A PNG screenshot of the design (needs Playwright).
theme_palette The theme's token colors + WCAG ratios for key pairs.
check_contrast Per-text-node contrast + grade, worst-first.
validate_document Structural + semantic issues.
get_document The full EditorDocument JSON.
load_document / reset_document Replace from JSON / start over.

Resources: craft://document.json (the live envelope) and craft://preview.html (its HTML preview).

A worked example

Prompt: "Build a pricing hero — a headline, a subheading, and a card with a plan name and a Subscribe button."

A capable agent runs roughly:

create_document        { adapterId: "shadcn" }
add_node               { parentId: "ROOT", canonical: "heading",
                         nodeProps: { content: "Simple, honest pricing" },
                         classes: { root: "text-4xl font-bold" } }      → heading-1
add_node               { parentId: "ROOT", canonical: "text",
                         nodeProps: { content: "One plan. Everything included." } } → text-1
add_node               { parentId: "ROOT", canonical: "card" }          → card-1
add_node               { parentId: "card-1", slot: "header", canonical: "heading",
                         nodeProps: { content: "Pro", level: "3" } }     → heading-2
add_node               { parentId: "card-1", slot: "footer", canonical: "button",
                         nodeProps: { label: "Subscribe" } }            → button-1
render_image                                                             # SEE it
check_contrast                                                           # is the text legible?
get_document                                                             # → EditorDocument JSON

The resulting JSON drops straight into the editor or the renderer:

import { DocumentRenderer } from '@crafted-design/editor/renderer'
<DocumentRenderer document={generated} />

Seeing colors & contrast

Structure tools (outline_document, render_html) tell the agent what it built, not how it looks. Three tools close that gap:

  • render_image → a PNG, rendered by a persistent headless page that mounts the real <DocumentRenderer> through the document's design system (the same output a host ships). The multimodal client sees it inline. Requires Playwright (optional); without it the tool returns a hint.
  • theme_palette → the theme's token colors with WCAG ratios for the key pairs (body / muted / card text, primary / secondary / accent buttons). No browser needed.
  • check_contrast → every text node's foreground/background + ratio + grade, worst-first. Exact (in-browser computed styles) when Playwright is installed; a deterministic token-based report otherwise — which flags nodes using literal/arbitrary colors as indeterminate (verify those with render_image).

The loop: build → render_image (look) → check_contrast (measure) → fix the failing nodes → look again.

Fonts: offline renders may substitute glyphs, but color, contrast, spacing, and layout — what these tools are for — are faithful.

Verifying the Playwright tools

render_image needs three things present: the MCP SDK, Playwright, and the render harness (dist-lib/harness/, produced by the build). Quick checklist:

npm i -D @modelcontextprotocol/sdk playwright
npx playwright install chromium     # the browser render_image drives
npm run build:dist                  # builds dist-lib/cli.js + mcp.js AND dist-lib/harness/

(For a published install via npx -y @crafted-design/editor mcp, the harness already ships in the package — only the SDK + Playwright steps apply.)

Then drive the built server over stdio and confirm a real PNG comes back — from the package root so the SDK resolves:

// smoke.mjs — node smoke.mjs
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const t = new StdioClientTransport({ command: 'node', args: ['dist-lib/cli.js', 'mcp'] })
const c = new Client({ name: 'smoke', version: '1' })
await c.connect(t)
await c.callTool({ name: 'create_document', arguments: { adapterId: 'shadcn' } })
await c.callTool({
  name: 'add_node',
  arguments: { parentId: 'ROOT', canonical: 'heading', nodeProps: { content: 'Hello' } },
})
const img = await c.callTool({ name: 'render_image', arguments: {} })
console.log(img.content[0].type, img.content[0].mimeType, 'b64 bytes:', img.content[0].data?.length)
console.log((await c.callTool({ name: 'check_contrast', arguments: {} })).content[0].text.split('\n')[0])
await c.close()

Expected: image image/png b64 bytes: ~10000 and a check_contrast line that begins exact (rendered) (proving the in-browser audit engaged). If you instead see a "render_image needs Playwright" message, Playwright isn't installed; a harness error means the build didn't include dist-lib/harness/ (use npm run build:dist, not a partial build).

Troubleshooting

  • Headless Linux / CI: chromium may need system libraries — npx playwright install-deps chromium (requires root). The server launches with --no-sandbox already.
  • First call is slow (~1–2s): the browser launches lazily on the first render_image, then is reused for the server's lifetime — later renders are fast.
  • Renders are hermetic: the page is network-blocked except the loopback harness, so no external fetches; web fonts may be substituted (colors and layout are unaffected).

What it is and isn't

  • render_image is structure + style faithful, not a design mockup — it's exactly what <DocumentRenderer> produces. render_html is the lighter, no-browser structural view (real DOM + classes, no resolved colors).
  • Stateless across sessions. The server holds one in-progress document per process; persisting it is the host's job (get_document → your storage / StorageAdapter).
  • Adapter-independent build. Documents are canonical-id based; the agent can target any registered adapter via set_adapter. render_html always previews through the dependency-free HTML adapter for reliability.
Guides

Tutorial — building an adapter

Goal: add a third UI library to the editor in ~30 minutes. We'll build a "Chakra (example)" adapter that renders five canonicals (Box, Heading, Button, Stack, Card) using a minimal Chakra-like primitive library. Real Chakra is a drop-in replacement at the end.

The completed example lives at examples/adapter-chakra/ — refer to it if you get stuck.

Start from a skeleton. Scaffold a wired-up adapter (a Wrapper, two component impls, and a passing smoke test) and fill it in:

npx @crafted-design/editor scaffold adapter my-design-system

This tutorial explains what that skeleton contains and how to extend it.

Prerequisites

  • Familiarity with React + TypeScript.
  • The editor running locally (npm run dev).
  • Read SDK_GUIDE.md for the public-API reference.

Step 1 — Scaffold the directory

Create examples/adapter-mylib/ at the repo root (sibling to src/). Files we'll add:

examples/adapter-mylib/
  lib.tsx                # Your library's primitives (or a mock)
  components/
    Box.tsx              # Adapter impls per canonical
    Heading.tsx
    Button.tsx
    Stack.tsx
    Card.tsx
  index.ts               # registerAdapter call
  README.md              # Documentation

Step 2 — Write your first impl (Box)

AdapterRenderProps is the contract. Pattern A canonicals (single root slot) read className + inlineStyle + children + rootRef.

// components/Box.tsx
import type { AdapterRenderProps } from '@crafted-design/editor/sdk'

export function MyBox({ children, rootRef, className, inlineStyle }: AdapterRenderProps) {
  return (
    <div ref={rootRef} className={className} style={inlineStyle}>
      {children}
    </div>
  )
}

That's the entire impl. className already contains the responsive class string composed by CanonicalNode; inlineStyle already contains the arbitrary-value inline CSS. Don't read style.classes.root directly — you'd miss the responsive breakpoint prefixes.

Step 3 — Repeat for Heading / Button / Stack

Each impl is a thin wrapper. Read props for canonical props, use rootRef, forward className + inlineStyle:

// components/Button.tsx
import type { AdapterRenderProps } from '@crafted-design/editor/sdk'

export function MyButton({ props, rootRef, className, inlineStyle }: AdapterRenderProps) {
  const { label, intent, disabled } = props as {
    label: string; intent: string; disabled: boolean
  }
  return (
    <button ref={rootRef as never} className={className} style={inlineStyle} disabled={disabled}>
      {label}
    </button>
  )
}

Canonical prop names + types are documented in src/registry/components/*.ts.

Step 4 — Multi-canvas impl (Card)

Pattern B canonicals (Card, with header/body/footer canvas slots) consume composedClasses[slot], composedInlineStyles[slot], and slotChildren[slot] per region:

// components/Card.tsx
import type { AdapterRenderProps } from '@crafted-design/editor/sdk'

export function MyCard({
  rootRef,
  composedClasses = {},
  composedInlineStyles = {},
  slotChildren = {},
}: AdapterRenderProps) {
  return (
    <div ref={rootRef} className={composedClasses.root} style={composedInlineStyles.root}>
      <header className={composedClasses.header} style={composedInlineStyles.header}>
        {slotChildren.header}
      </header>
      <section className={composedClasses.body} style={composedInlineStyles.body}>
        {slotChildren.body}
      </section>
      <footer className={composedClasses.footer} style={composedInlineStyles.footer}>
        {slotChildren.footer}
      </footer>
    </div>
  )
}

The slotChildren[slot] entries are React elements that are themselves canvases — drop targets that accept dragged children. You just place them where you want each region rendered.

Step 5 — Register the adapter

// index.ts
import { registerAdapter } from '@crafted-design/editor/sdk'
import { MyBox } from './components/Box'
// ...other imports

registerAdapter({
  id: 'mylib',
  displayName: 'My Library',
  components: {
    box: MyBox,
    heading: MyHeading,
    button: MyButton,
    stack: MyStack,
    card: MyCard,
  },
})

Required fields are id, displayName, and components. The shape is validated via Zod at registration time — bad manifests throw at boot with a readable error.

Step 6 — Optional: global provider via Wrapper

If your library needs a global React provider (e.g., theme, locale), add a Wrapper:

import { ChakraProvider } from '@chakra-ui/react'

registerAdapter({
  // ...
  Wrapper: ({ children }) => <ChakraProvider>{children}</ChakraProvider>,
})

Critical: the Wrapper must be a pure context provider. No document listeners, no global CSS injection, no browser API mutation. ALL registered adapters' Wrappers stay mounted (composed around the canvas), even inactive ones — leaking side effects from a Wrapper applies them unconditionally.

For side-effecting setup (e.g., calling a library's init()), use mount / unmount — those fire only on active-adapter change.

Step 7 — Wire into the editor

Add a side-effect import in src/App.tsx:

import '../examples/adapter-mylib'

Reload. The AdapterSwitcher (top right of the editor) now shows "My Library". Pick it — every node re-renders via your impls. Tree state survives (canonical-based persistence; the document references canonical ids, not adapter ids).

Step 8 — Verify the SDK boundary

Your adapter should import only from the SDK entry — @crafted-design/editor/sdk in your own package, or the equivalent @design/sdk alias when working inside this repo's examples/ (an ESLint rule enforces it there). Reaching into ../src/... is unsupported — internal APIs move between versions.

grep -r "from '\\.\\./\\.\\./src" examples/adapter-mylib/
# Should print nothing.

Where to next

  • Add more canonicals. Browse src/registry/components/*.ts for the full list — 48 canonicals total (npm run docs:matrix prints them). Add impls + entries to the components map.
  • Override built-ins. Add an alternative impl for an existing canonical; users can swap to your adapter to get your styling.
  • Author a custom canonical. See TUTORIAL_CANONICAL.md.
  • Author a custom inspector panel. See TUTORIAL_PANEL.md.
Guides

Tutorial — adding a canonical

Goal: add a new abstract palette entry the editor's Toolbox exposes. We'll build a Stepper — a horizontal progress indicator with currentStep and totalSteps props.

The canonical contract is just data + a Zod schema. Adapters provide the actual rendering — see TUTORIAL_ADAPTER.md.

Start from a skeleton. Scaffold a canonical (Zod schema, defaults, applicable panels, and a passing smoke test) and adjust it:

npx @crafted-design/editor scaffold canonical stepper

This tutorial explains each field the skeleton sets.

Step 1 — Define the props schema

import { z } from 'zod'

export const stepperPropsSchema = z.object({
  currentStep: z.number().int().min(0),
  totalSteps: z.number().int().min(1).max(10),
  showLabels: z.boolean(),
})
export type StepperProps = z.infer<typeof stepperPropsSchema>

The schema drives PropsPanel's auto-form. Supported Zod kinds:

  • z.string() → text input
  • z.number() → number input
  • z.boolean() → checkbox
  • z.enum([...]) → dropdown
  • z.array(z.object({...})) → list editor with add/remove/reorder
  • z.object({...}) → recursive nested form

Other kinds render an "unsupported" badge — file an issue if you hit a real case.

Step 2 — Register the canonical

import { registerCanonical } from '@crafted-design/editor/sdk'

registerCanonical<StepperProps>({
  id: 'stepper',                       // stable — persisted in documents
  category: 'navigation',
  displayName: 'Stepper',
  tags: ['wizard', 'progress'],
  isCanvas: false,
  styleSlots: ['root'],
  propsSchema: stepperPropsSchema,
  defaults: {
    props: { currentStep: 0, totalSteps: 3, showLabels: true },
    style: { classes: { root: 'flex items-center gap-2' } },
  },
})

Field-by-field

  • id — stable string. Persisted in saved documents. Don't rename without a migration.
  • category — buckets components in the Toolbox. One of 'layout', 'input', 'display', 'navigation', 'feedback', 'media', 'content'. Unknown categories fall into "Other".
  • displayName — shown in the Toolbox and persisted as Craft's resolver key.
  • tags — keywords for Toolbox search. Lowercase, no spaces.
  • isCanvas — true if dropped instances accept children. False for leaves (Button, Text, Stepper).
  • styleSlots — named class buckets. ['root'] for single-region canonicals. For Pattern B composites with sub-regions, add more (['root', 'header', 'body', 'footer'] for Card).
  • propsSchema — your Zod schema.
  • defaults — initial values for new instances.
  • applicablePanels (optional) — whitelist of inspector panel ids that apply. Omit to let each panel's applicableTo predicate decide.

Step 3 — Add to the barrel

If your canonical lives at src/registry/components/stepper.ts, append one line to src/registry/components/index.ts:

import './stepper'

The side-effect import triggers registration at module load.

Step 4 — Provide adapter impls

Without an impl, dropped Steppers render a "no impl in adapter" placeholder. Add an impl per adapter you support:

// src/adapters/shadcn/components/Stepper.tsx
import type { AdapterRenderProps } from '@crafted-design/editor/sdk'

export function ShadcnStepper({ props, rootRef, className, inlineStyle }: AdapterRenderProps) {
  const { currentStep, totalSteps, showLabels } = props as {
    currentStep: number; totalSteps: number; showLabels: boolean
  }
  return (
    <div ref={rootRef} className={className} style={inlineStyle}>
      {Array.from({ length: totalSteps }, (_, i) => (
        <span
          key={i}
          className={
            'h-2 w-8 rounded-full ' +
            (i <= currentStep ? 'bg-primary' : 'bg-muted')
          }
          title={showLabels ? `Step ${i + 1}` : undefined}
        />
      ))}
    </div>
  )
}

Register the impl in the adapter's index.ts:

import { ShadcnStepper } from './components/Stepper'

registerAdapter({
  // ...
  components: {
    // ...existing
    stepper: ShadcnStepper,
  },
})

Step 5 — Pattern B (multi-canvas) variant

If your canonical has named sub-regions that should each accept dropped children — say, a Splitter with a left and right panel — declare them as canvasSlots:

registerCanonical({
  id: 'splitter',
  // ...
  isCanvas: false,                            // outer is not a canvas
  styleSlots: ['root', 'left', 'right'],
  canvasSlots: ['left', 'right'],             // both panels accept drops
  defaults: {
    props: {},
    style: { classes: { root: '', left: '', right: '' } },
  },
})

The adapter impl receives slotChildren.left and slotChildren.right — React elements that wrap independent Craft canvases. Place each one in the appropriate DOM region.

Step 6 — Inspector behavior

Once registered, your canonical appears in the Toolbox grouped by category and is selectable on the canvas. The Inspector mounts:

  • The applicable inspector panels (based on applicablePanels whitelist or each panel's applicableTo predicate).
  • A SlotPicker if styleSlots.length > 1.
  • The PropsPanel auto-form derived from your schema.
  • Per-slot class editing via the existing panels.

Step 7 — Tailwind safelist (if you emit dynamic classes)

If the inspector panels can write Tailwind classes outside tw-classes.ts's known vocabulary (e.g., your default uses gap-3 and no other canonical does), make sure the utility is in scripts/gen-safelist.ts. The script runs on npm run dev / npm run build; missing safelist entries silently fail to apply CSS even though the class appears in the DOM.

For tokens from existing slices (typography, layout, spacing, size, appearance, effects), the safelist already covers them at every breakpoint — no action needed.

Step 8 — Register at module load

Register at module load via side-effect imports in src/App.tsx (the canonical app does this for all 48 built-ins). Post-mount registration is also supported — the registry bumps a version counter and the editor re-resolves, so a hot-reloaded canonical appears in the toolbox without a page reload — but module-load registration is the predictable default.

Verifying

  1. Reload the dev server.
  2. Toolbox shows "Stepper" in the Navigation category.
  3. Drag onto canvas — three dots appear (your default totalSteps: 3).
  4. Inspector's PropsPanel exposes currentStep, totalSteps, showLabels.
  5. Change currentStep to 1 — first two dots highlight.
  6. Edit the root slot's classes via the existing panels — the Stepper responds.

Where to next

  • Override defaults from an external adapter. Call unregisterCanonical('stepper') and re-register with different defaults.
  • Author a custom panel for your canonical. See TUTORIAL_PANEL.md.
Guides

Tutorial — adding an inspector panel

Goal: add a custom inspector panel that appears for selected nodes. We'll build a "Notes" panel — a free-text annotation stored on each node as a synthetic prop.

The seven built-in panels (Layout, Size, Spacing, Typography, Appearance, Effects, Properties) register themselves the same way. SDK consumers add custom panels via registerPanel — they show in the Inspector alongside the built-ins, sorted by order.

Start from a skeleton. Scaffold a panel (a useNodeClasses-backed component, registerPanel call, and a passing smoke test) and customize it:

npx @crafted-design/editor scaffold panel notes

This tutorial explains how the generated panel works and how to extend it.

Step 1 — Author the panel component

A panel is a React component that receives { nodeId, slot }. For panels that edit canonical props (not slot classes), slot is harmless to ignore.

// src/editor/inspector/NotesPanel.tsx
import { useEditor } from '@craftjs/core'

export function NotesPanel({ nodeId }: { nodeId: string }) {
  const { actions, notes } = useEditor((_, q) => ({
    notes: (q.node(nodeId).get().data.props as { __notes?: string }).__notes ?? '',
  }))

  const setNotes = (value: string) => {
    actions.setProp(nodeId, (props: { __notes?: string }) => {
      props.__notes = value || undefined  // undefined removes the key
    })
  }

  return (
    <textarea
      value={notes}
      onChange={(e) => setNotes(e.target.value)}
      placeholder="Designer notes for this node…"
      rows={4}
      className="w-full rounded border border-gray-300 bg-white px-1.5 py-1 text-sm text-gray-700"
    />
  )
}

For panels that edit slot classes, use the useNodeClasses hook:

import { useNodeClasses } from '@crafted-design/editor/sdk'

export function CustomClassPanel({ nodeId, slot = 'root' }: { nodeId: string; slot?: string }) {
  const { classString, writeClasses } = useNodeClasses(nodeId, slot)
  return (
    <textarea
      value={classString}
      onChange={(e) => writeClasses(e.target.value)}
    />
  )
}

useNodeClasses is the single I/O funnel — it routes reads/writes between the base breakpoint and the responsive buckets (style.responsive, style.responsiveInline) based on the editor's activeBreakpoint. Your panel gets responsive support for free.

Step 2 — Register the panel

import { registerPanel } from '@crafted-design/editor/sdk'
import { NotesPanel } from './NotesPanel'

registerPanel({
  id: 'notes',                          // unique id; matches applicablePanels entries
  displayName: 'Notes',                 // section header in the Inspector
  order: 100,                           // after every built-in (10–70)
  applicableTo: () => true,             // every canonical
  component: NotesPanel,
})

Place the registration in a module that loads at boot — e.g., a new file src/editor/inspector/custom-panels.ts imported as a side-effect from src/App.tsx.

Resolution rules

When the Inspector renders for a selected node:

  1. If the canonical declares applicablePanels (a whitelist), only panels with ids in that list render. Custom panel ids not in the whitelist are excluded.
  2. Otherwise, each panel's applicableTo(def) predicate decides.

In practice: if you want your custom panel to apply universally, set applicableTo: () => true. Canonicals using the legacy applicablePanels whitelist (Button, all 5 form canonicals) won't show your panel unless they add its id to their whitelist.

Step 3 — Verify

  1. Reload the editor.
  2. Select any non-form node on the canvas.
  3. Inspector shows a "Notes" section below "Properties" (order=100 > 70).
  4. Type some notes. Select away, then back — the notes persist.
  5. Save the document, reload — notes survive.

Step 4 — Replacing a built-in

To swap out a built-in (e.g., a custom Typography panel with HSL color sliders), registerPanel with the same id:

registerPanel({
  id: 'typography',                     // same id as built-in
  displayName: 'Typography (HSL)',
  order: 40,                            // keep built-in's order
  applicableTo: (def) => def.category === 'content' || def.category === 'layout',
  component: TypographyHSLPanel,
})

The second registerPanel call overwrites the first — same id, replaced definition. The built-in id: 'typography' registers at App.tsx side-effect-import time; your replacement registers after that.

Order matters: your registration must run after import './editor/inspector/built-in-panels'. Place your side-effect import below it in App.tsx.

Step 5 — Panel hooks reference

Custom panels typically need to read / write node state. The SDK exposes:

  • useNodeClasses(nodeId, slot) — class string + inline style read/write, responsive-aware.
  • useEditor() (from @craftjs/core, not the SDK) — direct access to Craft's actions and query. Use for cases the SDK doesn't cover.

For setting canonical props (synthetic or schema-typed), use Craft's actions.setProp(nodeId, (props) => { ... }). The mutator receives an Immer draft — mutate in place, don't return.

Where to next

  • Panel that reads the canonical definition. Use getComponentByDisplayName(displayName) from @crafted-design/editor/sdk to look up the canonical's metadata (e.g., to show the schema in a debug pane).
  • Panel that interacts with multiple nodes. useEditor exposes state.events.selected — your panel can react to selection changes.
  • Panel scoped to specific canonicals. Narrow applicableTo: (def) => def.id === 'card'.
Reference

Adapter compatibility matrix

Which canonical components each registered adapter implements. ✅ = a real renderer; — = no renderer, so the editor falls back to the missing-renderer placeholder for that canonical under that adapter.

The three built-in adapters (shadcn, mui, html) ship in the package and cover 100% of the registry. chakra-example lives under examples/ and is intentionally partial — a reference for authoring your own adapter, not a complete one.

Canonical Category shadcn MUI Plain HTML Chakra (example)
box layout
card
container
divider
grid
section
spacer
stack
heading content
text
button input
checkbox
date-picker
date-range-picker
input
radio
select
switch
textarea
time-picker
avatar display
badge
code
data-list
data-list-item
icon
skeleton
table
table-cell
breadcrumb navigation
link
nav-item
nav-menu
pagination
stepper
tabs
alert feedback
drawer
modal
popover
progress
spinner
toast
tooltip
audio media
carousel
image
video
Coverage 48 / 48 48 / 48 48 / 48 20 / 48

Peer dependencies

The npm packages each adapter needs the host to install, mapped to the semver range it's tested against. These are optional peers — a host installs only the peers for the adapters it imports (see package.json peerDependenciesMeta). shadcn and html need none (they use the package's own deps). See ADAPTER_VERSIONING.md for the policy.

Adapter id Peer dependencies (tested range)
shadcn shadcn none
MUI mui @mui/material ^9
@emotion/react ^11
@emotion/styled ^11
Plain HTML html none
Chakra (example) chakra-example @chakra-ui/react ^3

Regenerate with npm run docs:matrix. CI runs npm run docs:matrix -- --check, which fails if a built-in adapter ever drops below full coverage.

Reference

Adapter versioning & peer dependencies

How @crafted-design/editor handles the libraries its adapters render on top of — what you must install, what versions are supported, and how a breaking change in an underlying library surfaces.

See ADAPTER_MATRIX.md for the live per-adapter coverage and peer-dependency table (generated by npm run docs:matrix).

Adapters are optional, and so are their dependencies

The editor renders canonical components through an adapter — a map from canonical id to a concrete renderer. Three adapters ship in the package:

Adapter Peer dependencies Notes
shadcn (default) none uses the package's bundled radix-ui + Tailwind
html none dependency-free semantic HTML
mui @mui/material, @emotion/react, @emotion/styled opt-in

Because not every host wants MUI, the heavy UI libraries are declared as optional peerDependencies in package.json (peerDependenciesMeta marks them optional: true). You install only the peers for the adapters you actually import:

// host package.json — shadcn / html only: nothing extra to install.
// MUI adapter? add the peers:
{
  "dependencies": {
    "@crafted-design/editor": "^1.0.0",
    "@mui/material": "^9",
    "@emotion/react": "^11",
    "@emotion/styled": "^11"
  }
}

Importing an adapter is what pulls its peers in. The lean entry never imports MUI:

import '@crafted-design/editor/core'          // editor + shadcn + html
import '@crafted-design/editor/adapters/mui'  // add MUI — requires the peers

The full entry (@crafted-design/editor) bundles the MUI adapter glue, so consuming it requires the MUI + Emotion peers installed. Use /core if you don't want MUI. (This split landed in the 0.7.0 CHANGELOG entry, before the first published release.)

Declared, tested version ranges

Each adapter declares the npm peers it needs and the semver range it is tested against, in its registration:

registerAdapter({
  id: 'mui',
  peerDependencies: {
    '@mui/material': '^9',
    '@emotion/react': '^11',
    '@emotion/styled': '^11',
  },
  // …components
})

This declaration is the single source of truth: it is surfaced in the compatibility matrix, and src/adapters/peer-deps.test.ts asserts the bundled adapters' declarations stay in sync with the package's peerDependencies ranges (no drift between the docs/matrix and the install contract).

Custom adapters you author should declare the same way — peerDependencies is an optional field on the adapter manifest (validated, informational).

How a breaking change surfaces

Adapters are glue between a stable canonical contract (props, slots, composed classes) and a moving third-party API. When an underlying library ships a breaking change, the failure shows up in one of three predictable ways:

  1. Missing peer / wrong major — the adapter import throws at module load (Cannot find module '@mui/material') or a component throws on first render. Fix: install / align the peer to the declared range.
  2. A single primitive's API changed (e.g. a renamed MUI prop) — only that one canonical's renderer misbehaves; the rest of the adapter is fine. The fix is a localized patch in that adapter component, released as a patch/ minor of this package, with the adapter's tested range bumped.
  3. A canonical has no renderer in the active adapter — the editor renders the missing-renderer placeholder in its place rather than crashing the canvas. This is also how the partial chakra-example adapter behaves for the canonicals it doesn't implement.

The placeholder fallback (3) means a partial or out-of-date adapter degrades gracefully — one broken or absent renderer never takes down the whole editor.

Support policy

  • This package follows semver. A breaking change to the canonical contract or to which peers the bundled adapters require is a major/0.x minor bump with a CHANGELOG migration note.
  • Each bundled adapter is tested against the major declared in its peerDependencies. Bumping that major is a deliberate, noted change.
  • Patch/minor releases of an adapter's underlying library are expected to work within the declared major; if one breaks, it's fixed as case (2) above.

Stretch (documented follow-up, not in 0.7.0)

A CI matrix that builds each adapter against multiple library majors/minors (e.g. @mui/material 8 × 9) would catch upstream breakage before release. It's deliberately deferred — heavy CI for marginal gain while there's one externally -peered bundled adapter. The peer-dep declaration + sync test + tested-range docs are the lighter-weight contract that ships now.

Reference

Migration guide

How to move a host integration across major versions of @crafted-design/editor. Minor/patch upgrades within a major are non-breaking by policy (see CHANGELOG.md "What counts as a breaking change") and need no migration.

No 0.x → 1.0 entry yet. The package has only ever lived behind the next dist-tag with no published stable release, so there is nothing to migrate from. This file is the template every future major-version entry follows; the first real entry lands when a 2.0.0 (or a breaking 1.x during preview) ships.


Template — <old> → <new>

Each major-version section answers three questions in order.

1. What changed

A bullet list of every breaking change, grouped by area (entry points / exports, the document envelope, canonical ids, peer dependencies, rendered output contracts). Link each to its CHANGELOG line.

2. How to update integration code

Concrete before/after for each break. For example:

// before (<old>)
import { Editor } from '@crafted-design/editor'

// after (<new>)
import { Editor } from '@crafted-design/editor/core'

Cover: renamed/removed exports (the frozen surface in src/sdk/surface.test.ts is the source of truth for what exists each major), changed call signatures, and any new required peer dependencies (npm install …).

3. Document migrations (if the envelope changed)

If EditorDocument (the saved-document envelope) changed shape, a migration ships in src/persistence/migrations.ts and runs automatically on load — old documents upgrade in place. State here:

  • the envelope version bump,
  • whether the upgrade is automatic (it should be) or needs host action,
  • any data that can't be migrated and how it's handled (dropped vs. preserved as-is).

Hosts that persist documents themselves should re-save after load so the upgraded envelope is written back.


Deprecation policy

Breaking removals don't happen without warning. To retire a public export we ship the replacement first, mark the old one @deprecated (with a JSDoc pointer to the replacement) for at least one minor, then remove it in the next major — recording it in the relevant section above. See SDK_GUIDE.md "Public API stability".

Reference

FAQ / Troubleshooting

Quick answers to the questions hosts hit first. For embedding mechanics see INTEGRATION_GUIDE.md (which also has a Troubleshooting section); for the authoring surface see SDK_GUIDE.md.

Which entry point should I import?

  • @crafted-design/editor/core — the editor with shadcn + plain-HTML. No MUI, no extra peers. Start here.
  • @crafted-design/editor — the same, plus the MUI adapter. Requires the @mui/material + @emotion/* peers installed.
  • @crafted-design/editor/sdk — authoring functions/types only, no editor UI. For building a canonical / adapter / panel package.

Full matrix: INTEGRATION_GUIDE.md → Subpath exports.

Do I have to install MUI?

No. Import /core and you need no UI-library peers — shadcn and plain-HTML are built in. You only install @mui/material + @emotion/react + @emotion/styled if you use the full entry or import /adapters/mui (including when you pin <Editor adapter="mui" /> — the MUI adapter won't work without its peers).

Can my end users switch the design system?

Only if you let them. The host picks the adapter: <Editor adapter="mui" /> pins it, hides the toolbar AdapterSwitcher, and makes loaded documents render through your adapter regardless of which adapter they were saved under (documents are canonical-id based). Use allowUserToSwitchAdapter to control the switcher independently; with no props, the legacy behavior (switcher visible) is kept. See INTEGRATION_GUIDE.md → Pinning the adapter.

Can an AI build designs with this?

Yes — the crafted-design bin's mcp subcommand starts an MCP server that exposes the component registry + document model as tools, so an AI client (Claude Code / Claude Desktop) can author and edit EditorDocuments programmatically. Connect it with:

claude mcp add crafted-design -- npx -y @crafted-design/editor mcp

The generated document loads straight into <Editor /> or <DocumentRenderer />. See MCP_GUIDE.md.

How do I start a new adapter / canonical / panel?

Scaffold one — the CLI emits a typed skeleton already wired to @crafted-design/editor/sdk, with a passing smoke test:

npx @crafted-design/editor scaffold adapter   my-design-system
npx @crafted-design/editor scaffold canonical pricing-table
npx @crafted-design/editor scaffold panel     seo-meta

Then fill in the generated files and add the side-effect import before you render <Editor />. The tutorials walk through what each skeleton contains.

Can I make the editor UI match my app's (dark) theme?

Yes — <Editor editorTheme="dark" />, or pass a partial token map to brand it: <Editor editorTheme={{ accent: '#7aa2f7', surface: '#16161e' }} />. This themes the editor chrome (toolbox, inspector, toolbar, panels) and is host policy — there's no end-user chrome switcher. It's separate from the document theme: registerTheme / the canvas theme switcher / colorMode style the content your users design, and stay independent (dark chrome around a light page is fine). See INTEGRATION_GUIDE.md → Theming the editor chrome.

Why is there no CommonJS / UMD build? Is it minified?

The package is ESM-only (avoids the dual-package hazard; modern bundlers and Node ≥ 20 consume ESM directly) and ships unminified with source maps — your bundler minifies the final app. See INTEGRATION_GUIDE.md → Bundle format.

Can I export my design to React/JSX source code?

No — and it's not planned. The editor is a runtime component + a JSON document model; a source-code generator isn't part of that model. Persist the document (it's portable JSON) and display it with the standalone renderer instead:

import { DocumentRenderer } from '@crafted-design/editor/renderer'
<DocumentRenderer document={savedEnvelope} />

No editor chrome, a fraction of the editor's bundle — see INTEGRATION_GUIDE.md → Rendering saved documents.

How do I save documents to my own backend?

Two ways:

  1. Controlled component (1.6.0) — own the document in your own state and use onChange (or the imperative ref.getDocument()): JSON.stringify(doc) is what you persist; pass it back via value to restore. No editor persistence involved (persistence={false}). See INTEGRATION_GUIDE → Embedding as a controlled component.
  2. StorageAdapter — keep the editor's built-in document lifecycle but point it at your backend: implement the StorageAdapter interface and register it with setStorageAdapter before rendering <Editor />. The default is IndexedDB → localStorage. See COOKBOOK.md → Server-backed storage.

How do I read the design document in code?

If the editor is controlled, you already have it — it's the value you hold in state, kept current by onChange. Otherwise pass a ref and call ref.current.getDocument() for an on-demand EditorDocument envelope (the same shape Export writes). To build or transform a document without an editor mounted, use the headless buildDocument / renderDocumentToHtml. See INTEGRATION_GUIDE → Embedding as a controlled component.

Does importing the SDK pull the whole editor into my bundle?

No. /sdk is side-effect-free and tree-shakable — a bundler keeps only the symbols you import. (Importing it registers nothing beyond the editor's three baseline font tokens.) Enforced by src/sdk/side-effect-free.test.ts.

The editor renders but my custom component shows a placeholder.

That's the missing-renderer placeholder: the active adapter has no renderer for that canonical id. Provide one in your adapter, or switch to an adapter that covers it. Per-adapter coverage is in ADAPTER_MATRIX.md.

Are the TypeScript types reliable / is the API stable?

.d.ts files ship for every entry. The public runtime surface is frozen and enforced (src/sdk/surface.test.ts) — exports can't be added/removed without a deliberate, CHANGELOG-noted change. The 1.0 SemVer promise is described in SDK_GUIDE.md → Public API stability.

Styling / responsive / state edits don't show up.

The editor relies on Tailwind utilities being present in the CSS. If you build your own Tailwind pipeline, run the safelist plugin (@crafted-design/editor/vite-plugin) so dynamically-composed classes are generated. See INTEGRATION_GUIDE.md.

Under the hood

Architecture

This document describes the architecture as it stands. For task-oriented recipes (adding a canonical, adding an adapter, conventions, gotchas), see DEVELOPER_GUIDE.md.


Overview

craftjs-design is a drag-and-drop website builder. Its central design idea is to separate three concerns that most builders mix up:

  1. What the user is composing — an abstract tree of "components" (Button, Input, Box, Card…).
  2. Which UI library renders those components — shadcn, MUI, Chakra, or a custom kit.
  3. How the tree is edited — selection, drag/drop, undo/redo (handled by Craft.js).

Most builders couple #1 and #2: the user picks a "Button" from a palette, but that Button is hard-wired to whichever library was chosen at project setup. Swapping libraries means rebuilding documents.

This architecture decouples them via a Canonical Component Registry (the abstract palette) sitting above an Adapter SDK (the per-library renderers). Documents reference canonical ids only. Swapping adapter ≠ migrating documents.


Four-Layer Model

┌─────────────────────────────────────────────────────────────────┐
│  Editor UI            (Toolbox, Inspector, Canvas chrome)       │
├─────────────────────────────────────────────────────────────────┤
│  Canonical Component Registry  (abstract Button, Input…)        │
├─────────────────────────────────────────────────────────────────┤
│  Adapter Layer        (shadcn / MUI / Chakra → canonical)       │
├─────────────────────────────────────────────────────────────────┤
│  Craft.js kernel + Document JSON                                │
└─────────────────────────────────────────────────────────────────┘

Each layer talks only to its immediate neighbor. The discipline is enforced by the type contracts in src/registry/types.ts and src/adapters/types.ts — those are the only legal vocabulary between layers.

Layer 1 — Editor UI (src/editor/)

The user-facing chrome. Has no opinion about what components exist, only about how to edit them: panels, toolbars, the canvas frame.

File Role
Editor.tsx Top-level shell. Builds the resolver, mounts Craft.js, wraps the canvas in <ThemeProvider>, lays out the 3-column UI. On mount, calls _markEditorMounted() so the registry can warn about post-mount canonical registrations.
Toolbox.tsx Left panel. Reads listComponents() from the registry; renders entries grouped by category with a search input, a "Favorites" section (toggle via star icon), and a "Recently used" section. Favorites + recents persist to localStorage['craftjs-design.toolbox'] — user-level state, separate from the document envelope. Attaches Craft connectors.create() per entry; mousedown on a button records use into the recents LRU.
Inspector.tsx Right panel. Reads the selected node from Craft state, shows type/id, exposes Delete (root-guarded), mounts the ResponsiveBar, mounts a SlotPicker when the canonical declares more than one style slot, and renders panels from the panel registry (getPanelsFor(def)). Tracks activeSlot ('root' by default). Resize handles are rendered as a canvas overlay (see canvas/ResizeOverlay.tsx), not inside the inspector.
canvas/ResizeOverlay.tsx Fixed-position overlay rendered over the selected node's bounding rect. Four corner handles. Mutates dom.style.width/height directly during drag (60fps), commits final px to style.inline.root.{width,height} via setProp on release. Tracks node position on selection change, scroll (capture phase), window resize, and ResizeObserver ticks.
documents/DocumentMenu.tsx Top-bar dropdown showing the active document's name + chevron. Inline rename, duplicate, delete actions for the active doc; "New blank document", nested <TemplatePicker />, and a "Switch to" list of other documents.
documents/TemplatePicker.tsx Nested popover listing registered starter templates (name + description). Clicking a template invokes the supplied onPick and closes.
documents/useDocumentSwitcher.ts Hook orchestrating runtime document switches. switchTo(id) / createBlank(name) / createFromTemplate(id, name). Each one snapshots the current canvas via query.serialize(), persists to the active doc, swaps activeId, loads the target's blob (or the Empty template seed), calls actions.deserialize, and applies theme + adapter.
ResolverUpdater.tsx Side-effect component rendered inside <Craft>. Subscribes to the registry version counter via useSyncExternalStore and calls actions.setOptions((opts) => { opts.resolver = getResolver() }) on every bump. Powers hot canonical reload — registerCanonical at runtime updates Craft's internal resolver without remounting.
ShareButton.tsx Toolbar Share button. Popover renders the encoded URL ready to copy. Over the 30 KB cap, switches to "Copy as JSON" with a paste-into-importer message.
inspector/ResponsiveBar.tsx Six breakpoint pills (base / sm / md / lg / xl / 2xl). Active pill = which class slice the panels read/write. Loud "writing to: …" status line warns when an edit will only apply at and above a breakpoint.
inspector/SlotPicker.tsx Pill bar above the panels for Pattern B canonicals (styleSlots.length > 1). Switches activeSlot ('root', 'header', 'body', 'footer', etc.). Resets to 'root' on selection change.
inspector/panel-registry.ts Pluggable panel registry. registerPanel / unregisterPanel / listPanels / getPanelsFor. Inspector reads from this — both built-ins and SDK-authored panels live here.
inspector/built-in-panels.ts Side-effect module that registers the 7 built-in panels (Layout / Size / Spacing / Typography / Appearance / Effects / Properties) via registerPanel at module load. Imported from App.tsx.
inspector/{Typography,Layout,Spacing,Size,Appearance,Effects}Panel.tsx The 6 class-editing panels. Each accepts { nodeId, slot }. Backed by the matching tw-classes slice parse/merge pair.
inspector/PropsPanel.tsx Auto-form derived from each canonical's Zod propsSchema. Top-level component dispatches one PropField per schema entry; recursive PropField handles ZodEnum / ZodString / ZodBoolean / ZodNumber / ZodArray / ZodObject. Unsupported kinds render a labeled badge.
inspector/fields/PropField.tsx Recursive Zod-kind dispatcher extracted from PropsPanel. Used at the top level by PropsPanel and recursively by ArrayField / ObjectField when descending into nested element schemas.
inspector/fields/ArrayField.tsx z.array(z.object/scalar) editor — stacked item cards with ↑ / ↓ / 🗑 controls and a "+ Add" button. Caps at one level: z.array(z.array(…)) shows "unsupported deep nesting".
inspector/fields/ObjectField.tsx z.object editor used by ArrayField when the element is an object. Renders the sub-schema's fields recursively via PropField.
inspector/fields/defaults.ts defaultValueFor(schema) — seeds new items when the "+ Add" button fires.
inspector/shared/useNodeClasses.ts Read/write helper. Signature: useNodeClasses(nodeId, slot = 'root'). Returns classString (active breakpoint, scoped to the slot), inlineStyle (active-breakpoint arbitrary CSS for the slot), writeClasses, writeInline. At non-base, reads/writes route through style.responsiveInline[bp][slot]. Funnels every inspector style I/O through one place.
inspector/shared/ColorPicker.tsx Popover with three sections: token swatch grid, react-colorful visual picker (sat/lightness + hue), hex text input. Tagged-union ColorPickerValue (token / hex / unset). Works at every breakpoint.
inspector/shared/NumericInput.tsx Hybrid text input accepting tokens or arbitrary CSS values (13px, 50%, 1.5rem). Step buttons walk the token scale; Popover dropdown for picking. Works at every breakpoint.
inspector/shared/BoxSidesEditor.tsx Linked-corners editor (padding / margin). Linked mode uses NumericInput; per-side mode uses ValueSelect (token-only).
inspector/shared/CollapsibleSection.tsx Native <details>/<summary> wrapper used by Inspector to make each panel collapsible.
inspector/shared/ValueSelect.tsx Generic typed Select (Radix-backed shadcn Select) for closed enums. Supports per-item renderOption for icons/swatches.
inspector/shared/ColorSelect.tsx Deprecated — superseded by ColorPicker. Token-only native <select> retained for transition; remove once nothing imports it.
inspector/shared/PanelRow.tsx Label-on-left layout helper for consistent row rhythm.
SaveLoadBar.tsx Top bar. Mounts <DocumentMenu /> (replaces the static title), undo/redo, adapter switcher, theme switcher, Share, Import, Export, Save, Load.
UndoRedo.tsx Undo/redo toolbar buttons wired to actions.history.undo/redo. Subscribes to query.history.canUndo/canRedo for disabled state. Installs a global Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z keyboard handler (skipped when target is an editable element).
ThemeSwitcher.tsx Dropdown that flips activeThemeId in the editor store.
AdapterSwitcher.tsx Dropdown that flips activeAdapterId in the editor store.
Hydrator.tsx Renders null. On mount: if the URL has #doc=…, decodes the shared document and creates a new "Shared document" entry; otherwise loads the active document from documentStore. Module-level hydrated flag prevents re-restore on any future remount. Load passes through migrateDocument (Card props, Tabs content).

Layer 2 — Canonical Component Registry (src/registry/)

The abstract palette. A CanonicalComponent is a contract, not a React component:

{
  id: 'box',                       // stable string id — used in serialization
  category: 'layout',
  displayName: 'Box',
  tags: ['container', 'div'],
  isCanvas: true,                  // can hold children?
  styleSlots: ['root'],            // named class buckets
  propsSchema: zod schema,         // typed props
  defaults: { props, style },      // initial values for new nodes
}

Registration is side-effect based: each component file imports registerComponent and calls it at module load.

A canonical may declare an explicit applicablePanels: readonly PanelId[] to opt into a specific subset of inspector panels. When omitted, getApplicablePanels(c) derives a sensible default from category + isCanvas.

48 canonicals ship today, grouped by category. (This list is the live registry — regenerate it from listComponents() / npm run docs:matrix rather than hand-editing; the matrix's left column is the authoritative set.)

Category Canonicals
layout (8) Box, Card, Container, Divider, Grid, Section, Spacer, Stack
content (2) Heading, Text
input (10) Button, Checkbox, Date Picker, Date Range, Input, Radio, Select, Switch, Textarea, Time Picker
display (9) Avatar, Badge, Code, Data List, Data List Item, Icon, Skeleton, Table, Table Cell
navigation (7) Breadcrumb, Link, Nav Item, Nav Menu, Pagination, Stepper, Tabs
feedback (8) Alert, Drawer, Modal, Popover, Progress, Spinner, Toast, Tooltip
media (4) Audio, Carousel, Image, Video

A canonical's render shape is one of:

  • Pattern A — leaf (no drop zone): isCanvas: false, no canvasSlots. Buttons, inputs, text, badges, etc.
  • Pattern A — single canvas (one 'root' drop zone): isCanvas: true, no canvasSlots. Children arrive through Craft's children prop. Box, Stack, Grid, Container, Section, Data List, Nav Menu/Item, and the overlay bodies (Modal, Drawer, Popover) + Table Cell.
  • Pattern B — multi-canvas (named sub-slots, each its own drop zone): canvasSlots is set (a static list or a (props) => string[] function). CanonicalNode generates one <Element canvas id={slot}> per slot and hands the adapter impl slotChildren[slot] to place each region. Five canonicals: Card (header/body/footer, static), Table (per-cell, dynamic from rows×cols), Tabs (per-tab), Stepper (per-step), Carousel (per-slide). The dynamic ones derive their slot ids from props via the function form — add a tab/slide in the inspector and a new drop zone appears immediately. Slot ids are stable per entry (tab.id / slide id), so renaming a tab's value no longer orphans its canvas.

styleSlots (which the inspector can style independently) is a separate axis: a Pattern-A canonical can still expose multiple styleSlots without multiple canvases. The Inspector's SlotPicker exposes the named slots as pills above the class-editing panels; the activeSlot mode routes every panel write into style.classes[slot] (or style.responsive[bp][slot]).

File Role
types.ts CanonicalComponent (with optional canvasSlots: readonly string[] | ((props) => readonly string[])), NodeStyle (with optional responsive + inline + responsiveInline), CanonicalCategory, CanonicalId, PanelId.
registry.ts registerComponent / registerCanonical (aliases), unregisterCanonical, getComponent, getComponentByDisplayName, listComponents, getApplicablePanels, getCanvasSlots(def, nodeProps?), getRegistryVersion, subscribeRegistry, _markEditorMounted. Post-mount registrations bump the version counter; Editor subscribers re-resolve. In-memory map.
components/index.ts Barrel of side-effect imports. Adding a new canonical = one line here.
components/{box,text,button,input}.ts Canonicals. Button explicitly omits the typography panel (shadcn's flex-centered primitive doesn't respect text utilities).
components/{heading,link,image,stack,divider,icon,badge,avatar,alert}.ts Pattern A breadth — content, navigation, media, display, and feedback canonicals.
components/{select,checkbox,radio,switch,textarea}.ts Form canonicals. Their adapter impls render with no-op onChange handlers (and readOnly for Textarea) so the editor preview is visually faithful but interactively inert.
components/{card,tabs}.ts Pattern B composites. Multiple styleSlots; impls consume composedClasses[slot] and composedInlineStyles[slot].

Layer 3 — Adapter Layer (src/adapters/)

A module that says "given a canonical id and its props/style, render this React component." Different adapters render the same canonical id with different libraries.

Adapter is the SDK contract — three required fields plus five optional capability hooks:

interface Adapter {
  id: string
  displayName: string
  components: Partial<Record<CanonicalId, ComponentType<AdapterRenderProps>>>

  Wrapper?: ComponentType<{ children: ReactNode }>   // global React provider
  themeTokens?: Record<string, string>               // CSS variable declarations
  classMap?: ClassMapFn                              // canonical Tailwind → render props
  mount?: () => void                                 // imperative side effects
  unmount?: () => void                               // imperative cleanup
}

Adapters are registered by side-effect import. registerAdapter validates the manifest via Zod (AdapterManifestSchema.ts) before adding it to the registry — broken manifests throw at boot.

File Role
types.ts Adapter, AdapterRenderProps, ClassMapFn, ClassMapResult.
AdapterContext.tsx Module-level registry + React context + useActiveAdapter() hook + <AdapterProvider>.
AdapterManifestSchema.ts Zod schema validating adapter shape at register-time.
shadcn/ Reference adapter. Wraps shadcn primitives from src/components/ui/.
mui/ Second adapter. Wraps Material UI components. Ships a Wrapper for MUI's ThemeProvider.

AdapterRenderProps is the only shape an adapter impl ever sees:

{
  canonicalId: string
  props: Record<string, unknown>            // user-set props
  style: NodeStyle                          // { classes, responsive?, inline?, responsiveInline? }
  children?: ReactNode                      // Craft-managed children if Pattern A canvas
  rootRef?: (el: HTMLElement | null) => void

  // Populated by CanonicalNode. Pattern A impls use the root-slot fields:
  className?: string                        // composed responsive class string for the root slot
  sx?: Record<string, unknown>              // MUI's sx prop (from adapter.classMap)
  inlineStyle?: CSSProperties               // composed inline CSS for the root slot (base only — responsive inline is class-promoted)

  // Pattern B impls read per-slot maps. Pattern A impls can ignore these.
  composedClasses?: Record<string, string>          // slot → composed responsive class string
  composedInlineStyles?: Record<string, CSSProperties>  // slot → composed inline CSS (base only)
  slotChildren?: Record<string, ReactNode>          // slot → <Element canvas/> wrapper for multi-canvas Pattern B
}

rootRef is how the editor wires Craft's connect / drag to the actual rendered DOM. Without it, nested drop-target hit-testing breaks (see § rootRef on the adapter contract).

Pattern A impls consume className / sx / inlineStyle and use the children prop. Pattern B impls consume composedClasses[slot] / composedInlineStyles[slot] per region AND consume slotChildren[slot] (the <Element canvas> wrapper) for each canvas slot. Either way, impls must never read style.classes.root directly — CanonicalNode composes base + responsive breakpoint slices into the final class string AND merges arbitrary inline values from style.inline[slot] before passing to the impl. The root entries of composedClasses / composedInlineStyles always mirror className / inlineStyle. See § Adapter impls consume rendered className, § Pattern B slot routing, and § Multi-canvas via canvasSlots.

Layer 4 — Craft.js bridge (src/craft/)

Craft.js manages the document tree, selection set, drag/drop, and history. The bridge plugs our two-layer abstraction (registry + adapter) into Craft's "resolver" model.

File Role
CanonicalNode.tsx Generic React component. Given canonicalId + nodeProps + style, looks up the canonical def from the registry, the impl from the active adapter, and iterates def.styleSlots: for each slot, calls composeResponsive(style, slot) + composeInlineStyle(style, slot) and stores the result in composedClasses[slot] / composedInlineStyles[slot]. When def.canvasSlots is set, calls getCanvasSlots(def, nodeProps) (passes the node's current props so the function form of canvasSlots can return a dynamic slot list — e.g., Tabs returns one per tab), then generates one <Element id={slot} is="div" canvas/> wrapper per slot and passes them via slotChildren. When style.responsiveInline has entries for a slot, calls composeResponsiveInline to generate a hash-keyed CSS class with @media rules, appends the class to composedClasses[slot], and renders an inline <style> block sibling to the impl. The root-slot results are mirrored to className / inlineStyle for Pattern A backwards compat. Attaches Craft's connect/drag via rootRef. Renders a labeled placeholder if the active adapter has no impl for the canonical.
resolver.tsx Builds and caches the Craft.js resolver — one user-component per canonical id. The cache is keyed by getRegistryVersion(); calls after a registerCanonical / unregisterCanonical post-mount return a freshly-built resolver. Identity stays stable when no registry mutation has happened.
resolver.tsx buildResolver() walks listComponents() and produces one Craft user-component per canonical id, each delegating to CanonicalNode. getResolver() is the cached singleton accessor.

Supporting Modules

Cross-cutting infrastructure that doesn't fit the four-layer model but supports it.

Error handling (src/editor/errors/)

Two parallel surfaces, by failure-mode:

  • ErrorBoundary (ErrorBoundary.tsx) catches errors thrown during a React render. Four boundary layers — top-shell (App.tsx), canvas, toolbox, and per-panel (Inspector.tsx) — each with its own typed fallback in fallbacks.tsx. Inner boundaries keep their layer alive when sibling layers fail; the top-shell catches truly unrecoverable cases.
  • useGlobalErrorHandler + AsyncErrorBanner (asyncError.ts, useGlobalErrorHandler.ts, AsyncErrorBanner.tsx) catches what the boundaries miss: errors thrown inside effects, event handlers, or unhandled promise rejections (window.error / window.unhandledrejection). One toast appears at bottom-right with the message + a Dismiss button; the user keeps working. Critical async failures (Hydrator deserialize) are designed to bubble through the boundary instead, so this handles the long tail of non-fatal issues.
  • useConcurrentEditWatcher + ConcurrentEditBanner (src/editor/persistence/) detect when another browser tab modifies the active document or the doc-index. The hook attaches a window.storage listener (which only fires for writes from OTHER tabs by spec). Three outcomes, decided by the pure decideStorageEvent(event, activeId) helper:
    1. Doc-index changed → documentStore.reloadIndexFromStorage() re-reads the index in place so the document menu reflects the external rename / delete / create.
    2. Active doc's blob changed and parses cleanly → the remote envelope lands in editorStore.concurrentEditConflict, and ConcurrentEditBanner shows two actions: Reload (apply remote via applyEnvelopeSafely) or Overwrite (save local snapshot back, blowing away the remote write).
    3. Everything else (unparseable / unrelated / inactive doc) → ignored. Inactive docs' freshest version is naturally picked up by useDocumentSwitcher next time the user switches to them.
  • StorageQuotaBanner + StorageQuotaErrorModal (src/editor/persistence/, backed by editorStore's storageQuotaPercent / storageQuotaDismissed / storageSaveFailed) warn the user about localStorage pressure. documentRegistry.getStorageUsage() sums every craftjs-design:* key and reports a percentage against a conservative 5 MB ceiling. documentStore.reportWrite calls this after every save / index write; usage ≥ 80% surfaces the banner under the header, and a QuotaExceededError from localStorage.setItem (now caught and reported via the WriteResult returned by writeDocument / writeDocumentIndex) raises a blocking modal. The banner's dismiss state lives in sessionStorage so it survives a reload but resets between tabs.
  • applyEnvelopeSafely + MalformedDocumentBanner (craftJsonIntegrity.ts, applyEnvelopeSafely.ts, MalformedDocumentBanner.tsx) guards document-load failures. Both Hydrator (boot) and useDocumentSwitcher (runtime switch) route every actions.deserialize call through applyEnvelopeSafely. Before deserialize, the integrity check validates the craftJson: parses as an object, has ROOT, every parent / nodes / linkedNodes ref resolves, every type is either 'div' or a registered canonical. Either path of failure (pre-check OR deserialize throw) sets editorStore.malformedDocument; the editor shell swaps the Frame for MalformedDocumentBanner. The banner offers Show raw JSON, Export raw, and Reset to empty — the last archives the broken envelope under craftjs-design:doc:<id>:broken:<timestamp> before writing the Empty template into the doc's slot.

Normalisation lives in asyncError.ts — pure helpers (normalizeErrorEvent, normalizeRejectionEvent) that turn the browser event into a stable AsyncErrorInfo shape. Tested in isolation.

Theme Layer (src/themes/)

Themes are CSS-variable token packs scoped via [data-theme] selectors. Registered the same way canonicals and adapters are.

File Role
types.ts Theme interface — { id, displayName, dataThemeValue }.
registry.ts registerTheme / getTheme / listThemes.
index.ts Side-effect barrel.
default.ts, rose.ts Theme registrations. Empty dataThemeValue ⇒ no attribute (use :root defaults).
ThemeProvider.tsx Reads activeThemeId from editorStore, renders <div data-theme={…} style="display:contents">{children}</div>.

Token values live in src/index.css:

  • :root { … } — default token values.
  • [data-theme="<id>"] { … } — overrides for non-default themes. Only override tokens that differ from default; the cascade handles the rest.
  • .mui-bridge { … } — bridges MUI's generated --mui-palette-* variables to the same tokens (see § MUI palette bridge).

These are the document/canvas tokens (--primary, --background, …) — they style the content end users design.

Editor-chrome theme (--ed-*) is a separate, independent system. The chrome — toolbox, inspector, toolbar, panels, banners — references only --ed-* tokens (bg-ed-surface, text-ed-text-muted, …), never the canvas tokens above. The host sets it via the Editor editorTheme prop ('light' | 'dark' | a partial EditorChromeTokens map), resolved by src/editor/chromeTheme.ts and applied as data-editor-theme + inline --ed-* variables on <html> (so body-portaled chrome is themed too). :root holds the light preset, [data-editor-theme='dark'] the dark preset. The canvas is carved out of the chrome theme via the .cd-canvas hook (set by ThemeProvider) so a dark chrome never restyles a light document. scripts/check-chrome-tokens.ts (CI check:chrome) forbids palette literals and canvas-token borrows under src/editor/ so the two layers stay decoupled.

Editor State (src/state/)

Editor-side state that lives outside the Craft tree.

File Role
editorStore.ts Zustand store — { activeThemeId, activeAdapterId, activeBreakpoint } + setters. activeBreakpoint is UI-only (not persisted; resets to 'base' on reload).

Read patterns:

  • Components that render based on the value → useEditorStore((s) => s.activeThemeId) (subscribes; re-renders on change).
  • Click handlers / effects that just need the latest value → useEditorStore.getState().activeThemeId (no subscription, no re-render).

Style Layer (src/style/)

Single funnel for all node-style editing. Anything that writes to style.classes.root or style.inline[slot] must go through this layer. Direct string concat / inline-style mutation in components risks dropping classes the parser doesn't recognize or losing the token/arbitrary mutual-exclusion invariant.

File Role
tw-classes.ts Typed unions + parser/serializer/merge for six slices: typography, layout, spacing, size, appearance (fill + border + radius), effects.
responsive.ts composeResponsive(style, slot) — merges style.classes[slot] (base) with each style.responsive[bp][slot], prefixing breakpoint slices with bp:. Called by CanonicalNode before invoking adapter.classMap.
inline.ts composeInlineStyle(style, slot) — returns style.inline[slot] as React.CSSProperties for inline injection. Used by CanonicalNode to merge arbitrary user picks onto the impl's inlineStyle prop when the slot has no responsive inline entries. When responsive inline IS present, the base inline is promoted into the generated CSS class instead — see responsive-inline.ts.
responsive-inline.ts composeResponsiveInline(style, slot) — returns { className, css, consumesBaseInline }. When the slot has any style.responsiveInline[bp][slot] entry, generates a hash-keyed CSS class (e.g., ri-3jvn7) covering BOTH base and responsive declarations via plain rules + @media blocks. CanonicalNode appends the class to the slot's composed string and renders the CSS inside an inline <style> sibling. Empty result when no responsive entry — caller keeps the inline-style fast path.
safelist.generated.css Generated output of scripts/gen-safelist.ts. Listed in .gitignore; regenerated on every npm run dev / npm run build.

Per-slice API contract (parametrized over slice type):

  • parse<Slice>(classString){ slice, unknownClasses }. Recognized utilities populate the slice; unrecognized strings pass through as unknownClasses.
  • merge<Slice>(original, partialSlice) → new class string. Patch-friendly: caller passes only fields they want to change. Unknown classes (including classes from other slices) always pass through.

Slices are independent — parseTypography doesn't recognize bg-card, parseSpacing doesn't recognize flex. Each merge function passes classes from other slices through as unknownClasses. The inspector panels each operate on one slice; round-trips through multiple panels preserve every class.

Arbitrary CSS values (hex colors, custom 13px spacing) bypass the Tailwind class system entirely. At the base breakpoint, the inspector writes them to style.inline[slot][cssProperty]; CanonicalNode emits them via the React style prop (fast path). At non-base breakpoints, writes go to style.responsiveInline[bp][slot][cssProperty]; CanonicalNode generates a per-node CSS class with @media rules covering base + responsive and renders the class via an inline <style> block. Either way, token picks and arbitrary picks are mutually exclusive at the panel level: picking a token clears the matching inline property, and vice versa.

The safelist is the bridge between this single-funnel parser and Tailwind v4's JIT scanner. The inspector emits class strings via template literals (text-${size}, bg-${color}, …) that Tailwind can't see in source. scripts/gen-safelist.ts reads the slice arrays from tw-classes.ts (single source of truth) and emits @source inline() directives for every utility × every breakpoint prefix (~250 directives covering thousands of utility-prefix pairs). The result lands in safelist.generated.css, imported by index.css. Wired via predev / prebuild npm scripts.

Public SDK boundary (src/sdk/)

src/sdk/ is the public boundary for external authors. Anything in src/sdk/* is part of the contract; everything else is internal and can change without notice. The SDK is consumed via the @design/sdk path alias (wired in tsconfig.json, tsconfig.app.json, vite.config.ts).

File Role
index.ts Main entry — re-exports the full surface from the topical modules below.
adapter.ts Adapter, AdapterRenderProps, ClassMapFn, ClassMapResult types + registerAdapter, listAdapters, useActiveAdapter.
canonical.ts CanonicalComponent, CanonicalCategory, CanonicalId, PanelId types + registerCanonical / registerComponent, unregisterCanonical, getComponent, getComponentByDisplayName, listComponents, getCanvasSlots, getApplicablePanels.
style.ts NodeStyle.
hooks.ts useNodeClasses for panel authors.
panel.ts PanelDefinition type + registerPanel, unregisterPanel, listPanels, getPanelsFor.
boundary.test.ts Asserts every expected name is exported AND that internal symbols (CanonicalNode, resolver helpers) are NOT leaked.

Internal adapters (shadcn, MUI) dogfood the boundary — their index.ts files import registerAdapter from @design/sdk. The Chakra example at examples/adapter-chakra/ imports ONLY via the SDK; that subtree is included in tsconfig.app.json so it type-checks but isn't bundled into src/.

User-facing docs:

  • docs/SDK_GUIDE.md — full reference.
  • docs/TUTORIAL_ADAPTER.md — Chakra walkthrough.
  • docs/TUTORIAL_CANONICAL.md — adding a Stepper canonical.
  • docs/TUTORIAL_PANEL.md — adding a custom inspector panel.

Persistence (src/persistence/)

This layer is a multi-document index with import/export/share affordances.

File Role
schema.ts Zod-validated EditorDocument envelope: { version, adapterId, themeId?, craftJson }. Opaque craftJson — Craft owns its serialization.
migrations.ts migrateDocument(doc) walks the Craft JSON and applies idempotent migration steps. Steps include a Card-prop strip and a Tabs content-field strip. New canonical shape changes add a step here.
documentRegistry.ts Pure storage layer over localStorage. CRUD + the v1 → v2 migration. No module state — tests stub localStorage per-case.
documentStore.ts Zustand wrapper exposing createDocument / renameDocument / duplicateDocument / deleteDocument / saveActiveDocument / loadActiveDocument / setActiveId. UI subscribers re-render on changes.
exportDocument.ts exportDocument(doc): Blob (pure) + downloadDocument(doc, name): void (synthesizes a <a download> click).
importDocument.ts parseDocumentJson(raw): EditorDocument (pure) + importDocumentFromFile(file): Promise<EditorDocument>. Typed ImportError for the two failure modes (invalid JSON, schema mismatch).
share.ts URL-fragment encoding via lz-string. encodeDocument(doc) / decodeDocument(encoded) / shareUrlFor(doc, baseUrl) / readSharedFragment(hash) / clearSharedFragment(). SHARE_URL_MAX_PAYLOAD = 30_000 is the conservative threshold for "fits in a browser URL."
templates/registry.ts Template registry: registerTemplate / getTemplate / listTemplates.
templates/builder.ts buildTemplate({ root, adapterId?, themeId? }) walks a NodeSpec tree and emits a Craft-shaped serialized envelope. Reads canonical defaults from the registry; deterministic node ids (node-0, node-1, …) for reproducible JSON.
templates/{empty,landing-page,form}.ts Three starter templates. Pattern-A-only.
templates/index.ts Side-effect barrel importing all template modules so they register at boot.

Storage shape (v2):

craftjs-design:doc-index:v2  → { documents: [{ id, name, created, updated }], activeId }
craftjs-design:doc:<id>:v2   → EditorDocument

Plus the user-level Toolbox preferences (favorites / recents) at craftjs-design.toolbox — separate namespace.

shadcn-managed code (src/lib/, src/components/ui/)

Files that the shadcn CLI creates and that subsequent npx shadcn add <name> commands extend. Not hand-authored.

Path Role
src/lib/utils.ts The tailwind-merge-backed cn. Every adapter impl imports from here.
src/components/ui/ shadcn primitives. Adapter impls compose these inside AdapterRenderProps-shaped wrappers.

Directory Map

craftjs-design/
  components.json
  scripts/
    gen-safelist.ts             # reads tw-classes.ts slice arrays → emits safelist.generated.css
  src/
    main.tsx                    # ReactDOM root (dev — Vite app)
    App.tsx                     # Dev boot: side-effect imports + top-shell ErrorBoundary
    main-app.tsx                # dist entry — same side-effects + re-exports for integration consumers
    index.css                   # Tailwind v4 entry + @import safelist.generated.css + @theme inline bridge + token blocks + .mui-bridge
    lib/
      utils.ts                  # shadcn's tailwind-merge-backed cn
    components/
      ui/                       # shadcn primitives (button, input, select, popover, tooltip), managed by `npx shadcn add`
    registry/
      types.ts
      registry.ts
      components/
        index.ts                # barrel of side-effect registrations
        *.ts                    # 48 canonical defs
                                #   (layout/content/input/display/navigation/
                                #    feedback/media). dynamic-slots.ts holds the
                                #   pure Tabs/Carousel slot-key helpers.
    adapters/
      types.ts
      AdapterContext.tsx
      AdapterManifestSchema.ts
      shadcn/                   # default adapter — bundled, no external peer
        index.ts                # registerAdapter for all 48 canonicals
        components/             # one .tsx per canonical
      mui/                      # opt-in — optional @mui/material + emotion peers
        index.ts                # registerAdapter for all 48 canonicals
        theme.ts
        Wrapper.tsx
        components/             # one .tsx per canonical (parity with shadcn)
      html/                     # dependency-free, semantic HTML
        index.ts                # registerAdapter for all 48 canonicals
        components.tsx          # all 48 impls in one file, no UI library
    themes/
      types.ts
      registry.ts
      index.ts                  # side-effect barrel
      default.ts, rose.ts
      ThemeProvider.tsx
    state/
      editorStore.ts            # { activeThemeId, activeAdapterId, activeBreakpoint } + setters
    style/
      tw-classes.ts             # six slices: typography, layout, spacing, size, appearance, effects
      tw-classes.test.ts        # vitest — all slices + cross-slice isolation
      responsive.ts             # composeResponsive(style, slot) → Tailwind-prefixed className
      inline.ts                 # composeInlineStyle(style, slot) → React.CSSProperties from style.inline
      responsive-inline.ts      # composeResponsiveInline(style, slot) → CSS class + @media rules
      responsive-inline.test.ts
      safelist-extract.ts       # pure extractArbitraryClasses(tree) helper
      safelist-extract.test.ts
      safelist.generated.css    # gitignored — emitted by scripts/gen-safelist.ts
    craft/
      CanonicalNode.tsx         # invokes composeResponsive + adapter.classMap; placeholder for missing impls
      resolver.tsx
    editor/
      Editor.tsx
      Toolbox.tsx
      Inspector.tsx
      SaveLoadBar.tsx
      ThemeSwitcher.tsx
      AdapterSwitcher.tsx
      Hydrator.tsx
      UndoRedo.tsx                # toolbar buttons + Cmd+Z global handler
      ShareButton.tsx             # toolbar Share popover (URL or copy-as-JSON)
      ResolverUpdater.tsx         # hot canonical reload bridge
      canvas/
        ResizeOverlay.tsx         # fixed-position resize handles overlay
        snap.ts                   # snapToSizeToken(px) helper
        snap.test.ts
      errors/                     # 4-layer error boundaries
        ErrorBoundary.tsx         # generic class component (componentDidCatch)
        ErrorBoundary.test.tsx
        fallbacks.tsx             # TopShell / Canvas / Panel / Toolbox typed fallbacks
      documents/
        DocumentMenu.tsx          # top-bar dropdown (replaces title)
        TemplatePicker.tsx        # nested popover for starter templates
        useDocumentSwitcher.ts    # switchTo/createBlank/createFromTemplate
      inspector/
        ResponsiveBar.tsx
        SlotPicker.tsx              # Pattern B canonicals only (>1 slot)
        panel-registry.ts           # registerPanel / unregisterPanel / getPanelsFor
        built-in-panels.ts          # side-effect — registers the 7 built-ins
        TypographyPanel.tsx, LayoutPanel.tsx, SpacingPanel.tsx
        SizePanel.tsx, AppearancePanel.tsx, EffectsPanel.tsx
        PropsPanel.tsx              # top-level Zod schema → form
        fields/
          PropField.tsx             # recursive Zod-kind dispatcher
          ArrayField.tsx            # z.array(...) editor — DnD reorder + add/remove
          ObjectField.tsx           # z.object recursion for nested element schemas
          defaults.ts               # defaultValueFor(schema) for seeded "Add" items
          defaults.test.ts
          arrayOps.ts               # pure helpers (reorder/swap/removeAt/setAt)
          arrayOps.test.ts
        shared/
          color-conversions.ts    # hex / rgb / hsl pure helpers
          color-conversions.test.ts
          RgbSliders.tsx, HslSliders.tsx
          EyedropperButton.tsx    # feature-gated EyeDropper API
          gradient.ts             # Gradient types + parse / serialize
          gradient.test.ts
          GradientEditor.tsx      # popover-rendered gradient editor
        shared/
          useNodeClasses.ts       # reads/writes classes + inline; subscribes to activeBreakpoint
          ColorPicker.tsx         # tokens + react-colorful visual picker + hex input
          NumericInput.tsx        # tokens + arbitrary CSS values (px/%/em/…) + step buttons
          BoxSidesEditor.tsx
          CollapsibleSection.tsx
          ValueSelect.tsx         # shadcn Select wrapper with optional renderOption
          ColorSelect.tsx         # deprecated; kept for transition
          PanelRow.tsx
    persistence/
      schema.ts                 # Zod envelope around Craft's serialized JSON
      migrations.ts             # walk-the-tree migrations applied on load
      migrations.test.ts
      documentRegistry.ts       # pure localStorage CRUD + v1→v2 migration
      documentRegistry.test.ts
      documentStore.ts          # Zustand wrapper exposing the multi-doc API
      exportDocument.ts         # Blob/download helpers
      exportDocument.test.ts
      importDocument.ts         # file/JSON parse + ImportError
      importDocument.test.ts
      share.ts                  # lz-string URL-fragment encoding
      share.test.ts
      storage.ts                # empty marker — legacy single-doc API removed
      templates/
        registry.ts             # registerTemplate / listTemplates / getTemplate
        registry.test.ts
        builder.ts              # buildTemplate(NodeSpec) → EditorDocument
        builder.test.ts
        index.ts                # side-effect barrel
        empty.ts, landing-page.ts, form.ts
    sdk/                          # Public boundary — see § SDK boundary
      index.ts                  # re-export entry
      adapter.ts, canonical.ts, style.ts, hooks.ts, panel.ts
      boundary.test.ts          # asserts expected exports + no internal leakage
  examples/                     # SDK consumer examples — sibling to src/
    adapter-chakra/             # Chakra adapter walkthrough
      index.ts                  # registerAdapter via @design/sdk
      lib.tsx                   # mock primitives (swap for @chakra-ui/react)
      components/               # Box, Heading, Button, Stack, Card impls
      README.md
  docs/
    ARCHITECTURE.md             # this file
    DEVELOPER_GUIDE.md
    SDK_GUIDE.md                # public surface reference
    TUTORIAL_ADAPTER.md         # walkthrough — building an adapter
    TUTORIAL_CANONICAL.md       # walkthrough — adding a canonical
    TUTORIAL_PANEL.md           # walkthrough — adding an inspector panel
    plans/
      *.md                      # internal implementation plans

Data Flow Walkthroughs

Boot

main.tsx
  └── App.tsx
        ├── import './registry/components'   ← canonicals register themselves
        ├── import './adapters/shadcn'       ← shadcn adapter registers itself
        ├── import './adapters/mui'          ← MUI adapter registers itself
        ├── import './themes'                ← themes register themselves
        └── <Editor />
              └── <AdapterProvider>           ← composes all adapters' Wrappers
                    └── [composed Wrappers]
                          └── <Craft resolver={getResolver()}>
                                ├── <Hydrator/>     ← restores localStorage on mount
                                └── <ThemeProvider> ← reads activeThemeId, sets data-theme
                                      └── <Frame>
                                            └── <Element is={Bound['Box']} canvas defaults… />

Side-effect imports MUST run before <Editor /> renders — otherwise the registries are empty when getResolver() walks them. App.tsx is the only place that boot-orders these.

Before npm run dev / npm run build even reaches Vite, the predev / prebuild hook in package.json runs scripts/gen-safelist.ts, which reads slice arrays from src/style/tw-classes.ts and writes src/style/safelist.generated.css. The CSS file is gitignored; it's a build artifact that always reflects the current parser's coverage.

Drag-create (dropping a new component from the Toolbox)

  1. Toolbox.tsx builds a <button ref={el => connectors.create(el, <Element is={Bound} canvas={def.isCanvas} nodeProps={…} style={…} />)}> for each canonical.
  2. User mouse-down on the button → Craft starts a drag.
  3. User drops on the canvas → Craft creates a new node from the <Element>'s shape, assigns it an id, inserts it as a child of the drop target.
  4. Craft renders that node by looking up its displayName in the resolver. The match is the Bound thunk built by buildResolver().
  5. Bound calls <CanonicalNode canonicalId={…} {…} />.
  6. CanonicalNode reads:
    • The canonical def via getComponent(canonicalId) — what slots, what schema, isCanvas flag.
    • The active adapter via useActiveAdapter() — the React component to delegate to.
  7. CanonicalNode calls useNode() to get Craft's connect / drag connectors, packages them into a rootRef callback.
  8. CanonicalNode invokes adapter.classMap(style.classes.root, canonicalId) if defined; falls back to { className: style.classes.root }. The result feeds className / sx / inlineStyle props on the impl.
  9. The adapter impl renders. It attaches rootRef to its outermost DOM element. Craft now sees that element as the node's DOM and routes future events (clicks → selection; mouse-over → hover; drops → child insertion) through it.

If the active adapter has no impl for the canonical, CanonicalNode renders a labeled placeholder badge instead of throwing — the user can swap adapters or remove the node without crashing.

Selection

  1. User clicks a rendered node's DOM element.
  2. The element has Craft's data attributes (from connect via rootRef). Craft's global event listener traverses up from the click target, finds those attrs, identifies the node.
  3. actions.selectNode(id) runs. state.events.selected updates.
  4. Inspector.tsx's useEditor((state, query) => …) selector re-fires. It reads the first id from state.events.selected, calls query.node(id).get() for the displayName, and query.node(id).isRoot() for the delete-guard.
  5. Inspector re-renders and mounts inspector sub-panels for the selected node.

Save

  1. User clicks Save in SaveLoadBar.
  2. useEditorStore.getState() reads activeThemeId + activeAdapterId (imperative — Save isn't subscribed).
  3. query.serialize() returns Craft's tree as an opaque JSON string.
  4. The envelope { version: 1, adapterId, themeId, craftJson } is built.
  5. documentSchema.parse(...) validates the envelope.
  6. localStorage.setItem('craftjs-design:doc:v1', JSON.stringify(...)).

Load

Either via Hydrator (auto, once on mount) or useDocumentSwitcher (runtime). Both paths route through applyEnvelopeSafely:

  1. Read the envelope (documentRegistry.readDocument(id) for the registry path, decodeDocument(fragment) for the shared-URL path).
  2. validateCraftJson(envelope.craftJson) — pre-check parses JSON, requires ROOT, verifies every parent / nodes / linkedNodes ref resolves, every type.resolvedName is 'div' or a registered canonical.
  3. actions.deserialize(...) — Craft replaces the tree.
  4. Apply theme + adapter from the envelope.

Race conditions. applyEnvelopeSafely returns a Promise<ApplyEnvelopeResult>. Work runs through a module-level promise queue + generation counter:

  • Each call increments a global generation number; the work is enqueued behind any in-flight apply.
  • When the work fires (next microtask), it compares its captured generation to the current global one. If newer calls have come in, the work returns { ok: true, superseded: true } without touching Craft.
  • Rapid-fire applies (e.g., user clicks doc B while doc A is mid-load) collapse to "latest wins" — only the final envelope reaches deserialize, so the user never sees intermediate canvases.
  • A failing apply doesn't block the queue — the catch swallows rejections at the queue level so a broken envelope can't strand subsequent loads.

Two boot paths. Hydrator's module-level hydrated flag is intentionally narrow: it gates the initial restore from localStorage on first mount, nothing else. Document switching goes through useDocumentSwitcher exclusively (snapshot-current → setActiveId → load target → apply). The two paths share the same applyEnvelopeSafely queue so they can't race against each other either.

Failure handling. If the integrity check fails OR actions.deserialize throws, applyEnvelopeSafely sets editorStore.malformedDocumentEditor.tsx swaps the canvas Frame for <MalformedDocumentBanner> and the user can inspect, export, or reset. Resetting archives the broken envelope under craftjs-design:doc:<id>:broken:<timestamp> before writing the Empty template.

Theme swap

  1. User picks a different theme in ThemeSwitcher.
  2. onChangeuseEditorStore.getState().setActiveTheme(<id>).
  3. ThemeProvider (subscribed via useEditorStore((s) => s.activeThemeId)) re-renders.
  4. ThemeProvider looks up the theme, renders <div data-theme={…} style="display:contents">.
  5. The browser's CSS selector matches the wrapper. The cascading custom properties (--primary, etc.) inherit through the descendant tree.
  6. Every utility like text-primary / bg-primary resolves to var(--primary) and repaints with the new theme's value. No Craft tree state changed.

Adapter swap

  1. User picks a different adapter in AdapterSwitcher.
  2. onChangeuseEditorStore.getState().setActiveAdapter(<id>).
  3. AdapterProvider (subscribed via useEditorStore((s) => s.activeAdapterId)) re-renders. getAdapter(<id>) returns the chosen adapter.
  4. The useEffect([adapter]) cleanup fires the previous adapter's unmount (if any), then the new effect fires the new adapter's mount (if any).
  5. The provider's output is structurally unchanged — every adapter's Wrapper is always rendered (see § Wrappers compose, not switch). Only the React context value changes; no remount happens.
  6. Every CanonicalNode inside re-renders with the new active adapter. Each looks up its impl in adapter.components. Nodes with an impl render; nodes without get the missing-impl placeholder.
  7. The new impls' DOM replaces the previous impls' DOM in their slots. Craft tree state survives because nothing in the React tree shape changed.

Inspector edit (token value)

  1. User selects a node. Inspector mounts the applicable panels (filtered by getApplicablePanels(canonicalDef)).
  2. Each panel calls useNodeClasses(nodeId, slot) which returns { classString, inlineStyle, writeClasses, writeInline, activeBreakpoint }. The hook reads either style.classes[slot] (base) or style.responsive[activeBreakpoint][slot].
  3. The panel calls its slice's parse* to decompose classString into a typed slice, binds controls to slice fields.
  4. User changes a value (e.g., picks primary in ColorPicker, picks '4' in NumericInput). The panel calls writeClasses(mergeSlice(classString, patch)) AND clears any matching inline property via writeInline(cssProp, undefined) — tokens and arbitrary values stay mutually exclusive.
  5. writeClasses calls actions.setProp(nodeId, (props) => …). The Immer mutator writes to props.style.classes[slot] (base) or props.style.responsive[bp][slot] (non-base).
  6. Craft re-renders. CanonicalNode reads the new style, calls composeResponsive(style, 'root') + composeInlineStyle(style, 'root'), passes the result through adapter.classMap (or default), feeds className + inlineStyle to the adapter impl.
  7. Adapter impl renders <elt className={cn(className)} style={inlineStyle}>.

Inspector edit (arbitrary value)

  1. User opens ColorPicker, drags the visual picker or types a hex. NumericInput accepts 13px and commits on Enter.
  2. The panel detects the value isn't in the token enum (or comes from the picker's onChange) and calls writeInline(cssProperty, value) AND writeClasses(mergeSlice(classString, { <field>: undefined })) — clearing any matching token class.
  3. writeInline writes to props.style.inline[slot][cssProperty]. Always base-level — responsive arbitrary values aren't stored.
  4. Craft re-renders. composeInlineStyle returns the new inline map. The impl receives it as inlineStyle and applies it via the rendered element's style attribute.
  5. Theme swaps don't affect this value (inline style is fixed). That's correct semantically: the user picked a specific color, not a token reference.

Responsive edit (at a non-base breakpoint)

  1. User clicks md in ResponsiveBar. setActiveBreakpoint('md').
  2. Every component using useEditorStore((s) => s.activeBreakpoint) re-renders — ResponsiveBar itself and every inspector panel via useNodeClasses.
  3. Each panel's useNodeClasses re-reads from style.responsive.md[slot] (empty for a fresh md edit → classString = ''). The inlineStyle read still returns the base inline (responsive arbitrary isn't stored).
  4. ColorPicker's hex section and NumericInput's arbitrary mode both disable themselves and show "Arbitrary values supported at base breakpoint only." Token pickers remain interactive.
  5. User edits a control with a token. The panel writes the new class string to style.responsive.md[slot]. style.classes[slot] is untouched — the base value survives.
  6. composeResponsive now emits <base classes> md:<class1> md:<class2> …. The browser applies the md-prefixed utilities only at viewports ≥ 768px.

Responsive edit (at a non-base breakpoint)

  1. User clicks md in ResponsiveBar. setActiveBreakpoint('md').
  2. Every component using useEditorStore((s) => s.activeBreakpoint) re-renders — ResponsiveBar itself and every inspector panel via useNodeClasses.
  3. Each panel's useNodeClasses re-reads from style.responsive.md[slot] (empty for a fresh md edit → classString = '').
  4. User edits a control. The panel writes the new class string to style.responsive.md[slot]. style.classes[slot] is untouched — the base value survives.
  5. composeResponsive now emits <base classes> md:<class1> md:<class2> …. The browser applies the md-prefixed utilities only at viewports ≥ 768px.

Key Design Decisions

These are the architectural choices and their reasoning.

One Craft component per canonical id, not one generic

buildResolver() produces a distinct Bound thunk for every canonical id, each carrying its own Bound.craft.displayName = def.displayName.

The alternative would be a single generic <CanonicalNode> with canonicalId stuffed into props. That fails because Craft's resolver maps the string displayName → component, and persisted JSON references nodes by that string. One-per-canonical means:

  • Serialized JSON reads "Box", "Button", etc. — human-readable, stable across adapter swaps.
  • Renaming a canonical id is an explicit migration, not a silent corruption.
  • Craft's devtools show real names.

The cost is trivial: each Bound is a 3-line thunk.

Adapter context as the swap point

The active adapter is exposed via React context (AdapterProvider + useActiveAdapter), not imported as a singleton. CanonicalNode looks up the impl at render time, not at module-load time.

This is what makes adapter-swapping non-destructive: change one activeAdapterId in the store, every CanonicalNode re-resolves on its next render, and Craft's tree state is untouched.

rootRef on the adapter contract

Earlier drafts wrapped the adapter's output in <div ref={connectDrag} style={{display:'contents'}}> inside CanonicalNode. That broke nested drop-targeting because the wrapper had no bounding box for the browser's hit-test — elementsFromPoint returned the inner adapter's <div> (no Craft data attrs), Craft's lookup climbed to the root wrapper instead of the inner one, and all drops routed to the root.

The fix: a rootRef field on AdapterRenderProps. The editor passes a ref callback into the adapter; the adapter attaches it to its outermost real DOM element. Craft sees the visible element as the node's DOM. Nested targeting works because each nested instance has its own connected element with a real bounding box.

The cost: a one-line change to every adapter impl (<div ref={rootRef} …>). Worth it.

Container pattern: component IS the canvas

Craft.js offers two patterns for containers:

A. The component itself is the canvas. <Element is={Component} canvas> at creation marks the node as a canvas; the component just renders {children}. One drop zone per node.

B. The component has named sub-canvas slots inside it: <div className="card"><Element id="header" canvas>…</Element><Element id="body" canvas>…</Element></div>. Multiple drop zones per node.

This codebase uses Pattern A for single-slot containers. An earlier draft tried to combine both ("the component is a canvas AND has a nested canvas slot") and discovered the hard way that competing drop targets break hit-testing — so a Pattern-B node's outer element is NOT a canvas; only its named sub-slots are.

True multi-canvas Pattern B is live. A canonical declares canvasSlots — a static string[] or a (props) => string[] function — and CanonicalNode generates one <Element canvas id={slot}> per slot, handing the adapter impl slotChildren[slot] to place each independently-droppable region. Five canonicals use it:

  • Card — static canvasSlots: ['header', 'body', 'footer']. Three real drop zones; the inspector also styles each via styleSlots.
  • Table — dynamic, one cell slot per rows × cols (with merge handling).
  • Tabs — dynamic, one slot per tab (keyed by the stable tab.id, so renaming a tab's value no longer orphans its canvas).
  • Stepper — dynamic, one slot per step.
  • Carousel — dynamic, one slot per slide.

styleSlots (independently styleable regions) is a separate axis from canvasSlots (independently droppable regions): a Pattern-A canonical can expose several styleSlots without any sub-canvas.

Pattern B slot routing — composedClasses + composedInlineStyles

When a canonical's styleSlots has more than one entry, the inspector and the adapter impl coordinate via per-slot maps:

  1. Inspector side: SlotPicker exposes the slots as a pill bar. Inspector tracks activeSlot in local state (resets to 'root' on selection change). Every class-editing panel receives slot as a prop, which it threads into useNodeClasses(nodeId, slot). The hook reads from / writes to style.classes[slot] (base) or style.responsive[bp][slot] (non-base).

  2. Render side: CanonicalNode iterates def.styleSlots and computes composedClasses[slot] + composedInlineStyles[slot] for each. These maps are passed to the adapter impl alongside the root-slot className / inlineStyle (which are duplicates of the root entries, kept for Pattern A backwards compat).

  3. Adapter side: Pattern B impls read the maps:

    export function ShadcnCard({ composedClasses = {}, composedInlineStyles = {}, ... }) {
      return (
        <Card className={composedClasses.root} style={composedInlineStyles.root}>
          <CardHeader className={composedClasses.header} style={composedInlineStyles.header}>…</CardHeader>
          <CardContent className={composedClasses.body} style={composedInlineStyles.body}>{children}</CardContent>
          <CardFooter className={composedClasses.footer} style={composedInlineStyles.footer}>…</CardFooter>
        </Card>
      )
    }
    

The Inspector's Delete action always deletes the node, never the slot — slots aren't first-class deletable entities; they're styling regions of the same node.

Toolbox UX — categories, search, favorites, recents

The Toolbox is more than a flat list of components. It's the user's primary index into the registry and gets the UX time accordingly.

Categories: components are grouped by def.category (layout, content, navigation, media, display, input, feedback) with a fixed display order in Toolbox.tsx's CATEGORY_ORDER. Anything with an unrecognized category falls into "Other" at the bottom — defensive, so new categories don't silently disappear.

Search: a single text input filters by displayName, id, or any tag (case-insensitive substring). The Favorites and Recents sections also filter — an empty result hides them rather than showing empty stub sections.

Favorites: per-component star icon toggles a canonical id in the user's favorites set. Favorites render in their own section at the top.

Recents: tracks the last 5 dragged canonicals via an LRU on the button's onMouseDown (fires whether or not the drag completes — approximates "intent to use"). The LRU lives in user state alongside favorites.

Persistence: both favorites and recents persist to localStorage['craftjs-design.toolbox'] — a separate key from the document envelope (craftjs-design:doc:v1). Toolbox preferences are user-level; they survive document switches and aren't part of the saved document.

Multi-canvas Pattern B via canvasSlots

The Container Pattern decision (above) describes Pattern A — outer node is the single canvas — and earlier reserved Pattern B for genuine composites. Pattern B is now shipped for Card.

The contract: a canonical declares canvasSlots: readonly string[]. When set, CanonicalNode generates one <Element id={slot} is="div" canvas/> wrapper per slot and passes the wrappers via slotChildren: Record<string, ReactNode>. Each wrapper becomes a linked Craft child node — its own drop zone with its own subtree. The outer canonical node is NOT a canvas (declaring isCanvas: false); declaring both would create competing drop targets and break hit-testing.

The adapter impl places each wrapper inside its corresponding DOM region:

function ShadcnCard({ slotChildren = {}, composedClasses = {} }: AdapterRenderProps) {
  return (
    <Card className={composedClasses.root}>
      <CardHeader className={composedClasses.header}>{slotChildren.header}</CardHeader>
      <CardContent className={composedClasses.body}>{slotChildren.body}</CardContent>
      <CardFooter className={composedClasses.footer}>{slotChildren.footer}</CardFooter>
    </Card>
  )
}

Empty slot wrappers are invisible by default (zero height when their <Element>'s linked node has no children). The .canvas-slot class on each wrapper + :empty CSS in src/index.css give them a min-height + a dashed outline + a "Drop here" hint when empty. Disappears the moment the slot has children.

getCanvasSlots(def) is the single resolution function:

  • Explicit canvasSlots: [...] → Pattern B multi-canvas.
  • canvasSlots unset, isCanvas: true → Pattern A (legacy single canvas via ['root']).
  • canvasSlots unset, isCanvas: false → no canvas (leaf).

Tabs is props-driven — its canvas count varies with props.tabs.length, which is a different complexity class from Card's fixed three slots.

Responsive arbitrary inline via runtime <style> injection

An inline-style HTML attribute can't carry @media queries, which would otherwise limit arbitrary CSS values (hex colors, custom 13px spacing) to the base breakpoint. The limit is lifted using runtime CSS injection rather than a Vite-time safelist.

For each slot that has at least one entry in style.responsiveInline[bp][slot], CanonicalNode:

  1. Calls composeResponsiveInline(style, slot), which content-hashes the slot's combined inline (base + responsive) into a stable class id like ri-3jvn7.
  2. Generates CSS rules — base declarations + one @media (min-width: …) block per breakpoint.
  3. Appends the class id to composedClasses[slot].
  4. Emits the CSS inside an inline <style> element rendered as a sibling of the impl.
  5. Skips composedInlineStyles[slot] for that slot — base inline now lives inside the class so the inline-style attribute's higher specificity doesn't beat the @media class rule.

The content-hash means two nodes with identical responsive styling share the same class id; the browser dedups identical rules across multiple <style> tags effectively. No collector, no coordination, no flash-of-unstyled-content.

Compared to a Vite-time safelist this approach trades a small per-node <style> cost for simplicity. The full Vite safelist option was kept on the table but not pulled — runtime injection is sufficient for current document sizes.

SDK boundary — public surface in src/sdk/

src/sdk/ is the public boundary for adapter / canonical / panel authors. Two motivations:

  1. Stability. Internal types in src/adapters/types.ts, src/registry/types.ts, etc. evolve as the editor's internals shift. A pinned public surface insulates SDK consumers from those moves.
  2. Discoverability. A single import path (@design/sdk) is documentable in a way "import from this file or that file deep inside the project" is not.

The boundary is mostly social — the lint rule (when added) catches accidental cross-boundary imports in examples/, the boundary test catches accidental export removal, and ongoing review catches "would an SDK consumer reach for this?" That triad together is enough; full per-package separation would slow internal refactors without proportional benefit.

The path alias @design/sdk is wired in three places (must stay in sync):

  • tsconfig.json (root) — for tooling that reads the root config (shadcn CLI, etc.).
  • tsconfig.app.json — for tsc -b.
  • vite.config.ts — for runtime resolution.

Internal adapters dogfood the boundary — src/adapters/{shadcn,mui}/index.ts import registerAdapter from @design/sdk. The Chakra example at examples/adapter-chakra/ imports ONLY from the SDK, proving an external author can build an adapter without touching internal modules.

Pluggable inspector panels

The Inspector renders panels from a panel registry rather than a hardcoded panels.includes('layout') && <LayoutPanel/> cascade. PanelDefinition describes a panel:

interface PanelDefinition {
  id: string                                    // 'layout' | 'spacing' | ... | custom
  displayName: string
  order: number                                 // sort key; built-ins use 10–70
  applicableTo: (def: CanonicalComponent) => boolean
  component: ComponentType<{ nodeId: string; slot: string }>
}

The 7 built-ins (Layout, Size, Spacing, Typography, Appearance, Effects, Properties) register themselves at module load via src/editor/inspector/built-in-panels.ts. External panels register the same way via registerPanel from @design/sdk.

Resolution (getPanelsFor(def)):

  1. If def.applicablePanels is set, that's a whitelist — only registered panels whose id appears in the list render. Preserves the legacy semantics where Button explicitly excludes typography.
  2. Otherwise, each panel's applicableTo(def) predicate decides.

Inspector iterates the resolved list, sorts by order, and renders each via <panel.component nodeId={...} slot={activeSlot} /> wrapped in a CollapsibleSection. The PropsPanel passes slot but ignores it (it edits canonical props, not slot classes).

Document lifecycle

The editor supports a full document vocabulary — designers create, name, switch, duplicate, share, import, export.

Storage. Two key shapes:

  • craftjs-design:doc-index:v2 — the index. { documents: [{id,name,created,updated}], activeId }.
  • craftjs-design:doc:<id>:v2 — one envelope per document.

Old documents stored at :doc:v1 migrate to a single "Untitled" entry in the v2 index on first read. The legacy key is removed after migration; subsequent reads see the v2 state.

Boot flow: Hydrator runs once on mount. Two branches:

  1. Shared URL — if window.location.hash matches #doc=<encoded>, decode via share.decodeDocument, create a new "Shared document" entry via documentStore.createDocument, deserialize into Craft, clear the fragment. Non-destructive: the user's previous active doc stays in the index.
  2. Active doc — otherwise documentStore.loadActiveDocument() returns the active blob; actions.deserialize replaces the Craft tree; theme + adapter restore from the envelope.

Switch flow: useDocumentSwitcher.switchTo(id) snapshots the current canvas via query.serialize() + saveActiveDocument, swaps activeId, loads the target's blob (or falls back to the Empty template seed for never-saved docs), and deserializes. Auto-save before switch means in-progress changes never vanish silently.

Import / Export. exportDocument(env) returns a Blob; downloadDocument(env, name) triggers a download. importDocumentFromFile(file) reads + validates + migrates. The SaveLoadBar Import button overwrites the active doc; the Hydrator's shared-URL branch creates a new doc. Two gestures, two defaults.

Share. share.shareUrlFor(env, baseUrl) produces a URL with the lz-string-compressed envelope in the fragment. The ShareButton popover renders the URL ready to copy. Encoded payloads over SHARE_URL_MAX_PAYLOAD (30 KB) flip to a "Copy as JSON" fallback — the user pastes into another editor's Import. Documents shared via URL are visible to anyone with the link AND to anyone with browser history access; documented as a privacy note in the popover.

Templates. Three starter templates ship: Empty, Landing page, Sign-up form. Each is built via buildTemplate({ root: NodeSpec }) which produces a valid Craft envelope from a TS-authored spec (better than hand-typed JSON — type-checked against the live canonical registry). Templates register at module load via App.tsx's side-effect import.

Hot canonical reload. registerCanonical post-mount bumps registryVersion. The Toolbox subscribes via useSyncExternalStore and re-renders to show the new entry. A side-effect <ResolverUpdater /> inside <Craft> calls actions.setOptions((opts) => { opts.resolver = getResolver() }) on every bump, so Craft's internal resolver picks up new canonical ids for dragging + deserialization. Existing canvases that reference a removed canonical fall back to the missing-impl placeholder.

Drag-resize via canvas overlay

Resize is a fixed-position canvas overlay (<ResizeOverlay />), replacing an earlier Inspector toggle (CSS resize: both on the selected node's DOM, captured on toggle-off).

The overlay sits outside the Craft <Frame> tree, positioned over the selected node's bounding rect. Four corner handles. The position recomputes on selection change, window resize, scroll (capture phase, to catch the canvas <main>'s independent scroller), and ResizeObserver ticks on the node DOM.

Mousedown on a handle stops propagation (defense against any document-level Craft listener) and starts a drag loop: mousemove writes directly to dom.style.width/height (no React render → smooth 60fps), mouseup commits the final px to style.inline.root.{width,height} via actions.setProp. The setProp re-render passes the same value back through React's style prop pipeline, so there's no visible jump.

Craft drag-connector conflict: the handles aren't inside any Craft node's DOM (the overlay is its own subtree, rendered as a sibling of <Frame>), so Craft's per-node mousedown listeners never see the handle's pointer events. e.stopPropagation() is belt-and-suspenders for future Craft versions.

Selection model — editorStore is the UI source of truth

Multi-select splits selection into two stores with a one-way bridge:

  • editorStore.selection: string[] is the source of truth for the UI — Inspector, Layer tree, breadcrumbs, secondary-selection outlines all subscribe here.
  • Craft's events.selected stays the source of truth for the document/connector layer (resize overlay, default left-click connector, drag).
  • useSelectionSync mirrors Craft → editorStore one-way: when Craft's single-node selection changes (left-click connector, etc.), it resets editorStore.selection = [id].

Every user-initiated selection entry point (layer-tree click, keyboard arrow-nav, canvas search jump, modifier-click) writes editorStore.setSelection(...) synchronously via flushSync and then calls actions.selectNode. This is load-bearing: useSelectionSync's mirror runs in a passive useEffect (after paint), so relying on it alone left the editorStore-backed surfaces one frame behind the canvas — visible as off-by-one layer-tree clicks and laggy arrow-nav. flushSync commits the editorStore subscribers in the same frame as the Craft-backed canvas outline. Modifier-click semantics (toggle / range) are pure functions in editor/selection/modifierSelection.ts.

Layer tree placement — tab-toggle, not a third sidebar

The layout had two <aside> + one <main>. A third always-on sidebar for the layer tree would have eaten canvas width. Decision: toggle-replace — a tab strip at the top of the existing left aside switches between Components (Toolbox) and Layers (<LayerTree>); the choice persists in localStorage (craftjs-design.left-aside-tab:v1). Designers rarely need component-search and the layer tree open simultaneously, so toggling preserves real estate. buildTreeShape flattens the Craft tree into a DFS pre-order list (pure, testable) consumed by both plain rendering and @tanstack/react-virtual (engaged past 50 visible rows). Drag-reorder uses HTML5 DnD with a wouldCreateCycle guard.

Alignment guides — visual-only over Craft's drag

Smart guides depend on a drag coordinate model, but Craft uses native HTML5 drag-and-drop: the source element doesn't move during a drag (the browser renders a "drag image" ghost), and there's no pointermove stream — only dragover on drop targets, committed via insertion-index actions.move. The two escalation paths to true coordinate-snap — (1) a custom pointermove drag layer that bypasses Craft for in-document moves, or (2) forking @craftjs/core for a beforeMove hook — are each a weeks-scale rewrite and would force absolute positioning onto canvas nodes that currently live in flex flow.

Decision: visual-only for v1. useDragGuides listens to dragover, builds a "dragged rect" centered on the pointer, and runs the pure alignmentMatches math against same-parent sibling rects. Matching edges draw red guide lines (<GuideOverlay>, ≤2 lines, 4px threshold); Alt bypasses; guides are suppressed inside Pattern B multi-canvas slots. The drop still commits through Craft's normal insertion-index move — no coordinate snap. Designers get the alignment hint (the high-value half of the Figma behavior) without the drag-layer rewrite. Coordinate snap is a documented future stretch.

Asset backend — host-pluggable image provider

<EditorImageProvider> is a React context with { upload, list, delete?, canList }. Hosts wrap the editor to route uploads to their backend; absent a wrapper, a default provider inlines base64 data URLs and remembers session uploads in module scope (so the library lists all uploads, not just whatever src currently sits on a node). canList gates the host-only AssetLibraryPanel (the Inspector calls useEditorImageProvider() unconditionally and filters the panel out when false — applicableTo can't read context). The ImagePicker (Image src field) shows the union of provider list() + a document scan of existing Image src values.

Hot canonical reload — version counter + setOptions

registry.ts exposes a monotonic registryVersion counter that increments on every registerCanonical / unregisterCanonical after the Editor has mounted (editorMounted flag flipped by Editor.tsx's useEffect). Pre-mount registrations don't bump — they're part of the initial resolver build and there are no subscribers yet.

Two consumers subscribe via subscribeRegistry:

  • ToolboxuseSyncExternalStore triggers a re-render → listComponents() returns the new set → palette updates.
  • ResolverUpdater — same useSyncExternalStore pattern → actions.setOptions((opts) => { opts.resolver = getResolver() }) swaps Craft's internal resolver to a fresh build. New canonical ids are now resolvable for drag-create + deserialization.

The cached resolver in craft/resolver.tsx is keyed by cachedAtVersion; renders that don't move the version reuse the cache.

Caveats documented in the SDK:

  • Existing nodes referencing a removed canonical render the missing-impl placeholder (same path as cross-adapter coverage gaps).
  • Hot-replacing a canonical (same id, different propsSchema) doesn't re-validate existing node props. Old props on the new schema can produce unexpected behavior.

Form components are non-interactive in editor mode

Select, Checkbox, Radio, Switch, and Textarea would be unusable in the editor if they responded to clicks: every click on a checkbox during editing would toggle the prop's stored value, and the user can't actually edit the prop visibly. The adapter impls render them with onChange / onCheckedChange / onValueChange set to no-ops (() => {}), and Textarea uses native readOnly. The canonical's stored checked / value / defaultValue props drive what's shown; the user edits them via PropsPanel.

This is only a property of the editor preview — when the same document is rendered outside the editor (e.g., a future "preview" or "publish" mode), no-op handlers can be replaced with real ones. The non-interactive behavior lives in the adapter impls' editor-mode code, not in the canonical contract.

Zod-validated envelope around opaque Craft JSON

documentSchema only validates the envelope: { version, adapterId, themeId?, craftJson: string }. It does not schema-check Craft's internal tree shape.

Craft owns its serialization format. Re-typing it on our side would either drift over time (if hand-rolled) or duplicate types Craft already publishes (if imported). Neither is worth it. The envelope gives us:

  • A version literal to switch on later if the envelope itself changes.
  • An adapter pin (so the right adapter mounts on load).
  • A theme pin.
  • Validation that "this is at least the right kind of thing" before handing it to Craft.

Themes as [data-theme] CSS blocks

Themes ship as static CSS blocks scoped by [data-theme="<id>"] selectors, not as JSON tokens transformed at runtime.

Reasons:

  • Theme-swap performance is zero — one DOM attribute change; the browser handles the rest natively.
  • shadcn's themes ship as CSS blocks already. Pasting one is a 30-second job; a runtime token compiler would be a day.
  • JSON-driven user themes can be added later by injecting a <style> tag at runtime — the architecture isn't blocked.

tw-classes.ts — single funnel for class-string editing

The inspector doesn't edit style.classes.root by string concat. It parses to a typed slice, mutates the slice, and serializes back through mergeTypography. The merge preserves classes the parser didn't recognize (e.g., bg-card on a Text node) by passing them through as opaque.

This is the architecture's defense against a foot-gun. If any panel were to do classes.root = "text-bold " + classes.root, unrecognized classes get dropped the next time the parser re-serializes. The discipline is enforced by convention.

Tailwind v4 needs an explicit safelist for dynamically-emitted classes

Tailwind v4's JIT scans source for literal class strings. Inspector panels emit classes via template literals (`text-${size}`), which the scanner can't see. Without intervention, the classes land in the DOM but no CSS is generated — they fail silently.

The fix: explicit @source inline() blocks in src/index.css listing every utility a dynamically-emitting panel can produce. Theme-token utilities (text-primary, bg-card, …) are auto-generated by @theme inline and don't need to be listed there — they're declared as theme tokens.

MUI palette bridge — CSS-variable indirection, not adapter coordination

MUI's createTheme palette validator rejects var(--…) strings — the cssVariables: true mode generates MUI's own CSS variables from real color values; it doesn't accept CSS-variable references as input. Two-layer bridging:

  1. mui/theme.ts: pass valid placeholder hex colors to createTheme. MUI's validator is happy. These become fallback values if step 2 fails.
  2. .mui-bridge CSS block in index.css: override MUI's generated --mui-palette-* variables to reference our shadcn tokens. The <div className="mui-bridge"> in MuiWrapper applies these.

CSS variable resolution is lazy — each var() reference is resolved at the consuming element. So when [data-theme="rose"] flips --primary, MUI components below it repaint without re-creating the MUI theme. No JS coordination, no listener, no theme-recompute. The cascade does it.

Missing-impl placeholder, not a thrown error

When an adapter doesn't have an impl for a canonical, CanonicalNode could throw. It deliberately doesn't — it renders a small destructive-colored badge: "Button — no impl in adapter 'mui'".

The user can: swap to an adapter that covers the canonical; delete the offending node; or (as a developer) add the missing impl.

This decouples adapter coverage from canonical registration. Without the placeholder, every adapter would need to ship every canonical from day one — a brittle coupling that makes incremental adapter development impossible.

Adapter impls consume rendered className + inlineStyle, never style.classes.root

CanonicalNode composes responsive breakpoint slices into a single Tailwind-prefixed class string AND merges arbitrary inline values from style.inline[slot] before invoking the adapter impl. The composed string lands on className (or sx); the inline style lands on inlineStyle.

// ✅ Right
export function ShadcnBox({ children, rootRef, className, inlineStyle }: AdapterRenderProps) {
  return <div ref={rootRef} className={cn(className)} style={inlineStyle}>{children}</div>
}

// ❌ Wrong — bypasses composeResponsive AND composeInlineStyle
export function ShadcnBox({ style, children, rootRef }: AdapterRenderProps) {
  return <div ref={rootRef} className={cn(style.classes.root)}>{children}</div>
}

The wrong version works for base-only editing with token-only values — that's exactly what style.classes.root contains. The bug surfaces only when (a) responsive variants enter play, or (b) the user picks an arbitrary value via ColorPicker / NumericInput. In both cases the wrong-version impl silently drops the additions.

This convention isn't enforced by the type system — style is still in AdapterRenderProps for impls that need to read individual slot classes or other style metadata. Discipline-by-convention only. The developer guide spells it out.

Arbitrary values stored as inline CSS, not Tailwind classes

Tailwind v4's JIT compiles classes by scanning source files for literal strings. Inspector-emitted classes (text-${size}, bg-${color}) are caught by the generated safelist because their value sets are known. But truly arbitrary values — bg-[#fa8072], p-[13px] — can't be safelisted; the input space is infinite. The full solution (per-document safelist generated at save time, watched by Vite, regenerated on doc load) is a real engineering project.

The pragmatic alternative ColorPicker and NumericInput use: write arbitrary values as inline style={{...}} instead of Tailwind classes. Inline styles always apply; no compilation needed.

Trade-off: inline style="..." attributes don't support @media queries. So arbitrary values only work at the base breakpoint. Non-base breakpoints lock to token-only via the inspector's disabled state (ColorPicker hex section greys out; NumericInput rejects arbitrary on commit). The user is informed via hint text on the disabled controls.

Tokens and arbitrary values are mutually exclusive per CSS property at the panel level — picking a token clears the matching inline[cssProperty], and vice versa. The two never coexist for the same property on the same node, so there's no specificity confusion.

Inline-style storage shape:

interface NodeStyle {
  classes: Record<string, string>
  responsive?: Record<string, Record<string, string>>
  inline?: Record<string, Record<string, string>>   // slot → CSS prop → value
}

composeInlineStyle(style, 'root') reads style.inline.root and returns it as React.CSSProperties for the adapter. Empty/undefined returns undefined (so React doesn't take a no-op style-prop change).

Wrappers compose, not switch

A naïve AdapterProvider would conditionally render the active adapter's Wrapper:

return (
  <AdapterCtx.Provider value={adapter}>
    {Wrapper ? <Wrapper>{children}</Wrapper> : children}
  </AdapterCtx.Provider>
)

That changes the React tree shape on adapter swap (different element type at the same position), which makes React unmount and remount the entire children subtree. Two consequences observed in practice:

  1. Hydrator re-fires, re-reads localStorage, and reverts the user's adapter pick.
  2. Craft's <Frame> re-seeds its initial children, wiping the user's canvas content back to the empty root.

The fix: compose every registered adapter's Wrapper around children, always. Inactive adapters' Wrappers (e.g., MUI's ThemeProvider while shadcn is active) just provide React context that no rendered component reads.

function composeAllWrappers(all, children) {
  let wrapped = children
  for (const a of all) if (a.Wrapper) wrapped = <a.Wrapper key={a.id}>{wrapped}</a.Wrapper>
  return wrapped
}

The React tree shape is stable across every adapter swap. Nothing remounts. Frame stays mounted; Craft state persists; user content survives.

Implicit contract this puts on adapter authors: Wrappers must be pure context providers. They can return a React provider, a styled container div, anything whose effect is scoped to its own subtree. They must NOT:

  • attach document-level event listeners,
  • inject global CSS into <head>,
  • mutate browser APIs,
  • …or anything else that would apply unconditionally even when their adapter is inactive.

Side-effecting work goes in mount / unmount — the imperative lifecycle hooks. Those fire only on activeAdapter change.


The style "dimension" — breakpoint × state

A node's styling is addressed along two orthogonal axes: breakpoint (base, sm2xl) and pseudo-class state (base, hover, focus, active). Their cross-product yields four storage quadrants for classes and four for inline values on NodeStyle:

base state pseudo-state
base bp classes / inline states / stateInline
named bp responsive / responsiveInline stateResponsive / stateResponsiveInline

The complexity is funneled through one dispatch table (src/style/dimensions.ts): read/writeBucketClasses and read/writeBucketInline take (slot, breakpoint, state) and land in the right quadrant. Panels never see the quadrants — they call useNodeClassesMulti, which reads editorStore.activeBreakpoint + activeState and routes accordingly. Composition (responsive.ts, responsive-inline.ts) emits classes in breakpoint-outermost order (md:hover:…) and promotes state inline values into generated .cls:hover rules (an inline style attribute would otherwise beat a pseudo-class rule by specificity). The selected node previews a non-base state on the canvas by applying that quadrant's styles unprefixed (CanonicalNode).

Theme token derivation

Themes are authored from a small tokens map (often just primary). deriveTokens(tokens, scheme) (src/themes/tokens.ts) is a pure function that fills the full shadcn core set: neutrals from scheme defaults (light/dark), card/popover from background, ring from primary, each *-foreground via a lightness-contrast heuristic, sidebar accents in step. themeTokensToCss renders a [data-theme] block (+ optional .dark[data-theme]), injected into one <style data-craftjs-theme-tokens> element — the same runtime-injection mechanism as font tokens. The visual theme editor reuses this exact derivation for its live preview, so what a designer previews is what registerTheme produces. Color mode (light/dark/system) lives in editorStore, persists in the document, and ThemeProvider applies .dark to the canvas wrapper only.


Persistence Format

Stored at localStorage['craftjs-design:doc:v1']:

{
  "version": 1,
  "adapterId": "shadcn",
  "themeId": "rose",                                  // optional
  "craftJson": "<JSON string from query.serialize()>"
}
  • version: literal 1 today. Bump only when the envelope shape changes — not when craftJson's internal shape changes (Craft owns that).
  • adapterId: pinned at save time from useEditorStore.getState().activeAdapterId. Hydrator restores via setActiveAdapter. Required.
  • themeId: pinned at save time. Optional — old documents without one default to 'default' on load.
  • craftJson: an opaque string. Treat it as a blob; never parse and rewrite it directly. Each canonical node's props.style carries up to three fields: classes (base slot → class string), responsive (breakpoint → slot → class string) once the user has authored breakpoint variants, and inline (slot → CSS property → value) once the user has picked arbitrary hex colors or px sizes.

The :v1 suffix on the storage key reserves namespace for a future v2 envelope to coexist during migration.

The activeBreakpoint (which breakpoint the user is currently editing) is not persisted — it's a UI mode, not a document property. It resets to 'base' on every reload.

Migrations (src/persistence/migrations.ts). documentRegistry.readDocument pipes the deserialized envelope through migrateDocument before handing back. Each migration step walks the opaque Craft JSON and mutates node shapes in place. Steps are idempotent; running them on an already-current document is a no-op. Current steps include a Card-prop strip + isCanvas flip and a Tabs content-field strip per tab. Add a new migration step when bumping the envelope shape OR when changing a canonical's persisted shape in a way the current code can't read.


Extension Points

For step-by-step recipes (adding a canonical, adding an adapter, adding a theme), see DEVELOPER_GUIDE.md. The contracts those recipes touch are stable: CanonicalComponent, Adapter, Theme, AdapterRenderProps.

Under the hood

Accessibility

Target: WCAG 2.1 Level AA for the editor chrome (toolbar, toolbox, inspector). The output document — what designers build with the editor — is the designer's responsibility; this document is about the editing experience itself.

This is a static audit. Read the source, flag what's covered and what's not. The matching axe-core scan against a real browser run lands here as a "Audit findings" section in a future pass.

Coverage status

Shipped — icon-only buttons all carry aria-label

Verified by grep of the editor's interactive components:

File Icon-only button aria-label
Toolbox.tsx Favorite star toggle 'Favorite' / 'Unfavorite'
UndoRedo.tsx Undo / Redo buttons 'Undo' / 'Redo'
ShareButton.tsx Copy link / Copy as JSON (text-bearing buttons — no label needed)
EyedropperButton.tsx Pipette icon button 'Pick color from screen'
ResizeToggle.tsx (removed) n/a replaced by canvas overlay
ArrayField.tsx Move up / Move down / Remove 'Move up' / 'Move down' / 'Remove'
GradientEditor.tsx Add stop / Remove stop 'Add stop' / 'Remove stop'
Inspector.tsx Delete text-bearing (button reads "Delete")
ResizeOverlay.tsx Resize handles aria-hidden on the overlay container (decoration only — Inspector's SizePanel is the keyboard-accessible path)
DocumentMenu.tsx Rename / Duplicate / Delete text-bearing (icon + text label)

Shipped — text-bearing buttons

Inspector buttons (Delete, Rename, etc.) carry visible text labels; the icon is decorative. No aria-label needed; the button's text content is the accessible name.

Shipped — form inputs paired with labels

Every panel row wraps inputs in <PanelRow label="..."> which renders a <label> association via the visible label text — see src/editor/inspector/shared/PanelRow.tsx.

Shipped — focus rings via shadcn defaults

shadcn's primitives ship with :focus-visible rings via the outline-ring/50 Tailwind layer in index.css. Custom buttons (Toolbox entries, UndoRedo, etc.) inherit the browser default focus ring.

Acceptable — tabular-nums for numeric readouts

HslSliders / RgbSliders readouts use tabular-nums for stable character width during drag. Reduces visual jitter for users with vision sensitivity to small movements.

Acceptable — sliders use native <input type="range">

Browser handles keyboard focus + arrow-key adjustment + screen-reader semantics natively. No custom slider logic to break.

Known gaps

These are documented to scope the gap honestly.

Shipped — Toolbox keyboard navigation

The Toolbox implements the WAI-ARIA toolbar pattern with roving tabindex. The component list is role="toolbar" with aria-orientation="vertical"; exactly one component button is tab-focusable at a time. Keys:

Key Action
Tab Enters / exits the toolbar (one tab stop for the whole region)
ArrowDown / ArrowUp Move focus to next / previous component (crosses section boundaries)
Home / End First / last component
Enter or Space Drop the focused component. Selection-aware: child of ROOT (no selection), child of the selected canvas (canvas selected), sibling AFTER the selected node (non-canvas selected)
F Toggle the favorite star on the focused row
/ Focus the search input (also works from inside the input — refocuses + selects)
Escape (in search) Clear the search query and return focus to the first component

Implementation: src/editor/Toolbox.tsx. The favorited buttons stay tabIndex={-1} so they don't fragment the roving rotation — F is the keyboard path; mouse users click directly.

Shipped — Canvas keyboard navigation

The canvas region (<CanvasKeyboardRegion> in src/editor/canvas/CanvasKeyboardRegion.tsx) is a single tab stop. Arrow keys move the selection directly — there is no separate keyboard-focus state. The ResizeOverlay's dashed outline + 8 resize handles is the visual indicator, identical to mouse-driven selection. This matches the pattern used by Figma's layers panel, file managers, and IDE outline views: arrows = move selection.

Key map (active when focus is inside the canvas region):

Key Action
Tab Enters / exits the canvas region (one tab stop). When entering with no current selection, ROOT is selected.
ArrowDown Selects the next node in pre-order (first child if any, else next sibling, else next ancestor's sibling).
ArrowUp Selects the previous node in pre-order (previous sibling's deepest descendant, else parent).
ArrowRight Selects the first child (else next sibling).
ArrowLeft Selects the parent.
Escape Clears selection; returns focus to the region wrapper.
Delete or Backspace actions.delete(selectedId) (ROOT exempt); selection moves to next sibling, previous sibling, or parent in that preference order.

Implementation notes:

  • The keydown listener attaches at document level (not as a React onKeyDown) because some Craft.js connectors / Radix overlays attach direct DOM listeners that can call stopPropagation on synthetic events. The handler gates on containerRef.current?.contains(document.activeElement) so arrow keys outside the canvas (Inspector inputs, Toolbox) stay unaffected.
  • Form inputs nested inside a canvas node (an editable text canonical, for instance) keep their own arrow-key behaviour — the handler returns early when the event target is an <input>, <textarea>, or contenteditable element.
  • Each canvas node's DOM gets tabindex=-1 from CanonicalNode.attachRef so click-focus works for all browsers (Safari requires explicit tabindex to focus a <div> on click).
  • After delete or arrow navigation, the new selection auto-scrolls into view via scrollIntoView({ block: 'nearest', behavior: 'instant' }) (instant so reduced-motion / smooth-scroll settings don't fire a flood of scroll events on each keypress).
  • Arrow-nav selection writes go through editorStore.setSelection wrapped in flushSync, in lock-step with actions.selectNode, so the Inspector / Layer tree / breadcrumbs (which subscribe to editorStore) update on the same frame as the canvas outline rather than a frame behind.

Shipped — Multi-select + breadcrumbs

Multi-selection is mouse-driven (Cmd/Ctrl-click toggles, Shift-click range-extends within a parent); the keyboard arrow-nav remains single-selection by design — arrows always move to exactly one node so the "where am I" model stays unambiguous. Delete / Backspace operates on the whole multi-selection (every selected node, one undo step) regardless of how the selection was built.

Inspector breadcrumbs (<InspectorBreadcrumbs>) give a non-canvas path to ancestor nodes: each chip is a real <button> with an accessible name (the node's displayName), so the ancestor chain is reachable by Tab + Enter for keyboard and screen-reader users — the click-target node is no longer the only reachable one. Overflowed middle segments collapse into a button that opens a Radix dropdown (arrow-key navigable, roving focus from the primitive).

The Layer tree (Layers tab) is an additional keyboard/AT-friendly surface for the document structure: rows carry role="tree" / selection state, and selecting a row drives the same editorStore.setSelection path as the canvas.

Future — color contrast of token swatches

ColorPicker's token swatches show the token's color as background. Some token colors (muted, secondary) are subtle and may not meet 3:1 contrast against the popover background. Not a contrast failure for text (the swatch is decorative), but the active-state ring (ring-primary/40) should be verified.

Recommended: visual review with axe-core in a real browser.

Future — Toolbox search input lacks visible label

<input placeholder="Search components…" /> is the search input; the placeholder is the only visible label. Screen reader users hear the placeholder, which is acceptable but not ideal — best practice is an explicit <label>.

Recommended: wrap in <label> with a visually-hidden span: <label><span class="sr-only">Search components</span><input ... /></label>.

Future — modal dialog focus trap

window.confirm is used for the "Delete document?" confirmation in DocumentMenu. The browser-native dialog handles its own focus trap. Future polish replacing with a custom modal would need to manually trap focus.

Future — screen reader announcements for canvas changes

When a node is dropped on the canvas, screen readers don't announce it. Adding aria-live regions for "Component dropped" / "Node selected" would help. Not yet addressed.

Color contrast (informational)

Editor chrome uses the standard shadcn neutral palette:

  • Background: oklch(1 0 0) (white)
  • Foreground: oklch(0.145 0 0) (~#252525, near-black)
  • Muted background: oklch(0.97 0 0)
  • Muted foreground: oklch(0.556 0 0) (~#8c8c8c, gray-500)

The muted-foreground against background is ~4.6:1 — passes WCAG AA for normal text (4.5:1 required). Verified visually; an axe-core scan would confirm.

Audit plan

An axe-core scan runs in dev mode and records findings here. Initial expected findings:

  1. Toolbox.tsx search input — wrap in <label> per the gap above.
  2. Color contrast of muted-on-muted backgrounds — verify.
  3. ResizeOverlay handles announced as buttons — they aren't <button>s today (they're <div onMouseDown>); switch to <button> so keyboard users can at least focus them, then implement arrow-key resize.
Under the hood

Performance

A snapshot audit of where the editor spends render time and where it doesn't. This is a static audit — read the source, flag patterns. The matching React Profiler run (under real edit gestures) is the next step; results land here as a Profile Findings section as they're measured.

This document covers the goal of no re-render storms during typical edit flows. Findings are categorized as shipped (already optimized), acceptable (works fine, no fix needed), or future (a fix is queued for later).

Subscription model

Two reactive state systems drive the editor:

  1. Craft.js editor state — accessed via useEditor(collector). The collector's return value is shallow-compared per render; collectors that return stable objects re-render only when their slice of the editor's state changes.
  2. Zustand editorStore — accessed via useEditorStore((s) => s.field). Selector-based; subscribers re-render only when the selected field changes.

Shipped — selector hygiene

Every useEditorStore call across the codebase uses a selector:

const activeThemeId = useEditorStore((s) => s.activeThemeId)

Confirmed via grep useEditorStore in src/ — 9 call sites, all selector form. No component subscribes to the entire store.

Shipped — split selectors over compound state

useNodeClasses famously deals with state from two systems (Craft's per-node props + Zustand's activeBreakpoint). The hook reads activeBreakpoint in the body, not inside useEditor's collector — see src/editor/inspector/shared/useNodeClasses.ts. This guarantees activeBreakpoint updates trigger a re-render and the body reads the fresh value, instead of the collector closing over a stale one.

Documented in docs/DEVELOPER_GUIDE.md § useEditor collector reads stale non-Craft state.

Render hot paths

Shipped — direct DOM mutation during drag-resize

src/editor/canvas/ResizeOverlay.tsx mutates selectedDom.style.width/height directly during the drag loop (no React render per mousemove). The final value commits via actions.setProp on mouseup — one re-render at the end of the gesture, ~60 fps during.

Shipped — direct DOM mutation during ColorPicker visual drag

react-colorful's HexColorPicker calls our onChange per drag tick. The chain is: pick tick → setState in ColorPicker → actions.setProp → React re-render of the selected node. At ~60Hz this is well within budget for single-node updates; not currently bottlenecked. If the user drags while many siblings are observing the same node (rare), profile and consider debouncing.

Acceptable — runtime <style> injection per node

The responsive-arbitrary inline path emits a <style> element per node whose style.responsiveInline has entries. For documents with ≤100 nodes that have responsive inline, this is fine. A Vite-plugin replacement was considered but V1 was partially pulled — the runtime path stays. See src/style/safelist-extract.ts for the foundation if a build-time path becomes worthwhile later.

Acceptable — Inspector renders all panels on every selection change

src/editor/Inspector.tsx renders the seven built-in panels (plus any SDK extras). Each <panel.component> runs useEditor + useNodeClasses. For the current set the cost is sub-millisecond per panel; collapsed sections skip their content since <details> doesn't render closed content.

Acceptable — ResizeOverlay updates rect on scroll

window.addEventListener('scroll', recompute, { capture: true, passive: true }) fires often during a smooth scroll. setRect re-renders the overlay; the overlay only renders four positioned divs. Cheap. Removed requestAnimationFrame batching for simplicity; revisit if scroll feels janky on slow devices.

Future — Toolbox button refs may re-register Craft sources

src/editor/Toolbox.tsx per-button ref callback calls connectors.create(el, ...) on every render. Each render of the Toolbox (e.g., when favorites change) re-registers the drag source. Craft's internal de-dupe may handle this cheaply, but it's worth measuring — if the registry mutation cost is non-trivial, memoize the ref callback via useCallback.

Future — <HexColorPicker> allocates a new color on every drag tick

External component; not directly fixable. Consider debouncing via mouseup commit (the slider state stays local until release) for nodes inside large canvases where per-tick re-render starts to drop frames.

Measurement plan

Baseline numbers are recorded via React DevTools Profiler during six tracked flows:

  1. Mount — initial Editor render with one default document.
  2. Drop component — drag a Box from the Toolbox to the canvas.
  3. Select node — click an existing node.
  4. Token color edit — pick a Tailwind color token in the ColorPicker.
  5. Hex color edit — drag the visual S/L picker.
  6. Adapter swap — flip from shadcn to MUI.

Each flow's expected scope:

Flow Expected re-renders Critical fast path
Mount One pass through each registered component Don't double-render via StrictMode in dev
Drop component Canvas + Toolbox button + Inspector New node mounts; existing nodes stable
Select node Inspector only (the canvas doesn't re-render unless props change) Selection in Craft state
Token color edit One node + Inspector className change propagates one render
Hex color edit One node + ColorPicker (live) Direct DOM mutation during drag (visual picker)
Adapter swap Every canvas node + adapter switcher Tree shape stays stable (compose-not-switch)

Anything that exceeds the expected scope is a re-render storm; fix via useMemo, React.memo, or refactoring shared state.

Profiler baselines (2026-05-25)

Recorded with React DevTools Profiler against the editor in dev mode (StrictMode active). Raw exports live in profiler/ at the repo root — one JSON per flow. Numbers are warm-cache: each flow profiled in isolation after a one-off mount, so totals exclude the StrictMode double-render of the initial render path.

Summary

# Flow Commits Total ms Max commit Verdict
1 Mount 2 34.8 34.1 ✅ Healthy (one-time cost)
2 Drop component 4 13.1 10.8 ⚠️ Toolbox re-renders fully (6.6 ms self)
3 Select node 8 90.7 66.6 ⚠️ Inspector subtree dominates
4 Token color edit 11 55.0 41.5 ⚠️ NumericInput × ColorPicker fan-out
5 Hex color edit 376 816.9 52.6 🚨 Critical — re-render loop
6 Adapter switch 2 8.3 8.2 ✅ Healthy
7 Large doc open 3 80.7 49.4 ✅ Acceptable for doc load
8 Tabs with all canvases 8 14.6 5.9 ✅ Healthy
9 Rapid resize 570 374.9 26.2 🚨 Critical — render storm

A frame budget at 60 fps is 16.7 ms; at 120 fps, 8.3 ms. "Healthy" means all commits land inside one frame; "acceptable" means a single one-off commit exceeds a frame (load / mount) but ongoing gestures stay smooth; "critical" means user-driven gestures generate sustained sub-frame storms whose aggregate exceeds the gesture duration.

🚨 Flow 5 — Hex color edit (816.9 ms / 376 commits)

Top renderers during a single hex-input edit:

Component Renders Self-time
ChevronDown 376 37.6 ms
ChevronDown 242 48.4 ms
ChevronDown 133 26.6 ms
ColorPicker 87 13.6 ms
ColorPicker 77 13.9 ms
ColorPicker 69 11.7 ms
NumericInput 55 11.8 ms
ValueSelect 41 11.6 ms

The profile JSON for this flow alone is 56 MB — the size is itself a symptom: 376 commits each captured the full fiber tree. Every keystroke in the hex input propagates through the entire ColorPicker popover, re-rendering every neighbouring icon (ChevronDown × 3 — the dropdown chevrons in the popover header), every sibling numeric input, every preset color swatch.

Hypothesis: the hex <input> is controlled by state held at the ColorPicker root, and the root re-renders the whole popover on every keystroke. Sub-components aren't memoized, so even unchanged siblings get reconciled.

Fix shipped (defer ALL React state during drag). Approach taken:

An rAF-coalescing approach was tried first and rejected — on a 60 Hz display the pointermove input rate already matches the frame rate, so coalescing was a no-op (re-profile after rAF: 398 commits, virtually unchanged from baseline 376).

The shipped fix defers BOTH the Craft.js commit AND ColorPicker's own local state updates (pickerColor / hexInput) until pointerup. An intermediate version that only deferred the Craft.js commit cut Inspector renders from 376 → 4 but left ColorPicker re-rendering on every drag tick — and each re-render cascaded into Radix Popover's positioning machinery (PopoverContent re-rendered 1928 times during a single 5.5 sec drag, ChevronDown 3044 times; total 3044 commits with 1534 of them landing within 1 ms of the previous one — a clear render chain).

During drag, the hex value is stashed in a ref. No setState runs, so:

  • ColorPicker doesn't re-render
  • Popover / PopoverContent / floating-ui positioning doesn't re-render
  • All popover internals stay quiescent

Why it's safe:

  • react-colorful's HexColorPicker manages its visual cursor with its own internal Saturation/Hue state. Once mounted with an initial color, the cursor follows the pointer without parent re-renders.
  • HSL / RGB sliders are native <input type="range">; during pointer interaction the browser shows the thumb at the pointer position regardless of the value prop. The next React render after release reconciles back to the controlled state.
  • The text-input hex display stays stale during drag (designers read the color from the visual cursor / swatch, not from the text field). Refreshes on release.

On pointerup the final hex commits via onChange (one Craft.js dispatch). The useEffect([value]) then re-syncs pickerColor and hexInput from the new external value — one final ColorPicker render. Pointer release is listened for on document so dragging outside the popover surface still commits.

Trade-off: the canvas loses live preview during the drag. The underlying document updates only when the user releases. This matches Figma/Photoshop slider UX and was foreshadowed in the static audit ("Consider debouncing via mouseup commit (the slider state stays local until release)…").

Measured post-fix numbers (recorded 2026-05-25):

Before After
ColorPicker renders 950 8
AppearancePanel renders 4 2
PopoverContent renders 1928 26
Total commits 3044 2874
Total ms 1699 1058
Subjective feel sluggish smooth, no lag

The reactive layer this codebase controls — ColorPicker, AppearancePanel, the Inspector subtree — now barely re-renders during drag. The remaining ~2855 commits per drag come from inside the popover after it opens: ~6 unnamed Radix Popover positioning / react-colorful internal fibers + the popover-content ChevronDown firing in unison at ~360 Hz (react-colorful's Saturation / Hue / Interactive components call setState on every pointermove tick).

Each of those commits is ~0.3 ms (sub-frame), so the drag stays at 60 fps in practice. Total CPU during drag is ~13 % over the gesture — not zero, but not perceptible. The library-internal churn is out of scope for this codebase; eliminating it would require either swapping react-colorful for a direct-DOM color picker or wrapping it in a memoization layer that defeats its internal state model. Documented as out-of-scope; revisit only if drag becomes laggy on slower devices.

Discrete actions (eyedropper, token click, gradient toggle, clear, hex text input blur/Enter) keep firing immediately — only the drag-rate sources are deferred.

Re-measure by recording a new Flow 5 profile and saving over profiler/performance_flow_5_hex_color_edit.json.

🚨 Flow 9 — Rapid resize (374.9 ms / 570 commits)

Component Renders Self-time
ResizeOverlay 568 209.0 ms
Handle (×8) 145–165 each 14.8–19.3 ms each

The "shipped — direct DOM mutation during drag-resize" note above is still accurate for the resized node — its style.width/height is mutated directly with no React render. The 570 commits come from ResizeOverlay itself: the overlay's own position rectangle is held in React state (setRect) and recomputed on every pointermove. All eight resize handles re-render with the overlay because they're children of it.

Hypothesis: ResizeOverlay holds its rect in useState. Each mousemove tick calls setRect, triggering one commit. Over a typical 1-second resize gesture at 60 fps that's ~60 commits; 570 suggests either an unbounded high-rate event source (no rAF coalescing) or that pointermove inside React's input pipeline fires more often than the display refresh.

Fix shipped (direct-DOM overlay sync). Approach taken:

Tracked the actual source: every mousemove tick mutates selectedDom.style.width/height directly (no Craft.js dispatch — that part was already shipped). But the DOM mutation triggers the ResizeObserver watching the selected node, which calls recompute()setRect(getBoundingClientRect()) → React re-render of ResizeOverlay + all 8 handles. 568 renders per gesture.

Fix:

  • Added isResizingRef set on mousedown / cleared on mouseup.
  • recompute() bails out when isResizingRef.current === true. The ResizeObserver keeps firing during the drag but its setRect calls are skipped.
  • Added overlayRef and during onMouseMove the overlay's own style.width / style.height are mutated directly to track the node — same direct-DOM pattern the node itself uses. The visual outline + handle positions stay in lock-step with no React render.
  • On mouseup recompute() is called once to sync rect state back to React. Total: 1 React render per gesture (the final size sync).

Measured post-fix numbers (recorded 2026-05-25):

Before After
Total commits per gesture 570 3
ResizeOverlay renders 568 2
Handle renders (per handle) ~150 2
Total render time 374.9 ms 35.4 ms

The 3 remaining commits are all post-release:

  1. The setProp commit reconciling the canvas tree with the new size class (33 ms, ~2000 fibers — one-time cost on release).
  2. An inspector Select tick reading the new size into the size dropdown.
  3. The final ResizeOverlay + 8 Handles render syncing the React rect state to the post-commit DOM size.

Zero commits during the active drag motion. Outline + handles track the node 1:1 via direct DOM mutation; no per-frame React work.

Secondary observations

  • Flow 2 — Toolbox rendered fully at 6.6 ms self on every component drop. Already flagged in the static audit (see "Toolbox button refs may re-register Craft sources" above). Confirmed by measurement. Memoize candidate but not critical.
  • Flow 3 — Inspector runs 8 commits / 90.7 ms on every node select. The Inspector renders 7 built-in panels; the static audit calls this "acceptable" but the measured 66.6 ms actual-time for the dominant Inspector commit pushes against 4 frames. Re-examine whether panels can render lazily based on <details> open state.
  • Flow 4 — Token color edit at 11 commits / 55 ms is the same ColorPicker hot path as Flow 5, but milder because a token click is a single commit instead of N keystrokes. Fixing Flow 5 will likely improve Flow 4 as a side effect.

Re-measurement protocol

To re-profile after a fix:

  1. Open the editor in dev mode (npm run dev).
  2. Open React DevTools → Profiler tab.
  3. Click record, perform exactly one instance of the target flow, click stop.
  4. Save to profiler/performance_flow_<n>_<name>.json.
  5. Run python3 /tmp/analyze_profiles.py (or re-extract via the same logic) to print the commit count, total ms, and top renderers.
  6. Compare against the table above. A passing fix:
    • Reduces commits by ≥ 80 % for re-render-storm flows.
    • Keeps total ms below one frame per discrete user action.
    • Doesn't regress healthy flows.

Bundle size (informational)

Not currently measured. A dist build target ships; bundle size will be recorded once measured.

Under the hood

Developer Guide

Task-oriented guide for working in this codebase. For the why-and-how-it's-structured, see ARCHITECTURE.md.

For SDK consumers (writing adapters, canonicals, or panels for the editor without modifying internals), see SDK_GUIDE.md + the three tutorials. This guide covers in-tree contribution.


Getting started

# 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 → <Editor />
  main.tsx               ReactDOM root
  index.css              Tailwind v4 + token blocks + safelist

Full architectural breakdown in 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/<id>.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<typeof tooltipPropsSchema>
    
    registerCanonical<TooltipProps>({
      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:

    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).

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:

    registerCanonical<DialogProps>({
      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.

    export function ShadcnDialog({
      children, rootRef,
      composedClasses = {},
      composedInlineStyles = {},
    }: AdapterRenderProps) {
      return (
        <div ref={rootRef} className={cn(composedClasses.root)} style={composedInlineStyles.root}>
          <header className={cn(composedClasses.header)} style={composedInlineStyles.header}>
            {/* header content */}
          </header>
          <section className={cn(composedClasses.body)} style={composedInlineStyles.body}>
            {children}
          </section>
          <footer className={cn(composedClasses.actions)} style={composedInlineStyles.actions}>
            {/* action buttons */}
          </footer>
        </div>
      )
    }
    
  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.

    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<slot, ReactNode> — each entry is a <Element canvas/> wrapper that becomes its own linked Craft child node:

    export function ShadcnSplitter({
      rootRef,
      composedClasses = {},
      composedInlineStyles = {},
      slotChildren = {},
    }: AdapterRenderProps) {
      return (
        <div ref={rootRef} className={cn('grid grid-cols-2', composedClasses.root)}>
          <div className={cn(composedClasses.left)} style={composedInlineStyles.left}>
            {slotChildren.left}
          </div>
          <div className={cn(composedClasses.right)} style={composedInlineStyles.right}>
            {slotChildren.right}
          </div>
        </div>
      )
    }
    
  3. Each slotChildren[slot] renders as a <div class="canvas-slot">…</div>. 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:

    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):

    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(<Preview ref={rootRef}>{children}</Preview>, stage) : null
      }
      // Runtime: the library's real overlay with its own open / dismiss behavior.
      return <RealDialog>{children}</RealDialog>
    }
    
  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/<name>/components/<Canonical>.tsx for each canonical you want to support. Match AdapterRenderProps:

    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 (
        <button ref={rootRef as never} className={className} style={sx as never} disabled={disabled}>
          {label}
        </button>
      )
    }
    

    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/<name>/index.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 <Editor /> 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 <Frame>, 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:

    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/<name>/components/<Canonical>.tsx matching AdapterRenderProps.
  2. Add it to the adapter's components map in src/adapters/<name>/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="<id>"]. Only override tokens that differ from :root — the cascade handles the rest:

    [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/<id>.ts:

    import { registerTheme } from './registry'
    registerTheme({ id: 'forest', displayName: 'Forest', dataThemeValue: 'forest' })
    
  3. Add one line to src/themes/index.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.

    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<AnimationSlice>): 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/<Name>Panel.tsx. Use the shared building blocks. The Inspector wraps each panel in a CollapsibleSection, so don't render your own title:

    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<AnimationSlice>) => {
        writeClasses(mergeAnimation(classString, patch))
      }
      return (
        <section className="space-y-2">
          <PanelRow label="Animate">
            <ValueSelect
              value={slice.animate ?? ''}
              options={ANIMATIONS}
              onChange={(v) => update({ animate: v as Animation | undefined })}
            />
          </PanelRow>
        </section>
      )
    }
    

    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):

    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

npx shadcn add <component-name>

This writes to src/components/ui/<component-name>.tsx. The adapter impl wraps it.

Watch out: if the shadcn CLI writes to ./@/components/ui/<name>.tsx instead, your tsconfig.json is missing the @/* path aliases (the CLI reads root tsconfig). Fix per § 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.

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 (
    <p
      ref={rootRef}
      className={className}
      onDoubleClick={(e) => {
        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. */}
      <EditableText text={content} propPath="content" multiline />
    </p>
  )
}

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:

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 (
    <EditorImageProvider value={backend}>
      <Editor />
    </EditorImageProvider>
  )
}

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/<id>.ts; SDK consumers call the same registerTheme.

// 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.

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.

// ✅ 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.

// ✅ 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:

// ✅ Right
export function MyBox({ children, rootRef, className, inlineStyle }: AdapterRenderProps) {
  return <div ref={rootRef} className={cn(className)} style={inlineStyle}>{children}</div>
}

// ❌ Wrong — drops md:* utilities AND the user's arbitrary hex / px picks
export function MyBox({ children, rootRef, style }: AdapterRenderProps) {
  return <div ref={rootRef} className={cn(style.classes.root)}>{children}</div>
}

Pattern B (multiple slots) — read composedClasses[slot] / composedInlineStyles[slot] per region:

// ✅ Right — each slot gets its own composed classes + inline styles
export function MyCard({ composedClasses = {}, composedInlineStyles = {}, children, rootRef }: AdapterRenderProps) {
  return (
    <div ref={rootRef} className={cn(composedClasses.root)} style={composedInlineStyles.root}>
      <header className={cn(composedClasses.header)} style={composedInlineStyles.header}>…</header>
      <section className={cn(composedClasses.body)} style={composedInlineStyles.body}>{children}</section>
    </div>
  )
}

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.

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.

// ✅ Right — ref on the visible element
<div ref={rootRef} className={className}>{children}</div>

// ❌ Wrong — Craft can't find a DOM node to attach to
<div className={className}>{children}</div>

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:

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 <Editor /> 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:

// 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.

    // 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:

    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<string, unknown> — shallow-merged over the canonical's defaults.
  • style?: Partial<NodeStyle> — 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:
// 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 <Editor /> mounts:

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.<prop> 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-<id>) and — for URL-backed fonts — the @font-face family name.

  2. Register:

    // 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:

    // 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:

    import type { ErrorFallbackProps } from '@/editor/errors/ErrorBoundary'
    import { AlertTriangle, RefreshCcw } from 'lucide-react'
    
    export function MyToolFallback({ error, reset }: ErrorFallbackProps) {
      return (
        <div className="rounded border border-destructive p-2">
          <div className="flex items-center gap-1.5 text-xs">
            <AlertTriangle size={12} className="text-destructive" />
            My tool failed
          </div>
          <p className="text-[11px] text-gray-600">{error.message}</p>
          <button onClick={reset} className="text-[11px] text-primary hover:underline">
            <RefreshCcw size={10} /> Retry
          </button>
        </div>
      )
    }
    
  2. Wrap your subtree:

    import { ErrorBoundary } from '@/editor/errors/ErrorBoundary'
    import { MyToolFallback } from './MyToolFallback'
    
    <ErrorBoundary fallback={MyToolFallback} onError={(err, info) => myTelemetry(err, info)}>
      <YourComponent />
    </ErrorBoundary>
    
  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.

// ✅ 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:

  • Basestyle.inline[slot][cssProp] (unchanged).
  • Non-basestyle.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 <style> block.

Don't read from style.inline[slot] directly when authoring a panel — use useNodeClasses(nodeId, slot).inlineStyle. That returns the active-breakpoint slice. Direct reads give you the base slice regardless of where the user is currently editing.

Form components are non-interactive in editor mode

The shadcn / MUI impls for Select, Checkbox, Radio, Switch, and Textarea pass no-op onChange (or onCheckedChange / onValueChange) handlers and use readOnly where applicable. This is deliberate: a Checkbox that toggles state when the user is editing would corrupt the prop store every click.

If you're adding a new form-like canonical, do the same:

// ✅ Right — controlled, no-op handler, optionally disabled
<Checkbox checked={checked} disabled={disabled} onCheckedChange={() => {}} />

// ❌ Wrong — real state mutation happens during editing
const [c, setC] = useState(checked)
<Checkbox checked={c} onCheckedChange={setC} />

This non-interactive behavior lives in the adapter impl, not the canonical contract. A future "preview" or "publish" mode can swap in real handlers without touching the canonical definition.


Common gotchas

Tailwind safelist

Symptom: an inspector control writes a class like text-2xl to the DOM, you can see it in DevTools, but the styling doesn't change.

Cause: Tailwind v4's JIT scans source for literal class strings. Classes built via template literals (`text-${size}`) are invisible to the scanner — no CSS is generated.

Fix: scripts/gen-safelist.ts reads the slice arrays from src/style/tw-classes.ts and emits src/style/safelist.generated.css with @source inline() directives for every utility × every breakpoint. Runs automatically via predev / prebuild hooks. If you added a new slice or new values to an existing slice, run npm run gen-safelist (or just npm run dev) — the generated file is gitignored and rebuilt from source on every dev/build cycle.

Theme-token utilities (text-primary, bg-card, …) are auto-generated by @theme inline — they don't need to be safelisted, but the generator includes them anyway as a hedge.

className lands but doesn't apply

Symptom: DevTools shows md:flex-row in a node's className, but resizing the browser to ≥ 768px doesn't change layout.

Cause: likely your adapter impl is reading style.classes.root directly rather than the composed className prop from AdapterRenderProps. style.classes.root only contains the base-breakpoint slice; the prefixed responsive utilities live in style.responsive[bp][slot]. CanonicalNode merges them into className for you — impls must consume that.

Fix: see § Adapter impls consume className.

shadcn primitives + refs

Status: the editor runs on React 19. Refs flow directly through shadcn's plain function components via React 19's ref-as-prop semantics — adapter impls pass ref={rootRef as never} directly to the shadcn component without needing a wrapper.

The old display: contents span workaround is gone. Any new adapter impl follows the direct-ref pattern; see src/adapters/shadcn/components/Button.tsx as the reference.

actions.setProp is an Immer mutator, not an immutable update

Craft uses Immer for setProp. You receive a draft — mutate it in place, don't return a new object.

// ✅ Right
actions.setProp(nodeId, (props) => { props.title = 'New title' })

// ❌ Wrong — returns are ignored
actions.setProp(nodeId, (props) => ({ ...props, title: 'New title' }))

Adapter Wrappers must be pure context providers

All registered adapters' Wrappers are always mounted (composed around the canvas), even when their adapter isn't active. A Wrapper that injects global CSS, attaches document-level event listeners, or otherwise leaks side effects would apply unconditionally — including when its adapter is inactive.

Side-effecting work goes in mount / unmount instead. Those fire only on active-adapter change.

MUI's color validator rejects CSS variables

Symptom: MUI: Unsupported var(--primary) color. at createTheme time.

Cause: MUI's cssVariables: true mode generates MUI's own CSS variables from real color values; it doesn't accept CSS-variable references as input.

Fix (in this codebase, already applied): pass valid placeholder colors to createTheme and override the generated --mui-palette-* variables via CSS. The .mui-bridge block in index.css redirects them to our shadcn tokens. See ARCHITECTURE.md § MUI palette bridge.

tsconfig path aliases

@/* paths must live in two places:

  • tsconfig.json (root config) — the shadcn CLI reads this when resolving @/* to write files.
  • tsconfig.app.json (the actual app config) — tsc -b reads this; without it, builds break.

vite.config.ts needs its own resolve.alias for runtime resolution:

import path from 'node:path'

export default defineConfig({
  // …
  resolve: { alias: { '@': path.resolve(__dirname, './src') } },
})

If npx shadcn add writes a literal @/ directory at the project root, the root tsconfig is missing the paths block.

baseUrl is deprecated in TypeScript 6+

paths resolves relative to the tsconfig file itself; no baseUrl needed. Older shadcn CLI versions emit baseUrl: "." — drop it when reconciling.

Inspector panel visible for a canonical where its controls don't apply

The inspector mounts each panel only when getApplicablePanels(canonicalDef).includes(<panelId>). By default, panels are derived from the canonical's category + isCanvas:

  • Containers (isCanvas: true) get the Layout panel.
  • content / layout category canonicals get the Typography panel.
  • Every canonical gets Spacing / Size / Appearance / Effects / Props.

Canonicals can override the default with an explicit applicablePanels: readonly PanelId[] field. Button does this — it omits Typography because shadcn's button primitive uses inline-flex centering and h-* size variants that ignore Tailwind text utilities.

If a panel's controls don't visibly affect some canonical, drop that PanelId from that canonical's applicablePanels (or extend getApplicablePanels's default rule if multiple canonicals share the issue).

useEditor collector reads stale non-Craft state

Symptom: an inspector hook depends on both Craft node state AND non-Craft state (Zustand, props, etc.). When the non-Craft state changes, the hook still reads old values until Craft state changes too.

Cause: useEditor's collector only re-runs on Craft state changes. The collector closure captures its other dependencies at the previous Craft state change.

Fix: compute the derived value in the hook body, not the collector. Use the collector to subscribe to the right slice of Craft state (e.g., return { props } so it re-runs when props change), but compute final outputs in the body where every re-render reads fresh values.

const { actions, props } = useEditor((_, q) => ({ props: q.node(id).get().data.props }))
// activeBreakpoint is fresh on each render via Zustand subscription:
const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
// classString combines both — body-level computation:
const classString = activeBreakpoint === 'base'
  ? props.style.classes[slot] ?? ''
  : props.style.responsive?.[activeBreakpoint]?.[slot] ?? ''

See src/editor/inspector/shared/useNodeClasses.ts for the canonical example.


Persistence

Documents are saved to localStorage['craftjs-design:doc:v1'] with the envelope shape:

{
  "version": 1,
  "adapterId": "shadcn",
  "themeId": "rose",                            // optional
  "craftJson": "<query.serialize() output>"
}

Validated via Zod (persistence/schema.ts). The craftJson field is opaque — never parse/rewrite it directly.

Wipe local state during development:

// In the browser DevTools console
localStorage.removeItem('craftjs-design:doc:v1')

Or call clearDocument() from persistence/storage.ts.


Testing

Vitest is wired in:

npm test              # watch mode
npm run test -- --run # single-pass (CI-style)
npm run test:ui       # vitest UI in browser

The test suite currently covers style/tw-classes.ts — every parser/serializer/merge across all six slices plus cross-slice isolation. When adding a new slice (see § Adding an inspector panel), add the matching test block.

The pattern is consistent: for each slice, test

  1. Extraction — every recognized field parses correctly.
  2. Unknown passthrough — classes from other slices land in unknownClasses.
  3. Disambiguation (where applicable) — e.g., text-center (align) vs text-foreground (color).
  4. Merge patch — changes only the patched field; other slices' classes survive.
  5. Round-trip stabilitymerge(input, {}) yields a token-set equal to the input.

UI components and panels aren't covered by unit tests. Verify visually in the browser when adding new panels.


Debugging tips

A dropped component doesn't render

  • "No impl in adapter" placeholder visible: the active adapter doesn't cover that canonical. Either swap adapter or add the impl (see § Adding an adapter impl for an existing canonical).
  • Console error from Zod: an adapter manifest is malformed. The error message includes the adapter id and the failing field.

Class lands in the DOM but styling doesn't change

Two possible causes:

  1. Tailwind safelist — class isn't in safelist.generated.css. Run npm run gen-safelist; check the slice arrays in tw-classes.ts. See § Tailwind safelist above.
  2. Adapter impl reading style.classes.root directly — see § className lands but doesn't apply above.

Responsive variant doesn't apply when the viewport crosses a breakpoint

If md:flex-row is in the className but resizing to ≥ 768px doesn't change layout, check that Tailwind compiled CSS for .md\:flex-row:

curl -s "http://localhost:5173/src/index.css?direct" | grep "md\\\\:flex-row"

If the rule is missing, the safelist's breakpoint multiplier didn't include this utility. If the rule is present, browser DevTools should show it under the @media (min-width: 48rem) block in Computed Styles — if flex-direction: row isn't applied, there's a specificity conflict with another rule.

Theme swap doesn't affect MUI components

Inspect a MUI component's computed styles in DevTools. The --mui-palette-primary-main etc. should resolve through var(--primary) from [data-theme="<id>"]. If MUI's generated names differ from what's in .mui-bridge, update the bridge block.

Adapter swap loses canvas content

Should not happen. If it does, the React tree shape between adapters is changing. Check that AdapterProvider.composeAllWrappers is running and rendering ALL adapters' Wrappers, not just the active one. See ARCHITECTURE.md § Wrappers compose, not switch.

Hydrator restoring state on every adapter swap

Should not happen with the hydrated module-level flag in Hydrator.tsx. If you see this, the module reloaded — typically Vite HMR resetting module state. Reload the page; production builds don't HMR.


When to update this guide

Update when you:

  • Change a public-facing contract (adapter, canonical, theme manifest).
  • Discover a non-obvious gotcha worth saving the next person from.
  • Add a new file pattern other devs will replicate (a new inspector panel type, a new layer).

Don't update for:

  • Internal refactors with no API change.
  • Routine progress tracking — that belongs in the changelog, not here.