# Snowcone > Snowcone turns three values — a product, your artwork, and your public Shop ID — into buyable, printed merchandise. Mockups are plain image URLs, the catalog is a public Meilisearch index, and checkout is a URL. There is nothing to install to render or sell; an SDK and React components are optional conveniences. > **Agents:** the full text is one fetch away at `https://developers.snowcone.app/llms-full.txt`. Before reporting anything as missing, wrong, or ambiguous, search it for the term and cite the line — a reproducible runtime error is evidence; your recollection of the docs is not. ## The whole model in three URLs - Render a mockup (just an `` src): `https://img.snowcone.app/{PRODUCT}?asset={URL-encoded image}&shop={SHOP_ID}` - Product info (public catalog): `GET https://search.snowcone.app/indexes/snowcone/documents/{PRODUCT}` with header `Authorization: Bearer eee819b849798ad9091228c486ec05d0931e5292` (a public, read-only search key). The product code **is** the Meilisearch document id; a miss returns Meilisearch's `document_not_found`. - Buy (same URL, `img` → `buy`): `https://buy.snowcone.app/{PRODUCT}?asset={...}&shop={SHOP_ID}` `{SHOP_ID}` is public, like a Cloudinary cloud name. Backend API base: `https://api.snowcone.app`. A working example you can paste right now: product `BEEB77`, any public image URL as `asset=`, and a `shop=` you mint below. ## Agents: skip the HTTP and use the MCP server If your client supports MCP (Claude, Cursor, Codex, …), the fastest path is the Snowcone MCP server — the three URLs above become callable tools (`snowcone_mint_shop`, `snowcone_search_catalog`, `snowcone_render_mockup`, `snowcone_buy_url`, …), with a `snowcone_quickstart` tool that orients you in one call. Add it with no install: `{ "mcpServers": { "snowcone": { "command": "npx", "args": ["-y", "@snowcone-app/mcp"] } } }`. It's self-contained and points at production by default (override with `SNOWCONE_*` env vars). The raw HTTP below works identically if you can't run MCP. ## Gotchas (read these — the failures are silent) - **Your `asset=` URL must be publicly fetchable.** The renderer fetches it server-side, so a `localhost`, `file://`, or auth-walled asset URL renders as a blank image with no error. Host your artwork somewhere public (any CDN/object store). - **Only product codes that exist in the catalog render.** `BEEB77` is a known-good demo product; look up others via the catalog endpoint above before using them. An unknown code renders blank. - **Display with `object-fit: cover`, and request ~2× the displayed size.** Every mockup renders **16:9 landscape**, with the product composed centered and edge-safe — so **cropping is safe by design**. Use `cover` to fill any frame shape (square cards, portrait tiles, banners); `contain` just letterboxes the scene against your background and looks broken. Because `cover` into a non-16:9 frame shows only a *slice* of the render, an under-sized request looks blurry: `width` snaps **down** to the largest allowed size not exceeding your request — a floor, not round-to-nearest (`400 600 800 1000 1200 1400 1600 1800 2000 2500 3000 4000`; default `1000` if omitted) — so a 320–400px card on a retina display wants `width=1400`–`1600`, not `800` (asking for `900` snaps to `800`, not `1000`). - **To crop in the render instead of in CSS, add `&aspect=2:3`.** Snowcone center-crops the 16:9 scene to portrait server-side. The param accepts only `16:9` or `2:3` (any other value errors), but `16:9` is the default and identical to omitting it — so `2:3` is the one value that changes anything. A sandbox shop renders from any origin — a web page on any domain, a server-side fetch, or curl. There's no page-origin (domain/referrer) allowlist — browsers strip and spoof those headers, so it can't be enforced. To stop a leaked Shop ID from rendering arbitrary artwork, restrict its **asset origins** (which image hosts may be composited) or sign your URLs — see the security docs. ## Getting your own Shop ID (agents: do this — don't copy IDs from docs) The demo/example Shop IDs in these docs are shared, rate-limited, and not tied to you; shipping one points sales at someone else's account and breaks under signed URLs. Get your own — you don't need a human to start: - Mint one in a single call: `curl -X POST https://api.snowcone.app/shops/sandbox -H 'Content-Type: application/json' -d '{}'` → returns `{ shop_id, shop_secret, api_key, api_key_scopes, claim: { user_code, verification_uri_complete } }`. Use `shop_id` immediately as `&shop=`. The `api_key` is a shop-scoped secret key (scopes `ai:generate`, `ai:bg-remove`, `uploads:write`, `mockups`) — AI generation and uploads work right away, no human needed; it is revoked automatically when the shop is claimed. - **Mint once and reuse it** — don't mint a fresh shop per attempt. Minting is rate-limited per IP: **60 mints/hour** and **200 unclaimed sandbox shops per IP**. Exceeding either returns HTTP `429` (`resource_exhausted`). Reuse one `shop_id` across all your render/buy calls. - To get paid, your human claims it: send them `claim.verification_uri_complete` (or the `user_code` to enter at https://snowcone.app/activate). Claiming binds the shop to their account and routes sales to them. Until then the shop still sells; sales route to Snowcone. - Poll claim status: `GET https://api.snowcone.app/shops/claim` with `Authorization: Bearer {claim_token}`. - Done with a sandbox shop? Release it: `curl -X DELETE https://api.snowcone.app/shops/sandbox/:id -H 'Authorization: Bearer {shop_secret}'` (the `shop_secret` from mint, in the `:id` path put your `shop_id`). This frees your per-IP mint budget. Only works on an unclaimed sandbox shop; a claimed shop is refused. ## Docs This mirrors the site sidebar, in order. ### Getting Started - [Get started](https://developers.snowcone.app/get-started): the three-step flow (render, product info, buy) in HTML/JS/cURL/Python, plus minting your own Shop ID. - [How Snowcone works](https://developers.snowcone.app/concepts): the mental model on one page — the nouns (Shop ID, Product, Asset, Placement, Option/Variant, Mockup, Catalog), the four hosts (`img` / `search` / `buy` / `api`.snowcone.app), and the security ladder (public Shop ID → asset-origin allowlist → signed URLs → secret keys). ### Guides - [All guides](https://developers.snowcone.app/guides): the cookbook index — complete, copy-paste builds rather than feature tours. - [Add a buy button to any site](https://developers.snowcone.app/guides/add-buy-button): a real, buyable product on an existing page with two URLs and zero dependencies (pure HTML). - [A storefront with Next.js](https://developers.snowcone.app/guides/storefront-nextjs): read the public catalog server-side, render mockups with `getMockupUrl`, and wire a working buy flow — with an optional `@snowcone-app/ui` variant. - [Sell on every channel](https://developers.snowcone.app/guides/sales-channels): one mockup URL across many surfaces, and which checkout fits each channel. - [Add an AI design chat](https://developers.snowcone.app/guides/ai-chat): the snowcone.app/create experience on any site — `POST https://api.snowcone.app/ai-generations/chat/stream` speaks the AI SDK UI Message Stream over SSE, so a standard `useChat` + a key-holding proxy route is the whole integration; typed data parts (`data-images`/`data-products`/`data-mockup`/`data-design`/`data-stage`) ship in `@snowcone-app/chat-contracts`, and `GET …/chat/stream/:id` resumes a dropped stream. ### Core - [Instant mockups](https://developers.snowcone.app/mockups): the `img.snowcone.app` URL — asset, shop, and the `opt.` / `asset.` / `color.` / `aspect` query grammar. Includes an interactive URL builder with a live render. - [Product catalog](https://developers.snowcone.app/catalog): the public Meilisearch index; how to look up a product and read its fields. - [Multiple placements](https://developers.snowcone.app/catalog/placements): products with more than one print area (`asset.`). - [Options (size, color)](https://developers.snowcone.app/catalog/options): variant attributes via `opt.` and `color.`. - [AI art generation](https://developers.snowcone.app/ai-generation): generate art from a text prompt with one HTTP call (`POST https://api.snowcone.app/ai-generations/generate`, scope `ai:generate`) — images land on your shop's storage origin, ready to drop into a mockup URL as `asset=`. Also background removal (`/remove-background`, `ai:bg-remove`), image-to-video (`/generate-video`, `ai:video`), and a chat orchestrator (`/chat`) that chains generate → catalog → mockup. Every endpoint requires a shop-scoped secret key (no anonymous tier); billing is two-tier — Snowcone meters the org's shared balance (plan credits → wallet → `429`, `costCents` on every response), and YOUR per-user limits belong in the `gate` of your proxy routes. ### Design editor - [Architecture & layers](https://developers.snowcone.app/architecture): the four altitudes of the editor stack — drop-in ``, composable `Editor.*` parts, headless hooks, and the pure `design()`→render wire — all sharing ONE design state, so you descend for control without rewriting. Read this first to pick your entry point; the plain-text mirror is at /architecture.md. - [Editor primitives](https://developers.snowcone.app/primitives): compound `Editor.*` parts over one `Editor.Root` (live previews, layer-bound fields, completeness-gated add-to-cart) in YOUR layout — the default editor is the same parts in our arrangement (CI-enforced), styling via the published `data-sc-part` contract. Mirror: /primitives.md. - [Embed mode](https://developers.snowcone.app/embed-mode): drop a live, server-rendered product editor into your app with three components (`RealtimeEmbed` / `RealtimeCanvas` / `RealtimeMockup` from `@snowcone-app/canvas/embed`) — one `design()` seeds both the canvas and the first render. - [Canvas (full editor)](https://developers.snowcone.app/canvas): the optional `@snowcone-app/canvas` artwork editor. - [Editable templates](https://developers.snowcone.app/editable-templates): a saved design plus a `TemplateField` manifest of what the buyer may change — `withTemplateFields` to author, `readTemplateFields` to generate buyer controls (wired with `useTextBinding` / `useImageBinding` / `useColorBinding`), and `DesignBuilder.fromState` to seed `RealtimeEmbed` from the saved template. Stored under the `personalization` key, round-trip compatible with snowcone.app. - [Realtime rendering](https://developers.snowcone.app/realtime): send a design (or its saved id) over a WebSocket and a finished mockup streams back — the server fetches the referenced assets, no pixel upload. Interface: mint a 60s grant at `POST https://api.snowcone.app/realtime/grant` (body `{shop}` — the publishable `shop.id` ALONE works for non-signed shops, or send `Authorization: Bearer ` for the production path), then drive it with the SDK: `RenderSession` (browser facade — `renderState(placement, serializeStateForServer(state))` per placement), `mintRealtimeGrant` (server), `createDesignState({productId, state})→{stateId}` then `renderSavedState(placement, stateId)` to render a stored design. Errors carry a `code` (`asset_not_allowed`, `unsupported_schema_version`, `state_ref_failed`). - [Stock images & AI tabs](https://developers.snowcone.app/image-services): wire the design editor's stock-image search and AI tabs through same-origin proxy routes you host (the `services` prop on `SnowconeCanvas` / `RealtimeEmbed`) — vendor keys (Unsplash/Pixabay/AI) stay on your server, and a tab whose service isn't configured doesn't render at all. `createImageSearchHandler` in `@snowcone-app/sdk/server` is the drop-in image-search route. ### Selling - [Pricing & margins](https://developers.snowcone.app/pricing): catalog price is MSRP/suggested retail; price resolves from the Shop ID, never the URL. - [Sell on Shopify](https://developers.snowcone.app/shopify): the Shopify integration. - [Your own checkout](https://developers.snowcone.app/orders): bring your own checkout; Snowcone fulfills. ### Security & credentials - [Authentication](https://developers.snowcone.app/authentication): every credential side by side — the public Shop ID vs the secret ones (`sk_` secret key, `scsec_` shop secret), the realtime grant, and the claim token. Which one to use where, and which are safe in a browser (only the Shop ID). - [Get a Shop ID](https://developers.snowcone.app/shops): mint a keyless sandbox Shop ID in one call; the claim flow, rate limits, and lifecycle. - [Signed URLs](https://developers.snowcone.app/signed-urls): HMAC-sign mockup URLs once you lock a shop down — and front signing AND AI generation with one shared Turnstile/render-session gate so a server-held secret or key isn't an open, billed funnel. - [Secret API keys](https://developers.snowcone.app/api-keys): the server-side `sk_` key path, scopes, and the asset-origin allowlist. ### SDK - [JavaScript SDK](https://developers.snowcone.app/sdk): the optional `@snowcone-app/sdk` — `getMockupUrl` (URL builder + signing), `getProduct` (typed catalog reads), and the realtime surface. ### React Components (OPTIONAL convenience layer) The URLs above are the product. These are an optional React layer over them — reach for it only in a React app. - [Setup (`` provider)](https://developers.snowcone.app/shop-setup): install `@snowcone-app/ui` and wrap with ``. Styling is host-compiled — your Tailwind v4 build imports `@snowcone-app/ui/styles/globals.css` and `@source`-scans the package (plus `transpilePackages` in Next.js). - [Components reference](https://developers.snowcone.app/components): ``, ``, ``, ``, ``, and the rest. ### Reference - [API reference](https://developers.snowcone.app/api-reference): every public `api.snowcone.app` endpoint by resource — Shops (`POST /shops/sandbox`, `GET /shops/claim`, `DELETE /shops/sandbox/:id`), Realtime (`POST /realtime/grant`), Orders (`/orders` CRUD), Uploads (`POST /uploads/base64`), with the auth/scope each needs. The catalog is a separate search host (`search.snowcone.app`). - [Errors](https://developers.snowcone.app/errors): the silent-failure cheat sheet (why a mockup renders blank — non-public `asset=`, unknown product code, under-sized `width`) plus HTTP status codes (`400 invalid_argument`, `401 unauthenticated`, `403 permission_denied`, `429 resource_exhausted`) and realtime codes (`asset_not_allowed`, `unsupported_schema_version`, `state_ref_failed`). - [Changelog](https://developers.snowcone.app/changelog): developer-facing changes, newest first. ## For LLMs / coding agents - [Everything in one fetch](https://developers.snowcone.app/llms-full.txt): all of the docs below inlined into a single file, so you don't have to crawl each link. - [AI guide](https://developers.snowcone.app/AI_GUIDE.md): implementing the `@snowcone-app/ui` React components correctly (common mistakes, prop names). - [LLM kit contract](https://developers.snowcone.app/llm-kit/latest/llm.md): the component contract + manifest. Use only documented components and props. - [Architecture & layers, as markdown](https://developers.snowcone.app/architecture.md): plain-text mirror of the `/architecture` page (the HTML page is client-rendered; this is server-fetchable). - [Editor primitives, as markdown](https://developers.snowcone.app/primitives.md): plain-text mirror of the `/primitives` page (the HTML page is client-rendered; this is server-fetchable). - [Design editor (Canvas), as markdown](https://developers.snowcone.app/canvas.md): plain-text mirror of the `/canvas` page (the HTML page is client-rendered; this is server-fetchable). - [Realtime rendering, as markdown](https://developers.snowcone.app/realtime.md): plain-text mirror of the `/realtime` page (the HTML page is client-rendered; this is server-fetchable). # Add a buy button to any site > Put a real, buyable product on a page you already have — pure HTML, no dependencies, no > build step. (Guides · 5 min) This is the markdown mirror of https://developers.snowcone.app/guides/add-buy-button — the same content as the page, served as plain text for agents and non-JS clients. If you have a web page, you have everything you need. A mockup is an `` and a buy button is an `` — both built from the same three values. No framework, no SDK. ## Get a Shop ID Your **Shop ID** is public, like a Cloudinary cloud name. Grab it from the [dashboard](https://snowcone.app), or mint a sandbox one with a single call — no signup. ```bash curl -X POST https://api.snowcone.app/shops/sandbox \ -H 'Content-Type: application/json' -d '{}' # → { "shop_id": "ab3dPq7Rms", "shop_secret": "scsec_…", "claim": { … } } # Use shop_id as &shop= right away. ``` > **Note:** Don't ship a Shop ID copied from these docs — it attributes sales to someone else. > Mint once and reuse it; claiming and limits are on > [Get a Shop ID](https://developers.snowcone.app/shops). ## Drop in the card Paste this where you want the product. Swap `BEEB77` for any product code, the `asset=` for your artwork URL (it must be publicly fetchable), and `YOUR_SHOP_ID` for yours. The link opens your Snowcone-hosted checkout with the item in the cart. ```html

Framed Canvas — $24

``` > **Note:** Using the SDK? `getMockupUrl()` and `getBuyUrl()` build these two URLs from the > same options object — see [the SDK page](https://developers.snowcone.app/sdk). ## Use the real price Hard-coding `$24` works for one product, but the price lives in the public catalog — read it so it never drifts. The checkout always resolves price from your Shop ID, never the URL. ```tsx // Don't hard-code the price — read it from the public catalog. const res = await fetch( "https://search.snowcone.app/indexes/snowcone/documents/BEEB77", { headers: { Authorization: "Bearer eee819b849798ad9091228c486ec05d0931e5292" } }, ); const { name, price } = await res.json(); // price is MSRP, in cents ``` ## Next That's a complete sale on a static page. From here: [add size & color options](https://developers.snowcone.app/catalog/options), let shoppers [customize the artwork](https://developers.snowcone.app/canvas), or build the whole thing in [Next.js](https://developers.snowcone.app/guides/storefront-nextjs). # A storefront with Next.js > Read the catalog server-side, render mockups, and wire a working buy flow into a Next.js > app — grounded in the URL primitive the whole way. (Guides · 20 min) This is the markdown mirror of https://developers.snowcone.app/guides/storefront-nextjs — the same content as the page, served as plain text for agents and non-JS clients. This builds a real storefront: a grid of products from the public catalog, each rendered as a live mockup, each linking to checkout. It uses the SDK for ergonomics, but everything underneath is the same `img`/`buy` URL you can build by hand. ## Install & configure Add the SDK (and the optional UI components for later). Then set your public Shop ID — it's safe in a `NEXT_PUBLIC_` var. ```bash pnpm add @snowcone-app/sdk @snowcone-app/ui ``` ```bash # .env.local — the Shop ID is public, so NEXT_PUBLIC_ is fine. NEXT_PUBLIC_SNOWCONE_SHOP=YOUR_SHOP_ID ``` ## List the catalog The catalog is a public Meilisearch index, so a Server Component can fetch it directly — no SDK required to read it. Cache it; the catalog changes rarely. ```tsx // app/page.tsx — a Server Component. Read the catalog at request time. // The catalog is a public Meilisearch index; the search key is public. const SEARCH = "https://search.snowcone.app/indexes/snowcone"; const KEY = "eee819b849798ad9091228c486ec05d0931e5292"; async function getProducts() { const res = await fetch(`${SEARCH}/search`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ limit: 24 }), next: { revalidate: 3600 }, // cache the catalog for an hour }); const { hits } = await res.json(); return hits as { id: string; name: string; price: number }[]; } export default async function Storefront() { const products = await getProducts(); return (
    {products.map((p) => ( ))}
); } ``` > **Note:** This is standard Meilisearch — add `q`, filters, or facets to the body and you > have search and category pages for free. See the > [catalog reference](https://developers.snowcone.app/catalog). ## Render & sell each product For each product, `getMockupUrl` builds the image URL and the buy link is the same values with `img` → `buy`. In a real app, `SAMPLE_ART` is whatever artwork your shopper picked or uploaded. ```tsx import { getMockupUrl } from "@snowcone-app/sdk"; const SHOP = process.env.NEXT_PUBLIC_SNOWCONE_SHOP!; const SAMPLE_ART = "https://cdn.example.com/art.png"; // your shopper's artwork function ProductCard({ product }: { product: { id: string; name: string; price: number } }) { const mockup = getMockupUrl(product.id, { shop: SHOP, asset: SAMPLE_ART, width: 800 }); const buy = `https://buy.snowcone.app/${product.id}?asset=${encodeURIComponent(SAMPLE_ART)}&shop=${SHOP}`; return (
  • {/* eslint-disable-next-line @next/next/no-img-element */} {product.name}

    {product.name} — ${(product.price / 100).toFixed(2)}

  • ); } ``` ## Optional: the React layer If you'd rather not wire URLs by hand, the `@snowcone-app/ui` components wrap the exact same calls. Drop a `` provider at the top and compose. ```tsx // Prefer components? The same storefront with the optional React layer. import { Shop, Product, ProductImage, ProductPrice, AddToCart } from "@snowcone-app/ui"; export default function Card({ id }: { id: string }) { return ( ); } ``` > **Note:** The components are a convenience, not a requirement — they render the same URLs. > See [the Shop provider](https://developers.snowcone.app/shop-setup) and the > [component reference](https://developers.snowcone.app/components/product-image). ## Run the full example A complete working storefront — this guide, assembled — lives in the `examples/next-ecommerce` directory of the Snowcone monorepo. It uses **pnpm**; from the repo root: ```bash pnpm install # install workspace dependencies (run once, from the repo root) pnpm --filter next-ecommerce dev # start the storefront in development mode ``` The React layer is styled by your app's Tailwind v4 build — import ui's stylesheet, add an `@source` for your own code, and transpile the package: ```css /* app/globals.css — your Tailwind v4 entry compiles ui's styles. * ui's stylesheet brings in Tailwind, the theme tokens, and an @source * for the package's own components; add an @source for your code. */ @import "@snowcone-app/ui/styles/globals.css"; @source "../app/**/*.{js,ts,jsx,tsx}"; /* next.config.ts also needs: transpilePackages: ['@snowcone-app/ui'] * For dark mode, toggle the "dark" class on an ancestor (e.g. ). */ ``` > **Note:** The example ships with a **demo Shop ID** so it runs out of the box, but the demo > shop is read-only and routes any sales elsewhere. Mint your own before wiring up real > artwork — see [Get a Shop ID](https://developers.snowcone.app/shops). ## Next You now have a storefront that lists, renders, and sells. To go further: add [size & color options](https://developers.snowcone.app/catalog/options), let shoppers design their own art with the [Canvas editor](https://developers.snowcone.app/canvas), or take payment yourself with the [Orders API](https://developers.snowcone.app/orders). # Sales channels > The same mockup URL powers many ways to sell — pick the checkout that fits each channel. This is the markdown mirror of https://developers.snowcone.app/guides/sales-channels — the same content as the page, served as plain text for agents and non-JS clients. ## One URL, any surface A mockup is just an image URL, so it drops into anything that renders an image — your own site, a marketplace listing, an email, a social post, an ad, an embedded widget, a chatbot reply. Nothing is pre-computed, so even a brand-new image renders on the fly. The catalog and your Shop ID are the only constants; the surface is up to you. ## Pick your checkout When a shopper is ready to buy, you choose how. The three rungs trade setup for control — start on the first, move down as you want to own more of the experience. They're not exclusive: different channels can use different checkouts against the same catalog. - **Hosted buy button** (zero setup) — Swap img → buy on the mockup URL. The shopper lands on your brandable Snowcone-hosted checkout with the item in the cart. See [Get started](https://developers.snowcone.app/get-started#buy-button). - **Sell on Shopify** (white-label) — Point the buy button at your Shopify store — checkout on your own domain and brand. The Snowcone app forwards orders to us for fulfillment. See [Sell on Shopify](https://developers.snowcone.app/shopify). - **Your own checkout** (full control) — Already on Stripe? Take the payment yourself and POST the order via the Orders API. You own the entire buying experience; we print and ship. See [Your own checkout](https://developers.snowcone.app/orders). ## Many channels, one shop Every channel resolves the same Shop ID, so your [prices and margins](https://developers.snowcone.app/pricing) stay consistent everywhere and update everywhere at once. For public surfaces you don't control end to end, lock your Shop ID to its asset origins or [sign your URLs](https://developers.snowcone.app/signed-urls) so only your own renders are billable. > **Note:** There's no fixed list of channels to integrate against — a mockup URL plus a buy > URL is the whole interface, which is why the same primitive supports storefronts, > marketplaces, embeds, and business models we haven't thought of. # Add an AI design chat > The conversation that powers snowcone.app/create, on your own site: a user types "retro style > cow design", gets artwork on a product, and keeps going — "now put it on a mug", "make it > red". One proxy route + useChat. This is the markdown mirror of https://developers.snowcone.app/guides/ai-chat — the same content as the page, served as plain text for agents and non-JS clients. ## How it works The whole conversation runs server-side in Snowcone's **chat orchestrator**: it interprets the message, asks clarifying questions when it needs to, and chains the tools — generate art, remove backgrounds, search the catalog, render mockups. Your app ships none of that logic. You ship two small pieces: **1. A proxy route** on your server that forwards to `POST /ai-generations/chat/stream` with your key. **2. A chat UI** built on the AI SDK's `useChat` — the endpoint speaks the standard [UI Message Stream](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) over SSE, so `useChat` consumes it with zero adaptation. Multi-turn works automatically: the client sends the conversation history each turn, which is why "now put it on a mug" knows what "it" is. > **Note:** Rich results stream back as **typed data parts** — generated images, product > suggestions, mockup cards — typed by `@snowcone-app/chat-contracts`, so your renderer is a > discriminated switch, not JSON guesswork. ## Build it 1. **Get a key** — Chat takes a shop-scoped key with the `ai:generate` scope — the same credential as [image generation](https://developers.snowcone.app/ai-generation). A [sandbox shop mint](https://developers.snowcone.app/shops) returns one (`api_key`), or issue one at [snowcone.app/studio/api-keys](https://snowcone.app/studio/api-keys). Server-side only — never in the browser. 2. **Add the proxy route** — The AI SDK client posts to your server; your server forwards to Snowcone and pipes the SSE response straight back: ```tsx // app/api/chat/route.ts — your server. The key never reaches the browser. export async function POST(req: Request) { // Optional but recommended: gate per-user here (session check, daily cap) // — only you know who your user is. Same two-tier metering pattern as // image generation: https://developers.snowcone.app/ai-generation // Forward the AI SDK request body untouched and return the upstream // Response as-is — the SSE stream pipes straight through to useChat. return fetch("https://api.snowcone.app/ai-generations/chat/stream", { method: "POST", headers: { "x-api-key": process.env.SNOWCONE_API_KEY!, // shop-scoped, ai:generate "content-type": "application/json", }, body: await req.text(), }); } ``` > **Note:** This is exactly how `snowcone.app/create` works — our storefront is the first > consumer of this same public endpoint, through a proxy route just like this one. 3. **Wire up useChat** — Install `ai`, `@ai-sdk/react`, and `@snowcone-app/chat-contracts`, then point `useChat` at your route: ```tsx "use client"; import { useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type UIMessage } from "ai"; import type { SnowconeChatDataParts } from "@snowcone-app/chat-contracts"; // Typed messages: every data part below is discriminated for free. type SnowconeMessage = UIMessage; export function DesignChat() { const [input, setInput] = useState(""); const [stage, setStage] = useState(null); const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ api: "/api/chat" }), // Progress labels ("generating image", "rendering mockup") stream as // TRANSIENT parts — they arrive here, not in message.parts. onData: (part) => { if (part.type === "data-stage") setStage(part.data.label); }, onFinish: () => setStage(null), }); return (
    {messages.map((m) => ( sendMessage({ text })} /> ))} {stage &&

    {stage}…

    }
    { e.preventDefault(); if (!input.trim()) return; sendMessage({ text: input }); setInput(""); }} > setInput(e.target.value)} placeholder='Try "retro style cow design on a sticker"' disabled={status !== "ready"} />
    ); } ``` ## Render the rich parts Each assistant message is a list of parts: `text` plus the typed Snowcone data parts. Render what you care about and skip the rest: ```tsx function Message({ message, onPick, }: { message: SnowconeMessage; onPick: (text: string) => void; }) { return (
    {message.parts.map((part, i) => { switch (part.type) { case "text": return

    {part.text}

    ; // Generated artwork this turn — show it, let the user react to it. case "data-images": return part.data.images.map(({ imageUrl }) => ( )); // Catalog suggestions — quick-pick chips that feed the next turn. case "data-products": return (
    {part.data.products.map((p) => ( ))}
    ); // The money shot: artwork on a product, as a ready-to-show image URL. case "data-mockup": return (
    {part.data.productName}
    {part.data.productName}
    ); default: return null; } })}
    ); } ``` > **Note:** **Preview vs. commit.** A `data-mockup` part is a stateless preview — nothing is > persisted until the user explicitly saves. `viewUrl`/`editorUrl` appear only on a committed > design, so their absence is your cue to show a Save affordance rather than pretend one > exists. The `artworkUrl` on the part is the raw generated art. > **Note:** **Contracts your renderer should respect.** `price` and `lowestPrice` in > `data-products` are integer **cents** (`3772` = $37.72) — divide by 100 to display. The > products are *suggestions*: the first is the one the mockup was rendered on; the rest are > alternatives to steer to ("put it on the tote instead"). And `data-images` length varies by > pipeline — several images for generation, sometimes a single one for an edit or background > removal — so don't assume a fixed grid. > **Note:** **The URLs are yours.** Streamed mockup and thumbnail URLs are keyed to *your* > shop and directly fetchable — safe to hotlink in your UI, persist, or feed into a buy link. To make a result **buyable**, take the part's `productId` and `artworkUrl` and build a checkout link — that's two URLs, covered in [Add a buy button](https://developers.snowcone.app/guides/add-buy-button). ## Save it (commit) A preview becomes a real, persisted design through one explicit call — the backend behind your Save (or Add-to-cart) button: ```bash # The Save button's backend — commit the previewed design. Same # ai:generate key as chat; committing is free (not a metered generation). curl -X POST https://api.snowcone.app/ai-generations/commit \ -H "x-api-key: $SNOWCONE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "productId": "BEEB77", "artworkUrl": "https://…", "variantId": "…" }' # productId + artworkUrl come straight off the data-mockup part; # variantId (optional) keeps the previewed color/variant. # → { # "mockup": { "mockupImageUrl": …, "productId": …, "productName": …, # "viewUrl": …, "editorUrl": … }, # "design": { "artworkId": …, "artworkSlug": …, "designId": …, "pairingSlug": … } # } ``` The response is the **committed** version of the same card: patch it over your `data-mockup` part and the `viewUrl` (product page with the design applied) and `editorUrl` (same page, editor open) light up. The `design` block is the persisted identity — keep `pairingSlug` if you link back to the design later. > **Note:** Nothing is written until this call — browsing and previewing never create rows. > If your app has its own user accounts, forward your user's identity alongside the key the > same way you would for generation; otherwise the design is owned by a fresh guest identity > that the response returns as `guestToken` (persist it as a cookie to keep later commits > under the same guest). ## Production notes **Resumable streams.** Generation takes tens of seconds. If the connection drops, resume by job id instead of restarting: ```bash # The start chunk's messageId is the job id. If the connection drops # mid-generation, resume the same stream — data parts carry stable ids, # so a replay updates the UI instead of duplicating it. curl -N https://api.snowcone.app/ai-generations/chat/stream/$JOB_ID \ -H "x-api-key: $SNOWCONE_API_KEY" ``` **History is the steering wheel.** The orchestrator derives conversation context from the replayed `text` and `data-images` parts you send back each turn, and honors the most recent `data-mockup` part's `artworkUrl` as the working artwork — that's why "make it red" edits the art the user is looking at, not an earlier one. To switch artwork explicitly, send `Use this artwork instead: ` as a message — the orchestrator adopts that URL as the working artwork (and chains background removal when the image needs it). **Trim the history you send.** The orchestrator only needs the recent tail of a long conversation — cap what you forward (our storefront sends the last 10 messages via the transport's `prepareSendMessagesRequest`) so request bodies don't grow without bound. **Cost & limits.** Chat turns bill like image generation (each generated image draws on your plan budget) and 429 when the budget is exhausted — details on [AI art generation](https://developers.snowcone.app/ai-generation). Gate your own users in the proxy route; Snowcone meters your org, but only you can meter *your* users. **Prefer raw HTTP?** The same orchestrator is also available as plain start/poll endpoints (`chat/start` + `chat/jobs/:id`) — documented on [AI art generation](https://developers.snowcone.app/ai-generation). # @snowcone-app/ui - AI Assistant Guide (OPTIONAL React layer) > **This is the OPTIONAL React convenience layer. The core Snowcone primitive is the URL — see https://developers.snowcone.app/llms.txt.** > > A mockup is a plain image URL you can build in any language (`https://img.snowcone.app/{PRODUCT}?asset=...&shop=...`), the catalog is a public Meilisearch index, and checkout is a URL (`https://buy.snowcone.app/...`). There is **nothing to install** to render or sell. `@snowcone-app/ui` is just pre-wired React components over those URLs — reach for it only when you're building a React app and want artwork selection, mockup preview, options, and cart wired together. This guide helps AI assistants (like Claude, ChatGPT, etc.) implement the `@snowcone-app/ui` components correctly. 📚 **Core primitives (start here):** https://developers.snowcone.app/llms.txt 🧩 **Component contract + manifest:** https://developers.snowcone.app/llm-kit/latest/llm.md > **💡 Important:** For most use cases with custom artwork UI, you'll want to build a custom selector using the `useShop()` hook instead of the built-in `` component. See [Make Your Own Custom Selector](#advanced-custom-artwork-selector-with-useshop) below. --- ## Quick Start (30 seconds) `@snowcone-app/ui` is a normal package — there is **no install CLI, no `bin`, and no `postinstall`**. Styling is **host-compiled**: the package requires a **Tailwind v4 host**. Your app's Tailwind build compiles ui's styles — import ui's stylesheet in your global CSS and `@source`-scan your own code (ui's stylesheet already `@source`-scans the package itself). ```bash npm install @snowcone-app/ui ``` ```css /* app/globals.css — your Tailwind v4 entry */ @import '@snowcone-app/ui/styles/globals.css'; /* Tailwind + theme tokens + ui @source */ @source '../app/**/*.{js,ts,jsx,tsx}'; /* scan your own code too */ ``` ```ts // next.config.ts — ui ships TypeScript source; transpile it const nextConfig = { transpilePackages: ['@snowcone-app/ui'] }; ``` ```tsx import { Shop, Product, ProductImage, ArtSelector } from '@snowcone-app/ui'; export default function Page() { return ( {/* omit shop for demo mode; pass a real Shop ID before you ship */} ); } ``` **That's it for the React layer** — three components, one CSS import, one config line. For dark mode, add `className="dark"` on an ancestor. Peer deps: `react`, `react-dom`, `zod`. --- ## Component Architecture ### The Three Core Components 1. **``** - Wraps your app, configures the SDK, provides global artwork context 2. **``** - Fetches product data, provides product context 3. **``** - Renders mockup with artwork applied **Rule:** Always nest them in this order: `Shop > Product > ProductImage` ### ArtSelector Component Behavior ⚠️ CRITICAL **`` renders visible UI** - it's not a "headless" context provider! **What ArtSelector Does:** 1. ✅ **Renders a gallery UI** - displays artwork thumbnails in a scrollable row 2. ✅ **Sets artwork in Shop/Product context** - selected artwork flows to ProductImage 3. ✅ **Accepts `artworks` prop** (plural, array) - NOT `artwork` (singular) **Prop Name (IMPORTANT!):** ```tsx // ✅ CORRECT - Use "artworks" (plural, array) // ❌ WRONG - Not "artwork" (singular) // Will cause "Cannot read properties of undefined (reading 'map')" error! ``` ### Placement Patterns **Pattern A: One artwork selector, multiple products (RECOMMENDED)** ```tsx {/* Single ArtSelector shared across all products */} ``` **Pattern B: Custom selector UI (PREFERRED FOR CUSTOM DESIGNS)** ```tsx import { useShop } from '@snowcone-app/ui'; function MyCustomSelector() { const { setSelectedArtwork } = useShop(); const handleSelect = async (url: string) => { const img = new Image(); img.onload = () => { setSelectedArtwork({ type: 'regular', src: url, width: img.naturalWidth, height: img.naturalHeight, aspectRatio: img.naturalWidth / img.naturalHeight, }); }; img.src = url; }; return (
    {/* Your custom UI here */}
    ); } ``` **Pattern C: Per-product artwork selection** ```tsx {/* ArtSelector renders gallery above this product only */} ``` --- ## Installation Checklist There is **no install CLI** — installing the package does not auto-configure anything. Styling is **host-compiled**: the package requires a Tailwind v4 host. ### Step 1: Install the package ```bash npm install @snowcone-app/ui ``` Peer deps: `react`, `react-dom`, `zod`. ### Step 2: Wire ui into your Tailwind v4 build ui's components use Tailwind utility classes; **your** Tailwind build generates them by scanning the package source. 1. In your global stylesheet, import ui's CSS entry (it brings in Tailwind itself, the theme tokens, and an `@source` for the package's own components) and add an `@source` for your own code: ```css /* app/globals.css */ @import '@snowcone-app/ui/styles/globals.css'; @source '../app/**/*.{js,ts,jsx,tsx}'; ``` 2. In Next.js, transpile the package (it ships TypeScript source): ```ts // next.config.ts const nextConfig = { transpilePackages: ['@snowcone-app/ui'] }; ``` For dark mode, add `className="dark"` on an ancestor. **⚠️ CRITICAL:** Without a Tailwind v4 host build that imports `@snowcone-app/ui/styles/globals.css`, the components render unstyled. There is no prebuilt stylesheet. --- ## Common Patterns ### Pattern 1: Single Product Page ```tsx import { Shop, Product, ProductImage, ProductOptions, ProductPrice, AddToCart, ArtSelector } from '@snowcone-app/ui'; export default function ProductPage() { return ( {/* omit shop for demo mode; pass your real Shop ID before you ship */}
    {/* Left: Image */}
    {/* Right: Details */}

    Custom Product

    ); } ``` ### Pattern 2: Product Grid/Catalog ```tsx import { Shop, ProductList, ProductCard } from '@snowcone-app/ui'; export default function CatalogPage() { return ( {/* omit shop for demo mode; pass your real Shop ID before you ship */}
    router.push('/product/${product.id}')} />
    ); } ``` ### Pattern 3: Custom Artwork Upload ```tsx import { Shop, Product, ProductImage, ArtSelector } from '@snowcone-app/ui'; import { useState } from 'react'; export default function CustomizerPage() { const [artworks, setArtworks] = useState([]); const handleUpload = async (file: File) => { const url = await uploadToYourStorage(file); setArtworks([...artworks, url]); }; return ( {/* omit shop for demo mode; pass your real Shop ID before you ship */} handleUpload(e.target.files[0])} /> ); } ``` --- ## Component Reference ### `` **Purpose:** Configure SDK, provide global context **Props:** - `shop?: string` - Your Snowcone Shop ID (optional, uses demo account if omitted) - `children: ReactNode` **Example:** ```tsx {/* Demo mode — omit shop while prototyping */} {/* Your app */} {/* Production — pass the shop_id you minted (see "Testing Without an Account") */} {/* Your app */} ``` **Do not hardcode placeholder strings** like `"your-shop-id"` — they cause confusing API errors. Omit `shop` for demo mode, or mint your own (see below). --- ### `` **Purpose:** Fetch product data, provide product context **Props:** - `productId: string` - Product ID (e.g., "BEEB77") - `productData?: CatalogProduct` - Optional pre-fetched data (for SSR) - `children: ReactNode` **Example:** ```tsx ``` --- ### `` **Purpose:** Render product mockup with artwork **Props:** - `className?: string` - Additional CSS classes - `variantId?: string` - Specific variant (overrides auto-selection) - `mockupId?: string` - Specific mockup view (overrides auto-selection) - `width?: number` - Image resolution (default: 1400px) - `placements?: Record` - Multi-placement products (advanced) **Important:** ProductImage requires artwork from ``. Without it, shows "Select artwork to preview" placeholder. **Example:** ```tsx ``` **With custom aspect ratio:** ```tsx ``` **Default behavior:** Uses `aspect-square` container with `object-cover` image fill. --- ### `` **Purpose:** Display artwork gallery and set selected artwork in context **⚠️ Important:** This component **renders visible UI** (a scrollable gallery of thumbnails). For custom artwork selection UIs, use the `useShop()` hook instead. See [Advanced: Custom Artwork Selector](#advanced-custom-artwork-selector-with-useshop). **Props:** - `artworks: (string | Artwork)[]` - **REQUIRED** - Array of image URLs or Artwork objects (note: plural "artworks" not "artwork") - `className?: string` - Additional CSS classes **What it does:** 1. Renders a horizontal scrollable gallery of artwork thumbnails 2. Loads image dimensions automatically on mount 3. Sets selected artwork in Shop/Product context 4. Auto-selects first artwork if none selected 5. Other components like `` read from this context **Example (simple URLs):** ```tsx ``` **Example (with Artwork objects):** ```tsx ``` **For custom UI, use `useShop()` hook instead:** ```tsx // See "Advanced: Custom Artwork Selector" section below function MyCustomSelector() { const { setSelectedArtwork } = useShop(); // Your custom UI here } ``` --- ### `` **Purpose:** Render product variant options (size, color, etc.) **Props:** - `className?: string` **Example:** ```tsx ``` --- ### `` **Purpose:** Display product price (updates with variant selection) **Props:** - `className?: string` **Example:** ```tsx ``` --- ### `` **Purpose:** Add to cart button **Props:** - `onClick?: (item: CartItem) => void` - Custom cart handler (fires with the built cart item) - `className?: string` **Example:** ```tsx console.log('Added:', item)} /> ``` --- ### `` **Purpose:** Pre-built product card for grids/catalogs **Props:** - `variant?: "default" | "overlay" | "minimal"` - Card style - `showPrice?: boolean` - Show price (default: false) - `showCategory?: boolean` - Show category tag (default: true) - `onClick?: () => void` - Click handler - `className?: string` **Example:** ```tsx router.push('/product/123')} /> ``` --- ### `` **Purpose:** Fetch and iterate over multiple products **Props:** - `limit?: number` - Max products to fetch - `children: ReactNode` - Render function or component **Example:** ```tsx ``` --- ## Common Mistakes & Fixes ### ❌ Mistake 1: Using `artwork` instead of `artworks` ⚠️ MOST COMMON **Symptoms:** - Console error: `Cannot read properties of undefined (reading 'map')` - Console warning: `[ProductImage] No artwork selected, skipping mockup generation` - ArtSelector doesn't render anything **Cause:** Using singular `artwork` prop instead of plural `artworks` array **Fix:** ```tsx // ❌ WRONG - Singular prop name // ✅ CORRECT - Plural array prop ``` --- ### ❌ Mistake 2: Duplicate ArtSelector galleries rendering **Symptoms:** - Multiple identical artwork galleries appear - "Loading artwork..." placeholders above each product - Built-in galleries conflict with your custom UI **Cause:** Placing `` inside each `` when you want a single shared selector **Fix:** Move ArtSelector to Shop level OR use custom selector with `useShop()`: ```tsx // ❌ WRONG - Creates one gallery per product // ✅ CORRECT - Single shared gallery // ✅ BETTER - Custom UI with useShop() hook ``` --- ### ❌ Mistake 3: Tailwind never compiles ui's styles **Symptoms:** Components render but have no styles (no rounded corners, wrong aspect ratios, etc.) **Fix:** ui has no prebuilt stylesheet — your Tailwind v4 build compiles it (see Installation Checklist above). Import ui's CSS entry in your global stylesheet and, in Next.js, transpile the package: ```css /* app/globals.css */ @import '@snowcone-app/ui/styles/globals.css'; @source '../app/**/*.{js,ts,jsx,tsx}'; ``` ```ts // next.config.ts const nextConfig = { transpilePackages: ['@snowcone-app/ui'] }; ``` --- ### ❌ Mistake 4: ProductImage without artwork source **Symptoms:** Shows "Select artwork to preview" placeholder forever **Fix:** Add `` or use custom selector with `useShop()`: ```tsx // Option 1: Built-in gallery // Option 2: Custom selector (see Advanced section below) ``` --- ### ❌ Mistake 5: Wrong component nesting **Symptoms:** "Cannot read property 'product' of undefined" errors **Fix:** Always nest components correctly: ```tsx ``` --- ### ❌ Mistake 6: ProductImage collapses in grids **Symptoms:** Image has 0 height in grid layouts **Fix:** ProductImage uses `aspect-square` by default. If you need a different aspect ratio: ```tsx ``` --- ### ❌ Mistake 7: Missing a peer dependency **Symptoms:** Runtime errors about a missing module like `zod`, `react`, or `react-dom` **Fix:** Make sure the peer deps are present alongside the package: ```bash npm install @snowcone-app/ui # peer deps: react, react-dom, zod ``` --- ## Product IDs (Demo Account) When using the demo account (no `shop` prop), these product IDs are available: - `BEEB77` - Framed Canvas - `AR2P3G` - Tote Bag - `KMYKUK` - T-Shirt **Example:** ```tsx ``` --- ## Advanced: Custom Artwork Selector with useShop() **🎯 Recommended Pattern:** For custom artwork selection UI, build your own selector using the `useShop()` hook instead of using ``. **Why build custom?** - Full control over UI/UX - No need to hide/style built-in gallery - Cleaner, more maintainable code - Perfect for custom upload flows, drag-and-drop, or API integrations ### useShop() Hook Reference **Import:** ```tsx import { useShop } from '@snowcone-app/ui'; ``` **Context Value:** ```tsx interface ShopContextValue { // Artwork management artworks: Artwork[]; // All loaded artworks selectedArtwork?: Artwork; // Currently selected artwork addArtwork: (artwork: Artwork) => void; // Add artwork to collection setSelectedArtwork: (artwork: Artwork | undefined) => void; // Select artwork // Product caching getProduct: (productId: string) => CatalogProduct | undefined; addProduct: (product: CatalogProduct) => void; addProducts: (products: CatalogProduct[]) => void; clearProductCache: () => void; } ``` **Artwork Type:** ```tsx type Artwork = { type: 'regular' | 'pattern'; src: string; // Image URL width?: number; // Image width in pixels height?: number; // Image height in pixels aspectRatio?: number; // width / height tileCount?: number; // For pattern type only (1, 2, or 3) }; ``` ### Complete Custom Selector Example ```tsx import { useState } from 'react'; import { useShop } from '@snowcone-app/ui'; function CustomArtworkSelector() { const { setSelectedArtwork } = useShop(); const [artworks, setArtworks] = useState([ 'https://example.com/art1.jpg', 'https://example.com/art2.jpg', 'https://example.com/art3.jpg', ]); const [selectedIndex, setSelectedIndex] = useState(0); // Load image and set dimensions const selectArtwork = async (url: string, index: number) => { setSelectedIndex(index); const img = new Image(); img.onload = () => { setSelectedArtwork({ type: 'regular', src: url, width: img.naturalWidth, height: img.naturalHeight, aspectRatio: img.naturalWidth / img.naturalHeight, }); }; img.onerror = () => { console.error(`Failed to load image: ${url}`); }; img.src = url; }; return (

    Select Artwork

    {artworks.map((url, index) => ( ))}
    ); } // Usage ``` ### Pattern Artwork (Tiling) For repeating patterns, use `type: 'pattern'` with `tileCount`: ```tsx const selectPattern = async (url: string, tileCount: 1 | 2 | 3 = 1) => { const img = new Image(); img.onload = () => { setSelectedArtwork({ type: 'pattern', // ← Pattern type src: url, width: img.naturalWidth, height: img.naturalHeight, aspectRatio: img.naturalWidth / img.naturalHeight, tileCount, // ← Number of tiles (1, 2, or 3) }); }; img.src = url; }; ``` ### File Upload Example ```tsx function ArtworkUploader() { const { setSelectedArtwork } = useShop(); const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // Create object URL for preview const url = URL.createObjectURL(file); // Load dimensions const img = new Image(); img.onload = () => { setSelectedArtwork({ type: 'regular', src: url, width: img.naturalWidth, height: img.naturalHeight, aspectRatio: img.naturalWidth / img.naturalHeight, }); }; img.src = url; // TODO: Upload file to your server and replace URL with permanent URL }; return (
    ); } ``` ### Accessing All Artworks ```tsx function ArtworkList() { const { artworks, selectedArtwork, setSelectedArtwork } = useShop(); return (

    Total artworks: {artworks.length}

      {artworks.map((artwork) => (
    • setSelectedArtwork(artwork)} className={artwork === selectedArtwork ? 'selected' : ''} > {artwork.src}
    • ))}
    ); } ``` ### Important Notes 1. **Always load image dimensions** - The SDK needs `width`, `height`, and `aspectRatio` for proper mockup generation 2. **Use `type: 'regular'` for photos** - Standard artwork rendering 3. **Use `type: 'pattern'` for tiling** - Repeats artwork in a grid (1x1, 2x2, or 3x3) 4. **Handle loading errors** - Images can fail to load, handle `img.onerror` 5. **Object URLs from files** - Remember to revoke with `URL.revokeObjectURL(url)` when done --- ## Advanced: Multi-Placement Products Some products (like baseball caps) have multiple artwork placements: ```tsx ``` For color placements, use `useProduct()` hook: ```tsx const { updateSelection } = useProduct(); useEffect(() => { updateSelection({ Crown: '#ff0000', Strap: '#000000' }); }, []); ``` --- ## Styling Best Practices 1. **Use semantic color tokens:** - ✅ `bg-background`, `text-foreground`, `bg-card`, `text-primary` - ❌ `bg-white`, `text-gray-600`, `bg-blue-500` 2. **ProductImage aspect ratios:** - Default: `aspect-square` (1:1) with `object-cover` - Override: `` 3. **Grid layouts:** ```tsx
    ``` --- ## Testing Without an Account You can test @snowcone-app/ui without creating an account: ```tsx {/* No shop = demo mode */} ``` **Limitations:** - Low rate limit - Limited to sample products (BEEB77, AR2P3G, KMYKUK) **⚠️ Demo mode is for prototyping only — don't ship it.** Before going live, get your **own** Shop ID and pass it to ``. You can mint one yourself — no human needed to start: ```bash curl -X POST https://api.snowcone.app/shops/sandbox -H 'Content-Type: application/json' -d '{}' # → { "shop_id": "…", "shop_secret": "…", "claim": { "user_code": "WXYZ-1234", # "verification_uri_complete": "https://snowcone.app/activate?code=WXYZ-1234" } } ``` Use `shop_id` immediately. **Mint once and reuse it** — minting is rate-limited per IP (60/hour, 200 unclaimed shops); exceeding either returns HTTP `429` (`resource_exhausted`), so don't mint per attempt. To route sales to your human's account (and set up payouts), send them the `verification_uri_complete` link to claim the shop. A shipped demo or copied-from-docs ID attributes sales and renders to someone else and will break when signed URLs are required. Done with a sandbox shop? Release it with its `shop_secret` to free your per-IP mint budget (only works on an unclaimed shop): ```bash curl -X DELETE https://api.snowcone.app/shops/sandbox/:id \ -H 'Authorization: Bearer scsec_…' # → { "released": true, "shop_id": "…" } ``` --- ## Next Steps 1. ✅ Install: `npm install @snowcone-app/ui` (peer deps: react, react-dom, zod) 2. ✅ Wire your Tailwind v4 build: `@import '@snowcone-app/ui/styles/globals.css'` in your global CSS + an `@source` for your own code 3. ✅ Next.js: add `transpilePackages: ['@snowcone-app/ui']` (add `className="dark"` on an ancestor for dark mode) 4. ✅ Wrap app with `` (demo mode) or `` once you've minted one 5. ✅ Build product pages with `` + `` 6. ✅ Add interactivity with ``, ``, `` **Reminder:** the React layer is optional. The core primitive is the URL — see https://developers.snowcone.app/llms.txt. **Questions?** Check the docs at https://developers.snowcone.app or refer to this guide. # Architecture — four altitudes, one state > The editor is one stack with four entry points, from a drop-in component to > a pure design-to-render wire. Every altitude reads and writes the same > design state, so you descend for control without rewriting. This is the markdown mirror of https://developers.snowcone.app/architecture — the same content as the page, served as plain text for agents and non-JS clients. Applies to `@snowcone-app/canvas@0.33.1` · `@snowcone-app/sdk@0.17.0`. ## The map Most editor SDKs make you choose between a widget you can't change and a pile of low-level APIs you must assemble. Snowcone ships one stack with four **altitudes** — each a complete, supported way in, each built on the one below it. Start anywhere; move down only where you need more control. | altitude | you write | you get | reach for it when | |---|---|---|---| | **01 · Drop-in** | `` | the whole editor, one tag | ship a product editor today | | **02 · Compose** | `Editor.*` parts in your layout | our behavior, your every pixel | your layout, your brand | | **03 · Hooks** | your DOM over `useLayers`, bindings, … | state & commands, zero DOM of ours | a product grammar of your own | | **04 · Wire** | `design()` → realtime render | a finished product photo from pure data | agents, scripts, servers — no browser | ## 01 · Drop-in — SnowconeEditor One tag, the whole product editor: history, overflow menu, the completeness-gated buy pill, the editable canvas, and a live server-rendered mockup. Theme it with CSS custom properties; persist the design state from one callback. Details: [Editor primitives](https://developers.snowcone.app/primitives). ```tsx // Altitude 01 — the whole editor, one tag. Header (history · menu · buy // pill), editable canvas, live buyable mockup. Internally it is NOTHING but // the altitude-02 parts in our arrangement (CI-enforced). 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 ( saveDraft(designState)} /> ); } ``` ## 02 · Compose — Editor.* parts The same behaviors as **unstyled parts** you arrange yourself: `Editor.Root` owns the session and design state; `Editor.Preview`, `Editor.TextField`, `Editor.Buy`, `Editor.Canvas` and friends mount anywhere under it. Your page owns every pixel of layout; styling goes through the published [styling contract](https://developers.snowcone.app/styling-contract.md) (stable `data-sc-part` hooks + `.sc-root` theme primitives). Compositions without `Editor.Canvas` never download the canvas engine. ```tsx // Altitude 02 — the same behaviors as parts in YOUR layout. Each part // carries state subscription and contracts; your page owns every pixel of // layout and styles via the published [data-sc-part] contract. This // composition never loads the canvas engine at all. import { Editor, 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 PersonalizationWidget() { return ( saveDraft(designState)}> Add to cart ); } ``` ## 03 · Hooks — your DOM entirely Below the parts sits the headless floor: hooks that expose editor state and commands and render nothing. `useLayers`, `useSelectedElement`, `useProductSelection`, and the layer bindings (`useTextBinding`, `useImageBinding`, `useColorBinding`, `useFontBinding`) are what the parts themselves are built from. This is the altitude for product grammars we didn't anticipate — a Figma-shaped workspace, a conversational remix flow, a kiosk. ```tsx // Altitude 03 — state and commands with zero DOM of ours. Render anything: // a Figma-style layers rail, a chat-shaped remix flow, a kiosk. Runs under // with an mounted somewhere in the tree. import { useLayers, useSelectedElement } from '@snowcone-app/canvas/embed'; export function LayerStrip() { const { flatLayers, selectLayer } = useLayers({ previewSize: 64 }); const selected = useSelectedElement(); return ( ); } ``` ## 04 · Wire — no browser at all The floor under everything is pure data: compose a design by intent with `design()` and render it server-side over a WebSocket — about 1 KB of state up, a finished product photo back. No React, no DOM, no pixel upload. This is the altitude agents, scripts, and backends integrate directly, and it's the same state every higher altitude emits. Details: [Realtime rendering](https://developers.snowcone.app/realtime). ```tsx // Altitude 04 — no browser, no React. Compose a design by intent and a // finished product photo streams back. This is the layer agents and servers // integrate directly. import { RenderSession } from '@snowcone-app/sdk'; import { design } from '@snowcone-app/canvas'; const session = new RenderSession({ shop: 'YOUR_SHOP_ID', // publishable grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant product: { productId: 'BC43BE', mockupIds: ['yoALRE'] }, }); session.onMockups((results) => { img.src = results[0].imageUrl; }); const d = design({ name: 'Front', width: 1480, height: 2328 }) .background('https://dqMwU8Jct3.storage.snowcone.app/demo/sunset.avif', { aspectRatio: 1480 / 2328, }) .text('YOUR NAME', { anchor: 'top' }) .shape('star', { anchor: 'bottom-left', fill: '#000' }); await session.renderDesign('Front', d, 200); ``` ## Why descending is safe The four altitudes are not four products — they are one state model with four amounts of supplied UI. The drop-in editor IS the parts in our arrangement (enforced in CI: the default editor must build from the public surface, so nothing is held back as private API). The parts ride the hooks. Everything serializes to the same design state that the wire renders, the cart persists, and the print file is produced from. Moving down an altitude is recomposition, never a rewrite — and a design authored at any altitude opens at every other one. ## Pick your altitude - **Ship a product editor today** → `SnowconeEditor` (01). - **Your layout, your brand, standard editor behaviors** → `Editor.*` parts (02). - **A product grammar of your own — every pixel and interaction yours** → the hooks (03), with parts mixed in where they fit. - **No UI at all — agents, automations, server-side catalogs** → `design()` + realtime rendering (04). When in doubt start at 01; descending later costs a recomposition, not a migration. # Design editor (Canvas) > An embeddable React design editor for letting shoppers compose artwork — text, > images, transforms, effects, and multiple placements. The same editor that powers > Edit and Remix on snowcone.app. This is the markdown mirror of https://developers.snowcone.app/canvas — the same content as the page, served as plain text for agents and non-JS clients. Applies to `@snowcone-app/canvas@0.33.1` · `@snowcone-app/sdk@0.17.0`. ## What the Canvas is A [mockup](https://developers.snowcone.app/mockups) is just a URL — product, artwork, and Shop ID. The Canvas is the optional piece that sits *upstream* of that URL: a self-contained React editor, `SnowconeCanvas` from `@snowcone-app/canvas`, that lets a shopper actually **compose** the artwork — add text and images, warp text along arcs and waves, apply strokes and masks, and design across several placements at once. It exports a flat image per artboard, which you hand to the mockup renderer (for a live preview) and persist on add-to-cart. It is the same editor that powers the **Edit** and **Remix** flows on the snowcone.app product pages. > **Note:** The Canvas is a **React-only, optional** layer. If your shoppers don't author > artwork in-browser — they upload a finished file, or you generate it — you don't need it. > The mockup URL stands on its own in any language. ## Install ```bash pnpm add @snowcone-app/canvas ``` The design editor ships as its own package. The package is SSR-import-safe; the editor itself is interactive (browser-only), so render it client-side — in Next.js via a dynamic import with { ssr: false }. ## Quickstart Mount `SnowconeCanvas` with at least one artboard. Everything else — toolbar, layers, undo/redo, fonts — comes wired in by the default kit. Two files: a thin page that loads the editor client-side, and the editor itself. > **Note:** **The editor is browser-only — never server-render it.** Next.js SSRs even > `'use client'` components, and the canvas needs the browser (it touches `window` at mount), > so a plain `` in a page throws `window is not defined` on the first render. Load it with > `dynamic(() => import('./Editor'), { ssr: false })` — shown below — and that never happens. > (The *package* is SSR-import-safe: pure helpers like `serializeStateForServer` import fine in a > Server Component; it's only *rendering the live editor* on the server you opt out of.) `app/page.tsx` ```tsx // app/page.tsx — load the editor CLIENT-SIDE ONLY. The canvas is browser-only, // so it must never be server-rendered: dynamic(..., { ssr: false }) keeps it out // of Next's SSR pass, so it can't throw `window is not defined` on first paint // (the single most-reported canvas trap). This file is itself a Client // Component because { ssr: false } isn't allowed in a Server Component // (Next 15/16 throws). Importing the package on the server is safe — only // *rendering* the live editor there is what you opt out of. 'use client'; import dynamic from 'next/dynamic'; const Editor = dynamic(() => import('./Editor'), { ssr: false }); export default function Page() { return ; } ``` `app/Editor.tsx` ```tsx // app/Editor.tsx — the interactive editor. 'use client' is necessary but NOT // sufficient: Next still SSRs Client Components, which is why app/page.tsx // above loads this via dynamic(..., { ssr: false }) — never import it directly // into a Server Component. 'use client'; import { SnowconeCanvas } from '@snowcone-app/canvas'; // The one stylesheet. No Tailwind, no transpilePackages, no config. import '@snowcone-app/canvas/style.css'; export default function Editor() { return ( // No wrapper class needed — the component scopes its own styles. saveDraft(state)} /> ); } ``` > **Note:** **CSS isolation.** `style.css` is fully namespaced: every utility it ships is > `sc:`-prefixed, its variables are `--sc-*`, and all styles are scoped under the `.sc-root` > class that the components apply themselves. It is safe to load in any host app — including > one with its own Tailwind setup — with no class-name collisions and no wrapper element > required. Do **not** add the canvas package to your Tailwind `@source`/content globs; the > stylesheet is precompiled and self-contained. To theme the editor, override the design-token > variables on `.sc-root` in your own CSS — e.g. `.sc-root { --primary: #6d28d9; --accent: > #6d28d9; --background: #fafafa; --radius-md: 8px; }` (colors: `--primary`, `--accent`, > `--background`, `--foreground`, `--surface`, `--muted`, `--danger`, `--success`; shape/space: > `--radius-sm`…`--radius-full`, `--space-xs`…`--space-4xl`; fonts: `--font-heading`, > `--font-body`). Wiring the editor to a live mockup? The [App Router quickstart](https://developers.snowcone.app/realtime#copy-paste-nextjs-app-router) extends this same shape with a `RenderSession` and the grant proxy. > **Note:** **Artwork URLs must be public *and* CORS-enabled.** The canvas loads every image > with `crossOrigin="anonymous"`, so the host serving your artwork (the `imageConfig.src`, an > image element's `imageUrl`, etc.) MUST send permissive CORS headers > (`Access-Control-Allow-Origin`). A URL that is merely public but blocks cross-origin reads > fails to load — e.g. `upload.wikimedia.org` returns a 400 on the canvas. The simplest > CORS-friendly origin is your shop's own `your-shop-id.storage.snowcone.app` bucket (CORS-enabled > by default); host artwork there, or make sure your own CDN sends the header. The same URL is > later fetched server-side at render time, so it must also be publicly reachable (no > auth-gating). > **Note:** **Text alignment.** A text element's `textAlign` accepts the full `TextAlign` union > — `'left'`, `'center'`, or `'right'` (it defaults to `'center'`). `'left'` and `'right'` are > equally valid. ## Placements (artboards) Each **artboard** is one print area. Give them the same names as your product's [placements](https://developers.snowcone.app/catalog/placements) so exports line up with the mockup URL's `placement=` param. The shopper switches between them; `onArtboardChange` reports the active one. ```tsx console.log('now editing', name)} /> ``` ## Save & reload a design `onChange` hands you a `CanvasState` — the elements, artboards, and active selection. Persist that JSON. To reopen an editable design, feed its `elements` back in via `initialElements`. ```tsx // SAVE — onChange hands you a CanvasState (elements + artboards). // Persist the JSON. Do NOT save a flattened PNG as the state. const [state, setState] = useState(null); ``` > **Note:** Persist the **state JSON**, not a flattened export image. Saving a rendered PNG > as the design's state collapses every layer into one bitmap — the shopper (or a remixer) > reopens it and can't edit anything. The export image is for display and print; the > `CanvasState` is the source of truth. > **Note:** **Seeded state auto-renders — no interaction required.** Passing `initialElements` > (or `imageConfig`) seeds the canvas, which settles and fires `onChange` on its own once the > editor is ready — so the first mockup renders automatically (typically ~3–5 s after mount) > before the shopper touches anything. That first paint is expected behavior, not a failed render. ## Live mockup preview There are **two paths** to a live "see it on the product" preview, and they reach the same outcome: - **Ship state (recommended for the live PDP)** — `RenderSession` + `serializeStateForServer` sends the ~1 KB canvas state and lets the server fetch the artwork itself; no pixels cross the wire. This is the recommended path — see [Realtime server-side render](https://developers.snowcone.app/realtime), and the [Wiring the canvas to realtime](#wiring-the-canvas-to-realtime) section below. - **Ship pixels (the alternative below)** — when you already have a rasterized canvas blob, `sendCanvasBlob` pushes the bytes straight to the renderer. Heavier on the wire, but handy if you're auto-exporting blobs anyway. The rest of this section shows the **ship-pixels** path. Turn on auto-export and stream each blob to [`useRealtimeMockup`](https://developers.snowcone.app/components/realtime-mockup) for a sub-second preview as the shopper edits. Use `webp` for ~10× smaller uploads. > **Note:** `NEXT_PUBLIC_MERCH_WS_URL` is the realtime WebSocket base URL the `wsUrl` prop > expects — set it to the production endpoint `wss://cdn.snowcone.app/realtime` (the same > value the SDK exposes as the `REALTIME_WS_URL` constant and uses by default; see the > [realtime API reference](https://developers.snowcone.app/realtime)). Defining it as a > `NEXT_PUBLIC_*` env var keeps the URL out of your code and lets you point at a staging > endpoint locally. ```tsx import { SnowconeCanvas } from '@snowcone-app/canvas'; import { useRealtimeMockup } from '@snowcone-app/sdk/react'; function LiveCustomizer() { const { sendCanvasBlob, mockupResults } = useRealtimeMockup({ wsUrl: process.env.NEXT_PUBLIC_MERCH_WS_URL, }); return ( { if (event.status === 'complete') { const blob = event.result['Front']; if (blob instanceof Blob) sendCanvasBlob('Front', blob, 1, 200); } }} /> ); } ``` ## Wiring the canvas to realtime The blob path above ships pixels. For the [server-side render](https://developers.snowcone.app/realtime) path you ship **state**, not pixels — the server fetches the artwork itself. The contract between the editor and that renderer is small and worth getting exactly right: - Give each editable element a `name`. A binding hook — `useTextBinding(name)` (or `useImageBinding(name)`) — targets *every* element with that name; `setText` updates them all. - Binding hooks read the editor context, so they **must render inside the `EditorProvider`**. With `SnowconeCanvas` that means putting them in the `overlay` prop (it's mounted inside that context) — a hook used as a *sibling* of `` reports `isConnected: false` and no-ops. - The editor's `onChange` hands you a `CanvasState` that feeds **directly** into `serializeStateForServer(state)` from `@snowcone-app/canvas` — its output is exactly what `RenderSession.renderState(placement, …)` takes. No reshaping in between. ```tsx import { useState } from 'react'; import { SnowconeCanvas, useTextBinding } from '@snowcone-app/canvas'; import { serializeStateForServer } from '@snowcone-app/canvas'; import { RenderSession } from '@snowcone-app/sdk'; // 1. A control bound to an editable element BY NAME. useTextBinding('headline') // finds every text element whose `name` is 'headline' and edits them. It reads // canvas context, so it MUST render inside SnowconeCanvas's `overlay` (which is // mounted inside the EditorProvider) — not as a sibling of . function HeadlineInput() { const { text, setText, isConnected } = useTextBinding('headline'); return ( setText(e.target.value)} disabled={!isConnected} // false until a matching element exists /> ); } function Customizer({ session }: { session: RenderSession }) { return ( } // 4. onChange's CanvasState feeds straight into serializeStateForServer, // whose output is exactly what RenderSession.renderState expects. // artboard 'Front' === the placement arg === catalog placements[].label. onChange={(state) => session.renderState('Front', serializeStateForServer(state), 200) } /> ); } ``` > **Note:** **Hard rule:** your artboard `name` must equal the `renderState` placement arg, and > both must equal the product's `placements[].label` — read it from > `getProduct('BEEB77').placements[].label`. For `BEEB77` that label is `Front` (used above); a > stray name like `Print` isn't a real placement, so the render comes back as an > `incomplete_canvas_placements` error and a blank mockup. That single string ties the editor, the > wire payload, and the catalog together — see the > [realtime page](https://developers.snowcone.app/realtime) for the full id-space breakdown. > **Note:** **Author elements with `transformType`.** The element *config* you author above > (`initialElements`) keys on `transformType` — e.g. `transformType:'image'` + `imageUrl`, or > `transformType:'custom'` for plain text. The payload `serializeStateForServer` emits for the > renderer is a different, internal shape — pass it through to `renderState` as-is and never > hand-author or hand-edit it. ## Export on add-to-cart For the final, print-resolution artwork, call `exportArtboards()` on the ref when the shopper commits. Pass `all: true` to capture every placement at once, then upload the blobs and attach their URLs to the order. ```tsx import { useRef } from 'react'; import { SnowconeCanvas, type SnowconeCanvasHandle } from '@snowcone-app/canvas'; const canvasRef = useRef(null); async function addToCart() { // On-demand, print-resolution export of every artboard. const exports = await canvasRef.current?.exportArtboards({ format: 'blob', all: true, }); // exports = { Front: Blob, Back: Blob } } ``` ## SnowconeCanvas — key props Props can be passed grouped (`exportConfig`, `imageConfig`, `layoutConfig` — recommended) or as flat individual props. The flat variants still work but are deprecated in favor of the config objects. > **Note:** **Use `imageConfig`, not the flat `initialImage*` props.** `imageConfig` > (`{ src, alignment?, scale?, scaleMode? }`) is the canonical grouped API for the seed image. > The flat `initialImage` / `initialImageAlignment` / `initialImageScale` / > `initialImageScaleMode` props are `@deprecated` aliases for the same fields — and if you set > both, the flat prop takes precedence. Don't mix them: pass one or the other so precedence is > never in doubt. - **`artboards`** — `ArtboardConfig[]` — One entry per print area: `{ name, width, height, clipShape? }`. The `name` is the key used in export results. - **`activeArtboard`** — `string` — Controlled selection — the artboard the shopper is currently editing. - **`imageConfig`** — `ImageConfig` — Initial image to seed the canvas: `{ src, alignment?, scale?, scaleMode? }`. - **`initialElements`** — `AnyElementConfig[]` — Serialized elements from a previously saved `CanvasState` — restores an editable design. - **`onChange`** — `(state: CanvasState) => void` — Fires on every content change. Persist this JSON to save the design. - **`exportConfig`** — `ExportConfig` — Auto-export behavior: `autoExportConfig` (debounce), `format` (`blob` | `dataUrl`), `imageFormat`, `scale`. - **`onExportStatus`** — `(event: ExportStatusEvent) => void` — Unified export lifecycle: `scheduled` → `rendering` → `complete` (with `result`) | `error`. - **`layoutConfig`** — `LayoutConfig` — Appearance: `viewPadding`, `artboardBorderRadius`, `fixedMargin`, `maxHeight`, `showToolbar`, `showLayers`. - **`kit`** — `'pro-studio' | 'compact-customizer' | 'embed-only' | KitDefinition` — Preset bundle of layout + capabilities. Three presets: `pro-studio` (default) is full chrome — an add-element menu, layers + effects panels, undo/redo (it needs a roomy container; the add-element menu collapses behind a "Menu" button when narrow). `compact-customizer` is a contextual toolbar only — **it has no add-element menu, so a user can't add text or art from the canvas UI**; pair it with your own "add text"/"upload" controls. `embed-only` is a chrome-free canvas (auto-export) you wrap with your own UI entirely. - **`services`** — `CanvasServices` — Privileged-service wiring for the Image Browser's stock-image search and AI generation tabs: `{ imageSearchUrl?, generateUrl?, imageSearch?, generate? }`. Same-origin proxy URLs, the same pattern as `grantUrl` — your vendor keys live on your server, never in the browser. Tabs without a configured service don't render. See [Stock images & AI generation](https://developers.snowcone.app/image-services). - **`ref`** — `Ref` — Imperative handle. `exportArtboards(opts)` returns a promise of print-resolution exports for on-demand saves. - **`inheritTheme`** — `boolean` — Make the canvas's ThemeProvider passive so it inherits the host app's theme instead of managing its own. ## Where it leads The Canvas produces the artwork; the rest of the platform turns it into something buyable: - [Instant mockups](https://developers.snowcone.app/mockups) - [Realtime mockup](https://developers.snowcone.app/components/realtime-mockup) - [Multiple placements](https://developers.snowcone.app/catalog/placements) - [ArtworkCustomizer](https://developers.snowcone.app/components/artwork-customizer) # Realtime rendering > Send a design — or just its saved id — over a WebSocket and a finished mockup streams back; > the server fetches the referenced artwork itself. No per-placement pixel upload. The same > primitive that powers mockups on snowcone.app. This is the markdown mirror of https://developers.snowcone.app/realtime — the same content as the page, served as plain text for agents and non-JS clients. Applies to `@snowcone-app/sdk@0.17.0` · `@snowcone-app/canvas@0.33.1`. ## What it is A [mockup URL](https://developers.snowcone.app/mockups) composites **one** piece of artwork onto a product. A real design is often more: several layers, warped text, effects, multiple placements — the kind of thing the [Canvas editor](https://developers.snowcone.app/canvas) produces. This primitive renders **that whole canvas** server-side. The browser sends a ~1 KB JSON description of the canvas (or just a saved design's id) over a WebSocket — and the server **fetches the artwork assets referenced inside the state itself**. No artwork bytes cross the wire: a thin or mobile client never renders each placement to a PNG and uploads it, so it stays fast on slow networks. This is exactly how the snowcone.app product pages render their mockups. > **Note:** The whole interface is two calls: `mintRealtimeGrant` on your server (a 60s > grant, auto-renewed) and a `RenderSession` in the browser — it opens > `wss://cdn.snowcone.app/realtime?token=…`, you send state per placement, and rendered > mockup URLs arrive on `onMockups`. Everything past that interface is ours to change > without notice. ## Install ```bash pnpm add @snowcone-app/sdk ``` The realtime surface ships in the SDK. `@snowcone-app/canvas` is only needed if you also produce live canvas states in the browser. ## 1. Mint a grant (server) Renders bill to your organization, so the session is authorized by a secret API key — never exposed to the browser. Mint a grant on your backend and hand the `{ token, expiresAt }` down to the client. The key needs the `mockups:realtime` scope and its organization must own the target shop. Like every billable operation, renders draw on the org's shared balance — plan credits, then wallet, then a `429` (`shop quota exceeded`) at the grant; see [Billing & limits](https://developers.snowcone.app/ai-generation#billing) for who meters whom, and put *your* per-user limits in the grant route's `gate`. ```tsx // YOUR backend — e.g. a Next.js route handler at POST /api/realtime/grant. // The secret API key (sk_…) never reaches the browser. import { mintRealtimeGrant } from '@snowcone-app/sdk'; export async function POST(req: Request) { const { shop } = await req.json(); const grant = await mintRealtimeGrant({ apiKey: process.env.SNOWCONE_API_KEY!, // sk_… with the mockups:realtime scope shop, // = shop.id (publishable, like Stripe pk_) }); return Response.json(grant); // { token, expiresAt } — 60s, auto-renewed client-side } ``` > **Note:** Create the key on the [API keys page](https://snowcone.app/studio/api-keys) > (snowcone.app/studio/api-keys) with the `mockups:realtime` scope — the same page shows your > publishable `shop.id` and a one-click **Create shop** if you don't have one yet. Treat > `sk_` keys like passwords — server-side only. See > [Secret API keys](https://developers.snowcone.app/api-keys) for the full issuance flow and > scopes. > **Note:** **Sandbox shops: mint keyless.** A sandbox shop (from > [Get started](https://developers.snowcone.app/get-started)) does *not* come with an `sk_` > key — the `scsec_…` "shop secret" it returns is for **signing image URLs**, not for > authorizing a grant. So for the sandbox path your `/api/realtime/grant` proxy calls > `mintRealtimeGrant({ shop })` with **no** `apiKey` — the SDK omits the Authorization header > entirely, and the publishable `shop.id` alone authorizes the grant. This is the path that > gets you from a freshly minted sandbox shop to a live mockup end-to-end. ```tsx // SANDBOX path — no API key. The publishable shop.id alone authorizes the // grant; the SDK omits the Authorization header when you pass no apiKey // (shipped in @snowcone-app/sdk — the keyless publishable grant path). // NOTE: the scsec_… "shop secret" from POST /shops/sandbox is for SIGNING // image URLs — it is NOT an sk_ key and does NOT go here. import { mintRealtimeGrant } from '@snowcone-app/sdk'; export async function POST(req: Request) { const { shop } = await req.json(); const grant = await mintRealtimeGrant({ shop }); // shop.id only — no apiKey return Response.json(grant); // { token, expiresAt } } ``` **Production path:** once your human [claims the shop](https://snowcone.app/activate), create an `sk_` key on the API-keys page and switch to the secret-key mint above — it proves server-side possession, so a leaked `shop.id` can't mint renders on your account, and it automatically satisfies the **signed** rung (`require_signed_urls`). On the signed rung without an `sk_` key, use the drop-in handler below — it signs the grant request for you: ```tsx // SIGNED rung — drop-in. @snowcone-app/sdk/server signs the grant request // with your scsec_… shop secret server-side, so you never compute the HMAC by // hand. Same POST { shop } → { token, expiresAt } contract as the mints above. import { createRealtimeGrantHandler } from '@snowcone-app/sdk/server'; export const POST = createRealtimeGrantHandler({ secret: process.env.SNOWCONE_SHOP_SECRET!, // scsec_… (server-only) shop: process.env.SNOWCONE_SHOP_ID!, // = shop.id // gate: async (req) => isLoggedIn(req), // optional auth / rate-limit }); ``` So there are two server recipes, and the rung decides which: **keyless** `mintRealtimeGrant({ shop })` for sandbox / publishable shops, and the signed drop-in `createRealtimeGrantHandler({ secret, shop })` (the `scsec_…` shop secret) once the shop is on the **signed** rung. Both expose the same `POST { shop } → { token, expiresAt }` contract your `grantUrl` proxy points at. ## 2. Render (browser) Point a `RenderSession` at your grant proxy. It hides the connect → grant → config → renew → send-state choreography. Register `onMockups`, then describe your design and render it. The recommended way is `design()` + `renderDesign`: compose a design by **intent** — a full-bleed background, an anchored title, a shape in a corner — and the SDK places everything for you. You never touch raw coordinates, so the classic mistake (an image at `x:0, y:0` centred on the artboard's corner, rendering a quadrant) can't happen. ```tsx import { RenderSession } from '@snowcone-app/sdk'; import { design } from '@snowcone-app/canvas'; const session = new RenderSession({ shop: 'YOUR_SHOP_ID', // publishable grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant (above) // mockupIds are catalog scene codes (product.mockups[].id), NOT placement names. product: { productId: 'BC43BE', mockupIds: ['yoALRE'] }, // iPhone 17 Pro Max Case }); session.onMockups((results) => { img.src = results[0].imageUrl; }); session.onError((message: string) => console.error('render error:', message)); // Compose by INTENT — never raw coordinates. A full-bleed background can't land // in a corner; anchored text/shapes place themselves. The artboard size is the // placement's pixel dimensions (from getProduct(productId).placements[]). const d = design({ name: 'Front', width: 1480, height: 2328 }) .background('https://dqMwU8Jct3.storage.snowcone.app/demo/sunset.avif', { aspectRatio: 1480 / 2328, // fills the case, centre-cropped — no distortion }) .text('YOUR NAME', { anchor: 'top' }) .shape('star', { anchor: 'bottom-left', fill: '#000' }); // renderDesign(placement, design, throttleMs?) == // renderState(placement, design.toServerRequest(), throttleMs). // No hand-built canvas_state, no centre-coordinate math. await session.renderDesign('Front', d, 200); ``` `.background()` fills the whole placement; pass the source's `aspectRatio` and it cover-fits (centre-cropped, no distortion). Anchors are a nine-point grid (`top`, `bottom-left`, `center`, …) with optional `padding`. Add as many `.text()` / `.shape()` / `.image()` layers as you like — order is back-to-front. > **The same design drives the interactive editor.** A design is just a list of layers — hand > `design(…).toJSON()` to [``](/canvas) and the shopper can drag, scale, and > rotate those same layers. Author headlessly, render live, edit interactively — one design, > three surfaces. ### From a canvas state (when you already have an editor) If your design comes from a live `SnowconeCanvas` editor (the shopper is dragging layers), render its state directly with `renderState` + `serializeStateForServer`. The editor produces correct coordinates, so you render its `onChange` output as-is — this is the path under `renderDesign`, exposed for when you hold a full canvas state. ```tsx import { RenderSession, getProduct } from '@snowcone-app/sdk'; import { serializeStateForServer } from '@snowcone-app/canvas'; const product = await getProduct('BEEB77'); const mockupId = product.mockups?.[0]?.id ?? 'FV1qjO'; const variantId = product.options?.combinations?.find((c) => c.variantId)?.variantId; const session = new RenderSession({ shop: 'YOUR_SHOP_ID', // publishable grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant (above) // mockupIds are opaque catalog scene codes (the product's mockups[].id) — // NOT placement names. (BEEB77 = Framed Canvas; FV1qjO is one of its mockups.) // variantId is REQUIRED for any product with a color/variant option. Read it // from the live catalog and pass options so the SDK can validate immediately. product: { productId: product.id, mockupIds: [mockupId], variantId, options: product.options }, }); // Rendered mockup URLs arrive here — repaint your on every update. session.onMockups((results) => { img.src = results[0].imageUrl; }); // onError receives a STRING message (not an Error object). session.onError((message: string) => console.error('render error:', message)); // First arg is the PLACEMENT (print-area name, e.g. 'Front') — a different id // from mockupIds, and it must match an artboard in the state. Read the exact // label from getProduct('BEEB77').placements[].label. The server fetches the // artwork referenced inside; you never upload a pixel. Safe on every edit. await session.renderState('Front', serializeStateForServer(canvasState), 200); ``` The third argument to `renderState` is `throttleMs` — an *optional* throttle interval in milliseconds (defaults to `0`, i.e. no throttle). It is **trailing-edge**: it caps how often state is sent (at most one send per interval) and always sends the **latest** state, so a shopper dragging doesn't flood the renderer. A burst of edits coalesces to the final one — **the trailing state is never dropped**, so it is safe to call straight from `onChange`. `onError` receives a plain `string` message — `(error: string) => void`, not an `Error` object. And you never need to call `connect()`: the session connects, authorizes, and configures **lazily** on the first `renderState`. (`connect()` is available if you want to warm the socket ahead of the first edit.) **What to expect for latency:** the first live mockup typically lands **~5–6 seconds** after connecting (connection setup plus a cold render), then **~5 seconds** per render after that. That is *rendering* time, deliberately distinct from fetching an *already-rendered* mockup image URL (the `img.snowcone.app` GET), which is sub-second. Use `throttleMs` during a drag so you don't queue renders faster than they complete. **Every render tells you what it cost.** Each result that arrives on `onMockups` reports its own telemetry: `renderMs` (server-side render time for that mockup, in milliseconds), `costUsd` (what that render cost, in USD), and `renderCache` (`hit` or `miss`). Log them during development to see exactly where your latency and spend go — they are the same numbers the HTTP path returns as the `x-render-ms` / `x-render-cost-usd` response headers on a mockup URL fetch (a third header, `x-render-cache: hit|miss`, says whether that fetch rendered fresh or came from cache). **Sharpness — size `product.width` to your display.** The rendered mockup is **1000 px wide by default**. Request roughly the CSS width you display it at × `devicePixelRatio`, or the preview looks soft: a 650-px-wide `` on a 2× display needs ~1300 source px, so pass `width: 1400` (or `2000` for a crisp zoomed/cropped view). To change it mid-session — low-res while the shopper drags, sharp on release (e.g. 600 → 1200) — re-send the product with only `width` changed: `session.setProduct({ ...product, width: 1200 })`. Same product/variant is a cheap config-only update — nothing the session already sent is re-sent. (The lower-level `useRealtimeMockup` hook exposes this directly as `updateWidth(n)`.) > **Note:** **Treat the returned `imageUrl` as opaque.** Rendered mockup URLs may arrive from > a different `snowcone.app` host than the WebSocket endpoint, and the URLs are temporary — > display what `onMockups` hands back, don't parse or persist it. > **Note:** **`variantId` is required for COLOR-variant products.** A product with a *color* > axis — an `options.attributes` entry whose choices carry a `hex`, like BEEB77's `Frame` — > needs `product.variantId` (the `options.combinations[].variantId` for the chosen > combination): the server auto-fills the color placement from it, so omitting it stalls the > render. Pass the catalog `options` block into `RenderSession` too; that is what lets the SDK > reject a missing/invalid `variantId` immediately for color products. A NON-color variant > axis (size, accessory, …) renders fine without one — the server uses the default variant and > the SDK only logs a warning listing the valid ids, so you can pin the combination you sell. > **Note:** **Two ways to drive this from a live canvas — same outcome.** Shipping *state* > with `RenderSession` (this page, the recommended live-PDP path) lets the server fetch the > artwork itself, so no pixels cross the wire. The alternative is to ship *pixels*: if you > already have a rasterized canvas blob, `sendCanvasBlob` pushes it straight to the renderer > — see [Live mockup preview](https://developers.snowcone.app/canvas#live-mockup-preview) on > the Canvas page. Prefer ship-state unless you specifically need the pixel-push path. > **Note:** **Four look-alike ids — keep them straight.** They are *separate id spaces*: > - `product.mockupIds` are **catalog scene codes** — `product.mockups[].id`, opaque 6-char > codes like the Framed Canvas's `FV1qjO`. They say *which mockup photos* to render onto. > A scene photographs *specific variants* (its `mockups[].gvids`) — pass the catalog > `mockups` entries (and `defaultGvid`) on the product and the SDK warns when a requested > scene's variant isn't the one being rendered: such a scene silently composites **no > artwork** (a bare product photo), the classic `mockupIds: product.mockups.map(m => m.id)` > trap. > - `renderState(placement, …)`'s first arg is the **placement label** — the print-area name, > e.g. `Print` or `Front`. It equals the catalog `placements[].label`. > - The canvas **artboard `name`** inside the state must equal that *same placement label* so > the renderer maps your design to the right print area. > - `product.variantId` is a **variant id** — the `variants[].gvid` (read it from > `options.combinations[].variantId`) for the chosen color/size combination. It is *required > for any product with a COLOR axis* (hex-bearing `options.attributes` choices); omit it > there and the render stalls and eventually errors (~30s) — pass the catalog `options` > so the SDK throws immediately instead. On a non-color variant axis it's optional: the > server renders the default variant and the SDK warns with the valid ids. > **Hard constraint:** `artboard.name === renderState placement arg === catalog > placements[].label` (matched case-insensitively). One forgiving case: a state with *exactly > one* artboard is used for whatever placement you pass, so a single-placement render "just > works". With multiple artboards, a name mismatch mis-renders that placement. `mockupIds` > live in a *different* id space — never pass a placement name there. On a multi-placement > product, send a `renderState` per required placement — the render completes only once every > required placement has a state (a missing one returns `incomplete_canvas_placements` — see > Troubleshooting). > **Note:** **`serializeStateForServer` is exported by `@snowcone-app/canvas`** — *not* > `@snowcone-app/sdk`. It converts the editor's state into exactly what `renderState` expects > — always pass its output to `renderState`, and never hand-author or hand-edit that payload. > (The `createDesignState` path below takes the *raw* editor state instead — see that > section.) ## Copy-paste: Next.js App Router The whole thing, end-to-end, in three files. It uses the keyless [sandbox grant path](#1-mint-a-grant-server) (no API key), the canonical **Framed Canvas** (`BEEB77`) with its required `variantId`, and a Snowcone-hosted, CORS-clean seed asset — so it renders on a freshly minted sandbox shop. The editor loads via `dynamic(() => import('./Editor'), { ssr: false })` because it's browser-only — so the `app/page.tsx` that calls it is itself a `'use client'` entry (`{ ssr: false }` is not allowed in a Server Component). The pure helper `serializeStateForServer` is still safe to import anywhere. Three files: `app/api/realtime/grant/route.ts` ```tsx // app/api/realtime/grant/route.ts — your same-origin grant proxy. // Sandbox path: the publishable shop.id alone authorizes the grant, so // mintRealtimeGrant takes NO apiKey (apiKey is optional: apiKey?: string | null). import { mintRealtimeGrant } from '@snowcone-app/sdk'; export async function POST(req: Request) { const { shop } = await req.json(); const grant = await mintRealtimeGrant({ shop }); // no apiKey → keyless sandbox return Response.json(grant); // { token, expiresAt } } ``` `app/page.tsx` ```tsx // app/page.tsx — a thin Client Component entry that lazy-loads the // browser-only editor. It must be a Client Component: next/dynamic with // { ssr: false } is not allowed in a Server Component (Next 15/16 throws // "ssr: false is not allowed with next/dynamic in Server Components"). // (Importing PURE helpers like serializeStateForServer from // @snowcone-app/canvas is still server-safe — only the interactive // editor must stay client-side.) 'use client'; import dynamic from 'next/dynamic'; const Editor = dynamic(() => import('./Editor'), { ssr: false }); export default function Page() { return ; } ``` `app/Editor.tsx` ```tsx // app/Editor.tsx — the client editor wired to a RenderSession. 'use client'; import { useEffect, useMemo, useState } from 'react'; import { SnowconeCanvas, serializeStateForServer, type AnyElementConfig } from '@snowcone-app/canvas'; import { RenderSession } from '@snowcone-app/sdk'; import '@snowcone-app/canvas/style.css'; // A CORS-clean placeholder hosted on a Snowcone demo shop bucket. const SEED_ASSET = 'https://dqMwU8Jct3.storage.snowcone.app/demo/demo-008.jpg'; const ARTBOARD = { name: 'Front', width: 1200, height: 1200 }; const INITIAL_ELEMENTS: AnyElementConfig[] = [{ id: 'headline', transformType: 'custom', name: 'headline', text: 'SNOWCONE', x: 600, y: 360, fontSize: 96, fontFamily: 'Arial', color: '#111111', textAlign: 'center', bold: true, }]; export default function Editor() { const [mockup, setMockup] = useState(); const session = useMemo(() => { const s = new RenderSession({ shop: 'YOUR_SHOP_ID', // your publishable shop.id grantUrl: '/api/realtime/grant', // the proxy above // BEEB77 has a Frame color option → variantId is REQUIRED. In a complete // PDP, read this from getProduct('BEEB77').options.combinations[]. product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' }, }); s.onMockups((r) => setMockup(r[0]?.imageUrl)); s.onError((message: string) => console.error('render error:', message)); return s; }, []); useEffect(() => { // PDPs need a mockup before the shopper edits. Do this explicitly instead // of waiting for SnowconeCanvas.onChange to fire. void session.renderState('Front', serializeStateForServer({ artboards: [ARTBOARD], elements: INITIAL_ELEMENTS, })); // Tear the session down on unmount so the WebSocket is closed and the // grant-renewal timer stops. The teardown method is close() — NOT // disconnect(). return () => session.close(); }, [session]); return ( // No wrapper class needed — the canvas scopes its own styles. <> session.renderState('Front', serializeStateForServer(state), 200) } /> {mockup && live mockup} ); } ``` > **Note:** Replace `YOUR_SHOP_ID` with your own publishable `shop.id` (mint one from > [Get started](https://developers.snowcone.app/get-started)) and point `imageConfig.src` at > art on your own `your-shop-id.storage.snowcone.app` bucket (CORS-enabled by default). The > demo asset here is a Snowcone-hosted placeholder for copy-paste, not your art. ## Multi-placement products The quickstart renders a single-placement product (the Framed Canvas — one `Front` area). Most apparel has several print areas. The model is the same, repeated: call `renderState(placement, state)` **once per required placement**. The server collects them and renders only once it holds a state for **every** placement the product requires — a missing one comes back as an `incomplete_canvas_placements` error on `onError`. Read the real placement labels and mockup ids from `getProduct(productId)` (or `search.snowcone.app`) — the `placements[].label` values (and the renderer's own `requiredPlacements[].label` contract) are the exact strings you pass as the first arg, and the `mockups[].id` values are the opaque scene codes for `mockupIds`. For a product with a color/size **variant**, pass the chosen `variantId` (from `options.combinations[].variantId`) and pass the catalog `options` block to `RenderSession`: the renderer then auto-fills that variant's color and per-placement geometry, and the SDK can fail fast on a missing or invalid variant. ```tsx import { RenderSession } from '@snowcone-app/sdk'; import { serializeStateForServer } from '@snowcone-app/canvas'; // A multi-placement product. The Cotton Women's Tee (KMYKUK) has FIVE print // areas — its catalog placements[].label values: 'Front', 'Back', 'Left sleeve', // 'Right sleeve', 'Collar' — and one mockup scene (mockups[].id = '3jnLNX'). // Read them from getProduct('KMYKUK'): placements[].label (and the renderer's // own contract, requiredPlacements[].label) are the EXACT strings to pass. const session = new RenderSession({ shop: 'YOUR_SHOP_ID', grantUrl: '/api/realtime/grant', product: { productId: 'KMYKUK', mockupIds: ['3jnLNX'], // KMYKUK has a Size variant — pass the variantId for the chosen variant so // the renderer auto-fills that variant's color/placement geometry. Omit a // required variantId on a color/variant product and the SDK fails fast. variantId: 'qsFaIZ', // the 'S' Size variant (from options.combinations[].variantId) }, }); session.onMockups((results) => { img.src = results[0].imageUrl; }); // THE PER-PLACEMENT MODEL: call renderState ONCE PER required placement. The // server holds them and renders only once it has a state for EVERY placement // the product requires — a missing one surfaces as an // incomplete_canvas_placements error on onError. Each placement arg must equal // an artboard.name in that placement's state AND the catalog placements[].label. await session.renderState('Front', serializeStateForServer(frontState)); await session.renderState('Back', serializeStateForServer(backState)); await session.renderState('Left sleeve', serializeStateForServer(leftState)); await session.renderState('Right sleeve', serializeStateForServer(rightState)); await session.renderState('Collar', serializeStateForServer(collarState)); ``` **From a live editor.** You don't hand-author those per-placement states when a shopper is editing — one multi-artboard `` hosts every print area, and its `onChange` state carries **artboard membership**: every element is tagged with the name of the artboard it lives on (`element.artboard`). `serializeStateForServer` emits **every** artboard, each holding its own elements, so the per-placement states fall out of one loop: ```tsx 'use client'; import { RenderSession } from '@snowcone-app/sdk'; import { SnowconeCanvas, serializeStateForServer } from '@snowcone-app/canvas'; // FROM A LIVE EDITOR: one multi-artboard hosts every print // area (artboard name === catalog placements[].label), and onChange's // CanvasState tags each element with the artboard it lives on // (element.artboard). serializeStateForServer therefore emits EVERY artboard, // each holding its own elements — slice it per placement instead of // hand-authoring frontState/backState: const session = new RenderSession({ shop: 'YOUR_SHOP_ID', grantUrl: '/api/realtime/grant', product: { productId: 'KMYKUK', mockupIds: ['3jnLNX'], variantId: 'qsFaIZ' }, }); session.onMockups((results) => { img.src = results[0].imageUrl; }); export function Customizer() { return ( { // One render state PER placement, derived from the one editor. const wire = serializeStateForServer(state); for (const ab of wire.artboards) { void session.renderState(ab.name, { schemaVersion: wire.schemaVersion, artboards: [ab], }, 200); } // Save/reload: persist the RAW onChange state. Handing state.elements // back via initialElements restores each element onto its named // artboard (elements saved before the artboard tag existed land on // the first artboard, as before). saveDraft(state); }} /> ); } ``` > **Note:** **Save/reload round-trips placements too.** Persist the RAW `onChange` state (as > always — not the `serializeStateForServer()` output). On reload, pass `state.elements` back > as `initialElements`: each element is restored onto its named artboard. Elements saved before > the `artboard` tag existed (or with a name the product no longer has) land on the first > artboard — the previous behavior. Requires `@snowcone-app/canvas ≥ 0.8`; older releases emit > only the first artboard, holding all elements. ## Render a saved canvas by id For fulfillment, re-renders, or an agent that only carries an id, render a design by **reference** instead of sending the JSON each time. Persist a state once with `createDesignState` to get an opaque `stateId`, then `renderSavedState` resolves it, fetches its assets, and renders. (A design saved through the Snowcone editor already has a `stateId` you can reuse.) > **Version availability:** `createDesignState` and `RenderSession.renderSavedState` are > available in **`@snowcone-app/sdk` ≥ 0.2.0** (the current published release). Both the live > `renderState` path above and saved-state-by-reference work today on `0.2.0`. > **Note:** **Footgun — store the RAW canvas state, not the render payload.** > `createDesignState` takes exactly what the editor hands you — the canvas's `onChange` state > (or `toJSON()`). **Do NOT store `serializeStateForServer()` output** here; that payload is > for `renderState` and will mis-render if saved. Rule of thumb: > `serializeStateForServer()` → `renderState` (live); `onChange`/`toJSON()` → > `createDesignState` (saved). ```tsx import { createDesignState, RenderSession } from '@snowcone-app/sdk'; // 1. Persist a canvas state once and get back an opaque stateId. // (Public endpoint — no key needed. Skip this if you already have one.) // NOTE: createDesignState takes the RAW editor state (onChange / toJSON()) — // NOT the serializeStateForServer() payload used by renderState above; // don't cross them. const { stateId } = await createDesignState({ productId: 'BEEB77', state: rawCanvasState, }); // 2. Render it by reference — no canvas JSON in hand at render time. Ideal for // fulfillment, re-renders, or an agent that only carries a stateId. const session = new RenderSession({ shop: 'YOUR_SHOP_ID', grantUrl: '/api/realtime/grant', // mockupIds = catalog scene codes (mockups[].id), not a placement. BEEB77 has a // Frame color option, so variantId (options.combinations[].variantId) is required. product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' }, }); session.onMockups((results) => { img.src = results[0].imageUrl; }); await session.renderSavedState('Front', stateId); ``` ## Asset origins Because the server fetches the artwork URLs *inside* the state, lock your shop to an **asset-origin allowlist** — the hostnames it's allowed to fetch from (your own CDN, R2, etc.). An empty allowlist allows all origins; once set, a state that references a host you didn't list is rejected with an explicit error rather than a blank render. This is the same origin model as the [mockup URL security ladder](https://developers.snowcone.app/signed-urls). > **Note:** The artwork URLs inside the state must be **publicly fetchable** (the server > downloads them at render time — a private/auth-gated URL renders blank). If those same URLs > are also loaded in the browser canvas that produced the state, they must additionally be > **CORS-enabled** (`Access-Control-Allow-Origin`), since the canvas loads images with > `crossOrigin="anonymous"`. Hosting artwork on your shop's own > `your-shop-id.storage.snowcone.app` origin satisfies both. ## React hook Prefer `RenderSession` for most apps. If you want to drive the session from React state yourself, `useRealtimeMockup` gives you the lower-level handle — pass the same grant proxy as `getToken`. ```tsx 'use client'; import { useRealtimeMockup } from '@snowcone-app/sdk/react'; function LivePreview({ shop, state }: { shop: string; state: object }) { const { mockupResults, sendCanvasState } = useRealtimeMockup({ // Same grant proxy — the hook opens the WS with the returned token. getToken: () => fetch('/api/realtime/grant', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shop }), }).then((r) => r.json()), }); // Call sendCanvasState('Front', state) after connecting + sending config. // RenderSession (above) wraps this choreography — prefer it unless you need // the lower-level handle. return mockup; } ``` ## Troubleshooting Grant failures come back as HTTP errors from your proxy; render failures arrive on the session as `onError` messages with a `code`. The common ones: - **`shop is required / shop not found`** (grant 400 / 404) — Missing or wrong `shop.id` on the grant request. Copy it from the API keys page. - **`shop is revoked`** (grant 403) — The shop is disabled. Re-enable it (or pick another shop). - **`API key is not authorized for this shop`** (grant 403) — The key's org doesn't own that shop, or it lacks the `mockups:realtime` scope. Use a key whose org owns the shop, with the scope. - **`this shop requires a signed grant request`** (grant 403) — The shop is on the signed rung. Send `signature` + `ts`, or use the secret-key path (which satisfies it). - **`shop quota exceeded`** (grant 429) — The org hit its render budget. Raise the plan/quota. - **`asset_not_allowed`** (ws error) — A state references an asset host not on the shop's allowlist. Add the hostname under **Asset origins**, or leave the allowlist empty to allow all. - **`unsupported_schema_version`** (ws error) — The `canvas_state.schemaVersion` is newer than the renderer supports. Pin `@snowcone-app/canvas` to a matching version. - **`state_ref_failed`** (ws error) — A `stateId` couldn't be resolved (wrong id, or its state JSON is unreachable). Re-create it with `createDesignState`. - **`grant expired`** (ws close) — The 60s token lapsed. `RenderSession` auto-renews; if you drive the socket yourself, refresh the grant before `expiresAt`. ## API reference ### RenderSession options - **`shop`** — `string` — Your publishable `shop.id` (safe in the browser, like Stripe's `pk_`). Required. - **`grantUrl`** — `string` — Your same-origin proxy that returns `{ token, expiresAt }` for `POST { shop }` — it calls `mintRealtimeGrant` server-side. Provide this *or* `getToken`. - **`getToken`** — `() => Promise` — Full control over fetching the grant, instead of grantUrl. - **`product`** — `RenderProduct — { productId, mockupIds, variantId?, options?, placements?, requiredPlacements?, mockups?, defaultGvid?, width?, ar? }` — What to render: a catalog `productId` and the `mockupIds` (the opaque catalog scene codes — *not* placements) you want back. `variantId` is **required for color/variant products** (from `options.combinations[].variantId`) — omitting it on such a product fails fast. The rest are **advisory catalog passthroughs** — pass them verbatim from `getProduct()`, they are never sent to the server: `options` validates `variantId` up front, `placements` warns on a `renderState` placement name that isn't real, `requiredPlacements` (the renderer's placement contract; variant-auto-filled color placements don't count) lets the embed flag a multi-placement product at mount, and `mockups` (`{ id, gvids }`) + `defaultGvid` warn when a requested `mockupId`'s scene photographs a *different* variant than the one being rendered — it would composite no artwork. `width` is the rendered mockup width in px — **default 1000**; request ≈ your displayed CSS width × `devicePixelRatio` or the preview looks soft (a 650-px-wide image on a 2× display wants ~1300 → pass `width: 1400`, or `2000` for a crisp zoomed/cropped view). Can also be set later via `setProduct` — re-sending with only `width` changed is a cheap config-only update (low-res while dragging, sharp on release). - **`wsUrl`** — `string` — Realtime WS endpoint. Defaults to `REALTIME_WS_URL` (`wss://cdn.snowcone.app/realtime`). ### Exports — @snowcone-app/sdk - **`mintRealtimeGrant`** — `(opts) => Promise` — **Server-side.** Mint a 60s grant with a secret API key (`mockups:realtime` scope). Never call from the browser. - **`fetchRealtimeGrant`** — `(grantUrl, shop) => Promise` — **Browser.** Fetch a grant from your proxy. This is what `RenderSession` uses under the hood when given a `grantUrl`. - **`RenderSession`** — `class` — The high-level facade: `onMockups`, `onError`, `connect`, `renderState`, `renderSavedState`, `updateMockupIds`, `close`. All methods ship in the current published `@snowcone-app/sdk` ≥ 0.2.0. - **`createDesignState`** — `(input) => Promise<{ stateId, … }>` — Persist a canvas state and get back a `stateId` for `renderSavedState`. Public endpoint — no key needed. Available in `@snowcone-app/sdk` ≥ 0.2.0 (the current published release). - **`useRealtimeMockup`** — `(opts) => { … }` — React hook (from `@snowcone-app/sdk/react`) — the lower-level handle when you want to drive the session yourself. - **`REALTIME_WS_URL`** — `string` — The production realtime WebSocket endpoint constant. ## Where it leads The realtime renderer turns a canvas into a live mockup; the rest of the platform turns it into something buyable: - [Design editor (Canvas)](https://developers.snowcone.app/canvas) - [Instant mockups](https://developers.snowcone.app/mockups) - [Multiple placements](https://developers.snowcone.app/catalog/placements) - [Signed URLs](https://developers.snowcone.app/signed-urls) # Embed mode > Drop a live, server-rendered product editor into your app with three > components. The shopper edits; a fresh mockup streams back as they type. No > per-placement pixel upload — the server fetches your assets (ADR-0079). This is the markdown mirror of https://developers.snowcone.app/embed-mode — the same content as the page, served as plain text for agents and non-JS clients. Applies to `@snowcone-app/canvas@0.33.1` · `@snowcone-app/sdk@0.17.0`. ## What it is The [Canvas editor](https://developers.snowcone.app/canvas) has two modes. By default it renders full studio chrome — a toolbar, layers, effects panels. **Embed mode** (`kit="embed-only"`) renders just the canvas and lets *you* supply the UI, so the editor drops into your own product page and matches your design. Embed mode used to mean wiring the canvas, a realtime [`RenderSession`](https://developers.snowcone.app/realtime), an auto-export bridge, and a seed render by hand — about 80 lines, with several silent traps. The `@snowcone-app/canvas/embed` primitives collapse that into one declarative tree where every required piece is a typed prop: - **``** — owns the session and holds the required inputs: `shop`, `product`, `design`, and a grant source (`grantUrl` or `grant`). - **``** — the editor. You pass your own controls as children. - **``** — the live preview, linked to the editor through context so it can't desync. A single [`design()`](https://developers.snowcone.app/realtime#declarative-design) seeds **both** the canvas and the first render — there's no second "seed state" to keep in sync. ## The required pieces Everything an embed needs is a required prop on `` — there's no silently-optional prop to forget: | Piece | What it is | |---|---| | `shop` | Your publishable shop id (`shop.id`, like a Stripe `pk_`). The render bills here. | | `grantUrl` *or* `grant` | How the session authorizes — a same-origin proxy that mints a grant (recommended), or a token you supply. Exactly one. | | `product` | The catalog product to render against: `mockupIds`, optional `variantId`, `placements`. | | `design` | A `design()` builder — the single source of truth that seeds the canvas and the first render. | **Step 0: get a shop id.** Every example below needs one. Mint a keyless sandbox shop in a single call — `POST https://api.snowcone.app/shops/sandbox` returns a `shop_id` you can render against immediately. See [Get a Shop ID](https://developers.snowcone.app/shops). In development, `validateEmbed` (run automatically) prints a console warning for the classic mistakes: a missing grant, a design whose placement isn't a real product placement, or two layers sharing a name. ## 1. Mint a grant (server) The browser never holds a secret key. A same-origin route mints a short-lived grant. For a sandbox shop the publishable `shop.id` alone authorizes it — no key: `app/api/realtime/grant/route.ts` ```tsx // app/api/realtime/grant/route.ts — your same-origin grant proxy. // 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); } ``` For a production shop, pass an `sk_…` key with the `mockups:realtime` scope — see [Realtime](https://developers.snowcone.app/realtime#grant) for the secret and signed grant rungs. (The `scsec_…` "shop secret" you get when minting a sandbox shop is for *signing image URLs* — it is not an `sk_` key and doesn't go here.) ## 2. The editor (browser) The canvas is browser-only. A thin Client Component lazy-loads it with `next/dynamic` and `{ ssr: false }`: `app/page.tsx` ```tsx // app/page.tsx — a thin Client Component that lazy-loads the browser-only editor. // next/dynamic with { ssr: false } must live in a Client Component. 'use client'; import dynamic from 'next/dynamic'; const Editor = dynamic(() => import('./Editor'), { ssr: false }); export default function Page() { return ; } ``` The editor itself is one tree. Compose the design by intent with `design()`, give the layers you want to personalize a `name`, and bind your own controls to them: `app/Editor.tsx` ```tsx // app/Editor.tsx — the entire live editor, client-side. 'use client'; import { RealtimeEmbed, RealtimeCanvas, RealtimeMockup, design, useTextBinding, } from '@snowcone-app/canvas/embed'; import '@snowcone-app/canvas/style.css'; // ONE design() seeds the canvas AND the first render — no duplicate seed state. // Named layers ('art', 'headline') are what the binding hooks target. The // artboard `name` is the placement; it must match a product placement label. const d = design({ name: 'Front', width: 1480, height: 2328 }) .image('https://your-shop-id.storage.snowcone.app/demo/art.jpg', { anchor: 'center', name: 'art', }) .text('YOUR NAME', { anchor: 'top', name: 'headline' }); // Bring your own control. embed-only ships no chrome; useTextBinding(name) wires // an input to a named layer. Every keystroke re-renders the mockup. function HeadlineInput() { const { text, setText, isConnected } = useTextBinding('headline'); return ( setText(e.target.value)} disabled={!isConnected} /> ); } export default function Editor() { return ( ); } ``` That's the whole editor. `` and `` can be laid out however you like — side by side, stacked, the preview in a different column — because they share one session through context. ## Editing controls: bring your own, or use a kit `` takes a `kit` prop that decides how much editor UI it mounts. Two paths — pick by how much control you want over the look: **1. Bring your own controls (`kit="embed-only"`, the default).** A chrome-less canvas — you supply every control, so they match your product page. The binding hooks connect a control to a layer by `name`: - **`useTextBinding(name)`** → `{ text, setText, isConnected, element }` — drive a text layer from an ``. - **`useImageBinding(name)`** → swap the image on a named image layer (e.g. an upload button or a preset picker). Great for a fill-in-the-blanks personalizer (change this text, swap this photo). Bindings only resolve inside ``, so render your controls as its children (as `HeadlineInput` above). `isConnected` is `false` until the named layer exists — use it to disable a control until the canvas is ready. **2. Use a kit (`kit="compact-customizer"` or `"pro-studio"`).** A full in-canvas editor with no UI to build — the shopper can select a layer and change its font size, add an element, reorder layers, apply effects. Every edit still streams a fresh mockup through the same session; you wire nothing extra. The fastest way to a real editor, and it stays on the turnkey path (no eject): ```tsx // Don't want to hand-build controls? Pass a kit to and get // a full in-canvas editor — select a layer, change its font size, add an // element, reorder layers. Every edit still streams a fresh mockup; you wire // nothing. (Default is kit="embed-only", the chrome-less bring-your-own path.) // full studio chrome // or // a trimmed toolbar // Mixing is fine — pass children to ADD controls on top of a kit: {/* your bound control, alongside the kit's toolbar */} ``` The binding hooks work the same under any kit, so mixing is fine — pass a kit *and* your own bound controls when your controls add to, rather than duplicate, the kit's. Two kits' worth of the same toolbar is the one thing to avoid. ### The upload → bind loop (swap a photo) The most common customizer control: the shopper picks a photo, you host it, and `setImageUrl(url)` swaps the named layer — a fresh mockup streams back. Host the photo with `POST https://api.snowcone.app/uploads/base64` — it is **self-serve**: any shop API key with the `uploads:write` scope works, and a freshly minted sandbox shop's key already carries it. Keep the key server-side by proxying the call same-origin, exactly like `grantUrl`: ```ts // app/api/upload/route.ts — same-origin upload proxy; the API key stays // server-side. POST https://api.snowcone.app/uploads/base64 is self-serve: // any shop key with the uploads:write scope works (a freshly minted sandbox // shop's key already carries it). // // Request: { base64Image } — a data URL (data:image/png;base64,…) or raw // base64. Allowed types: png, jpg, jpeg, webp, gif, svg. Max 50 MB. // Response: { url } — a permanent asset on your shop's // your-shop-id.storage.snowcone.app origin, which is already on the // shop's asset-origin allowlist — renderable with zero extra config. export async function POST(req: Request) { const { base64Image } = await req.json(); const res = await fetch('https://api.snowcone.app/uploads/base64', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': process.env.SNOWCONE_API_KEY!, // needs uploads:write }, body: JSON.stringify({ base64Image }), }); return Response.json(await res.json(), { status: res.status }); } ``` ```tsx // PhotoPicker.tsx — render as a child, next to HeadlineInput. // The shopper picks a photo → POST /uploads/base64 (via your proxy above) → // setImageUrl(url) swaps the named 'art' layer — a fresh mockup streams back. import { useImageBinding } from '@snowcone-app/canvas/embed'; function PhotoPicker() { const { setImageUrl, isConnected } = useImageBinding('art'); return ( { const file = e.target.files?.[0]; if (!file) return; const base64Image = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(file); }); const { url } = await ( await fetch('/api/upload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ base64Image }), }) ).json(); setImageUrl(url); // canvas swaps the layer; the mockup re-renders }} /> ); } ``` > **Note:** The returned `url` lives on your shop's own > `your-shop-id.storage.snowcone.app` origin, which is already on the shop's > asset-origin allowlist — so the renderer can fetch it with zero extra config. > Full endpoint contract on the > [API reference](https://developers.snowcone.app/api-reference#uploads). ## Building chrome around the canvas (layout contract) When you build your own editor chrome — a tool dock, an inspector, a layers rail — around ``, four layout rules decide whether it works on the first try: **1. Children render *inside* the canvas's box.** Your `` children mount as an overlay inside the editor's own `position: relative` container — not as siblings of it. That's what makes the binding hooks resolve, and it makes the canvas box the positioning context: a child with `absolute top-2 right-2` floats over the artwork, and a child anchored with `right-full` sits just outside the canvas's left edge while still living in its DOM. **2. Anchor outside panels with `*-full` utilities, and reserve their space with wrapper padding.** The supported pattern for chrome *around* the canvas: `right-full` (panel to the left), `left-full` (panel to the right), `top-full` (panel below), `bottom-full` (panel above) — then pad a wrapper you own so the page layout leaves room for them. These are your app's own Tailwind classes (the canvas's internal styles are `sc:`-prefixed and never collide): ```tsx // Chrome around the canvas: anchor each panel just OUTSIDE the canvas box // (right-full / left-full / top-full / bottom-full) and reserve its space // with padding on a wrapper YOU own. The class names are your app's own // Tailwind utilities — the canvas's internal styles are sc:-prefixed and // never collide with them. import { RealtimeCanvas } from '@snowcone-app/canvas/embed'; // The wrapper reserves the room the anchored panels occupy: pl-16 for the // tool dock, pr-44 for the layers rail, pb-28 for the inspector. No // overflow-hidden here — or on ANY ancestor — it would clip the panels. function EditorWithChrome() { return (
    {/* Tool dock — anchored LEFT of the canvas */}
    {/* …tool buttons… */}
    {/* Layers rail — anchored RIGHT */}
    {/* …layer list… */}
    {/* Inspector — anchored BELOW */}
    {/* …selected-layer controls… */}
    ); } ``` **3. The `overflow: hidden` trap.** Panels anchored with `*-full` live inside the canvas's box but render outside its bounds — so *any* ancestor with `overflow: hidden` (or `auto`/`scroll`/`clip`) silently clips them. The usual offender is a card wrapper with `overflow-hidden rounded-xl`. Don't put one between the canvas and your page: rounded corners don't need `overflow-hidden` on the canvas's ancestors — round (and clip) an inner element that doesn't contain the canvas, and reserve panel space with wrapper padding as above. **4. Sizing is width-driven; cap height with `maxHeight`.** The canvas measures only its container's *width*, fits the artboard to it, and sets its *own* height from the artboard's aspect ratio — it never reads the parent's height. `maxHeight` *refits* rather than clips: the artboard scales down until it fits and the leftover width becomes side margin, so the artwork stays centered and fully visible. The footgun is a height-constrained *parent*: a fixed-height pane (`h-[480px]` + `overflow-hidden`) does **not** make the canvas shrink — it keeps its width-derived height and the parent silently hides the bottom of the canvas. Give the slot's height to `maxHeight` instead, and let the parent grow to fit. ## Stock images & AI generation The kits' Image Browser has stock-image search and AI-generation tabs that need privileged vendor APIs. The canvas never holds those vendor keys — you host same-origin proxy routes (the same pattern as `grantUrl`) and pass their URLs via the `services` prop. A tab whose service isn't configured doesn't render at all, so an unconfigured embed simply shows only the URL/upload tab. ```tsx // Stock images & AI generation in the Image Browser — same pattern as grantUrl: // same-origin proxy URLs, your vendor keys stay on your server. A tab whose // service isn't configured doesn't render at all. ``` `@snowcone-app/sdk/server` ships `createImageSearchHandler` — a drop-in route handler for the image-search side (bring your own Unsplash/Pixabay keys, server-only). The full wiring, the wire contracts, and the AI route recipe live on [Stock images & AI generation](https://developers.snowcone.app/image-services). ## The live preview `` renders the latest mockup and updates as the shopper edits. For a custom preview (a gallery, your own loading treatment), use the hook: - **`useRealtimeMockups()`** → `{ mockups, isRendering, error }`. `mockups` is one entry per requested `mockupId`; `` picks a specific scene, or the default shows the first that has rendered. ## Catalog field → prop mapping [`getProduct(id)`](https://developers.snowcone.app/sdk) reads the public catalog. Its fields map onto the `product` prop — crossing them is the #1 silent-failure trap: | Catalog field | Prop | Namespace | |---|---|---| | `product.mockups[].id` | `mockupIds` (`string[]`) | scene code, e.g. `FV1qjO` | | `product.placements[].label` | `placements[]` — and the `design` artboard `name` | print-area name, e.g. `Front` | | `product.options.combinations[].variantId` | `variantId` | gvid for the chosen variant | | `product.requiredPlacements` | `requiredPlacements` (verbatim) | the renderer's placement contract — `{ label, type, autoFilledByVariant }` | | `product.mockups` + `product.defaultGvid` | `mockups` + `defaultGvid` (verbatim) | scene ↔ variant (gvid) map | - **`mockupId` ≠ `placement`.** A mockupId is an opaque scene code; a placement is a print-area name. The `design`'s artboard `name` must equal the placement. - **`variantId` is required** when the product has a COLOR axis — an `options.attributes` entry whose choices carry a `hex`. Omit it there and the render hangs waiting for a color blob; pass the catalog `options` so the embed fails fast with an actionable error instead. A non-color variant axis (size, accessory, …) renders without one — the server uses the default variant and the SDK warns with the valid ids. - **Pass the advisory fields too.** `requiredPlacements`, `mockups`, and `defaultGvid` are catalog passthroughs (verbatim, never sent to the server) that power dev-time guards. `requiredPlacements` lets `` flag a product that demands more placements than its single artboard **at mount** (instead of a ~15s "Timed out waiting for all placements"). `mockups` (`{ id, gvids }` entries) plus `defaultGvid` (or `variantId`) warn when a requested `mockupId`'s scene photographs a *different* variant than the one being rendered — such a scene silently composites **no artwork** (a bare product photo), the classic `mockupIds: product.mockups.map(m => m.id)` trap on a product whose scenes are split across variants. Don't hardcode the ids — read them from the catalog and pass them down: ```tsx // Find the ids instead of hardcoding them. getProduct() reads the PUBLIC // catalog — no key. Do this in a Server Component and pass the result down. import { getProduct } from '@snowcone-app/sdk'; const product = await getProduct('BEEB77'); const mockupId = product.mockups?.[0]?.id ?? 'FV1qjO'; const placement = product.placements?.[0]?.label ?? 'Front'; // First variant combination. REQUIRED when the product has a COLOR axis. const variantId = product.options?.combinations?.find((c) => c.variantId)?.variantId; // Pass the advisory catalog fields too — verbatim, never sent to the server. // They power dev-time guards: options lets a wrong/missing variantId FAIL FAST, // requiredPlacements flags a multi-placement product AT MOUNT (not a ~15s // timeout), and mockups + defaultGvid warn when a requested scene photographs // a different variant (it would composite no artwork — a bare product photo). const productProp = { productId: product.id, mockupIds: [mockupId], variantId, options: product.options, placements: [placement], requiredPlacements: product.requiredPlacements, mockups: product.mockups, defaultGvid: product.defaultGvid ?? undefined, // catalog may carry null }; ``` ## Beyond the trio: the Editor.* primitives `RealtimeEmbed` / `RealtimeCanvas` / `RealtimeMockup` are the turnkey arrangement of a finer, **stable** public surface: the `Editor.*` compound parts (`Editor.Root`, `Editor.Canvas`, `Editor.Preview`, `Editor.TextField`, `Editor.Buy`, `Editor.Layers`, `Editor.Controls`, `Editor.History`, `Editor.Menu`, `Editor.Add`) — same session, same `design()`, but YOU own the layout. They add what the trio doesn't have: a smart **add-to-cart part** with completeness gating and payload assembly, **typed layer tokens** for personalization fields, host-rendered **variant pickers** via `useProductSelection()`, and a no-canvas composition where the design editor never loads at all. The turnkey `SnowconeEditor` is those same parts in our arrangement — customizing means recomposing, never ejecting. See [Editor primitives](https://developers.snowcone.app/primitives). ## Ejecting to full control > **`` renders a single placement.** It only ever sends the > `design`'s active artboard (one placement). The server requires a > `canvas_state` for *every* required placement, and the turnkey embed has no > prop to switch placement — so a multi-placement product (a tee with Front + > Back + sleeves) can never complete through it. Pass the catalog's > `product.requiredPlacements` (verbatim) and the embed flags this **at mount** > with an error pointing at the eject path; without it, the failure is the > server's ~15s `Timed out waiting for all placements`. For Front + Back > products, eject (below). `` is the turnkey path. When you need something it doesn't cover — **multi-placement products**, color/variant blobs, your own session lifecycle — drop to the underlying primitives directly: mount a [``](https://developers.snowcone.app/canvas) with one artboard per placement and drive a [`RenderSession`](https://developers.snowcone.app/realtime) yourself, calling [`renderState(placement, state)` once per placement](https://developers.snowcone.app/realtime#multi-placement). (The same `kit` presets apply when you eject — pass `kit="pro-studio"` for the full editor.) The embed components are a thin layer over exactly those, so nothing is lost by ejecting. ## Where it leads - [Design editor (Canvas)](https://developers.snowcone.app/canvas) - [Stock images & AI generation](https://developers.snowcone.app/image-services) - [Realtime server-side render](https://developers.snowcone.app/realtime) - [Multiple placements](https://developers.snowcone.app/catalog/placements) - [Asset origins](https://developers.snowcone.app/signed-urls) # Editor primitives > Compound `Editor.*` parts over one `Editor.Root`: live previews, layer-bound > fields, and smart add-to-cart in YOUR layout. The default editor is the same > parts in our arrangement — customizing means recomposing, never ejecting. This is the markdown mirror of https://developers.snowcone.app/primitives — the same content as the page, served as plain text for agents and non-JS clients. 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. - **``** — the one provider: session, design state, built-in error boundary. Takes `shop`, `product`, `design`, and a grant source. - **``** — the live product mockup. Several under one Root (front + back) is valid. - **``** — a form input bound to a design text layer by typed token. - **``** — 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. - **``** — the editable artboard. The heavy editor loads only behind this part — a composition without it never downloads it. - **`` / `` / `` / `` / ``** — editor chrome as parts. `Layers` works canvas-free; the rest require a mounted `Editor.Canvas` and throw a structured error naming both parts if it's missing. **Stable.** The `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. ```tsx 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 (
    Rendering…
    } /> { // 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
    ); } ``` Don't forget `import '@snowcone-app/canvas/style.css'` once, anywhere in your app. The demo ids render against any shop; [mint a sandbox shop](https://developers.snowcone.app/shops) to use your own product and assets, and read real ids from the catalog with `getProduct()` (see [catalog → prop mapping](https://developers.snowcone.app/embed-mode#catalog)). ## 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](https://developers.snowcone.app/realtime#grant)). The same five lines in the three common shapes: ```tsx // 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); } ``` ```ts // 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 })); } ``` ```ts // 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 ```tsx // 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 ( 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 ```tsx // 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.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 ```tsx // 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]) => (
    {attr.choices.map(({ label = '', hex }) => ( ))}
    ))} {price != null && {(price / 100).toFixed(2)}} ); } ``` 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:`, `variant`, `layer:`, `layer-seed:`, `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: ```ts 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'); ``` **Loud degradation — read this if your designs come from a CMS or database.** A design parsed from JSON (`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. ```ts 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-checked ``` ## Fully 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 ``: ```tsx // 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 (
    {flatLayers.map((layer) => ( ))} {selectedElement && editing: {selectedElement.id}}
    ); } ``` ## 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](https://developers.snowcone.app/styling-contract.md) (also shipped in the tarball as `STYLING_CONTRACT.md`, and inlined in [llms-full.txt](https://developers.snowcone.app/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. ```ts 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:name ``` In 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. ## Where it leads - [Embed mode (turnkey trio)](https://developers.snowcone.app/embed-mode) - [Styling contract](https://developers.snowcone.app/styling-contract.md) - [Realtime rendering](https://developers.snowcone.app/realtime) - [Editable templates](https://developers.snowcone.app/editable-templates) - [Get a Shop ID](https://developers.snowcone.app/shops) # `Editor.*` styling contract Every `Editor.*` part renders minimal styled DOM carrying a stable `data-sc-part` attribute, accepts `className`/`style`, and is themed by the `.sc-root` CSS custom properties below. Restyle by targeting `[data-sc-part="…"]` selectors and overriding `.sc-root` tokens — you never need this package's source. Attribute values shown are the stable enums; `` values are runtime data. `sc-*` classes are additional stable hooks; `sc:`-prefixed utility classes are internal and NOT contract. ## Part index | `data-sc-part` | rendered by | |---|---| | `add` | Editor.Add | | `buy` | Editor.Buy | | `canvas` | Editor.Canvas | | `controls` | Editor.Controls | | `error` | Editor.Root | | `error-retry` | Editor.Root | | `history` | Editor.History | | `layers` | Editor.Layers | | `layers-item` | Editor.Layers | | `loading-overlay` | Editor.LoadingOverlay | | `menu` | Editor.Menu | | `placements` | Editor.Placements | | `placements-item` | Editor.Placements | | `preview` | Editor.Preview | | `preview-error` | Editor.Preview | | `preview-image` | Editor.Preview | | `preview-loading` | Editor.Preview | | `root` | Editor.Root | | `snowcone-editor` | SnowconeEditor | | `snowcone-editor-header` | SnowconeEditor | | `snowcone-editor-idle-dock` | SnowconeEditor | | `snowcone-editor-layer-thumb` | SnowconeEditor | | `snowcone-editor-menu` | SnowconeEditor | | `snowcone-editor-menu-item` | SnowconeEditor | | `snowcone-editor-menu-list` | SnowconeEditor | | `snowcone-editor-pip` | SnowconeEditor | | `snowcone-editor-stage` | SnowconeEditor | | `snowcone-editor-swap-back` | SnowconeEditor | | `snowcone-editor-swap-stage` | SnowconeEditor | | `snowcone-editor-zone` | SnowconeEditor | | `textfield` | Editor.TextField | ## The DOM each part renders ### `add` — Editor.Add ```html