Cookbook · 4 of 8

Rich 3-col + header — full SaaS chrome

Three sub-systems wired (widgets · canvas · settings) plus an in-wrapper 48 px header strip carrying Save / Undo / Redo / Export / Preview. First cookbook entry where action buttons live INSIDE the engine's wrapper, not in a host toolbar above it. Pick this when your host app delegates the whole top-of-editor area to BuilderJS.

Walkthrough

  1. Wrap four slots — header (with action buttons) · widgets · canvas · settings

    The --rich-3col-header variant has FOUR slots: a 48 px __header strip across the top of the wrapper, plus the same __widgets · __canvas · __settings trio as 3-compact-3col. The action buttons (Save / Undo / Redo / Export / Preview) live inside the header — no host toolbar above the wrapper. Each interactive element gets an id; init.js wires Builder + 5 click handlers to them.

    HTML
    <div class="demo-mini-builder demo-mini-builder--rich-3col-header">
        <div class="demo-mini-builder__header">
            <span class="demo-mini-builder__title">My builder</span>
            <div class="demo-mini-builder__actions" role="toolbar" aria-label="Editor actions">
                <button type="button" id="CookbookRich3colHeaderUndo"    class="demo-mini-builder__action">Undo</button>
                <button type="button" id="CookbookRich3colHeaderRedo"    class="demo-mini-builder__action">Redo</button>
                <button type="button" id="CookbookRich3colHeaderExport"  class="demo-mini-builder__action">Export</button>
                <button type="button" id="CookbookRich3colHeaderPreview" class="demo-mini-builder__action" aria-pressed="false">Preview</button>
                <button type="button" id="CookbookRich3colHeaderSave"    class="demo-mini-builder__action demo-mini-builder__action--primary">Save</button>
            </div>
        </div>
        <div class="demo-mini-builder__widgets"  id="CookbookRich3colHeaderWidgets"></div>
        <div class="demo-mini-builder__canvas"   id="CookbookRich3colHeaderCanvas"></div>
        <div class="demo-mini-builder__settings" id="CookbookRich3colHeaderSettings"></div>
    </div>
  2. Mount all three containers — palette · canvas · settings

    Same constructor shape as 3-compact-3col: every sub-system gets a real DOM id. The widget palette renders on the left, canvas in the middle, per-element Settings on the right. The __header slot is purely buyer-owned HTML — Builder doesn't mount anything inside it; you do. historyUI: false suppresses the engine's default history pill so the in-wrapper Undo / Redo buttons own that affordance. The engine boots in 'preview' mode (clean WYSIWYG, no debug overlays); the in-wrapper Preview button toggles between design + preview on demand. Buyers wanting design-on-boot uncomment the setMode('design') line in the callback.

    JavaScript
    var builder = new Builder({
        mainContainer:     '#CookbookRich3colHeaderCanvas',
        widgetsContainer:  '#CookbookRich3colHeaderWidgets',
        settingsContainer: '#CookbookRich3colHeaderSettings',
        historyUI:         false  // header buttons own Undo / Redo
    });
    
    builder.load(
        window.THEME_JSON,
        window.THEME_TEMPLATES,
        window.THEME_CONFIG_DATA,
        window.MEDIA_URL,
        function () {
            registerWidgets(builder);
            // builder.setMode('design');   // opt-in: design-on-boot for editor UX
            wireSave(builder);
            wireUndoRedo(builder);
            wireExport(builder);
            wirePreview(builder);
        }
    );
  3. Populate the widget palette — same as 3-compact-3col

    The engine renders the wrapper but does NOT auto-populate the palette — buyers pick which widgets ship and how they're grouped. Inside the load() callback, call builder.widgetsBox.addWidget(new W(), { group: '…' }) for each widget you want, then render(). The same registerWidgets() helper from the compact-3col cookbook works here verbatim — the only thing that changed between the two variants is where the action buttons live, not how widgets register.

    JavaScript
    function registerWidgets(b) {
        var basic = [
            'ParagraphWidget', 'HeadingWidget', 'DividerWidget',
            'ImageWidget',     'ButtonWidget',  'GridWidget',
        ];
        basic.forEach(function (name) {
            var W = window[name];
            if (typeof W === 'function') {
                b.widgetsBox.addWidget(new W(), { group: 'Basic' });
            }
        });
        b.widgetsBox.render();
    }
  4. Wire the 5 header actions — getHtml() · undo() · redo() · Blob download · setMode()

    Each handler is an independent addEventListener on a document.getElementById — copy / drop freely. Save alerts the round-trip output (production hosts swap for a fetch() POST). Undo / Redo call builder.undo() / builder.redo(); both buttons toggle their own disabled attribute reactively via the history:change event so they grey-out when the stack is empty. Export downloads the rendered HTML as a Blob — no backend needed. Preview flips setMode('preview'|'design') and swaps its own label between "Preview" and "Edit".

    JavaScript
    function wireSave(b) {
        document.getElementById('CookbookRich3colHeaderSave').addEventListener('click', function () {
            var html = b.getHtml();
            var data = b.getData();
            alert('Saved · ' + html.length + ' chars HTML, ' + Object.keys(data).length + ' top-level keys.');
        });
    }
    
    function wireUndoRedo(b) {
        var u = document.getElementById('CookbookRich3colHeaderUndo');
        var r = document.getElementById('CookbookRich3colHeaderRedo');
        u.addEventListener('click', function () { b.undo(); });
        r.addEventListener('click', function () { b.redo(); });
        var sync = function () {
            u.toggleAttribute('disabled', !b.canUndo());
            r.toggleAttribute('disabled', !b.canRedo());
        };
        sync();
        b.events.on('history:change', sync);
    }
    
    function wireExport(b) {
        document.getElementById('CookbookRich3colHeaderExport').addEventListener('click', function () {
            var blob = new Blob([b.getHtml()], { type: 'text/html' });
            var url  = URL.createObjectURL(blob);
            var a    = document.createElement('a');
            a.href = url; a.download = 'page.html';
            document.body.appendChild(a); a.click(); a.remove();
            setTimeout(function () { URL.revokeObjectURL(url); }, 0);
        });
    }
    
    function wirePreview(b) {
        var btn  = document.getElementById('CookbookRich3colHeaderPreview');
        var wrap = document.querySelector('.demo-mini-builder--rich-3col-header');
        var sync = function () {
            var previewing = b.getMode() === 'preview';
            btn.textContent = previewing ? 'Edit' : 'Preview';
            btn.setAttribute('aria-pressed', String(previewing));
            wrap.classList.toggle('is-previewing', previewing);
        };
        sync();
        b.events.on('mode:changed', sync);   // W6.A.A.4 — engine fires on every setMode()
        btn.addEventListener('click', function () {
            b.setMode(b.getMode() === 'design' ? 'preview' : 'design');
        });
    }

Live demo

demo-mini-builder--rich-3col-header
My builder

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

2 files · 415 lines · 16.4 KB
/* style.css — cookbook/4-rich-3col-header — `.demo-mini-builder--rich-3col-header`.
 *
 * Self-contained chrome variant (W6.C.B.4). Pair this file with init.js
 * (sibling) and dist/builder.css + dist/builder.js, and you have a fully-
 * working Builder mount in 4 files. NO shared mini-builder.css. NO
 * examples.css. The `.demo-mini-builder--rich-3col-header` rules below
 * ARE the variant — copy them verbatim into your own stylesheet.
 *
 * ─── Buyer use case ─────────────────────────────────────────────────
 * Full SaaS feel. Three sub-systems wired (widgets · canvas · settings)
 * PLUS an in-wrapper header strip with action buttons. This is the FIRST
 * cookbook entry where Save / Undo / Redo / Export / Preview live INSIDE
 * the engine's own chrome, not in a host-supplied toolbar above the
 * wrapper. Pick this when your host app delegates the whole top of the
 * editor to BuilderJS.
 *
 * ─── In-wrapper header vs host toolbar ─────────────────────────────
 * `2-compact-2col` and `3-compact-3col` ship a `.demo-host-toolbar`
 * convention — the action button(s) live OUTSIDE the wrapper, in the
 * host page's own bar. That works when the variant has no header slot.
 *
 * `4-rich-3col-header` is the alternative: the variant declares a 48 px
 * `__header` slot at the top, sized as the first row of the grid. Action
 * buttons live INSIDE the wrapper. Buyers learn ONE rule — "if the
 * variant has a header slot, put the chrome there; otherwise host
 * toolbar above". The handlers stay identical either way (button click →
 * `builder.<method>()`).
 *
 * ─── Constructor (no null args) ─────────────────────────────────────
 *   new Builder({
 *       mainContainer:     '#YourCanvas',
 *       widgetsContainer:  '#YourWidgets',
 *       settingsContainer: '#YourSettings',
 *   });
 * All three sub-systems mount cleanly. Same shape as `3-compact-3col`;
 * the only difference is where the action row lives.
 *
 * ─── Tokens consumed (already exposed by dist/builder.css) ──────────
 *   --bjs-bg, --bjs-bg-subtle, --bjs-border, --bjs-border-light,
 *   --bjs-radius-lg, --bjs-radius-md, --bjs-text, --bjs-text-secondary,
 *   --bjs-fs-sm, --bjs-fs-base, --bjs-space-1, --bjs-space-2,
 *   --bjs-space-3, --bjs-primary.
 */

/* ─── Base wrapper (5-slot grid; canvas-only fallback) ─────────────── */
.demo-mini-builder {
    display: grid;
    width: 100%;
    height: 520px;
    border: 1px solid var(--bjs-border);
    border-radius: var(--bjs-radius-lg);
    background: var(--bjs-bg);
    color: var(--bjs-text);
    font-size: var(--bjs-fs-base);
    overflow: hidden;
    box-sizing: border-box;
    grid-template-areas: "canvas";
    grid-template-columns: 1fr;
    grid-template-rows: 1fr;
}

.demo-mini-builder *,
.demo-mini-builder *::before,
.demo-mini-builder *::after { box-sizing: border-box; }

.demo-mini-builder__header,
.demo-mini-builder__widgets,
.demo-mini-builder__canvas,
.demo-mini-builder__settings {
    overflow: auto;
    min-height: 0;
    min-width: 0;
}

.demo-mini-builder__header {
    grid-area: header;
    background: var(--bjs-bg-subtle);
    border-bottom: 1px solid var(--bjs-border-light);
    padding: 0 var(--bjs-space-3);
    display: flex;
    align-items: center;
    gap: var(--bjs-space-2);
    font-size: var(--bjs-fs-sm);
    color: var(--bjs-text-secondary);
}

.demo-mini-builder__widgets {
    grid-area: widgets;
    background: var(--bjs-bg-subtle);
    border-right: 1px solid var(--bjs-border-light);
    padding: var(--bjs-space-2);
}

.demo-mini-builder__canvas {
    grid-area: canvas;
    background: var(--bjs-bg);
    position: relative;
}

.demo-mini-builder__settings {
    grid-area: settings;
    background: var(--bjs-bg-subtle);
    border-left: 1px solid var(--bjs-border-light);
    padding: var(--bjs-space-2);
}

/* ─── Variant: --rich-3col-header ── 3-col + header actions row ──────
 *
 * Two-row · three-column grid:
 *
 *   ┌─────────── header (48 px) ────────────┐
 *   ├──────┬─────────────────────┬─────────┤
 *   │ wid- │     canvas          │ settings │
 *   │ gets │    (1fr · auto-grow)│   (280) │
 *   │ (220)│                     │          │
 *   └──────┴─────────────────────┴─────────┘
 *
 * Sidebars sized for SaaS app-chrome at 1280 px+ — 220 px widgets,
 * 280 px settings (vs `--compact-3col`'s 200 / 240). The wider settings
 * column gives format controls more breathing room because the header
 * action row absorbed the host toolbar's "advanced controls live up
 * here" affordance.
 */
.demo-mini-builder--rich-3col-header {
    grid-template-areas:
        "header  header  header"
        "widgets canvas  settings";
    grid-template-rows: 48px 1fr;
    grid-template-columns: 220px 1fr 280px;
}

/* ─── Header action row ──────────────────────────────────────────────
 *
 * Title on the left (`__title`) takes the leading slot; spacer pushes
 * the action buttons to the right. Each `__action` is a flat-tone
 * button with hover / focus / active states pulled from the same
 * tokens the engine's own chrome uses, so the row never reads as a
 * second design system grafted on top.
 *
 * `__action--primary` is reserved for the Save button — accent
 * background, white label — so the buyer's eye lands on the most-used
 * action without reading every label. Other actions stay neutral.
 *
 * `[hidden]` honours the `disabled` state: actions that aren't
 * applicable right now (e.g. Undo when history is empty) get
 * `disabled` set — a buyer-supplied `history:change` event listener
 * (see `events/3-history-change/`) toggles those flags reactively.
 */
.demo-mini-builder__title {
    flex: 1;
    margin-right: auto;
    font-weight: 600;
    color: var(--bjs-text);
    font-size: var(--bjs-fs-base);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.demo-mini-builder__actions {
    display: inline-flex;
    align-items: center;
    gap: var(--bjs-space-1);
}

.demo-mini-builder__action {
    appearance: none;
    -webkit-appearance: none;
    border: 1px solid var(--bjs-border-light);
    background: var(--bjs-bg);
    color: var(--bjs-text);
    padding: 4px 10px;
    border-radius: var(--bjs-radius-md);
    font: inherit;
    font-size: var(--bjs-fs-sm);
    line-height: 1.2;
    cursor: pointer;
    transition: background 120ms ease, border-color 120ms ease,
                color 120ms ease, transform 60ms ease;
}
.demo-mini-builder__action:hover {
    background: var(--bjs-bg-subtle);
    border-color: var(--bjs-border);
}
.demo-mini-builder__action:focus-visible {
    outline: 2px solid var(--bjs-primary);
    outline-offset: 2px;
}
.demo-mini-builder__action:active {
    transform: translateY(1px);
}
.demo-mini-builder__action[disabled] {
    opacity: 0.5;
    cursor: not-allowed;
}

.demo-mini-builder__action--primary {
    background: var(--bjs-primary);
    border-color: var(--bjs-primary);
    color: #fff;
}
.demo-mini-builder__action--primary:hover {
    filter: brightness(0.92);
    background: var(--bjs-primary);
    border-color: var(--bjs-primary);
}

/* Preview-mode flag: when the action row toggles `is-previewing`,
 * the Preview button's label flips to "Edit" via JS, and the wrapper
 * gets a subtle inset shadow so the buyer SEES they're in a non-
 * editable mode. Optional polish — drop this rule if you don't want
 * the visual cue. */
.demo-mini-builder.is-previewing .demo-mini-builder__canvas {
    box-shadow: inset 0 0 0 2px var(--bjs-primary);
}

@media (prefers-reduced-motion: reduce) {
    .demo-mini-builder, .demo-mini-builder *,
    .demo-mini-builder__action { transition: none; }
}
// init.js — cookbook/4-rich-3col-header.
//
// Mounts a Builder instance into the `.demo-mini-builder--rich-3col-header`
// scaffold rendered alongside this file. Reads four named globals
// (BUILDER.md RULE I split-globals convention):
//
//     window.THEME_JSON        — page tree (the sample JSON)
//     window.THEME_TEMPLATES   — { templateKey: HTML EJS string }
//     window.THEME_CONFIG_DATA — theme's index.json verbatim
//     window.MEDIA_URL         — base URL for relative asset paths
//
// All three sub-systems wired (no null args) PLUS five action buttons
// inside the wrapper's header slot — Save / Undo / Redo / Export /
// Preview. This is the FIRST cookbook entry where chrome lives in the
// engine's own header, not in a `.demo-host-toolbar` above the wrapper.
//
// Each handler is a plain `addEventListener` on a `document.getElementById`
// — a buyer can copy any one block without touching the others. Production
// hosts swap the `alert()` / `download .html` for a `fetch()` POST or a
// file-system save.

(function () {
    'use strict';

    var canvas   = document.getElementById('CookbookRich3colHeaderCanvas');
    var widgets  = document.getElementById('CookbookRich3colHeaderWidgets');
    var settings = document.getElementById('CookbookRich3colHeaderSettings');
    if (!canvas || !widgets || !settings || typeof window.Builder !== 'function') return;

    var builder = new window.Builder({
        mainContainer:     '#CookbookRich3colHeaderCanvas',
        widgetsContainer:  '#CookbookRich3colHeaderWidgets',
        settingsContainer: '#CookbookRich3colHeaderSettings',
        // The engine's own history pill is suppressed — the in-wrapper
        // header owns Undo / Redo via dedicated buttons.
        historyUI:         false
    });

    builder.load(
        window.THEME_JSON,
        window.THEME_TEMPLATES,
        window.THEME_CONFIG_DATA,
        window.MEDIA_URL,
        function () {
            // Populate the widget palette. The engine ships every widget
            // class as a top-level `window.<Name>Widget` global; pick the
            // ones you want, set their group label, then call
            // `widgetsBox.render()` to draw the palette tiles. See
            // `cookbook/3-compact-3col/init.js` for the fuller curation
            // story — the rich-header variant uses the same approach.
            registerWidgets(builder);

            // The engine boots in 'preview' mode (read-only WYSIWYG); a
            // SaaS-feel editor wants 'design' mode (editable canvas) the
            // moment the page is interactive. setMode also fires the
            // W6.A.A.4 `mode:changed` event, which `wirePreview()` below
            // listens to so the toggle button stays in sync regardless of
            // which surface (button, keyboard shortcut, programmatic
            // call) flipped the mode.
            //
            // 2026-05-09 — engine default is 'preview' (Builder.js:150);
            // demos boot in preview (no debug overlays) per the
            // "default = preview unless this example IS the mode-toggle
            // showcase" rule. The Preview button below reflects the
            // active mode via mode:changed; on boot it reads "Edit"
            // (clicking flips to design). Buyers wanting design-on-
            // boot can `b.setMode('design')` here.

            // Wire the 5 in-wrapper header actions. Each handler is
            // independent; copy / drop freely.
            wireSave(builder);
            wireUndoRedo(builder);
            wireExport(builder);
            wirePreview(builder);

            // Expose for buyer-side debugging + spec assertions.
            window.cookbookRich3colHeaderBuilder = builder;
        }
    );

    function registerWidgets(b) {
        var basic = [
            'ParagraphWidget', 'HeadingWidget', 'DividerWidget',
            'ImageWidget',     'ButtonWidget',  'GridWidget',
        ];
        basic.forEach(function (name) {
            var W = window[name];
            if (typeof W === 'function') {
                b.widgetsBox.addWidget(new W(), { group: 'Basic' });
            }
        });

        var imageText = [
            'ImageTextLeftWidget', 'ImageTextRightWidget', 'ImageTextTopWidget',
        ];
        imageText.forEach(function (name) {
            var W = window[name];
            if (typeof W === 'function') {
                b.widgetsBox.addWidget(new W(), { group: 'Image & Text', type: 'image' });
            }
        });

        b.widgetsBox.render();
    }

    function wireSave(b) {
        var btn = document.getElementById('CookbookRich3colHeaderSave');
        if (!btn) return;
        // getHtml() returns the rendered email/page string; getData()
        // returns the page tree object for round-trip reload. Production
        // host replaces the alert with a fetch() POST.
        btn.addEventListener('click', function () {
            var html = b.getHtml();
            var data = b.getData();
            alert(
                'Saved · ' + html.length + ' chars HTML, ' +
                Object.keys(data).length + ' top-level page-tree keys.'
            );
        });
    }

    function wireUndoRedo(b) {
        var undoBtn = document.getElementById('CookbookRich3colHeaderUndo');
        var redoBtn = document.getElementById('CookbookRich3colHeaderRedo');
        if (undoBtn) {
            undoBtn.addEventListener('click', function () { b.undo(); });
        }
        if (redoBtn) {
            redoBtn.addEventListener('click', function () { b.redo(); });
        }
        // Reactive enable / disable. The engine fires `history:change`
        // every time the stack mutates; canUndo / canRedo report the
        // current pointer state. Buyers that want richer behaviour
        // (e.g. tooltip with the next-undo label) plug their listener
        // alongside this one.
        var sync = function () {
            if (undoBtn) undoBtn.toggleAttribute('disabled', !b.canUndo());
            if (redoBtn) redoBtn.toggleAttribute('disabled', !b.canRedo());
        };
        sync();
        if (b.events && typeof b.events.on === 'function') {
            b.events.on('history:change', sync);
        }
    }

    function wireExport(b) {
        var btn = document.getElementById('CookbookRich3colHeaderExport');
        if (!btn) return;
        // getHtml() → Blob → click an off-screen <a download>. No backend
        // needed; the file lands in the buyer's Downloads folder. Swap
        // the Blob for a fetch() POST if you want server-side delivery
        // (e.g. attach the HTML to an outbound email send).
        btn.addEventListener('click', function () {
            var html = b.getHtml();
            var blob = new Blob([html], { type: 'text/html' });
            var url  = URL.createObjectURL(blob);
            var a    = document.createElement('a');
            a.href     = url;
            a.download = 'page.html';
            document.body.appendChild(a);
            a.click();
            a.remove();
            // Free the object URL after the click event has dispatched.
            setTimeout(function () { URL.revokeObjectURL(url); }, 0);
        });
    }

    function wirePreview(b) {
        var btn = document.getElementById('CookbookRich3colHeaderPreview');
        if (!btn) return;
        var wrap = document.querySelector('.demo-mini-builder--rich-3col-header');
        // setMode() flips the engine between 'design' (live editing) and
        // 'preview' (read-only WYSIWYG). Rather than mutating the button
        // state inside the click handler — which gets out of sync if the
        // mode flips elsewhere (keyboard shortcut, programmatic call,
        // sibling toolbar) — sync through the `mode:changed` event
        // (W6.A.A.4). One source of truth: the engine fires, the button
        // reflects.
        var sync = function () {
            var previewing = b.getMode() === 'preview';
            btn.textContent = previewing ? 'Edit' : 'Preview';
            btn.setAttribute('aria-pressed', String(previewing));
            if (wrap) wrap.classList.toggle('is-previewing', previewing);
        };
        sync();
        if (b.events && typeof b.events.on === 'function') {
            b.events.on('mode:changed', sync);
        }
        btn.addEventListener('click', function () {
            b.setMode(b.getMode() === 'design' ? 'preview' : 'design');
        });
    }
})();

Notes

Why the in-wrapper header instead of a host toolbar? Two reasons. First, the buyer's host app might not have a chrome bar of its own — embedded inside an iframe, a modal, or a card, there's no "above the wrapper" to hang chrome on. Second, packaging the header inside the wrapper means a buyer can copy this single dir and have a complete editing surface, including its own action row, with no further integration work. Trade-off: the wrapper owns the top 48 px, so the canvas height drops a bit. 3-compact-3col trades back — host toolbar above, full canvas height inside.

Why does the Save button get the primary tone? One action per row should read as the "default" — the one a buyer's pinky-finger reaches for after every edit. Save is the universal default in editors. The other four (Undo · Redo · Export · Preview) stay neutral so they don't compete for attention. If your host app's primary action is something else (e.g. "Send", "Schedule", "Publish"), swap the --primary modifier onto that button instead.

Why does Undo / Redo grey-out reactively? The engine fires history:change after every push / pop / commit on the history stack. The handler in init.js calls builder.canUndo() / builder.canRedo() on each event and toggles each button's disabled attribute. Buyers who want richer behaviour (e.g. tooltip showing the next-undo label) plug their listener alongside this one. See events/3-history-change for the full event signature.

Why does Export use a Blob download instead of POSTing to a backend? The cookbook's job is to teach the chrome pattern, not lock the buyer into one delivery mechanism. builder.getHtml() returns a string; converting that to a Blob + an off-screen <a download> click puts the file in the buyer's Downloads folder with zero infrastructure. Production hosts that need server-side delivery (e.g. attaching the HTML to an outbound email send) replace the Blob path with fetch(exportUrl, { method: 'POST', body: html }). api/1-get-html-export shows the fetch variant.

Why does Preview flip its own label? A toggle button needs to communicate what pressing it again WILL do, not what just happened. When the editor is in design mode, the button reads "Preview" (pressing it goes to preview); once in preview mode, it reads "Edit" (pressing it returns). The aria-pressed attribute flips alongside so screen readers announce the state. Optional cosmetic: the wrapper picks up is-previewing for an inset accent ring on the canvas, telegraphing "this is non-editable". Drop the rule from style.css if you don't want the visual cue.

Why drive the Preview button via mode:changed instead of mutating its state inside the click handler? Single source of truth. If you mutate state inside the click handler, the button drifts the moment something else flips the mode — a sibling toolbar, a keyboard shortcut, a programmatic setMode() call from the host app. The W6.A.A.4 mode:changed event fires on every setMode(); the button reads builder.getMode() on each fire and renders accordingly. Click handler shrinks to one line — call setMode(); let the engine\'s event do the UI sync.

Where do the four window.* globals come from? The host page renders them server-side. In this demo, _partials/example.php calls ThemeRegistry::resolveBundle('default', 'master/sample/email/WelcomeCustomers') and emits the four named globals before init.js loads. In your own project, render them from any backend (PHP, Node, Python, Go) — only the four names matter to the engine. See Quickstart for the canonical pattern.

Want chrome above the wrapper instead? Pick 3-compact-3col — same 3-column layout, but the action buttons live in a .demo-host-toolbar above the wrapper. Buyers learn ONE rule: "if the variant has a header slot, put the chrome there; otherwise host toolbar above". Either pattern is correct; pick the one that matches your host app's layout.