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.The right-panel form is generated from settings_schema.json (theme-level) or schemas/sections/<type>.json β settings[] (section-level).| Type | Use case | Renders as |
|---|
text | Short strings | Single-line input |
inline_richtext | One-line WYSIWYG (Phase 2) | Inline editor |
richtext | Multi-line WYSIWYG | TipTap |
textarea | Long plain strings | Textarea |
number | Integers | Number input |
range | Bounded number | Slider + value |
range_with_unit | Bounded number + unit (Phase 2) | Slider + unit dropdown |
select | Enum | Dropdown |
radio | Enum (visible) | Radio group |
checkbox | Boolean | Checkbox |
color | Single color | Color picker + hex input |
color_scheme | Reference to a theme color scheme (Phase 2) | Scheme picker |
color_scheme_group | Parent for nested scheme settings (Phase 2) | Grouped scheme picker |
font_picker | Font from Google Fonts (Phase 2 β real picker) | Combobox |
image_picker | Single image | Upload + URL |
video_url | YouTube/Vimeo/mp4 URL | URL + preview |
url | Free-form URL | URL input |
link_list_picker | Reference to a navigation menu (Phase 2 β real picker) | Searchable picker |
page_picker | Single page (Phase 2 β real picker) | Searchable picker |
blog_picker | Single blog (Phase 2 β real picker) | Searchable picker |
product | Single product | Searchable picker |
product_list | Multi-product (Phase 2) | Searchable multi-select |
collection | Single collection | Searchable picker |
collection_list | Multi-collection (Phase 2) | Searchable multi-select |
variant_picker | Single variant | Searchable picker |
header | Visual divider in the form | Static 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,
"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: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 type | Payload | Effect |
|---|
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#
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.
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/....
Modified atΒ 2026-09-24 13:03:12