/* style.css — cookbook/7-mobile — `.demo-mini-builder--mobile`.
*
* Self-contained chrome variant (W6.C.B.7). Pair this file with init.js
* (sibling) and dist/builder.css + dist/builder.js, and you have a fully-
* working mobile-first Builder mount in 4 files. NO shared mini-builder.css.
* NO examples.css. The `.demo-mini-builder--mobile` rules below ARE the
* variant — copy them verbatim into your own stylesheet.
*
* ─── Buyer use case ─────────────────────────────────────────────────
* Mobile-first integration where canvas + sidebars don't fit side-by-side
* at narrow viewports (375 px – 600 px). The wrapper renders THREE
* sub-systems (widgets / canvas / settings) but only ONE is visible at
* a time — buyers tap a tab in the top strip to swap which sub-system
* fills the body. Pick this when:
*
* - Your host app needs to ship a working Builder on a phone-sized
* viewport (in-app email composer, mobile CMS).
* - Your buyers are non-technical and prefer a tap-one-at-a-time flow
* over juggling 3 columns at once.
* - You want a chrome that scales: the same tab-based pattern reads
* the same at 375 px, 600 px, or 1024 px (no media queries needed).
*
* The defining property: only ONE sub-system slot has `display: block`
* at any time — driven by the wrapper's `data-mobile-tab` attribute.
* Tab clicks flip the attribute (see init.js's `wireTabSwitcher()`); the
* CSS selectors below pick which slot becomes visible.
*
* ─── Constructor (no null args — all 3 wired) ──────────────────────
* new Builder({
* mainContainer: '#YourCanvas',
* widgetsContainer: '#YourWidgets',
* settingsContainer: '#YourSettings',
* });
*
* The W6.A.A.1 null-tolerance contract isn't needed here — every
* sub-system is mounted to a real DOM id. Mobile chrome shows them
* one-at-a-time visually but they're all live in the DOM, so the
* engine fires events / renders settings / accepts drags as normal.
*
* ─── Tokens consumed (already exposed by dist/builder.css) ──────────
* --bjs-bg, --bjs-bg-subtle, --bjs-border, --bjs-border-light,
* --bjs-radius-md, --bjs-radius-lg, --bjs-text, --bjs-text-secondary,
* --bjs-fs-sm, --bjs-fs-base, --bjs-space-2, --bjs-space-3,
* --bjs-primary.
*/
/* ─── Base wrapper (single-column grid; tabs strip + tabbody) ───────
* The mobile variant uses two extra slots (`__tabs` + `__tabbody`)
* unique to this variant — the tabs strip is always-visible across the
* top, the tabbody hosts widgets/canvas/settings stacked absolutely
* with only one visible at a time. 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__tabs,
.demo-mini-builder__tabbody,
.demo-mini-builder__widgets,
.demo-mini-builder__canvas,
.demo-mini-builder__settings {
overflow: auto;
min-height: 0;
min-width: 0;
}
/* ─── Variant: --mobile ── tab strip + stacked sub-systems ──────────
* The variant's defining property: ONE sub-system visible at a time,
* driven by `data-mobile-tab`. Any of `widgets` / `canvas` / `settings`
* can be the active value — the matching `[data-mobile-tab="…"]`
* descendant selector flips its `display` to `block` while the others
* stay `display: none`. The active tab button gets `.is-active` +
* `aria-selected="true"` for the underline + screen-reader cue.
*
* This is the rule that distinguishes `--mobile` from every other
* variant — locked by the cookbook spec's variant-defining-property
* gate (`mobile tab switcher flips data-mobile-tab + slot visibility`).
*/
.demo-mini-builder--mobile {
grid-template-areas:
"tabs"
"tabbody";
grid-template-rows: 44px 1fr;
}
.demo-mini-builder--mobile .demo-mini-builder__tabs {
grid-area: tabs;
display: flex;
background: var(--bjs-bg-subtle);
border-bottom: 1px solid var(--bjs-border-light);
}
.demo-mini-builder--mobile .demo-mini-builder__tab {
flex: 1;
padding: var(--bjs-space-3);
text-align: center;
cursor: pointer;
border: none;
background: transparent;
color: var(--bjs-text-secondary);
font: inherit;
font-size: var(--bjs-fs-sm);
border-bottom: 2px solid transparent;
transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
}
.demo-mini-builder--mobile .demo-mini-builder__tab:hover {
color: var(--bjs-text);
background: var(--bjs-bg);
}
.demo-mini-builder--mobile .demo-mini-builder__tab:focus-visible {
outline: 2px solid var(--bjs-primary);
outline-offset: -4px;
}
.demo-mini-builder--mobile .demo-mini-builder__tab.is-active {
color: var(--bjs-text);
border-bottom-color: var(--bjs-primary);
background: var(--bjs-bg);
}
.demo-mini-builder--mobile .demo-mini-builder__tabbody {
grid-area: tabbody;
position: relative;
}
.demo-mini-builder--mobile .demo-mini-builder__widgets,
.demo-mini-builder--mobile .demo-mini-builder__canvas,
.demo-mini-builder--mobile .demo-mini-builder__settings {
grid-area: unset;
border: none;
position: absolute;
inset: 0;
display: none;
}
.demo-mini-builder--mobile .demo-mini-builder__widgets {
background: var(--bjs-bg-subtle);
padding: var(--bjs-space-2);
}
.demo-mini-builder--mobile .demo-mini-builder__canvas {
background: var(--bjs-bg);
}
.demo-mini-builder--mobile .demo-mini-builder__settings {
background: var(--bjs-bg-subtle);
padding: var(--bjs-space-2);
}
.demo-mini-builder--mobile[data-mobile-tab="widgets"] .demo-mini-builder__widgets,
.demo-mini-builder--mobile[data-mobile-tab="canvas"] .demo-mini-builder__canvas,
.demo-mini-builder--mobile[data-mobile-tab="settings"] .demo-mini-builder__settings {
display: block;
}
/* ─── Host toolbar (lives OUTSIDE the wrapper) ──────────────────────
* The mobile variant has no `__header` slot, so Save lives in a host
* toolbar above the wrapper. Same structural convention as
* `2-compact-2col`, `3-compact-3col`, `5-sidebar-only`, `6-canvas-only`.
*
* In a real mobile-first integration you'd typically push this into
* the canvas tab (or onto the bottom of the device chrome) — host
* apps differ on where the primary action lives. The toolbar here
* teaches the wiring; geometry is up to you.
*/
.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;
}
@media (prefers-reduced-motion: reduce) {
.demo-mini-builder, .demo-mini-builder *,
.demo-host-toolbar button { transition: none; }
}
Mobile — tab-based chrome (Widgets / Canvas / Settings)
Phone-friendly chrome: a top tab strip swaps which sub-system fills the body. All three sub-systems are mounted (no null args) — only ONE is visible at a time, driven by the wrapper's <code>data-mobile-tab</code> attribute. Pick this when canvas + sidebars don't fit side-by-side at narrow viewports (375 px – 600 px), or when buyers prefer a tap-one-at-a-time flow over juggling 3 columns. Same engine, same API, different chrome geometry.
Walkthrough
-
Wrap the four slots — tabs strip + tabbody with widgets / canvas / settings stacked
The
--mobilevariant has FOUR slots:__tabs(always-visible top strip with one button per sub-system) +__tabbody(relative-positioned host) +__widgets+__canvas+__settings(all three absolute-positioned, only ONE visible at a time). The wrapper carriesdata-mobile-tab="canvas"at boot — the CSS selector[data-mobile-tab="canvas"] .demo-mini-builder__canvasflips it todisplay: block. Save lives OUTSIDE the wrapper in a.demo-host-toolbar(same convention as2-compact-2col/3-compact-3col/5-sidebar-only/6-canvas-only).HTML<div class="demo-host-toolbar"> <span class="demo-host-toolbar__title">Mobile builder</span> <button type="button" id="CookbookMobileSave">Save</button> </div> <div class="demo-mini-builder demo-mini-builder--mobile" data-mobile-tab="canvas"> <div class="demo-mini-builder__tabs" role="tablist" aria-label="Builder sections"> <button class="demo-mini-builder__tab" type="button" role="tab" data-tab="widgets" aria-selected="false">Widgets</button> <button class="demo-mini-builder__tab is-active" type="button" role="tab" data-tab="canvas" aria-selected="true">Canvas</button> <button class="demo-mini-builder__tab" type="button" role="tab" data-tab="settings" aria-selected="false">Settings</button> </div> <div class="demo-mini-builder__tabbody"> <div class="demo-mini-builder__widgets" id="CookbookMobileWidgets"></div> <div class="demo-mini-builder__canvas" id="CookbookMobileCanvas"></div> <div class="demo-mini-builder__settings" id="CookbookMobileSettings"></div> </div> </div> -
Mount all 3 sub-systems — palette · canvas · settings
Mobile chrome is geometry only — every sub-system is a live DOM mount, so the constructor takes the SAME arg shape as
3-compact-3col(no null args). The engine fires events, renders settings, accepts drag-drops as normal — the only difference is the CSS hides two of the three sub-systems at any time. The engine boots in'preview'mode (clean WYSIWYG, no debug overlays); buyers building an editable mobile UX uncomment thesetMode('design')line in the callback to flip on boot.JavaScriptvar builder = new Builder({ mainContainer: '#CookbookMobileCanvas', widgetsContainer: '#CookbookMobileWidgets', settingsContainer: '#CookbookMobileSettings', 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 registerWidgets(builder); // populate the Widgets tab wireTabSwitcher(); // tap-to-swap behaviour wireSave(builder); // host toolbar Save } ); -
Curate the Widgets tab — same registration as
3-compact-3colEngine ships every widget class as a top-level
window.<Name>Widgetglobal (auto-injected bydist/builder.js). Pick the ones you want, set their group label, then callwidgetsBox.render()to draw the palette tiles into#CookbookMobileWidgets. The palette renders into the same DOM whether the Widgets tab is currently visible or not — so all three sub-systems stay in sync regardless of which tab the buyer is on.JavaScriptfunction 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(); } -
Wire the tab strip — tap to flip
data-mobile-tabThe variant's defining behaviour: tap a tab → set the wrapper's
data-mobile-tabto the button'sdata-tabvalue → CSS swaps which sub-system slot isdisplay: block. Also flip.is-active+aria-selectedon the buttons for the active-tab underline + screen-reader cue. TheforEachover.demo-mini-builder--mobilewrappers means the same wiring scales unchanged across 1, 2, or N mobile builders on the same page.JavaScriptfunction wireTabSwitcher() { document.querySelectorAll('.demo-mini-builder--mobile').forEach(function (wrap) { wrap.querySelectorAll('.demo-mini-builder__tab').forEach(function (btn) { btn.addEventListener('click', function () { var nextTab = btn.getAttribute('data-tab'); if (!nextTab) return; wrap.setAttribute('data-mobile-tab', nextTab); wrap.querySelectorAll('.demo-mini-builder__tab').forEach(function (b) { var active = b === btn; b.classList.toggle('is-active', active); b.setAttribute('aria-selected', String(active)); }); }); }); }); } -
Wire
Save—getHtml()+getData()round-tripSame 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. In a real mobile-first integration you'd typically push the Save button onto the bottom of the device chrome instead of a top toolbar — the host toolbar here teaches the wiring, not the placement.JavaScriptfunction wireSave(b) { document.getElementById('CookbookMobileSave').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.' ); }); }
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.
// init.js — cookbook/7-mobile.
//
// Mounts a Builder instance into the `.demo-mini-builder--mobile`
// 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). The mobile variant shows
// only ONE sub-system at a time — driven by the wrapper's
// `data-mobile-tab` attribute. The CSS in style.css picks which slot
// becomes visible based on the attribute value; this init.js wires the
// tab buttons to flip the attribute on click.
//
// Save lives OUTSIDE the wrapper in a host toolbar (same convention as
// `2-compact-2col` / `3-compact-3col` / `5-sidebar-only` / `6-canvas-only`).
// Real host apps typically push the primary action onto the bottom of
// the device chrome instead — the host toolbar here teaches the wiring,
// not the placement.
//
// Engine boots in 'preview' mode → call `setMode('design')` after `load()`
// so the canvas accepts edits + selections from the first tab tap.
// (See `cookbook/4-rich-3col-header/` for the discovery context.)
(function () {
'use strict';
var canvas = document.getElementById('CookbookMobileCanvas');
var widgets = document.getElementById('CookbookMobileWidgets');
var settings = document.getElementById('CookbookMobileSettings');
if (!canvas || !widgets || !settings || typeof window.Builder !== 'function') return;
var builder = new window.Builder({
mainContainer: '#CookbookMobileCanvas',
widgetsContainer: '#CookbookMobileWidgets',
settingsContainer: '#CookbookMobileSettings',
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 this example IS the
// mode-toggle showcase" rule. Buyer wanting design-on-boot
// calls `builder.setMode('design')` here.
// Curate the widget palette. 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. Same
// pattern as `cookbook/3-compact-3col/`.
registerWidgets(builder);
// Wire the tab strip. Mobile chrome's defining behaviour:
// tap a tab → flip wrapper's `data-mobile-tab` → CSS swaps
// which sub-system slot is visible.
wireTabSwitcher();
// Save lives OUTSIDE the wrapper.
wireSave(builder);
// Expose for buyer-side debugging + spec assertions.
window.cookbookMobileBuilder = builder;
}
);
/**
* Register a representative widget set. Buyers customise this list
* by adding their own widget classes (see the `cookbook/.../extensions/`
* examples) or by curating which built-ins ship.
*
* @param {Builder} b
*/
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();
}
/**
* Wire every tab button under the mobile wrapper. Click flips the
* wrapper's `data-mobile-tab` to the button's `data-tab` value; the
* CSS in style.css picks which sub-system slot becomes visible.
* Also flips `is-active` + `aria-selected` for the active-tab
* underline + screen-reader cue.
*
* Buyers copy this verbatim — the wiring scales unchanged across
* 1, 2, or N mobile builders on the same page (each `forEach`
* loop scopes by wrapper).
*/
function wireTabSwitcher() {
document.querySelectorAll('.demo-mini-builder--mobile').forEach(function (wrap) {
wrap.querySelectorAll('.demo-mini-builder__tab').forEach(function (btn) {
btn.addEventListener('click', function () {
var nextTab = btn.getAttribute('data-tab');
if (!nextTab) return;
wrap.setAttribute('data-mobile-tab', nextTab);
wrap.querySelectorAll('.demo-mini-builder__tab').forEach(function (b) {
var active = b === btn;
b.classList.toggle('is-active', active);
b.setAttribute('aria-selected', String(active));
});
});
});
});
}
/**
* Save — `getHtml()` + `getData()` round-trip. Production hosts
* swap the alert for a `fetch()` POST to their backend. Identical
* pattern to every other cookbook entry's Save.
*
* @param {Builder} b
*/
function wireSave(b) {
var saveBtn = document.getElementById('CookbookMobileSave');
if (!saveBtn) return;
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.'
);
});
}
})();
Notes
Why tabs and not media queries? Media queries scale a layout — they don't reshape it. A 3-column desktop chrome that collapses to 1 column at 375 px usually leaves the buyer scrolling vertically through three separate sub-systems stacked end-to-end (canvas, then widgets, then settings) — fine in theory, painful in practice when adding a widget means scrolling past the entire canvas. Tabs solve that: each sub-system gets the full viewport while it's active, and the buyer's mental model is "I'm in the X tab", not "scroll until I find X". The same wrapper reads identically at 375 px, 600 px, and 1024 px — no media-query branches.
Why data-mobile-tab on the wrapper, not classes on the slots? One source of truth. The wrapper's attribute is the engine's-eye-view of which tab is active; the CSS uses that one attribute to drive THREE display rules (one per slot). Toggling slot classes individually would mean keeping three pieces of state in sync — easy to drift when a future maintainer adds a 4th sub-system (e.g. a "Layers" panel) and forgets one of the three flips. The single-attribute pattern scales: add a 4th tab + 4th slot + 4th selector — no other rule changes.
Why are all three sub-systems live, not lazy-mounted? The engine fires events (e.g. document:changed, region:focus) into all three sub-systems regardless of which tab is currently visible — settings panels need to render the moment a buyer selects an element on the canvas, even if the buyer hasn't tapped the Settings tab yet. Lazy-mounting would mean the Settings tab is empty until first tap, then race-conditions to populate against the engine's already-emitted events. display: none hides the slot visually but keeps it in the DOM tree, so the engine can render into it eagerly.
Why does the Save button live OUTSIDE the wrapper? Same reason as 5-sidebar-only + 6-canvas-only: the variant doesn't have an __header slot for in-wrapper actions. In a real mobile-first host you'd typically place Save at the BOTTOM of the device chrome (thumb-reachable) or as the primary action in the device's nav bar — geometry is host-app-specific. The host toolbar above teaches the wiring; placement is up to your design system.
Why setMode(\'design\') after load()? The engine boots in \'preview\' mode (read-only WYSIWYG). Without the flip, tapping the Canvas tab and then tapping an element wouldn't open settings on the Settings tab — same discovery as 4-rich-3col-header + 6-canvas-only. Setting design mode keeps the canvas behaviour consistent across cookbook entries.
What about the wrapper height? style.css sets height: 520px on the base wrapper for desktop showcase. In a real mobile-first integration, drop the explicit height + let the wrapper fill its parent (e.g. height: 100vh - 56px minus the device nav bar). The variant CSS for --mobile doesn't depend on a fixed pixel height — only the relative geometry (44 px tabs strip + flexible tabbody) matters.
Want a 4th tab? Add a button to the tabs strip with data-tab="layers", add a .demo-mini-builder__layers slot inside __tabbody, and add the CSS rule .demo-mini-builder--mobile[data-mobile-tab="layers"] .demo-mini-builder__layers { display: block; }. The wiring logic in wireTabSwitcher() is data-attribute-driven, so it scales unchanged. Whatever you mount inside the new slot (a custom layers panel, an asset browser, a raw-JSON editor) gets visibility-toggled by the same one attribute.
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/AdvertiseApp\') 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.