NUMU Docs
Contact
APIThemes
Partner Apps
APIThemes
Partner Apps
  1. Theme engine
  • πŸ—‚οΈ Themes overview
  • πŸš€ Start here
  • Glossary
  • Theme engine
    • Architecture
    • BYOT contract
    • Customizer
    • Federation runtime
    • Page data contract
    • Theme manifest
  • Theme SDK
    • Components
    • Federation helpers
    • Hooks
    • SDK overview
    • Type definitions
  • CLI & Vite plugin
    • CLI commands
    • CLI overview
    • Lint rules
    • Section library
    • Vite plugin
  • Storefront host
    • API proxies
    • Built-in fallbacks
    • BYOT fork
    • Routing
    • Storefront overview
  1. Theme engine

Customizer

The merchant-side theme editor. Lives in numo-merchant-hub/src/features/theme-editor-v3/.

Three-panel layout#

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              β”‚                              β”‚             β”‚
β”‚  LEFT PANEL  β”‚       IFRAME PREVIEW         β”‚ RIGHT PANEL β”‚
β”‚              β”‚                              β”‚             β”‚
β”‚  Templates,  β”‚  storefront URL with         β”‚  Setting    β”‚
β”‚  Sections,   β”‚  ?preview=1 and a postMessageβ”‚  inspector  β”‚
β”‚  Blocks,     β”‚  bridge for hot-reload       β”‚  for the    β”‚
β”‚  History     β”‚                              β”‚  current    β”‚
β”‚              β”‚                              β”‚  selection  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Left panel β€” Page template switcher, drag-reorderable section list, block list within a selected section, version history.
Iframe β€” Live preview. Settings are pushed via postMessage so the iframe re-mounts the bundle with the new context.
Right panel β€” Settings + blocks for the currently-selected section/block, driven by the section's schemas/sections/<type>.json.

State model#

interface CustomizerStore {
  draft: ThemeSettingsV3;         // unsaved edits
  published: ThemeSettingsV3;     // last published snapshot
  history: HistoryEntry[];        // last 50 patches (FIFO; older ones move to server-side)
  cursor: number;                 // current undo position
  selection: { sectionId?, blockId?, scope: "section" | "block" | "theme" };
  saving: boolean;
  publishing: boolean;
}
Implemented with Zustand. Source: numo-merchant-hub/src/features/theme-editor-v3/store/customizerStore.ts.

Persistence flow#

1. Merchant edits a setting (e.g. headline)
     ↓
2. customizerStore.patch(...)            ← updates `draft` + appends to `history`
     ↓
3. Debounced autosave (1500ms)
     PUT /api/v1/stores/{id}/themes/v3/customization
     headers: { If-Match: "<etag>" }     ← ETag concurrency
     ↓
4. Iframe re-mount via postMessage
     ↓
5. Merchant clicks "Publish"
     POST /api/v1/stores/{id}/themes/v3/customization/publish
     β†’ snapshots draft β†’ published, opens new draft on top
ETag concurrency means if two tabs are open and both autosave, one of them gets a 412 β€” the store re-fetches and the merchant sees a "your changes were merged" toast.

Input types#

The right-panel form is generated from settings_schema.json (theme-level) or schemas/sections/<type>.json β†’ settings[] (section-level).
Currently supported:
TypeUse caseRenders as
textShort stringsSingle-line input
inline_richtextOne-line WYSIWYG (Phase 2)Inline editor
richtextMulti-line WYSIWYGTipTap
textareaLong plain stringsTextarea
numberIntegersNumber input
rangeBounded numberSlider + value
range_with_unitBounded number + unit (Phase 2)Slider + unit dropdown
selectEnumDropdown
radioEnum (visible)Radio group
checkboxBooleanCheckbox
colorSingle colorColor picker + hex input
color_schemeReference to a theme color scheme (Phase 2)Scheme picker
color_scheme_groupParent for nested scheme settings (Phase 2)Grouped scheme picker
font_pickerFont from Google Fonts (Phase 2 β€” real picker)Combobox
image_pickerSingle imageUpload + URL
video_urlYouTube/Vimeo/mp4 URLURL + preview
urlFree-form URLURL input
link_list_pickerReference to a navigation menu (Phase 2 β€” real picker)Searchable picker
page_pickerSingle page (Phase 2 β€” real picker)Searchable picker
blog_pickerSingle blog (Phase 2 β€” real picker)Searchable picker
productSingle productSearchable picker
product_listMulti-product (Phase 2)Searchable multi-select
collectionSingle collectionSearchable picker
collection_listMulti-collection (Phase 2)Searchable multi-select
variant_pickerSingle variantSearchable picker
headerVisual divider in the formStatic label

visible_if#

Settings can declare conditional visibility:
{
  "type": "color",
  "id": "border_color",
  "label": "Border color",
  "default": "#E5E7EB",
  "visible_if": { "show_border": true }
}
The form hides this field unless show_border (an earlier setting) is true. Multiple keys are AND.

Blocks#

Sections can have blocks (think Shopify's "section blocks"). A block is a typed sub-component with its own schema:
{
  "type": "hero",
  "settings": [...],
  "blocks": [
    {
      "type": "cta_button",
      "name": "CTA Button",
      "limit": 2,                   // max instances per section
      "settings": [
        { "type": "text", "id": "label", "label": "Label" },
        { "type": "url",  "id": "href",  "label": "URL" },
        { "type": "select", "id": "style", "default": "solid",
          "options": [
            { "value": "solid",   "label": "Solid" },
            { "value": "outline", "label": "Outline" }
          ]
        }
      ]
    }
  ]
}
Themes render blocks via <Block> from the SDK:
import { Section, Block } from "@numueg/theme-sdk";

export default function Hero() {
  return (
    <Section>
      {(section) => (
        <div>
          <h1>{section.settings.headline}</h1>
          <div className="flex gap-2">
            {section.block_order?.map(id => (
              <Block key={id} blockId={id} />
            ))}
          </div>
        </div>
      )}
    </Section>
  );
}
<Block> resolves the block type, looks up the registered component, and renders it with its settings.

Presets#

Section-level presets (Phase 2.5) let your "Add Section: Hero" dialog show variants:
{
  "type": "hero",
  "presets": [
    { "name": "Hero with button",
      "settings": { "alignment": "center" },
      "blocks": [
        { "type": "cta_button", "settings": { "label": "Shop now", "style": "solid" } }
      ]
    },
    { "name": "Hero text-only",
      "settings": { "alignment": "left" }
    }
  ]
}
The customizer shows each preset as a card with the section's screenshot + name. Picking one creates a section instance with the preset's settings + blocks pre-populated.

Undo / redo#

Keyboard: Cmd+Z / Cmd+Shift+Z (Ctrl+Z / Ctrl+Y on Windows)
Skipped when focus is in a text input (the input's own undo takes precedence)
Client-side stack of 50 entries; older entries auto-persist to customizer_undo_entries table

Version history#

Every Publish action creates a snapshot. Stored in theme_customization_versions with a hard cap of 20 β€” older ones rotate out. Merchants can:
View any past snapshot
Restore a past snapshot (creates a new entry on top)
Compare any two snapshots (side-by-side JSON diff in the Phase 2 work)
Name a publish (e.g. "Spring sale 2026") shown in the version list

Iframe ↔ customizer postMessage protocol#

The iframe's PreviewBridge component (numu-storefront/src/components/theme-engine/PreviewBridge.tsx) listens for:
Message typePayloadEffect
numu:preview:set-context{ themeSettings, page }Re-renders the page with new settings
numu:preview:scroll-to-section{ sectionId }Smooth-scrolls + highlights
numu:preview:select-block{ sectionId, blockId }Adds a yellow outline + scroll-into-view
numu:preview:swap-theme{ bundle_url, css_url }Hot-swaps bundles for cross-theme preview
The customizer sends these on every relevant action. The iframe ACKs to confirm.

Extending#

To add a new input type:
1.
Add the type to the SettingInputV3 switch in numo-merchant-hub/src/features/theme-editor-v3/components/inputs/SettingInputV3.tsx.
2.
Update the SettingSchema TS union in services/themeApi.ts.
3.
If the type needs a special resolver (e.g. resolving a product_id to a product object for the bundle), update numu-storefront/src/lib/resolve-theme.ts.
4.
Update the Input types table above.
To add a new page type:
1.
Define a route in numu-storefront/src/app/[domain]/<your-route>/page.tsx.
2.
SSR-resolve any data the bundle needs and pass via page.data.
3.
Add the page type to PageContext["type"] in numu-theme-sdk/src/types/....
4.
Update Pages Data Contract with the new type's data shape.
Modified atΒ 2026-09-24 13:03:12
Previous
BYOT contract
Next
Federation runtime
Built with