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
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 seeexamples/adapter-chakraandexamples/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
- Add a design system, custom canonical, panel, or theme — the
docs/TUTORIAL_*guides +docs/COOKBOOK.md. - Entry-point + peer-dependency matrix —
docs/INTEGRATION_GUIDE.md.
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
exportsmap 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
registerThemeand the chrome viaeditorTheme, not by overriding editor utilities. (editorThemeworks 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
muibefore 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
adapterIdis 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/styledPinning
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 defaultshadcn.
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— theEditorDocumentenvelope (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'sadapterId. Adapters are per instance: several renderers with different adapters can coexist on one page.- The envelope's
themeId+colorModeapply 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 bygroup) that inserts the selected token at the caret; users can also type tokens directly. keysupports 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
valuesprop when present, else the variable'ssample, 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.
editorThemestyles the editor around the canvas. The canvas content your end users design is themed separately byregisterTheme/ 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 foreditorThemeto restyle the canvas, orregisterThemeto 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 oncedocumentRegistry.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 whenwriteDocument/writeDocumentIndexcatches aQuotaExceededErrorfromlocalStorage.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
safelistand 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.
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.
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 bysx-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. ForwardrootRefto the outermost real DOM element so Craft's connectors attach. - Pattern B impls read
composedClasses[slot],composedInlineStyles[slot], andslotChildren[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 viaslotChildren. Outer is NOT a canvas; inner slots are. Function form: supply(props) => readonly string[]for dynamic counts — Tabs uses this to expose one canvas perprops.tabsentry. 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 (
unregister→registerwith the same id + differentpropsSchema) 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
createPortalto#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
nameprop; triggering components (Button, Icon, …) flip it via theirtriggers: 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'sclassNamewith 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 itsprops:props as ButtonProps. Type-only.
Carousel / dynamic-canvas slot helper
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:
- Stepper —
stepperSlotKey(i)/stepperSlotKeys(count). - Table —
tableCellSlotKey(r, c)/tableCellSlotKeys(rows, cols, merges), plus the merge-geometry helperscontainingMerge/isCellCoveredand theTableMergetype, 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-classesslice 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 insrc/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 names —
check:sizebudgets 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.
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.json → mcpServers):
{
"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:
- Discover —
list_canonicals(every component, container vs leaf vs multi-canvas) anddescribe_canonical(full props JSON Schema, defaults, slots, panels). - Start —
create_document(root is a Box canvas), orapply_template/load_document. - Build —
add_nodereturns the new node's id; address later edits by it.- Pattern A containers (box, stack, section): pass
parentId. - Pattern B (card, tabs, table): pass
parentIdandslot(seedescribe_canonical→canvasSlots).
- Pattern A containers (box, stack, section): pass
- Refine —
update_node_props,update_node_style,move_node,remove_node. - See it —
render_image(a PNG you can look at),outline_document(cheap text tree), orrender_html(structure-faithful HTML). - Check colors —
theme_palette(the theme's pairs) +check_contrast(per text node, worst-first) so you don't ship illegible text. - Finish —
validate_document, thenget_documentfor theEditorDocumentJSON.
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 asindeterminate(verify those withrender_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-sandboxalready. - 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_imageis structure + style faithful, not a design mockup — it's exactly what<DocumentRenderer>produces.render_htmlis 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_htmlalways previews through the dependency-free HTML adapter for reliability.
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-systemThis 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.mdfor 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/*.tsfor the full list — 48 canonicals total (npm run docs:matrixprints them). Add impls + entries to thecomponentsmap. - 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.
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 stepperThis 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 inputz.number()→ number inputz.boolean()→ checkboxz.enum([...])→ dropdownz.array(z.object({...}))→ list editor with add/remove/reorderz.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'sapplicableTopredicate 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
applicablePanelswhitelist or each panel'sapplicableTopredicate). - 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
- Reload the dev server.
- Toolbox shows "Stepper" in the Navigation category.
- Drag onto canvas — three dots appear (your default
totalSteps: 3). - Inspector's PropsPanel exposes
currentStep,totalSteps,showLabels. - Change
currentStepto 1 — first two dots highlight. - 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.
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,registerPanelcall, and a passing smoke test) and customize it:npx @crafted-design/editor scaffold panel notesThis 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:
- If the canonical declares
applicablePanels(a whitelist), only panels with ids in that list render. Custom panel ids not in the whitelist are excluded. - 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
- Reload the editor.
- Select any non-form node on the canvas.
- Inspector shows a "Notes" section below "Properties" (order=100 > 70).
- Type some notes. Select away, then back — the notes persist.
- 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'sactionsandquery. 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/sdkto look up the canonical's metadata (e.g., to show the schema in a debug pane). - Panel that interacts with multiple nodes.
useEditorexposesstate.events.selected— your panel can react to selection changes. - Panel scoped to specific canonicals. Narrow
applicableTo:(def) => def.id === 'card'.
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.
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/coreif you don't want MUI. (This split landed in the0.7.0CHANGELOG 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:
- 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. - 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.
- 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-exampleadapter 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.xminor 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.
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.0entry yet. The package has only ever lived behind thenextdist-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 a2.0.0(or a breaking1.xduring 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
versionbump, - 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".
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:
- Controlled component (1.6.0) — own the document in your own state and use
onChange(or the imperativeref.getDocument()):JSON.stringify(doc)is what you persist; pass it back viavalueto restore. No editor persistence involved (persistence={false}). See INTEGRATION_GUIDE → Embedding as a controlled component. - StorageAdapter — keep the editor's built-in document lifecycle but point
it at your backend: implement the
StorageAdapterinterface and register it withsetStorageAdapterbefore 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.
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:
- What the user is composing — an abstract tree of "components" (Button, Input, Box, Card…).
- Which UI library renders those components — shadcn, MUI, Chakra, or a custom kit.
- 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, nocanvasSlots. Buttons, inputs, text, badges, etc. - Pattern A — single canvas (one
'root'drop zone):isCanvas: true, nocanvasSlots. Children arrive through Craft'schildrenprop. 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):
canvasSlotsis set (a static list or a(props) => string[]function).CanonicalNodegenerates one<Element canvas id={slot}>per slot and hands the adapter implslotChildren[slot]to place each region. Five canonicals: Card (header/body/footer, static), Table (per-cell, dynamic fromrows×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'svalueno 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 infallbacks.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 awindow.storagelistener (which only fires for writes from OTHER tabs by spec). Three outcomes, decided by the puredecideStorageEvent(event, activeId)helper:- Doc-index changed →
documentStore.reloadIndexFromStorage()re-reads the index in place so the document menu reflects the external rename / delete / create. - Active doc's blob changed and parses cleanly → the remote
envelope lands in
editorStore.concurrentEditConflict, andConcurrentEditBannershows two actions: Reload (apply remote viaapplyEnvelopeSafely) or Overwrite (save local snapshot back, blowing away the remote write). - Everything else (unparseable / unrelated / inactive doc) →
ignored. Inactive docs' freshest version is naturally picked up
by
useDocumentSwitchernext time the user switches to them.
- Doc-index changed →
StorageQuotaBanner+StorageQuotaErrorModal(src/editor/persistence/, backed by editorStore'sstorageQuotaPercent/storageQuotaDismissed/storageSaveFailed) warn the user about localStorage pressure.documentRegistry.getStorageUsage()sums everycraftjs-design:*key and reports a percentage against a conservative 5 MB ceiling.documentStore.reportWritecalls this after every save / index write; usage ≥ 80% surfaces the banner under the header, and aQuotaExceededErrorfromlocalStorage.setItem(now caught and reported via theWriteResultreturned bywriteDocument/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) anduseDocumentSwitcher(runtime switch) route everyactions.deserializecall throughapplyEnvelopeSafely. Before deserialize, the integrity check validates the craftJson: parses as an object, has ROOT, everyparent/nodes/linkedNodesref resolves, every type is either'div'or a registered canonical. Either path of failure (pre-check OR deserialize throw) setseditorStore.malformedDocument; the editor shell swaps the Frame forMalformedDocumentBanner. The banner offers Show raw JSON, Export raw, and Reset to empty — the last archives the broken envelope undercraftjs-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 asunknownClasses.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)
Toolbox.tsxbuilds a<button ref={el => connectors.create(el, <Element is={Bound} canvas={def.isCanvas} nodeProps={…} style={…} />)}>for each canonical.- User mouse-down on the button → Craft starts a drag.
- 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. - Craft renders that node by looking up its
displayNamein the resolver. The match is theBoundthunk built bybuildResolver(). Boundcalls<CanonicalNode canonicalId={…} {…} />.CanonicalNodereads:- The canonical def via
getComponent(canonicalId)— what slots, what schema, isCanvas flag. - The active adapter via
useActiveAdapter()— the React component to delegate to.
- The canonical def via
CanonicalNodecallsuseNode()to get Craft'sconnect/dragconnectors, packages them into arootRefcallback.CanonicalNodeinvokesadapter.classMap(style.classes.root, canonicalId)if defined; falls back to{ className: style.classes.root }. The result feedsclassName/sx/inlineStyleprops on the impl.- The adapter impl renders. It attaches
rootRefto 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
- User clicks a rendered node's DOM element.
- The element has Craft's data attributes (from
connectviarootRef). Craft's global event listener traverses up from the click target, finds those attrs, identifies the node. actions.selectNode(id)runs.state.events.selectedupdates.Inspector.tsx'suseEditor((state, query) => …)selector re-fires. It reads the first id fromstate.events.selected, callsquery.node(id).get()for the displayName, andquery.node(id).isRoot()for the delete-guard.- Inspector re-renders and mounts inspector sub-panels for the selected node.
Save
- User clicks Save in
SaveLoadBar. useEditorStore.getState()readsactiveThemeId+activeAdapterId(imperative — Save isn't subscribed).query.serialize()returns Craft's tree as an opaque JSON string.- The envelope
{ version: 1, adapterId, themeId, craftJson }is built. documentSchema.parse(...)validates the envelope.localStorage.setItem('craftjs-design:doc:v1', JSON.stringify(...)).
Load
Either via Hydrator (auto, once on mount) or useDocumentSwitcher (runtime). Both paths route through applyEnvelopeSafely:
- Read the envelope (
documentRegistry.readDocument(id)for the registry path,decodeDocument(fragment)for the shared-URL path). validateCraftJson(envelope.craftJson)— pre-check parses JSON, requires ROOT, verifies everyparent/nodes/linkedNodesref resolves, everytype.resolvedNameis'div'or a registered canonical.actions.deserialize(...)— Craft replaces the tree.- 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
catchswallows 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.malformedDocument — Editor.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
- User picks a different theme in
ThemeSwitcher. onChange→useEditorStore.getState().setActiveTheme(<id>).ThemeProvider(subscribed viauseEditorStore((s) => s.activeThemeId)) re-renders.ThemeProviderlooks up the theme, renders<div data-theme={…} style="display:contents">.- The browser's CSS selector matches the wrapper. The cascading custom properties (
--primary, etc.) inherit through the descendant tree. - Every utility like
text-primary/bg-primaryresolves tovar(--primary)and repaints with the new theme's value. No Craft tree state changed.
Adapter swap
- User picks a different adapter in
AdapterSwitcher. onChange→useEditorStore.getState().setActiveAdapter(<id>).AdapterProvider(subscribed viauseEditorStore((s) => s.activeAdapterId)) re-renders.getAdapter(<id>)returns the chosen adapter.- The
useEffect([adapter])cleanup fires the previous adapter'sunmount(if any), then the new effect fires the new adapter'smount(if any). - 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.
- Every
CanonicalNodeinside re-renders with the new active adapter. Each looks up its impl inadapter.components. Nodes with an impl render; nodes without get the missing-impl placeholder. - 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)
- User selects a node.
Inspectormounts the applicable panels (filtered bygetApplicablePanels(canonicalDef)). - Each panel calls
useNodeClasses(nodeId, slot)which returns{ classString, inlineStyle, writeClasses, writeInline, activeBreakpoint }. The hook reads eitherstyle.classes[slot](base) orstyle.responsive[activeBreakpoint][slot]. - The panel calls its slice's
parse*to decomposeclassStringinto a typed slice, binds controls to slice fields. - User changes a value (e.g., picks
primaryin ColorPicker, picks'4'in NumericInput). The panel callswriteClasses(mergeSlice(classString, patch))AND clears any matching inline property viawriteInline(cssProp, undefined)— tokens and arbitrary values stay mutually exclusive. writeClassescallsactions.setProp(nodeId, (props) => …). The Immer mutator writes toprops.style.classes[slot](base) orprops.style.responsive[bp][slot](non-base).- Craft re-renders.
CanonicalNodereads the new style, callscomposeResponsive(style, 'root')+composeInlineStyle(style, 'root'), passes the result throughadapter.classMap(or default), feedsclassName+inlineStyleto the adapter impl. - Adapter impl renders
<elt className={cn(className)} style={inlineStyle}>.
Inspector edit (arbitrary value)
- User opens ColorPicker, drags the visual picker or types a hex. NumericInput accepts
13pxand commits on Enter. - The panel detects the value isn't in the token enum (or comes from the picker's onChange) and calls
writeInline(cssProperty, value)ANDwriteClasses(mergeSlice(classString, { <field>: undefined }))— clearing any matching token class. writeInlinewrites toprops.style.inline[slot][cssProperty]. Always base-level — responsive arbitrary values aren't stored.- Craft re-renders.
composeInlineStylereturns the new inline map. The impl receives it asinlineStyleand applies it via the rendered element'sstyleattribute. - 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)
- User clicks
mdinResponsiveBar.setActiveBreakpoint('md'). - Every component using
useEditorStore((s) => s.activeBreakpoint)re-renders —ResponsiveBaritself and every inspector panel viauseNodeClasses. - Each panel's
useNodeClassesre-reads fromstyle.responsive.md[slot](empty for a fresh md edit →classString = ''). TheinlineStyleread still returns the base inline (responsive arbitrary isn't stored). - 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.
- 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. composeResponsivenow 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)
- User clicks
mdinResponsiveBar.setActiveBreakpoint('md'). - Every component using
useEditorStore((s) => s.activeBreakpoint)re-renders —ResponsiveBaritself and every inspector panel viauseNodeClasses. - Each panel's
useNodeClassesre-reads fromstyle.responsive.md[slot](empty for a fresh md edit →classString = ''). - 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. composeResponsivenow 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 viastyleSlots. - 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'svalueno 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:
Inspector side:
SlotPickerexposes the slots as a pill bar.InspectortracksactiveSlotin local state (resets to'root'on selection change). Every class-editing panel receivesslotas a prop, which it threads intouseNodeClasses(nodeId, slot). The hook reads from / writes tostyle.classes[slot](base) orstyle.responsive[bp][slot](non-base).Render side:
CanonicalNodeiteratesdef.styleSlotsand computescomposedClasses[slot]+composedInlineStyles[slot]for each. These maps are passed to the adapter impl alongside the root-slotclassName/inlineStyle(which are duplicates of the root entries, kept for Pattern A backwards compat).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. canvasSlotsunset,isCanvas: true→ Pattern A (legacy single canvas via['root']).canvasSlotsunset,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:
- Calls
composeResponsiveInline(style, slot), which content-hashes the slot's combined inline (base + responsive) into a stable class id likeri-3jvn7. - Generates CSS rules — base declarations + one
@media (min-width: …)block per breakpoint. - Appends the class id to
composedClasses[slot]. - Emits the CSS inside an inline
<style>element rendered as a sibling of the impl. - 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@mediaclass 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:
- 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. - 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— fortsc -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)):
- If
def.applicablePanelsis set, that's a whitelist — only registered panels whose id appears in the list render. Preserves the legacy semantics where Button explicitly excludes typography. - 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:
- Shared URL — if
window.location.hashmatches#doc=<encoded>, decode viashare.decodeDocument, create a new "Shared document" entry viadocumentStore.createDocument, deserialize into Craft, clear the fragment. Non-destructive: the user's previous active doc stays in the index. - Active doc — otherwise
documentStore.loadActiveDocument()returns the active blob;actions.deserializereplaces 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.selectedstays the source of truth for the document/connector layer (resize overlay, default left-click connector, drag). useSelectionSyncmirrors Craft → editorStore one-way: when Craft's single-node selection changes (left-click connector, etc.), it resetseditorStore.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:
- Toolbox —
useSyncExternalStoretriggers a re-render →listComponents()returns the new set → palette updates. - ResolverUpdater — same
useSyncExternalStorepattern →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:
mui/theme.ts: pass valid placeholder hex colors tocreateTheme. MUI's validator is happy. These become fallback values if step 2 fails..mui-bridgeCSS block inindex.css: override MUI's generated--mui-palette-*variables to reference our shadcn tokens. The<div className="mui-bridge">inMuiWrapperapplies 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:
- Hydrator re-fires, re-reads localStorage, and reverts the user's adapter pick.
- 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, sm…2xl) 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: literal1today. Bump only when the envelope shape changes — not whencraftJson's internal shape changes (Craft owns that).adapterId: pinned at save time fromuseEditorStore.getState().activeAdapterId. Hydrator restores viasetActiveAdapter. 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'sprops.stylecarries up to three fields:classes(base slot → class string),responsive(breakpoint → slot → class string) once the user has authored breakpoint variants, andinline(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.
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
documentlevel (not as a ReactonKeyDown) because some Craft.js connectors / Radix overlays attach direct DOM listeners that can callstopPropagationon synthetic events. The handler gates oncontainerRef.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>, orcontenteditableelement. - Each canvas node's DOM gets
tabindex=-1fromCanonicalNode.attachRefso 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' })(instantso reduced-motion / smooth-scroll settings don't fire a flood of scroll events on each keypress). - Arrow-nav selection writes go through
editorStore.setSelectionwrapped influshSync, in lock-step withactions.selectNode, so the Inspector / Layer tree / breadcrumbs (which subscribe toeditorStore) 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:
Toolbox.tsxsearch input — wrap in<label>per the gap above.- Color contrast of muted-on-muted backgrounds — verify.
- 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.
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:
- 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. - Zustand
editorStore— accessed viauseEditorStore((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:
- Mount — initial Editor render with one default document.
- Drop component — drag a Box from the Toolbox to the canvas.
- Select node — click an existing node.
- Token color edit — pick a Tailwind color token in the ColorPicker.
- Hex color edit — drag the visual S/L picker.
- 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
HexColorPickermanages its visual cursor with its own internal Saturation/Hue state. Once mounted with an initialcolor, 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 thevalueprop. 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
isResizingRefset on mousedown / cleared on mouseup. recompute()bails out whenisResizingRef.current === true. The ResizeObserver keeps firing during the drag but its setRect calls are skipped.- Added
overlayRefand during onMouseMove the overlay's ownstyle.width/style.heightare 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 syncrectstate 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:
- The
setPropcommit reconciling the canvas tree with the new size class (33 ms, ~2000 fibers — one-time cost on release). - An inspector Select tick reading the new size into the size dropdown.
- The final
ResizeOverlay+ 8 Handles render syncing the Reactrectstate 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:
- Open the editor in dev mode (
npm run dev). - Open React DevTools → Profiler tab.
- Click record, perform exactly one instance of the target flow, click stop.
- Save to
profiler/performance_flow_<n>_<name>.json. - Run
python3 /tmp/analyze_profiles.py(or re-extract via the same logic) to print the commit count, total ms, and top renderers. - 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.
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.
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' } }, }, })Add one line to
src/registry/components/index.ts:import './tooltip'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.
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: '' }, }, }, })Write the adapter impl. Consume
composedClasses[slot]andcomposedInlineStyles[slot]per region. The root slot is duplicated to the legacyclassName/inlineStylefields 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> ) }No changes needed in Inspector or panels —
SlotPickershows automatically whenstyleSlots.length > 1, and every class-editing panel already accepts aslotprop.
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:
Declare both
styleSlotsandcanvasSlots. The outer canonical'sisCanvasMUST befalse— declaring bothisCanvas: trueANDcanvasSlotswould 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: '' } }, }, })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> ) }Each
slotChildren[slot]renders as a<div class="canvas-slot">…</div>. The.canvas-slotclass insrc/index.cssgives empty slots a min-height + a dashed outline + a "Drop here" hint via:empty— disappears the moment the slot has children.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.tsthat 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 persistedisCanvas: truetofalse.
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:
Give each item a stable id and derive slot keys from it. Use a
z.string().default(() => genId())field namedid— the inspector hidesid-named ZodDefault fields automatically, so the designer never edits the slot key (editing it would orphan the dropped content). Export aslotKeys(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), // … })The adapter reads
slotChildren[key]for each item via the same helper. Export the helper fromsrc/sdk/canonical.tsso third-party adapters can match the keys (tabSlotKeys/slideSlotKeysare the precedents).Branch on
useIsEditing()if the runtime view differs from the authoring view (Carousel pins to the authoredcurrentSlidein 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:
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> }Hide it from the toolbox (
hidden: trueon 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'striggers: string[]to the overlay'sname.Open state lives in the overlay runtime store (keyed by the
nameprop), 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.Test the
useIsEditingbranch both ways — the top-bar Preview toggle flipsstate.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.
Create
src/adapters/<name>/components/<Canonical>.tsxfor each canonical you want to support. MatchAdapterRenderProps: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), orinlineStyle(raw CSS). Each is populated byCanonicalNodefromadapter.classMapor the default passthrough.(Optional) Provide capability hooks. The
Adapterinterface 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: nodocumentlisteners, 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.
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' }, })registerAdaptervalidates 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 aWrapper(a global provider like MUI'sThemeProvider), register it via a side-effect import in your entry module — never lazily after the editor is on screen.AdapterProvidercomposes 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.registerAdapteremits a dev warning if you break this. Adapters without a Wrapper can register any time (e.g. hot reload).Add a side-effect import to
src/App.tsx:import './adapters/mylib'AdapterSwitcherpicks the new adapter up automatically (iterateslistAdapters()).
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:
vite.config.dist.ts— add the adapter index tolib.entry(e.g.'adapters/mylib': resolve(__dirname, 'src/adapters/mylib/index.ts')). Externalize any heavy peer library there too (so it isn't bundled).package.jsonexports— add the subpath, pointingimportat./dist-lib/adapters/mylib.jsandtypesat the per-file./dist-lib/adapters/mylib/index.d.ts(vite-plugin-dts emits a file tree, not a single bundled.d.ts).package.jsonsideEffects— 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-lessdist-libin earlier versions). Add the same topeerDependenciespeerDependenciesMeta(optional) if it needs an external library.
src/core.tsx(and/or the fullsrc/main-app.tsx) — add the side-effect import so the chosen batteries-included entry registers it.- Export a non-type value from the index (e.g.
export const adapterId = 'mylib') so vite-plugin-dts emits a.d.tsfor the subpath; a bare side-effect import still registers the adapter. src/adapters/adapters-register.test.ts— extend the coverage-parity guard.npm run docs:matrixregenerates the matrix; CI's--checkfails 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:
- Create
src/adapters/<name>/components/<Canonical>.tsxmatchingAdapterRenderProps. - Add it to the adapter's
componentsmap insrc/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
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); }Create
src/themes/<id>.ts:import { registerTheme } from './registry' registerTheme({ id: 'forest', displayName: 'Forest', dataThemeValue: 'forest' })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.
Add a slice to
src/style/tw-classes.tsif 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 —parseXshould pass through every class that's not in X's prefix family asunknownClasses. 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 { /* … */ }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.Add a
PanelIdtosrc/registry/types.tsand extendgetApplicablePanelsdefaults if useful. If the panel applies only to specific canonicals, leave the default rule alone and let canonicals opt in via explicitapplicablePanels.Build the panel in
src/editor/inspector/<Name>Panel.tsx. Use the shared building blocks. The Inspector wraps each panel in aCollapsibleSection, 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 viarenderOption),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).useNodeClassesis 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 basestyle.inline[slot]. Your panel gets responsive support for free.Read the live class string at write time by passing the current
classStringintomerge*— that's the closure-captured value, refreshed on every render viauseNodeClasses. Don't callparseAnimationseparately just before writing; the merge function already does it.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.useNodeClassesroutes the writes automatically based onactiveBreakpoint; no panel-side gating needed.Register the panel via
registerPanel. The Inspector resolves panels through a registry. Add a side-effect import for your panel's registration inApp.tsx(or insrc/editor/inspector/built-in-panels.tsif 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'sapplicableTo(def)predicate decides. Canonicals with explicitapplicablePanels(Button, the 5 form canonicals) won't show your panel unless they add'animation'to their list.Add the slice's utilities to
scripts/gen-safelist.tsso Tailwind compiles them. The script reads slice arrays fromtw-classes.ts— addexpand('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:
EditableTextrenders a Fragment in display mode (no DOM wrapper — the parent's typography applies directly) and acontenteditable="plaintext-only"span in edit mode. It writes todata.props.nodeProps[propPath]— notdata.props[propPath](the canonical props live one level down, undernodeProps).- 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 usesuseNode()to resolve the node id). It's the only supported way to enter edit mode — adapter authors never toucheditorStoredirectly.
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.
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 ], }, }), })Add a side-effect import to
src/persistence/templates/index.ts:import './dashboard'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:
- Bump
CURRENT_DOCUMENT_VERSIONinsrc/persistence/schema.ts. - Add a step to
MIGRATION_STEPSwhoseup(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
downsteps (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.
readDocumentshould parse withdocumentSchemaand runmigrateDocument(the built-in adapters do) so older envelopes upgrade on load. - Versioning is opt-in. Omit the
*Versionmethods 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-changedyourself 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):
- Identify the selected node's DOM via
query.node(id).get().dom. - During the gesture, mutate
dom.style.<prop>directly. React doesn't track these writes — no re-render per mousemove, smooth 60fps. - 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.
Decide on an id. Lowercase, digits, hyphens only. Used as both the class suffix (
font-<id>) and — for URL-backed fonts — the@font-facefamily name.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', })Side-effect import:
// src/App.tsx — alongside the other side-effects import './your-fonts'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.
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> ) }Wrap your subtree:
import { ErrorBoundary } from '@/editor/errors/ErrorBoundary' import { MyToolFallback } from './MyToolFallback' <ErrorBoundary fallback={MyToolFallback} onError={(err, info) => myTelemetry(err, info)}> <YourComponent /> </ErrorBoundary>reset()clearsstate.errorand 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):
- Add the implementation in its natural internal location.
- Re-export it from the appropriate
src/sdk/*.tsfile. - Add the name to
src/sdk/boundary.test.ts'sEXPECTED_FUNCTIONSlist (catches accidental future removal). - Add JSDoc with a runnable usage example.
Responsive arbitrary inline works at every breakpoint
Arbitrary inline values are not restricted to the base breakpoint. The data shape:
- Base —
style.inline[slot][cssProp](unchanged). - Non-base —
style.responsiveInline[bp][slot][cssProp](new).
useNodeClasses routes reads / writes between the two automatically based on activeBreakpoint. Panel code doesn't gate by breakpoint anymore — calling writeInline(cssProperty, hexValue) at the md breakpoint writes to style.responsiveInline.md[slot][cssProperty]. CanonicalNode generates a hash-keyed CSS class with @media rules covering all breakpoints + the base entry; the class is appended to the slot's composed className and the CSS is rendered inside an inline <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 -breads 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/layoutcategory 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
- Extraction — every recognized field parses correctly.
- Unknown passthrough — classes from other slices land in
unknownClasses. - Disambiguation (where applicable) — e.g.,
text-center(align) vstext-foreground(color). - Merge patch — changes only the patched field; other slices' classes survive.
- Round-trip stability —
merge(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:
- Tailwind safelist — class isn't in
safelist.generated.css. Runnpm run gen-safelist; check the slice arrays intw-classes.ts. See § Tailwind safelist above. - Adapter impl reading
style.classes.rootdirectly — see §classNamelands 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.