Cookbook · 6 of 8

Canvas only — no chrome, programmatic-edit only

No widgets palette. No settings panel. No header. The wrapper has no border, no radius, no background — just the canvas. Every action — selection, mode toggle, save — comes from a method on <code>builder</code>. Pick this when your host app drives mutations programmatically (server-side AI generator, headless test rig, integration suite) or when the host already paints its own chrome around the canvas.

Walkthrough

  1. Wrap one slot — canvas only, with a host toolbar above for API actions

    The --canvas-only variant has ONE slot: __canvas takes the entire wrapper area. The variant CSS strips the wrapper's border + radius + background so the page reads as bare-bones — buyers SEE the engine rendering, no editor frame around it. Every button — including Save — lives OUTSIDE the wrapper in a .demo-host-toolbar (same convention as 2-compact-2col / 3-compact-3col / 5-sidebar-only — variants without an in-wrapper header slot).

    HTML
    <div class="demo-host-toolbar">
        <span class="demo-host-toolbar__title">Programmatic builder</span>
        <button type="button" id="CookbookCanvasOnlySelect">Select first element</button>
        <button type="button" id="CookbookCanvasOnlyTogglePreview" aria-pressed="false">Toggle preview</button>
        <button type="button" id="CookbookCanvasOnlySave">Save</button>
    </div>
    <div class="demo-mini-builder demo-mini-builder--canvas-only">
        <div class="demo-mini-builder__canvas" id="CookbookCanvasOnlyCanvas"></div>
    </div>
  2. Mount canvas only — DOUBLE null arg

    Both widgetsContainer AND settingsContainer are null — the W6.A.A.1 null-tolerance contract skips both sub-system mounts cleanly. The engine boots in 'preview' mode (clean WYSIWYG, no debug overlays); the API-demo buttons in the host toolbar work in either mode (programmatic selection + mode-toggle bypass the click flow). The "Toggle preview" button starts in preview state — clicking flips to design.

    JavaScript
    var builder = new Builder({
        mainContainer:     '#CookbookCanvasOnlyCanvas',
        widgetsContainer:  null,    // no palette
        settingsContainer: null,    // no settings panel
        historyUI:         false
    });
    
    builder.load(
        window.THEME_JSON,
        window.THEME_TEMPLATES,
        window.THEME_CONFIG_DATA,
        window.MEDIA_URL,
        function () {
            // builder.setMode('design');   // opt-in: design-on-boot
            wireApiDemoButtons(builder);
        }
    );
  3. Wire Select first element — programmatic selection drives canvas highlights without a settings panel

    Reach into builder.pageElement.blocks[0] for the first block, then its .elements[0] for the first leaf element, and call builder.selectElement(target). Even though there is NO settings panel to render controls into, the iframe DOM still gets [data-bjs-selected="true"] and the engine's selection outline renders on the canvas. Proves the pitch: selection works independently of chrome.

    JavaScript
    document.getElementById('CookbookCanvasOnlySelect').addEventListener('click', function () {
        var firstBlock = builder.pageElement.blocks[0];
        var firstChild = firstBlock && firstBlock.elements && firstBlock.elements[0];
        var target     = firstChild || firstBlock;
        if (!target) {
            alert('Page has no elements to select.');
            return;
        }
        builder.selectElement(target);
    });
  4. Wire Toggle preview — driven by the mode:changed event

    Flip between 'design' and 'preview' via builder.setMode(). Subscribe to the W6.A.A.4 mode:changed event so the button label + aria-pressed reflect the engine state — single source of truth, not a click-handler-local toggle. Same reactive pattern as the 4-rich-3col-header Preview button.

    JavaScript
    var btn = document.getElementById('CookbookCanvasOnlyTogglePreview');
    
    btn.addEventListener('click', function () {
        var next = builder.getMode() === 'preview' ? 'design' : 'preview';
        builder.setMode(next);
    });
    
    // Reactive label + aria-pressed — engine fires, button reflects.
    builder.events.on('mode:changed', function (e) {
        var previewing = e.to === 'preview';
        btn.textContent = previewing ? 'Edit' : 'Toggle preview';
        btn.setAttribute('aria-pressed', previewing ? 'true' : 'false');
    });
  5. Wire SavegetHtml() + getData() round-trip

    Same Save pattern as every other cookbook entry — the alert pops with the round-trip output; production hosts swap the alert for a fetch() POST to their backend.

    JavaScript
    document.getElementById('CookbookCanvasOnlySave').addEventListener('click', function () {
        var html = builder.getHtml();
        var data = builder.getData();
        alert(
            'Saved · ' + html.length + ' chars HTML, ' +
            Object.keys(data).length + ' top-level page-tree keys.'
        );
    });

Live demo

demo-mini-builder--canvas-only
Programmatic 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 · 315 lines · 12.5 KB
/* style.css — cookbook/6-canvas-only — `.demo-mini-builder--canvas-only`.
 *
 * Self-contained chrome variant (W6.C.B.6). 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--canvas-only` rules below ARE
 * the variant — copy them verbatim into your own stylesheet.
 *
 * ─── Buyer use case ─────────────────────────────────────────────────
 * "No chrome at all." Canvas takes the entire wrapper area; the wrapper
 * itself drops border + radius + background so the page reads as a
 * bare-bones render — JUST the page, no surrounding editor frame.
 * NO widgets palette, NO settings panel, NO header. Pick this when:
 *
 *   - The host app drives ALL mutations programmatically (server-side
 *     AI generator, headless test rig, integration suite, ML pipeline).
 *   - The host app already has its OWN UI shell for actions and only
 *     needs the engine to render + accept programmatic edits.
 *   - You're embedding the builder inside another tool that paints its
 *     own chrome around the canvas (Storybook, internal admin tool, etc).
 *
 * The cookbook entry's host toolbar with "Select first" / "Toggle
 * preview" / "Save" buttons demonstrates the ALL-API pattern: every
 * action a chrome-rich variant offers via UI is reachable via a JS
 * method on `builder`. The toolbar lives OUTSIDE the wrapper; the
 * wrapper itself stays clean of UI.
 *
 * ─── Constructor (DOUBLE null arg) ─────────────────────────────────
 *   new Builder({
 *       mainContainer:     '#YourCanvas',
 *       widgetsContainer:  null,            // no palette
 *       settingsContainer: null,            // no panel
 *   });
 *
 * The W6.A.A.1 null-tolerance contract skips both sub-system mounts
 * cleanly — no NPE, no console error, no empty-shell DOM.
 *
 * ─── Tokens consumed (already exposed by dist/builder.css) ──────────
 *   --bjs-bg, --bjs-bg-subtle, --bjs-border-light, --bjs-radius-md,
 *   --bjs-text, --bjs-text-secondary, --bjs-fs-sm, --bjs-fs-base,
 *   --bjs-space-2, --bjs-space-3, --bjs-primary.
 */

/* ─── Base wrapper (single-cell grid; canvas takes everything) ──────
 * Same single-cell shape as the `--sidebar-only` base; the variant's
 * own modifier strips the framing (border + radius + bg) so the
 * wrapper visually dissolves and the page render reads as bare-bones.
 * Height stays explicit because the engine's iframe needs a sized
 * host — `100%` would collapse to 0 inside an auto-sized parent.
 */
.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__canvas {
    grid-area: canvas;
    background: var(--bjs-bg);
    overflow: auto;
    min-height: 0;
    min-width: 0;
}

/* ─── Variant: --canvas-only ── strip the frame ─────────────────────
 * The variant's defining property: no border, no radius, no
 * background. The wrapper visually dissolves; only the canvas
 * remains. A buyer reading the page sees the engine's render, not
 * a bordered editor card. This is THE rule that distinguishes
 * `--canvas-only` from every other variant — locked by the
 * cookbook spec's variant-defining-property gate.
 */
.demo-mini-builder--canvas-only {
    border: none;
    border-radius: 0;
    background: transparent;
}
.demo-mini-builder--canvas-only .demo-mini-builder__canvas {
    background: transparent;
}

/* ─── Host toolbar (lives OUTSIDE the wrapper) ──────────────────────
 * The variant has zero in-wrapper chrome, so EVERY action button —
 * Save, plus the API-demo buttons that prove the engine is fully
 * API-drivable — lives in a host toolbar above the canvas. Same
 * structural convention as `2-compact-2col`, `3-compact-3col`, and
 * `5-sidebar-only` (variants without an `__header` slot).
 *
 * Three buttons by default:
 *   - "Select first element" — calls `builder.selectElement(...)`,
 *     proving selection drives canvas highlights even with no
 *     settings panel.
 *   - "Toggle preview"        — flips `builder.setMode('design'|'preview')`,
 *     proving mode changes are API-driven.
 *   - "Save"                  — `getHtml()` + `getData()` round-trip,
 *     same Save pattern as every other cookbook entry.
 */
.demo-host-toolbar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--bjs-space-2);
    margin-bottom: var(--bjs-space-3);
    font-size: var(--bjs-fs-sm);
    color: var(--bjs-text-secondary);
}

.demo-host-toolbar__title {
    margin-right: auto;
    font-weight: 600;
    color: var(--bjs-text);
    font-size: var(--bjs-fs-base);
}

.demo-host-toolbar button {
    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;
}
.demo-host-toolbar button:hover {
    background: var(--bjs-bg-subtle);
    border-color: var(--bjs-border);
}
.demo-host-toolbar button:focus-visible {
    outline: 2px solid var(--bjs-primary);
    outline-offset: 2px;
}
.demo-host-toolbar button:active {
    transform: translateY(1px);
}
.demo-host-toolbar button[disabled] {
    opacity: 0.5;
    cursor: not-allowed;
}
/* Pressed state for the Toggle Preview button — `aria-pressed="true"`
 * means we're currently in preview; tint the button so the buyer sees
 * the engine's mode at a glance even with no header/status indicator.
 */
.demo-host-toolbar button[aria-pressed="true"] {
    background: var(--bjs-bg-subtle);
    border-color: var(--bjs-primary);
    color: var(--bjs-primary);
}

@media (prefers-reduced-motion: reduce) {
    .demo-mini-builder, .demo-mini-builder *,
    .demo-host-toolbar button { transition: none; }
}
// init.js — cookbook/6-canvas-only.
//
// Mounts a Builder instance into the `.demo-mini-builder--canvas-only`
// 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
//
// DOUBLE null arg — `widgetsContainer: null` AND `settingsContainer: null`
// (no palette, no settings panel). The W6.A.A.1 null-tolerance contract
// skips both sub-system mounts cleanly. The wrapper holds ONLY the
// canvas — no in-wrapper chrome at all.
//
// Save + API-demo buttons live OUTSIDE the wrapper in a host toolbar
// (same convention as `2-compact-2col` / `3-compact-3col` / `5-sidebar-only`):
//
//     - Select first element  → builder.selectElement(...)
//     - Toggle preview        → builder.setMode('design'|'preview')
//     - Save                  → getHtml() + getData() round-trip
//
// Engine boots in 'preview' mode → call `setMode('design')` after `load()`
// so the canvas accepts edits + selections from the first click.
// (See `cookbook/4-rich-3col-header/` for the discovery context.)

(function () {
    'use strict';

    var canvas = document.getElementById('CookbookCanvasOnlyCanvas');
    if (!canvas || typeof window.Builder !== 'function') return;

    var builder = new window.Builder({
        mainContainer:     '#CookbookCanvasOnlyCanvas',
        widgetsContainer:  null,    // no palette
        settingsContainer: null,    // no settings panel
        historyUI:         false
    });

    builder.load(
        window.THEME_JSON,
        window.THEME_TEMPLATES,
        window.THEME_CONFIG_DATA,
        window.MEDIA_URL,
        function () {
            // 2026-05-09 — demo defaults to 'preview' (engine default)
            // per the "no debug overlays unless showcase" rule. The host
            // toolbar's "Toggle preview" button + the "Select first" API
            // demo button still drive the engine; this variant shows the
            // canvas-only chrome, not mode-toggle as a feature.

            wireApiDemoButtons(builder);

            // Reactively reflect engine mode on the Toggle Preview button —
            // single source of truth (engine fires `mode:changed`, button
            // reflects). Same pattern as `4-rich-3col-header` Preview.
            builder.events.on('mode:changed', function (e) {
                applyPreviewState(e.to);
            });

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

    /**
     * Wire the host toolbar's three API-demo buttons. Each handler is
     * independent — copy/drop into your own host shell freely.
     *
     * @param {Builder} b
     */
    function wireApiDemoButtons(b) {
        var selectBtn  = document.getElementById('CookbookCanvasOnlySelect');
        var previewBtn = document.getElementById('CookbookCanvasOnlyTogglePreview');
        var saveBtn    = document.getElementById('CookbookCanvasOnlySave');

        // (1) Select first element — drives the canvas selection
        // highlight programmatically. Even though there is NO settings
        // panel to render controls into, the iframe DOM still gets
        // `[data-bjs-selected="true"]` and the engine's selection
        // outline renders on the canvas. Proves: selection works
        // independently of chrome.
        if (selectBtn) {
            selectBtn.addEventListener('click', function () {
                var firstBlock = b.pageElement && b.pageElement.blocks && b.pageElement.blocks[0];
                var firstChild = firstBlock && firstBlock.elements && firstBlock.elements[0];
                var target     = firstChild || firstBlock;
                if (!target) {
                    alert('Page has no elements to select.');
                    return;
                }
                b.selectElement(target);
                alert(
                    'Selected · ' + (target.getName ? target.getName() : 'element') +
                    ' · highlight rendered on canvas (no settings panel needed).'
                );
            });
        }

        // (2) Toggle preview — flips engine mode. The button label +
        // aria-pressed reflect the engine state via the `mode:changed`
        // listener above; here we just call setMode().
        if (previewBtn) {
            previewBtn.addEventListener('click', function () {
                var next = b.getMode() === 'preview' ? 'design' : 'preview';
                b.setMode(next);
            });
            // Initialise label + aria-pressed for the boot mode.
            applyPreviewState(b.getMode());
        }

        // (3) Save — same round-trip pattern as every other cookbook
        // entry. Production hosts swap the alert for a fetch() POST.
        if (saveBtn) {
            saveBtn.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.'
                );
            });
        }
    }

    /**
     * Reflect the engine's mode on the Toggle Preview button.
     * `aria-pressed="true"` when previewing → CSS in style.css tints the
     * button so the buyer sees the active state even without a header
     * status indicator.
     *
     * @param {string} mode 'design' | 'preview'
     */
    function applyPreviewState(mode) {
        var btn = document.getElementById('CookbookCanvasOnlyTogglePreview');
        if (!btn) return;
        var previewing = mode === 'preview';
        btn.textContent = previewing ? 'Edit' : 'Toggle preview';
        btn.setAttribute('aria-pressed', previewing ? 'true' : 'false');
    }
})();

Notes

Why no chrome at all? Three real-world fits. (1) Headless render hosts — server-side AI generators or template seeders that emit page JSON, then mount the engine purely to call getHtml() for export; no human ever sees the canvas. (2) Embedded inside another tool — Storybook stories, internal admin pages, integration tests where the surrounding tool already paints chrome and just needs the engine inline. (3) Custom UX — your host app has its own design system and wants to wire its own buttons to builder.* methods rather than adopt the cookbook's chrome variants. The canvas-only variant is the maximum-flexibility option: zero opinions about chrome, full access to the engine's API surface.

Why does the wrapper drop border + radius + background? The point of "no chrome" is the page looks like a real page render, not a bordered editor card. With the framing dropped, the canvas blends into the host page; visitors don\'t parse the boundary "this is the editor area" because there isn\'t one. If your host app DOES want a subtle frame (e.g. a dashboard tile feel), pick 8-card instead — that variant ships a card-shaped wrapper purpose-built for tile layouts.

Why does the host toolbar exist if the variant pitch is "no chrome"? The toolbar is OUTSIDE the wrapper — it\'s host chrome, not engine chrome. The wrapper itself stays clean. The toolbar exists in this cookbook entry purely as a teaching aid: it demonstrates that every action a chrome-rich variant offers via UI (Save, Preview, Selection) is reachable via a method on builder. In your real host app, drop the toolbar entirely if your host doesn\'t need a UI affordance — just call builder.selectElement(...) / builder.setMode(...) / builder.getHtml() from your own code paths (route handlers, button listeners elsewhere on the page, AJAX callbacks, etc.).

Why builder.pageElement.blocks[0] for "Select first"? The page tree shape is PageElement → BlockElement[] → (BlockElement.elements OR GridElement.cells → CellElement.elements) → leaf elements. The first block\'s first child element is the natural "first thing on the page". For more sophisticated walks, see the engine\'s tree-traversal helpers (pageElement.getChildren(), BlockElement.getElements()) — but plain array indexing is enough for "select something to prove selection works".

Why subscribe to mode:changed instead of just toggling the button label inside the click handler? Single source of truth. If anything else flips the mode (a programmatic call elsewhere, a future keyboard shortcut, an integration test), the button still reflects the truth. Click-handler-local toggles silently lie when the engine\'s mode flips through a different path. This is the same pattern the 4-rich-3col-header Preview button uses — see DISCOVERIES D13.C.B.4 for the full rationale.

Why setMode(\'design\') after load()? The engine boots in \'preview\' mode (read-only WYSIWYG). Without the flip, "Select first" still works (selection is a separate API path), but clicks on the canvas itself don\'t open settings — and in this variant there\'s no settings panel anyway, so the buyer might not notice. Setting design mode keeps the canvas behaviour consistent with sibling cookbook entries: clicks select, drags reorder, etc. (See 4-rich-3col-header notes for the full discovery.)

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/SitewideSale\') and emits the four named globals before init.js loads. In your own project, render them from any backend — only the four names matter to the engine. See Quickstart for the canonical pattern.

Want to drive mutations programmatically too? The engine exposes builder.insertElements(elements) for inserts, builder.removeElement(element) for removes, builder.history.undo() / builder.history.redo() for history nav, plus the full element tree at builder.pageElement. Wire your own host UI to those — same as this entry wires Select first / Toggle preview / Save. The pattern scales as far as your host app needs.