Editable templates

A template is a finished design that ships with a manifest of what the buyer may change — type your name here, swap this logo, pick the accent color. Your designer authors once; every buyer personalizes inside the rails the designer set.

Applies to @snowcone-app/canvas@0.33.1 · @snowcone-app/sdk@0.17.0

What a template is

A plain design is a saved canvas state — elements, artboards, positions (see Canvas). An editable template is that same state document plus a list of TemplateFields describing which parts are buyer-editable:

  • { type: 'text', name, label, maxLength? } — a text slot. Binds to canvas elements by their name property; the buyer’s input replaces the element’s text. maxLength is the designer’s length limit (your control applies it — long names overflow fixed layouts).
  • { type: 'image', name, label, fit? } — a swappable image. Also binds by name; the buyer’s image is center-cropped (cover, default) or letterboxed (contain) into the original element’s bounds.
  • { type: 'color', color, label } — an editable color. Matches by VALUE: every element painted that color (text, shape fill, stroke) repaints together.
  • { type: 'select', name, label, property, options } — a pick-one choice over designer-authored options: “one of these 3 badges” (property: 'imageUrl'), a curated font list (property: 'fontFamily'), or preset text variants (property: 'text').

Every field also takes an optional group label. Controls render in manifest order; consecutive fields sharing a group belong together — that’s the whole ordering model, so the designer’s tick order IS the buyer’s control order unless you sort by group.

The fields live ON the state document itself, under the personalization key (TEMPLATE_FIELDS_KEY):

json
{
  "elements": [
    { "transformType": "custom", "text": "YOUR NAME", "name": "headline" }
  ],
  "artboards": [{ "name": "Front", "width": 1480, "height": 2328 }],
  "activeArtboard": "Front",
  "personalization": [
    { "type": "text", "name": "headline", "label": "Your name" }
  ]
}

One document is the whole template: state + manifest travel together through your storage, and readTemplateFields() reads the manifest back off any parsed state JSON — it returns [] for a plain, non-template design, so one code path handles both.

This is the same convention snowcone.app’s own editor writes, so a template authored in your app round-trips through Edit / Remix / the snowcone.app product page unchanged — and vice versa.

Authoring: let designers mark layers editable

Two rules make a layer editable:

  1. Name it. Binding is by the element’s name property — give layers stable, human-readable names (headline, logo). Two layers sharing a name become ONE field that updates both (useful for a front/pocket repeat).
  2. Manifest it. Add a TemplateField referencing that name (or, for colors, the hex value) to the fields list, and save the list with the state via withTemplateFields().

The editor side is a normal SnowconeCanvas mount; your “make editable” panel renders in its overlay (inside the editor context) and reads the layer list with useLayers():

tsx
// TemplateDesigner.tsx — your admin surface. The designer composes freely; the
// overlay panel turns named layers into buyer-editable fields.
'use client';
import { useState } from 'react';
import {
  SnowconeCanvas,
  useLayers,
  withTemplateFields,
  type CanvasState,
  type TemplateField,
} from '@snowcone-app/canvas';

// Renders inside the canvas `overlay`, so useLayers() can see the editor.
function MakeEditablePanel({
  fields,
  onToggle,
}: {
  fields: TemplateField[];
  onToggle: (field: TemplateField) => void;
}) {
  const { flatLayers } = useLayers();
  return (
    <div className="template-panel">
      {flatLayers
        .filter((layer) => layer.name && !layer.isGroup)
        .map((layer) => {
          const field: TemplateField =
            layer.type === 'image'
              ? { type: 'image', name: layer.name, label: layer.name }
              : { type: 'text', name: layer.name, label: layer.name };
          const editable = fields.some(
            (f) => f.type === field.type && 'name' in f && f.name === layer.name,
          );
          return (
            <label key={layer.id}>
              <input type="checkbox" checked={editable} onChange={() => onToggle(field)} />
              Buyer can edit “{layer.name}”
            </label>
          );
        })}
    </div>
  );
}

export default function TemplateDesigner() {
  const [draft, setDraft] = useState<CanvasState | null>(null);
  const [fields, setFields] = useState<TemplateField[]>([]);

  const toggle = (field: TemplateField) =>
    setFields((cur) => {
      const key = JSON.stringify(field);
      return cur.some((f) => JSON.stringify(f) === key)
        ? cur.filter((f) => JSON.stringify(f) !== key)
        : [...cur, field];
    });

  const saveTemplate = async () => {
    if (!draft) return;
    // ONE document: state + manifest. Persist it wherever you store designs.
    await fetch('/api/templates', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(withTemplateFields(draft, fields)),
    });
  };

  return (
    <>
      <SnowconeCanvas
        artboards={[{ name: 'Front', width: 1480, height: 2328 }]}
        onChange={setDraft}
        overlay={<MakeEditablePanel fields={fields} onToggle={toggle} />}
      />
      <button onClick={saveTemplate} disabled={!draft}>
        Save template
      </button>
    </>
  );
}

The same panel pattern extends to colors: offer the colors currently used in the design (collect them from useEditor()’s elements, or let the designer type a hex) and toggle { type: 'color', color, label } fields the same way.

The sample defaults each field’s label to the layer name to stay short — a real panel adds inputs next to each ticked layer for the buyer-facing label, the placeholder, and (for text) the maxLength cap. They’re plain properties on the field object in your panel state; whatever the designer types is what withTemplateFields persists.

Save the state JSON, not a flattened PNG. A template whose “state” is one rasterized image has nothing left to personalize — the named layers are the whole point. (Same rule as saving any design.)

Buyer side: controls generated from the manifest

Your storefront reads the manifest and renders one control per field. Each field type pairs with a binding hook, and every hook reports isConnected — whether a matching element actually exists — so your UI shows controls for exactly what this template exposes, and a renamed/deleted layer degrades to a hidden control instead of a dead one:

Field typeHookBinds by
textuseTextBinding(name)element name
imageuseImageBinding(name, { fit })element name
coloruseColorBinding(color)color value, across all elements
selectby property: useTextBinding / useImageBinding / useFontBindingelement name

Binding hooks need the editor context, so the controls render as <RealtimeCanvas> children (or in SnowconeCanvas’s overlay):

tsx
// PersonalizeEditor.tsx — the buyer's page. The saved template document drives
// BOTH the canvas seed and which controls exist.
'use client';
import type {
  CanvasState,
  TemplateField,
  TemplateTextField,
  TemplateImageField,
  TemplateColorField,
} from '@snowcone-app/canvas';
import {
  RealtimeEmbed,
  RealtimeCanvas,
  RealtimeMockup,
  DesignBuilder,
  readTemplateFields,
  useTextBinding,
  useImageBinding,
  useColorBinding,
} from '@snowcone-app/canvas/embed';
import '@snowcone-app/canvas/style.css';

function TextControl({ field }: { field: TemplateTextField }) {
  const { text, setText, isConnected } = useTextBinding(field.name);
  if (!isConnected) return null; // layer missing → no dead control
  return (
    <label>
      {field.label}
      <input
        value={text}
        placeholder={field.placeholder}
        maxLength={field.maxLength} // designer's limit — enforce it in the control
        onChange={(e) => setText(e.target.value)}
      />
    </label>
  );
}

function ImageControl({ field }: { field: TemplateImageField }) {
  const { setImageUrl, isConnected } = useImageBinding(field.name, { fit: field.fit });
  if (!isConnected) return null;
  return (
    <label>
      {field.label}
      <input type="url" placeholder={field.placeholder} onChange={(e) => setImageUrl(e.target.value)} />
    </label>
  );
}

function ColorControl({ field }: { field: TemplateColorField }) {
  const { color, setColor, isConnected } = useColorBinding(field.color);
  if (!isConnected) return null;
  return (
    <label>
      {field.label}
      <input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
    </label>
  );
}

function TemplateControls({ fields }: { fields: TemplateField[] }) {
  return (
    <>
      {fields.map((f, i) =>
        f.type === 'text' ? (
          <TextControl key={i} field={f} />
        ) : f.type === 'image' ? (
          <ImageControl key={i} field={f} />
        ) : f.type === 'color' ? (
          <ColorControl key={i} field={f} />
        ) : (
          // select — see "Select fields, fonts, and limits" below
          null
        ),
      )}
    </>
  );
}

export default function PersonalizeEditor({ template }: { template: CanvasState }) {
  // The saved template re-enters the turnkey embed path: fromState() lifts the
  // state back into a DesignBuilder, and the manifest decides the controls.
  const d = DesignBuilder.fromState(template);
  const fields = readTemplateFields(template);

  return (
    <RealtimeEmbed
      shop="YOUR_SHOP_ID"
      grantUrl="/api/realtime/grant"
      product={{ productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC', placements: ['Front'] }}
      design={d}
    >
      <RealtimeCanvas>
        <TemplateControls fields={fields} />
      </RealtimeCanvas>
      <RealtimeMockup />
    </RealtimeEmbed>
  );
}

Every buyer edit streams a fresh server-rendered mockup, exactly like any embed-mode editor — the template just decides which knobs exist. The shop, grant, and product wiring is the standard embed setup; see Embed mode for minting grants and mapping catalog ids.

Two ends of the control spectrum, same template document:

  • Fill-in-the-blanks (above): kit="embed-only" + manifest-generated controls. The buyer can ONLY touch what the designer exposed.
  • Full editor with shortcuts: pass kit="compact-customizer" or kit="pro-studio" and render <TemplateControls> as children on top — the manifest fields become prominent quick-edit controls while the full editor stays available.

How much freedom the buyer gets is your product decision; the manifest is the designer’s intent, not an enforcement mechanism. To HARD-lock the rest of the design, set locked: true on the non-editable elements before saving the template — locked layers can’t be dragged, resized, or rotated even in a chrome’d kit (they can still be selected and inspected).

Select fields, fonts, and limits

A select field is one control shape covering three asks: “pick one of these 3 badges” (property: 'imageUrl'), a designer-curated font list (property: 'fontFamily' — values must be Google Fonts family names; useFontBinding loads the chosen font into the page automatically), and preset text variants (property: 'text'). The field’s property picks which binding applies the chosen value:

tsx
// SelectControl.tsx — one control covers badges, curated fonts, and preset
// text: the field's `property` picks which binding applies the chosen value.
import {
  useTextBinding,
  useImageBinding,
  useFontBinding,
} from '@snowcone-app/canvas/embed';
import type { TemplateSelectField } from '@snowcone-app/canvas';

function SelectControl({ field }: { field: TemplateSelectField }) {
  const text = useTextBinding(field.name);
  const image = useImageBinding(field.name);
  const font = useFontBinding(field.name);
  const binding =
    field.property === 'text'
      ? { isConnected: text.isConnected, apply: text.setText }
      : field.property === 'imageUrl'
        ? { isConnected: image.isConnected, apply: image.setImageUrl }
        : { isConnected: font.isConnected, apply: font.setFontFamily };
  if (!binding.isConnected) return null;
  return (
    <label>
      {field.label}
      <select onChange={(e) => binding.apply(e.target.value)}>
        {field.options.map((o) => (
          <option key={o.value} value={o.value}>
            {o.label}
          </option>
        ))}
      </select>
    </label>
  );
}

Text limits ride the text field: maxLength is the designer’s cap (buyer names overflow fixed layouts), applied by your control (<input maxLength={…}>, as in TextControl above) — the canvas itself doesn’t truncate.

Older readers (including snowcone.app surfaces on an older canvas) simply drop field types they don’t recognize — a template carrying select fields still works everywhere, minus those controls. That’s the manifest’s forward-compat model: unknown types are skipped, never an error.

Upgrading from a canvas before select shipped: select joined the TemplateField union, so a switch/ternary that treated the last else as color (a two-arm text ? … : image ? … : color) no longer type-narrows — the else is now TemplateColorField | TemplateSelectField. Match each type explicitly (f.type === 'color' ? … : …, as the TemplateControls above does) so adding the next field type is a compile error to handle, not a silent miscast.

Layout: the editor’s box is your positioning context

Three layout facts save you a debugging session:

  1. The editor is width-driven. It sizes itself from its container’s WIDTH (times the artboard’s aspect ratio) and ignores the container’s height. maxHeight CLIPS the canvas — it does not refit it. Don’t mount the embed in a fixed-height box: the bottom of the canvas silently disappears. Give the wrapper a width and let height follow.
  2. Your controls’ positioned ancestor is the editor’s box, not your wrapper. Children of <RealtimeCanvas> mount inside the editor’s own position: relative container — a box as tall as width × artboard aspect, NOT as tall as the element you wrapped the embed in. An absolutely positioned control strip (bottom: 0) anchors to the editor’s box.
  3. Don’t rely on normal flow. The canvas itself is absolutely positioned inside that box, so an unpositioned child does NOT stack below the design — it can land at the top, overlapping the artwork. Position controls deliberately, one of two ways:

Overlay strip — pin the controls over an edge of the canvas. Pick this when the artwork region under the strip is decorative (a background, a pattern):

tsx
// Width-driven wrapper + a control strip pinned to the bottom of the EDITOR's
// box (its position:relative container is the positioned ancestor — not the
// wrapper div). The strip COVERS the bottom of the artwork.
<div style={{ maxWidth: 420 }}>
  <RealtimeCanvas>
    <div
      style={{
        position: 'absolute',
        left: 12,
        right: 12,
        bottom: 12,
        display: 'grid',
        gap: 8,
      }}
    >
      <TemplateControls fields={fields} />
    </div>
  </RealtimeCanvas>
</div>

Hang below — anchor the controls at top: 100% so they sit under the canvas, and reserve the space with wrapper padding (the editor’s box doesn’t grow for you). Pick this when buyer-editable layers live in the artwork’s lower region — on a camera-cutout product everything editable is in the bottom half, and an overlaid strip would cover exactly the text the buyer is typing into:

tsx
// Controls hang BELOW the canvas, still inside <RealtimeCanvas> so the binding
// hooks keep their editor context. The wrapper's paddingBottom reserves room.
<div style={{ maxWidth: 420, paddingBottom: 180 }}>
  <RealtimeCanvas>
    <div
      style={{
        position: 'absolute',
        top: '100%',
        left: 0,
        right: 0,
        paddingTop: 12,
        display: 'grid',
        gap: 8,
      }}
    >
      <TemplateControls fields={fields} />
    </div>
  </RealtimeCanvas>
</div>

Product dead zones are shown automatically

The artboard is the full printable file, but on some products part of it sits under hardware — on a phone case, the top of the Front placement is hidden by the camera module. Artwork placed there prints, but never shows on the product or in the mockup.

In the buyer embed, this is automatic. <RealtimeEmbed> fetches the product’s printable-area guides and the editor draws them — the same boundary / safe-area / dead-zone overlay snowcone.app’s own editor shows. Two conditions, both usually already true:

  • The embed needs a variant to look up: product.variantId, or the catalog’s defaultGvid (pass the catalog fields through, as the embed-mode docs recommend).
  • The design’s artboard must match the print file’s dimensions — author templates at the placement’s dims. On a mismatch the overlay is skipped (the coordinates wouldn’t line up) and a dev-console hint says so.

On your authoring surface, wire it once. The overlay is automatic only in the embed. A TemplateDesigner built on a raw SnowconeCanvas (the authoring example above) shows NO overlay by default — which is the worst place to miss it, since that’s where the designer drops a field into the dead zone. Fetch the same guides with the exported helpers and pass them to the canvas’s pieceGuides prop, so the designer sees the cutout while placing fields:

tsx
// In TemplateDesigner — fetch the product's guides and gate them on the
// artboard, then feed the canvas. `usePieceGuideBundle` + `pieceGuidesForArtboard`
// are the same fetch + dims-gate <RealtimeEmbed> uses, exposed for raw mounts.
import { SnowconeCanvas } from '@snowcone-app/canvas';
import {
  usePieceGuideBundle,
  pieceGuidesForArtboard,
} from '@snowcone-app/canvas/advanced';

const ARTBOARD = { name: 'Front', width: 1480, height: 2328 };

function AuthoringCanvas({ gvid }: { gvid: string }) {
  const bundle = usePieceGuideBundle(gvid); // null while loading / no guides
  const pieceGuides = bundle
    ? pieceGuidesForArtboard(bundle, ARTBOARD) // undefined on a dims mismatch
    : undefined;
  return (
    <SnowconeCanvas
      artboards={[ARTBOARD]}
      pieceGuides={pieceGuides}
      onChange={/* … */ undefined}
    />
  );
}

Belt and suspenders: still sanity-check each editable field against the live mockup (type into it, watch the mockup change) before publishing a template.

Saving the personalized result

The buyer’s personalized design is just new canvas state. Capture it the same way the designer’s draft was captured (onChange on the canvas — in embed mode, eject to SnowconeCanvas + RenderSession if you need the raw state stream), and persist it at add-to-cart as its own document. Don’t overwrite the template: a template is the master; each buyer session derives a new design from it.

Round-trip with snowcone.app

personalization is the key snowcone.app’s own editor reads and writes, so the concept is portable across surfaces:

  • A design saved on snowcone.app with personalization fields → your app reads the same document with readTemplateFields() and gets working controls.
  • A template your app authored → its fields appear on the snowcone.app artwork page and product pages as personalization inputs.
  • Legacy documents whose fields lack a type are normalized to text fields; malformed entries are dropped rather than throwing.

Where it leads