<?php
/**
* snippet.php — external Save button + 4-state status pipeline.
*
* Drop alongside `dist/` + `themes/default/` + `backend/save.php`,
* run a PHP server. Click Save → pill flips to "Saving…" → server
* round-trip → pill flips to "Saved ✓" → fades back to idle after 2s.
* Force an error (offline, kill the PHP server, etc) and the pill
* sticks to "Save failed — click Retry" until the buyer retries.
*
* IDs `c4SaveBtn` / `c4SaveStatus` match the live demo on
* /examples/events/4-save-click-custom-button/ AND Steps 1-3 of the
* walkthrough (BUILDER.md RULE H, D13.B.parity).
*
* NOTE: there is NO `save:click` event on the engine. Save is a
* host-side intent — any button calls `builder.save()` (or a
* hand-rolled fetch when the backend needs richer payload like
* `dir` + `sample`). The "event" framing in the original W6 plan
* was a misnomer — see W6_EXAMPLES_BIG.md C.4 row + DISCOVERIES
* D13.C.4 for the pivot rationale.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';
$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Save click — custom button + 4-state status</title>
<link rel="stylesheet" href="/dist/builder.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
<style>
body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
.my-header { display: flex; align-items: center; gap: 10px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
.my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
#c4SaveStatus {
font-size: 12px; padding: 4px 12px; border-radius: 999px;
background: transparent; color: #6b7280;
transition: background 240ms ease, color 240ms ease;
min-height: 20px;
}
#c4SaveStatus[data-state="saving"] { background: rgba(245, 158, 11, 0.12); color: #b45309; }
#c4SaveStatus[data-state="saved"] { background: rgba(16, 185, 129, 0.12); color: #047857; }
#c4SaveStatus[data-state="error"] { background: rgba(220, 38, 38, 0.10); color: #b91c1c; }
@keyframes c4-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
#c4SaveStatus[data-state="saving"]::before {
content: ''; display: inline-block; width: 6px; height: 6px;
border-radius: 50%; background: currentColor;
margin-right: 6px; vertical-align: middle;
animation: c4-pulse 900ms ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
#c4SaveStatus[data-state="saving"]::before { animation: none; }
}
#c4SaveBtn {
padding: 6px 14px; background: #111827; color: #fff;
border: 1px solid #111827; border-radius: 6px;
cursor: pointer; font: inherit; font-size: 13px; font-weight: 600;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
#c4SaveBtn:hover:not(:disabled) { background: #1f2937; }
#c4SaveBtn:disabled { opacity: 0.6; cursor: not-allowed; }
#c4SaveBtn[data-state="saved"] { background: #10b981; border-color: #10b981; }
#c4SaveBtn[data-state="error"] { background: #fff; color: #b91c1c; border-color: #dc2626; }
#c4SaveBtn[data-state="error"]:hover:not(:disabled) { background: #fef2f2; }
.my-builder-host { display: grid; grid-template-columns: 220px 1fr 280px; min-height: 0; }
#MyWidgets, #MySettings { background: #f8f9fa; padding: 12px; overflow: auto; }
#MyCanvas { overflow: auto; }
</style>
</head>
<body>
<header class="my-header">
<span class="my-header__title">save custom button — 4-state pipeline</span>
<span id="c4SaveStatus" data-state="idle" aria-live="polite"></span>
<button type="button" id="c4SaveBtn" data-state="idle">Save</button>
</header>
<div class="my-builder-host">
<div id="MyWidgets"></div>
<div id="MyCanvas"></div>
<div id="MySettings"></div>
</div>
<script>
window.THEME_JSON = <?= json_encode($bundle->themeJson, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.THEME_TEMPLATES = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
window.MEDIA_URL = <?= json_encode($bundle->mediaUrl, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="/dist/builder.js"></script>
<script>
const builder = new Builder({
mainContainer: '#MyCanvas',
widgetsContainer: '#MyWidgets',
settingsContainer: '#MySettings',
// Optional — only needed if you'll call the engine's built-in
// `builder.save()` (POSTs html + data only). For richer payloads,
// hand-roll the fetch as below — your backend likely needs more keys.
saveUrl: '/backend/save.php',
});
const btn = document.getElementById('c4SaveBtn');
const pill = document.getElementById('c4SaveStatus');
const states = {
idle: { label: 'Save', disabled: false, message: '' },
saving: { label: 'Saving…', disabled: true, message: 'Saving your changes…' },
saved: { label: 'Saved ✓', disabled: false, message: 'All changes saved' },
error: { label: 'Retry save', disabled: false, message: 'Save failed — click Retry' },
};
function setState(name) {
const { label, disabled, message } = states[name];
btn.textContent = label;
btn.disabled = disabled;
btn.dataset.state = name;
pill.dataset.state = name;
pill.textContent = message;
}
setState('idle');
let inFlight = false;
let lastSavedAt = 0;
// Hand-rolled fetch — adds dir + sample so /backend/save.php knows
// where to write. builder.save() would only POST html + data;
// for a real backend you almost always need more keys.
async function persist() {
const fd = new FormData();
fd.append('dir', '_c4_demo');
fd.append('sample', 'sample/SaveCustomButton');
fd.append('html', builder.getHtml());
fd.append('data', JSON.stringify(builder.getData()));
const res = await fetch('/backend/save.php', { method: 'POST', body: fd });
return res.json();
}
async function handleSave() {
if (inFlight) return; // double-POST guard
inFlight = true;
setState('saving');
try {
const result = await persist();
if (!result || result.status !== 'success') {
throw new Error((result && result.message) || 'Save failed');
}
lastSavedAt = Date.now();
setState('saved');
// Auto-fade ONLY on success — error state is sticky until retry.
setTimeout(() => {
if (Date.now() - lastSavedAt >= 2000) setState('idle');
}, 2000);
} catch (err) {
console.warn('save failed', err);
setState('error');
} finally {
inFlight = false;
}
}
btn.addEventListener('click', handleSave);
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>
</body>
</html>
Save click — custom button + 4-state status pipeline
Wire any host-side button to `builder.save()` and surface the full saving lifecycle (idle → saving → saved → error → retry). No engine `save:click` event — the pattern is imperative: button click → save Promise → state pill update.
Walkthrough
-
Wire your button to `builder.save()` — there is no save:click event
BuilderJS does NOT emit a
save:clickevent. Save is buyer-driven: any host-side<button>can callbuilder.save(), which returns aPromisethat resolves with the server's JSON response. The button lives anywhere in your page — top app-bar, side panel, modal footer, mobile sheet — outside the engine chrome.Pass
saveUrlto the constructor;builder.save()POSTshtml+dataas URL-encoded body and returns the response JSON. For richer payloads (slug, dir, user-id, CSRF token) hand-roll the fetch instead — see 2-hello-world Step 1 for both patterns.JavaScriptconst builder = new Builder({ mainContainer: '#MyCanvas', widgetsContainer: '#MyWidgets', settingsContainer: '#MySettings', saveUrl: '/backend/save.php', // engine.save() POSTs here }); // External button — your HTML, your styling, your placement. document.getElementById('c4SaveBtn').addEventListener('click', async () => { // builder.save() returns Promise<JSON>. Throws if saveUrl is unset // or if fetch rejects (network down). const result = await builder.save(); console.log('saved', result); }); -
Surface the lifecycle: idle → saving → saved → error
A naked
await builder.save()hides the network round-trip from the buyer. The polished pattern surfaces FOUR states on a status pill: idle (default), saving (in flight, with spinner or animated dot), saved (success — fades to idle after 2s), error (failure — sticky until next save). The state machine lives in your handler; the pill renders the current value.Why sticky on error? Failure must be impossible to miss. Auto-clearing an error means a buyer can drift away thinking their work is safe when it isn't. Make them click Retry. (The cookbook 4-rich-3col-header pattern follows the same convention for its in-wrapper Save.)
JavaScriptconst states = { idle: { label: 'Save', tone: '', disabled: false }, saving: { label: 'Saving…', tone: 'saving', disabled: true }, saved: { label: 'Saved ✓', tone: 'saved', disabled: false }, error: { label: 'Retry save', tone: 'error', disabled: false }, }; function setState(name) { const { label, tone, disabled } = states[name]; const btn = document.getElementById('c4SaveBtn'); const pill = document.getElementById('c4SaveStatus'); btn.textContent = label; btn.disabled = disabled; pill.dataset.state = name; pill.textContent = name === 'idle' ? '' : name === 'saving' ? 'Saving your changes…' : name === 'saved' ? 'All changes saved' : 'Save failed — click Retry'; } -
The async flow: pre-state, await, post-state, recover
Wrap the save call in
try/catch. On success, flip to saved, then schedule a idle reset after 2 seconds. On error, flip to error with no auto-recovery — buyer clicks Retry to re-enter the cycle. Track the in-flight save with a closure-scoped flag so a second click during saving is ignored (prevents double-POST).For a "save on every change" autosave, swap the click handler for a
document:changedlistener — see events/1-document-changed. The state-pipeline shape transfers identically; only the trigger differs.JavaScriptlet inFlight = false; let lastSavedAt = 0; async function handleSave() { if (inFlight) return; inFlight = true; setState('saving'); try { const result = await builder.save(); if (result.status !== 'success') throw new Error(result.message || 'Save failed'); lastSavedAt = Date.now(); setState('saved'); // Auto-fade to idle after 2 seconds — pure visual polish. setTimeout(() => { // Only fade if no further save has happened since. if (Date.now() - lastSavedAt >= 2000) setState('idle'); }, 2000); } catch (err) { console.error('save failed', err); setState('error'); } finally { inFlight = false; } } document.getElementById('c4SaveBtn').addEventListener('click', handleSave);
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.
Notes
Why imperative, not event-driven? Save is a host-side intent — the buyer decides WHEN to persist (a button click, a keyboard chord, a debounced timer, a route-change blocker). The engine emits document:changed on every mutation but does NOT emit save:click — that name was always a host-side concept, never an engine event. builder.save() is the imperative entry point; wire it however your UX dictates.
Sticky-error UX rule. The error state does NOT auto-fade. A naked spinner that disappears + a pill that says "Saved ✓" + a server that 500'd silently = a buyer thinking their work is safe when it isn't. Make failure impossible to miss: red pill, red button border, "Retry save" label. Buyer clicks Retry, the cycle re-enters at saving. The 2-second auto-fade only applies to the success state.
Double-POST guard. A second click during saving is silently ignored (the closure-scoped inFlight flag). Without this, a flaky network user mashes the button and the server sees N concurrent POSTs for the same edit — a race condition waiting to corrupt your DB. inFlight is the cheapest debounce that actually matters.
Compose with autosave. Wire BOTH a click handler AND a document:changed listener for the canonical SaaS UX: instant manual save (button) PLUS soft autosave (every 1.5s of inactivity). Both call handleSave(); the inFlight flag handles overlap. events/1-document-changed covers the autosave debounce; this example covers the manual button. Together they're the full pattern.
Production checklist. Add CSRF tokens to the POST body (the validator's regex won't catch a missing token). Rate-limit per dir+sample pair. Route auth through your session layer. For ungraceful disconnects (laptop closed mid-save), persist a draft to localStorage on every document:changed and reconcile on next mount.