Editor primitives
Applies to @snowcone-app/canvas@0.33.1 · @snowcone-app/sdk@0.17.0
The parts
Everything the default editor is made of, exposed from @snowcone-app/canvas/embed. Parts carry behavior and state subscription — the realtime session, design state, selection, checkout — and nothing else. Layout, sizing, and order belong to your page: mount any subset anywhere under Editor.Root, and each part works alone.
<Editor.Root>— the one provider: session, design state, built-in error boundary. Takesshop,product,design, and a grant source.<Editor.Preview>— the live product mockup. Several under one Root (front + back) is valid.<Editor.TextField>— a form input bound to a design text layer by typed token.<Editor.Buy>— the smart commerce part: owns variant + quantity state and completeness gating, assembles the cart payload from design state; only the final submit is your callback.<Editor.Canvas>— the editable artboard. The heavy editor loads only behind this part — a composition without it never downloads it.<Editor.Layers>/<Editor.Controls>/<Editor.History>/<Editor.Menu>/<Editor.Add>— editor chrome as parts.Layersworks canvas-free; the rest require a mountedEditor.Canvasand throw a structured error naming both parts if it’s missing.
Editor.* surface left experimental status when the ADR-0085 gate passed: SnowconeEditor — the shipped default — is rebuilt from exactly these public parts, enforced in CI. The surface is additive-only from here.The canonical example — a personalization widget, no canvas
A live preview, one personalized text layer, a completeness-gated buy button. This exact file ships inside the npm tarball at node_modules/@snowcone-app/canvas/example/src/Widget.tsx (with the package’s llms.txt and the styling contract) and is the same code our CI accepts against every change — copy it, change the design, the product ids, and the parts.
import React from 'react';
import { Editor, design, type EditorBuyPayload } from '@snowcone-app/canvas/embed';
const SHOP_KEY = import.meta.env.VITE_SNOWCONE_SHOP ?? 'demo-shop';
// The design is the single source of truth: it seeds the first render AND
// mints the typed layer tokens the TextField binds with.
const nameDrop = design({ name: 'Front', width: 1480, height: 2328 })
.background('https://dqMwU8Jct3.storage.snowcone.app/demo/demo-008.jpg', {
aspectRatio: 1480 / 2328,
name: 'art',
})
.text('YOUR NAME', { anchor: 'top', name: 'name' });
const product = {
productId: 'BEEB77',
mockupIds: ['FV1qjO'],
placements: ['Front'],
// BEEB77 has a COLOR axis (Frame) — a color product REQUIRES variantId, or
// a live render times out. Read real ids from the catalog with getProduct().
variantId: 'Pv1sLC',
};
export function PersonalizationWidget({
onAddToCart,
}: {
/** Your cart handler. NOTE the payload's `designState` is the EDITOR shape
* (flat `elements` + artboard metadata) — persist it as-is. It is NOT the
* server render shape (`artboards[].elements`) the session pushes;
* `payload.serverRequest` carries that one if you need it. */
onAddToCart?: (payload: EditorBuyPayload) => void;
} = {}): React.ReactElement {
return (
<Editor.Root shop={SHOP_KEY} product={product} design={nameDrop} grantUrl="/api/realtime/grant">
<div style={{ display: 'grid', gap: 12, maxWidth: 320 }}>
<Editor.Preview alt="Your personalized tee" fallback={<div aria-busy="true">Rendering…</div>} />
<Editor.TextField layer={nameDrop.layer('name')} placeholder="Your name" />
<Editor.Buy
onAddToCart={
onAddToCart ??
(({ productId, quantity, designState }) => {
// The host's one line: persist the designState (the
// re-renderable source of truth) with the cart line.
console.log('add-to-cart', { productId, quantity, designState });
})
}
>
Add to cart · $24
</Editor.Buy>
</div>
</Editor.Root>
);
}import '@snowcone-app/canvas/style.css' once, anywhere in your app. The demo ids render against any shop; mint a sandbox shop to use your own product and assets, and read real ids from the catalog with getProduct() (see catalog → prop mapping).The grant route (Next.js · Remix · Express)
grantUrl points at a small same-origin route on YOUR server that mints a short-lived render grant — the browser never holds a secret key. For a sandbox shop the publishable shop.id alone authorizes it; production passes an sk_… key (see Realtime). The same five lines in the three common shapes:
// Next.js — app/api/realtime/grant/route.ts
// Sandbox: the publishable shop.id alone authorizes the grant (no apiKey).
import { mintRealtimeGrant } from '@snowcone-app/sdk';
export async function POST(req: Request) {
const { shop } = await req.json();
const grant = await mintRealtimeGrant({ shop }); // { token, expiresAt }
return Response.json(grant);
}// Remix / React Router — app/routes/api.realtime.grant.ts (a resource route)
import { mintRealtimeGrant } from '@snowcone-app/sdk';
export async function action({ request }: { request: Request }) {
const { shop } = await request.json();
return Response.json(await mintRealtimeGrant({ shop }));
}// Plain Node/Express — server.ts
import { mintRealtimeGrant } from '@snowcone-app/sdk';
app.post('/api/realtime/grant', async (req, res) => {
const grant = await mintRealtimeGrant({ shop: req.body.shop });
res.json(grant);
});Zero config: the default is a composition we maintain
// One line gets the whole PDP-grammar editor — header (history · menu ·
// buy pill), stage with the buyable mockup picture-in-picture, and the
// morphing control zone. Internally it is NOTHING but the parts on this
// page in our arrangement (CI-enforced), so customizing = recomposing.
import { SnowconeEditor, design } from '@snowcone-app/canvas/embed';
import '@snowcone-app/canvas/style.css';
const d = design({ name: 'Front', width: 1480, height: 2328 })
.background('https://dqMwU8Jct3.storage.snowcone.app/demo/demo-008.jpg', {
aspectRatio: 1480 / 2328,
name: 'art',
})
.text('YOUR NAME', { anchor: 'top', name: 'headline' });
export function ProductPage() {
return (
<SnowconeEditor
shop="YOUR_SHOP_ID"
grantUrl="/api/realtime/grant"
product={{ productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC', placements: ['Front'] }}
design={d}
onAddToCart={({ designState }) => saveDraft(designState)}
/>
);
}There is no cliff between SnowconeEditor and your own layout — it is ~300 lines of public-surface composition. When a knob you want doesn’t exist, don’t ask for a knob: recompose the parts.
Compose your own layout
// The same parts in YOUR grid: canvas left, a commerce rail right,
// contextual controls docked below. No Snowcone layout opinions leak in —
// the grid, the gap, the rail width are all host CSS.
import { Editor, design } from '@snowcone-app/canvas/embed';
const d = design({ name: 'Front', width: 1480, height: 2328 })
.background('https://dqMwU8Jct3.storage.snowcone.app/demo/demo-008.jpg', {
aspectRatio: 1480 / 2328,
name: 'art',
})
.text('YOUR NAME', { anchor: 'top', name: 'headline' });
export function StudioSplit() {
return (
<Editor.Root
shop="YOUR_SHOP_ID"
grantUrl="/api/realtime/grant"
product={{ productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC', placements: ['Front'] }}
design={d}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 200px', gap: 24 }}>
<Editor.Canvas />
<aside style={{ display: 'grid', gap: 12, alignContent: 'start' }}>
<Editor.Preview />
<Editor.Buy onAddToCart={({ designState }) => saveDraft(designState)}>
Add to cart
</Editor.Buy>
<Editor.Layers direction="vertical" />
</aside>
</div>
<Editor.Controls placement="docked" />
</Editor.Root>
);
}Editor.Controls is the morphing contextual zone. placement="docked" (the default) renders it exactly where you mount it and suppresses the canvas’s built-in floating toolbar — one contextual zone at a time. Mount no Controls at all and the canvas keeps its floating toolbar, which stays anchored to the selection through zoom, pan, and page scroll.
Variants & quantity
// Editor.Buy OWNS variant selection + quantity; YOU render the variant UI
// with useProductSelection() — select() re-resolves the variant and the live
// preview follows on the open session (no remount, no session rebuild).
import { useProductSelection } from '@snowcone-app/canvas/embed';
function VariantPicker() {
const { attributes, selection, select, disabledChoices, price, quantity, setQuantity } =
useProductSelection();
return (
<>
{Object.entries(attributes).map(([key, attr]) => (
<div key={key}>
{attr.choices.map(({ label = '', hex }) => (
<button
key={label}
style={hex ? { background: hex } : undefined}
disabled={disabledChoices[key]?.includes(label)}
aria-pressed={selection[key] === label}
onClick={() => select(key, label)}
>
{label}
</button>
))}
</div>
))}
<button onClick={() => setQuantity(quantity + 1)}>qty {quantity} +</button>
{price != null && <span>{(price / 100).toFixed(2)}</span>}
</>
);
}The currency rule: attributes[key].choices are { label, hex? } objects (use hex for swatches), but select(), selection, and disabledChoices all speak the choice’s label string. A product with a COLOR axis still needs an initial variantId on the Root product — it seeds the selection; switching afterwards is the hook’s job, never a new variantId prop (that would rebuild the whole session). While incomplete, Editor.Buy renders disabled with machine-readable reason codes in data-sc-buy-missing (option:<key>, variant, layer:<name>, layer-seed:<name>, empty-design, quantity).
Typed layer tokens (and CMS designs)
Editor.TextField accepts only a layer token minted by the design that names it — never a bare string. When the design is built statically with design(), the token call is generic over the design’s declared text layer names — a typo fails at compile time, listing the valid names:
import { design } from '@snowcone-app/canvas/embed';
const d = design({ name: 'Front', width: 1480, height: 2328 })
.text('YOUR NAME', { anchor: 'top', name: 'headline' })
.text('EST. 2026', { anchor: 'bottom', name: 'subline' });
d.layer('headline'); // ✓ a LayerToken<'text'>
// @ts-expect-error — a typo is a COMPILE error listing the valid names
d.layer('headlnie');DesignBuilder.fromState) cannot carry literal layer names in its type, so layer() degrades to plain string typing and a typo compiles. The runtime net still fires — layer() validates eagerly and throws E_LAYER_NOT_FOUND (with the valid names per kind) at the call site, and Editor.TextField re-validates at mount — but you’ve lost the compile-time magic. For CMS-sourced designs, pin the names yourself: keep a satisfies-checked union of expected layer names next to the loader, or codegen the names from the CMS schema.import { DesignBuilder } from '@snowcone-app/canvas/embed';
// A design loaded from a CMS / JSON string: same API, but the layer-name
// typing degrades to plain string — a typo now COMPILES. The runtime net
// still fires: layer() validates EAGERLY and throws E_LAYER_NOT_FOUND at the
// call site, listing the valid names per kind.
const fromCms = DesignBuilder.fromState(cmsState);
const token = fromCms.layer('headline'); // runtime-checked, not compile-checkedFully headless — bring your own UI
Every part is sugar over the same headless floor. If our parts don’t fit, build yours on the stable useEditor() subset and the chrome hooks (useLayers, useCommands, useArtboards, useExport) — rendered anywhere under the same Root, next to <Editor.Canvas />:
// Every part is sugar over the same headless floor. The STABLE useEditor()
// subset: elements (elements, getElementById), selection (selectedId,
// selectedElement, setSelectedId, …), command execution (executeElementUpdate,
// executeAddElement, executeRemoveElement, executeReorderElement,
// executeCommandBatch), undo/redo (undo, redo, canUndo, canRedo).
import { useEditor, useLayers } from '@snowcone-app/canvas/embed';
function MyChrome() {
const { selectedElement, undo, redo, canUndo, canRedo } = useEditor();
const { flatLayers, selectLayer } = useLayers();
return (
<div>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
{flatLayers.map((layer) => (
<button key={layer.id} onClick={() => selectLayer(layer.id)}>
{layer.name}
</button>
))}
{selectedElement && <span>editing: {selectedElement.id}</span>}
</div>
);
}Restyling: the machine-readable contract
Every part renders minimal styled DOM tagged with a stable data-sc-part attribute, accepts className/style, and themes through the .sc-root CSS custom properties that Editor.Root applies once. The full contract — every data-sc-part value, the DOM each part renders, and every .sc-root token — is generated from the parts themselves and drift-gated in CI, so you can restyle without ever reading package source: the styling contract (also shipped in the tarball as STYLING_CONTRACT.md, and inlined in llms-full.txt).
The package stylesheet is fully namespaced (every class is sc-/sc:-prefixed, every variable --sc-*): it cannot collide with your app’s CSS, and your selectors target [data-sc-part="…"] hooks, never our internals.
Errors & testing
Composition mistakes throw, in dev AND prod — a part outside the Root, a Buy without product context, a token naming a missing layer, two Canvases, a requires-canvas part without one. Every throw is a structured EditorError: a stable code (E_LAYER_NOT_FOUND, E_REQUIRES_CANVAS, E_DUPLICATE_CANVAS, E_BUY_NO_PRODUCT, E_OUTSIDE_ROOT, …) plus error.data carrying the valid alternatives and the offending values, with the fix spelled out in the message. Render-time violations are contained by the Root’s error boundary — an error card in the widget slot, your page survives. Assert on error.code, never message prose.
import { assertRenders, test403Design, testIncompleteDesign } from '@snowcone-app/canvas/testing';
import { design } from '@snowcone-app/canvas/embed';
// The two-render contract check: renders your design as seeded, renders it
// again with every named text layer mutated, and asserts the images DIFFER —
// a dead binding "renders fine" and only this catches it. Network-real: use
// it in nightly/acceptance jobs and agent verify loops, not per-PR unit tests.
const d = design({ name: 'Front', width: 1480, height: 2328 })
.text('YOUR NAME', { anchor: 'top', name: 'headline' });
await assertRenders(d, { productId: 'BEEB77', mockupIds: ['FV1qjO'], placements: ['Front'] }, {
shop: 'YOUR_SHOP_ID',
});
// Throws structured errors: E_BINDING_INERT (byte-identical renders),
// E_ASSET_FORBIDDEN (allowlist denial, with failedAssets), E_RENDER_FAILED.
// Deterministic failure fixtures — build your error UI against REAL failures:
const denied = test403Design(); // asset-origin allowlist denial, on demand
const incomplete = testIncompleteDesign(); // Buy gates with layer-seed:nameIn tests, expectNoEditorErrors() (from @snowcone-app/canvas/testing) is the must-have afterEach: the boundary’s containment means a broken composition could otherwise “mount without crashing” and pass green — this is what makes it fail loudly.

