/* theme-builder / public/builderApp.js * * BUILDLESS, dependency-free Phase-1 editor (vanilla ES2020, no React, no build * step). This is the shipping Phase-1 UI: the committed artifact served at * /plugins/public/theme-builder/builderApp.js. The Phase-2 React + Craft.js * source tree (builder/src) compiles into this same path later; until then this * hand-written file IS the bundle. * * Contract (ARCHITECTURE.md 8): * - window.__TB__ = { apiBase, cssRoute, csrfToken, base, openThemeId } is set * by the shell (lib/page.js). * - MANAGER panel: GET /api/state -> list themes (active + builtin badges); * buttons New, Duplicate, Rename, Delete, Activate, Export, Import-file * (8.8). Top toolbar: New, Import. * - TOKEN panel: color/font/spacing inputs rendered FROM the manifest, with a * LIVE PREVIEW iframe whose --bs-* custom properties (and rule-based tokens) * are rewritten on every edit -- the SAME overlay mechanism production uses * on activate, so WYSIWYG with zero compile (8.6). * - Save (POST /api/themes/:id/save {tokens, layoutTree, baseVersion}) and * Activate (POST /api/themes/:id/activate {role}) are SEPARATE actions: load * and edit NEVER touch the live site; only Activate publishes (1.4 / 8.3). * - CSRF: sent as the "CSRF-Token" header on every POST (8.2 / api.js 8.4). * * The server TokenManifest is flat, kebab-keyed: * manifest.tokens[] = { kind, cssVar, selector, prop, derive[], default } * (lib/apiState.buildManifest). A theme's `tokens` is NESTED + camelCase by * section: { colors:{primary,...}, typography:{...}, borders:{...}, * components:{...}, custom:{...} } (lib/themeSchema.DEFAULT_TOKENS). The editor * works in the flat space for editing/preview and round-trips into the nested * shape on read/save, matching compile.flattenTokens (camel<->kebab). */ (function () { "use strict"; var TB = window.__TB__ || {}; var API = TB.apiBase || "/theme-builder/api"; var CSS_ROUTE = TB.cssRoute || "/theme-builder/theme.css"; var CSRF = TB.csrfToken || ""; // ---- token <-> manifest mapping ----------------------------------------- // The nested-token sections the compiler flattens (compile.FLAT_SECTIONS). var FLAT_SECTIONS = ["colors", "typography", "borders", "components"]; // Explicit section for each known kebab token key. Mirrors the shape of // DEFAULT_TOKENS + TOKEN_SCHEMA so that a value edited in the flat space lands // in the correct nested section on save. Keys not listed here fall back to the // classifier below. var SECTION_OF = { "primary": "colors", "secondary": "colors", "success": "colors", "info": "colors", "warning": "colors", "danger": "colors", "light": "colors", "dark": "colors", "body-bg": "colors", "body-color": "colors", "link-color": "colors", "link-hover-color": "colors", "border-color": "colors", "font-sans-serif": "typography", "font-monospace": "typography", "root-font-size": "typography", "body-font-size": "typography", "body-font-weight": "typography", "body-line-height": "typography", "headings-font-family": "typography", "border-radius": "borders", "border-width": "borders", "navbar-bg": "components", "card-bg": "components", "sidebar-bg": "components" }; // kebab-case -> camelCase (inverse of compile.camelToKebab). function kebabToCamel(key) { return String(key).replace(/-([a-z0-9])/g, function (_m, c) { return c.toUpperCase(); }); } // camelCase -> kebab-case (compile.camelToKebab). function camelToKebab(key) { return String(key).replace(/[A-Z]/g, function (m) { return "-" + m.toLowerCase(); }); } // Decide the nested section for a flat key. Rule/components first, then color // pickers, then anything font/size/weight/line -> typography, border -> // borders, default colors. function sectionForKey(key, desc) { if (SECTION_OF[key]) return SECTION_OF[key]; if (desc && desc.kind === "rule") return "components"; if (/font|size|weight|line|family/.test(key)) return "typography"; if (/^border/.test(key)) return "borders"; if (/(color|bg)$/.test(key) || /^(color|bg)/.test(key)) return "colors"; return "components"; } // Flatten a nested+camel tokens object into a flat kebab map (mirrors // compile.flattenTokens). custom/$tokensVersion are left out of the editor. function flatten(tokens) { var flat = {}; if (!tokens || typeof tokens !== "object") return flat; for (var i = 0; i < FLAT_SECTIONS.length; i++) { var sec = tokens[FLAT_SECTIONS[i]]; if (!sec || typeof sec !== "object") continue; for (var k in sec) { if (!Object.prototype.hasOwnProperty.call(sec, k)) continue; if (sec[k] != null) flat[camelToKebab(k)] = sec[k]; } } return flat; } // Rebuild a nested+camel tokens object from a flat kebab map, preserving the // base theme's custom/$tokensVersion and any sections we don't edit. function nest(flat, base, manifest) { var out = {}; // shallow-clone the base so custom/$tokensVersion/etc survive a save. if (base && typeof base === "object") { for (var bk in base) { if (!Object.prototype.hasOwnProperty.call(base, bk)) continue; if (typeof base[bk] === "object" && base[bk] !== null) { out[bk] = {}; for (var bj in base[bk]) { if (Object.prototype.hasOwnProperty.call(base[bk], bj)) out[bk][bj] = base[bk][bj]; } } else { out[bk] = base[bk]; } } } for (var key in flat) { if (!Object.prototype.hasOwnProperty.call(flat, key)) continue; var desc = manifest && manifest.tokens ? manifest.tokens[key] : null; var sec = sectionForKey(key, desc); if (!out[sec] || typeof out[sec] !== "object") out[sec] = {}; out[sec][kebabToCamel(key)] = flat[key]; } return out; } // ---- live-preview overlay (PRODUCTION PARITY) --------------------------- // Replicates compile.emitOverlayCss exactly: bsvar -> :root{--bs-:v} // (+ --bs--rgb companion when the token derives and the value is a // hex color), rule -> {: v}. No compile, no network. function deriveRgb(value) { if (typeof value !== "string") return null; var s = value.trim(); var m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(s); var m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(s); var r, g, b; if (m6) { r = parseInt(m6[1], 16); g = parseInt(m6[2], 16); b = parseInt(m6[3], 16); } else if (m3) { r = parseInt(m3[1] + m3[1], 16); g = parseInt(m3[2] + m3[2], 16); b = parseInt(m3[3] + m3[3], 16); } else { return null; } return r + ", " + g + ", " + b; } // Build the overlay CSS text from the flat tokens + manifest. function overlayCss(flat, manifest) { var rootDecls = []; var rules = []; var tokens = (manifest && manifest.tokens) || {}; for (var key in flat) { if (!Object.prototype.hasOwnProperty.call(flat, key)) continue; var v = flat[key]; if (v == null || v === "") continue; var desc = tokens[key]; if (desc && desc.kind === "bsvar" && desc.cssVar) { rootDecls.push("--bs-" + desc.cssVar + ": " + v + ";"); if (desc.derive && desc.derive.length) { var rgb = deriveRgb(v); if (rgb != null) rootDecls.push("--bs-" + desc.cssVar + "-rgb: " + rgb + ";"); } } else if (desc && desc.kind === "rule" && desc.selector && desc.prop) { rules.push(desc.selector + "{" + desc.prop + ": " + v + ";}"); } else if (!desc) { rootDecls.push("--tb-" + key + ": " + v + ";"); } } var css = ""; if (rootDecls.length) css += ":root, [data-bs-theme]{" + rootDecls.join("") + "}\n"; if (rules.length) css += rules.join("\n") + "\n"; return css; } // ---- tiny DOM helpers ---------------------------------------------------- function el(tag, attrs, children) { var node = document.createElement(tag); if (attrs) { for (var a in attrs) { if (!Object.prototype.hasOwnProperty.call(attrs, a)) continue; if (a === "class") node.className = attrs[a]; else if (a === "text") node.textContent = attrs[a]; else if (a === "html") node.innerHTML = attrs[a]; else if (a.slice(0, 2) === "on" && typeof attrs[a] === "function") node.addEventListener(a.slice(2).toLowerCase(), attrs[a]); else if (attrs[a] != null && attrs[a] !== false) node.setAttribute(a, attrs[a] === true ? "" : attrs[a]); } } if (children != null) { var list = Array.isArray(children) ? children : [children]; for (var i = 0; i < list.length; i++) { var c = list[i]; if (c == null) continue; node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); } } return node; } function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } // ---- REST adapter (8.4) -------------------------------------------------- // CSRF is sent as the "CSRF-Token" header on every request; X-Requested-With + // Accept force JSON handling server-side. POSTs never run unless the user // explicitly clicks Save/Activate/etc -- editing is purely local. function request(method, path, body) { var headers = { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest", "Accept": "application/json", "CSRF-Token": CSRF }; return fetch(API + path, { method: method, headers: headers, credentials: "same-origin", body: body ? JSON.stringify(body) : undefined }).then(function (r) { var ct = r.headers.get("content-type") || ""; var parse = ct.indexOf("json") >= 0 ? r.json() : r.text(); return parse.then(function (data) { if (!r.ok) { var msg = (data && data.error && data.error.message) || r.statusText || ("HTTP " + r.status); var err = new Error(msg); err.status = r.status; err.body = data; throw err; } return data; }, function () { if (!r.ok) throw new Error(r.statusText || ("HTTP " + r.status)); return null; }); }); } var api = { state: function () { return request("GET", "/state"); }, load: function (id) { return request("GET", "/themes/" + encodeURIComponent(id)); }, create: function (b) { return request("POST", "/themes", b); }, duplicate: function (id, b) { return request("POST", "/themes/" + encodeURIComponent(id) + "/duplicate", b); }, save: function (id, b) { return request("POST", "/themes/" + encodeURIComponent(id) + "/save", b); }, rename: function (id, name) { return request("POST", "/themes/" + encodeURIComponent(id) + "/rename", { name: name }); }, remove: function (id, autoSwitch) { return request("POST", "/themes/" + encodeURIComponent(id) + "/delete", { autoSwitch: autoSwitch }); }, activate: function (id, role) { return request("POST", "/themes/" + encodeURIComponent(id) + "/activate", { role: role }); }, exportUrl: function (id) { return API + "/themes/" + encodeURIComponent(id) + "/export"; } }; // Compile the draft tokens to the FULL production overlay CSS server-side, so // the preview is WYSIWYG -- including the deep button rules (.btn-primary etc.) // the client-side overlayCss() cannot synthesize. POSTs { tokens } and returns // the text/css body. CSRF + X-Requested-With as on every POST; this is preview // only -- it never saves or activates. function previewCss(tokens) { return fetch(API + "/preview-css", { method: "POST", headers: { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest", "CSRF-Token": CSRF }, credentials: "same-origin", body: JSON.stringify({ tokens: tokens }) }).then(function (r) { if (!r.ok) throw new Error(r.statusText || ("HTTP " + r.status)); return r.text(); }); } // multipart import: file field "file", CSRF header, JSON response. function importFile(file) { var fd = new FormData(); fd.append("file", file, file.name || "theme.json"); return fetch(API + "/import", { method: "POST", headers: { "X-Requested-With": "XMLHttpRequest", "Accept": "application/json", "CSRF-Token": CSRF }, credentials: "same-origin", body: fd }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (data) { if (!r.ok) throw new Error((data && data.error && data.error.message) || r.statusText); return data; }); }); } // ---- application state --------------------------------------------------- var state = { view: "manager", // "manager" | "editor" panel: "tokens", // "tokens" | "layout" -- "layout" only when caps.layoutMode (8.3) themes: [], activeThemeId: null, manifest: { tokens: {} }, caps: { layoutMode: false }, layoutMode: false, layoutPresets: [], // [{ id, label, tree }] full layout-tree JSON per preset (8.5) open: null // { id, name, builtin, baseFlat, draftFlat, baseTokens, layoutTree, baseLayoutTree, version, dirty } }; var root = null; var statusBar = null; function setStatus(msg, kind) { if (!statusBar) return; statusBar.textContent = msg || ""; statusBar.className = "tb-status" + (kind ? " tb-status-" + kind : ""); } function fail(e) { setStatus((e && e.message) || "Request failed", "error"); } // ---- data loads ---------------------------------------------------------- function refreshState() { return api.state().then(function (s) { state.themes = s.themes || []; state.activeThemeId = s.activeThemeId || null; state.manifest = s.manifest || { tokens: {} }; state.caps = s.caps || { layoutMode: false }; state.layoutMode = !!s.layoutMode || !!state.caps.layoutMode; state.layoutPresets = s.layoutPresets || []; render(); return s; }).catch(fail); } function openTheme(id) { return api.load(id).then(function (res) { var t = res.theme; var flat = flatten(t.tokens); state.open = { id: t.id, name: t.name, builtin: !!t.builtin, readOnly: !!res.readOnly, baseTokens: t.tokens, baseFlat: flat, draftFlat: copyFlat(flat), // working layout tree (deep copy) + a base snapshot for save/dirty tracking. layoutTree: deepCopy(t.layoutTree) || null, baseLayoutTree: deepCopy(t.layoutTree) || null, version: t.version, dirty: false }; state.panel = "tokens"; state.view = "editor"; render(); setStatus(state.open.builtin ? "Opened built-in (read-only) -- first Save creates an editable copy." : "Opened \"" + t.name + "\"."); }).catch(fail); } function copyFlat(flat) { var out = {}; for (var k in flat) { if (Object.prototype.hasOwnProperty.call(flat, k)) out[k] = flat[k]; } return out; } // structural deep copy for layout trees (plain JSON: object/array/scalar). Used // so selecting a preset hands the open theme an independent copy of its tree. function deepCopy(value) { if (value == null) return value; if (typeof structuredClone === "function") return structuredClone(value); return JSON.parse(JSON.stringify(value)); } // structural equality for layout trees (order-sensitive JSON compare). Used to // tell which preset (if any) matches the open theme's current layoutTree. function treesEqual(a, b) { if (a == null && b == null) return true; if (a == null || b == null) return false; return JSON.stringify(a) === JSON.stringify(b); } // ---- editor: save & activate (SEPARATE actions) -------------------------- function doSave(thenActivate) { var o = state.open; if (!o) return; var nestedTokens = nest(o.draftFlat, o.baseTokens, state.manifest); function persist(targetId, baseVersion, force) { return api.save(targetId, { tokens: nestedTokens, // the chosen layout (preset deep-copy or the theme's original) persists // here alongside tokens; null when the theme has no layout tree (8.3 / 8.7). layoutTree: o.layoutTree || null, baseVersion: baseVersion, force: force || undefined }); } var chain; if (o.builtin) { // builtin is read-only: duplicate to an editable row, then save into it. setStatus("Creating an editable copy..."); chain = api.duplicate(o.id, { name: o.name + " (copy)" }).then(function (res) { var nt = res.theme; o.id = nt.id; o.builtin = false; o.readOnly = false; o.name = nt.name; o.version = nt.version; return persist(nt.id, nt.version); }); } else { chain = persist(o.id, o.version); } chain.then(function (res) { var t = res.theme; o.version = t.version; o.name = t.name; o.baseTokens = t.tokens; o.baseFlat = flatten(t.tokens); o.draftFlat = copyFlat(o.baseFlat); o.layoutTree = deepCopy(t.layoutTree) || null; o.baseLayoutTree = deepCopy(t.layoutTree) || null; o.dirty = false; setStatus("Saved \"" + t.name + "\" (v" + t.version + "). The live site is unchanged.", "ok"); render(); if (thenActivate) doActivate(); else refreshState(); }).catch(function (e) { if (e.status === 409 && e.body && e.body.error) { var cur = e.body.error.currentVersion; if (window.confirm("This theme was changed elsewhere (current v" + cur + ", you have v" + o.version + ").\nOK = overwrite with your edits, Cancel = reload theirs.")) { persist(o.id, o.version, true).then(function (res2) { var t2 = res2.theme; o.version = t2.version; o.baseTokens = t2.tokens; o.baseFlat = flatten(t2.tokens); o.draftFlat = copyFlat(o.baseFlat); o.layoutTree = deepCopy(t2.layoutTree) || null; o.baseLayoutTree = deepCopy(t2.layoutTree) || null; o.dirty = false; setStatus("Overwrote with your edits (v" + t2.version + ").", "ok"); render(); if (thenActivate) doActivate(); else refreshState(); }).catch(fail); } else { openTheme(o.id); } } else { fail(e); } }); } // The ONLY action that publishes to the live site. function doActivate(id) { var targetId = id || (state.open && state.open.id); if (!targetId) return; setStatus("Activating..."); return api.activate(targetId, null).then(function () { setStatus("Activated. The live site now serves this theme.", "ok"); return refreshState(); }).catch(fail); } // ---- manager actions ----------------------------------------------------- function actNew() { var name = window.prompt("Name for the new theme:", "New theme"); if (name == null) return; name = name.trim(); if (!name) return; api.create({ name: name, engine: state.caps.engine }).then(function (res) { return refreshState().then(function () { return openTheme(res.theme.id); }); }).catch(fail); } function actDuplicate(item) { var name = window.prompt("Name for the duplicate:", item.name + " (copy)"); if (name == null) return; api.duplicate(item.id, { name: name.trim() || undefined }).then(function () { return refreshState(); }).catch(fail); } function actRename(item) { if (item.builtin) { setStatus("Built-in themes cannot be renamed.", "error"); return; } var name = window.prompt("Rename theme:", item.name); if (name == null) return; name = name.trim(); if (!name) return; api.rename(item.id, name).then(function () { return refreshState(); }).catch(fail); } function actDelete(item) { if (item.builtin) { setStatus("Built-in themes cannot be deleted.", "error"); return; } var autoSwitch = false; if (item.active) { if (!window.confirm("\"" + item.name + "\" is ACTIVE. Deleting it will switch the live site to another theme first. Continue?")) return; autoSwitch = true; } else if (!window.confirm("Delete \"" + item.name + "\"? This cannot be undone.")) { return; } api.remove(item.id, autoSwitch).then(function () { setStatus("Deleted \"" + item.name + "\".", "ok"); return refreshState(); }).catch(fail); } function actImport(file) { if (!file) return; setStatus("Importing " + file.name + "..."); importFile(file).then(function () { setStatus("Imported \"" + file.name + "\".", "ok"); return refreshState(); }).catch(fail); } // ---- rendering ----------------------------------------------------------- function badge(text, cls) { return el("span", { "class": "tb-badge tb-badge-" + cls, text: text }); } function button(label, onClick, opts) { opts = opts || {}; var attrs = { "class": "tb-btn" + (opts.primary ? " tb-btn-primary" : "") + (opts.danger ? " tb-btn-danger" : ""), type: "button" }; if (opts.disabled) attrs.disabled = true; if (opts.title) attrs.title = opts.title; var b = el("button", attrs, label); if (!opts.disabled) b.addEventListener("click", onClick); return b; } function renderManager() { var wrap = el("div", { "class": "tb-manager" }); // top toolbar: New + Import (8.8) var toolbar = el("div", { "class": "tb-toolbar" }); toolbar.appendChild(el("h1", { "class": "tb-title", text: "Theme Builder" })); var spacer = el("div", { "class": "tb-spacer" }); toolbar.appendChild(spacer); toolbar.appendChild(button("New", actNew, { primary: true })); var fileInput = el("input", { type: "file", accept: ".json,application/json", "class": "tb-file" }); fileInput.addEventListener("change", function () { if (fileInput.files && fileInput.files[0]) actImport(fileInput.files[0]); fileInput.value = ""; }); var importBtn = button("Import...", function () { fileInput.click(); }); toolbar.appendChild(importBtn); toolbar.appendChild(fileInput); wrap.appendChild(toolbar); // theme table var table = el("table", { "class": "tb-table" }); var thead = el("thead", null, el("tr", null, [ el("th", { text: "Theme" }), el("th", { text: "Engine" }), el("th", { "class": "tb-actions-col", text: "Actions" }) ])); table.appendChild(thead); var tbody = el("tbody"); state.themes.forEach(function (item) { var nameCell = el("td", null); nameCell.appendChild(el("span", { "class": "tb-name", text: item.name })); if (item.active) nameCell.appendChild(badge("active", "active")); if (item.builtin) nameCell.appendChild(badge("built-in", "builtin")); var actions = el("td", { "class": "tb-actions" }); // Activate -- always (8.8). The only publishing action. actions.appendChild(button("Activate", function () { doActivate(item.id); }, { disabled: item.active, title: item.active ? "Already active" : "Make this the live theme" })); // Load/Edit -- always; builtin opens read-only. actions.appendChild(button(item.builtin ? "Edit (copy)" : "Edit", function () { openTheme(item.id); })); // Duplicate -- always. actions.appendChild(button("Duplicate", function () { actDuplicate(item); })); // Rename -- not builtin. actions.appendChild(button("Rename", function () { actRename(item); }, { disabled: item.builtin })); // Delete -- not builtin (active needs autoSwitch confirm). actions.appendChild(button("Delete", function () { actDelete(item); }, { disabled: item.builtin, danger: true })); // Export -- always, plain download link. actions.appendChild(el("a", { "class": "tb-btn tb-btn-link", href: api.exportUrl(item.id), download: "" }, "Export")); var tr = el("tr", { "class": item.active ? "tb-row-active" : "" }, [nameCell, el("td", { text: item.engine || "" }), actions]); tbody.appendChild(tr); }); if (!state.themes.length) { tbody.appendChild(el("tr", null, el("td", { colspan: "3", "class": "tb-empty", text: "No themes yet. Click New or Import." }))); } table.appendChild(tbody); wrap.appendChild(table); return wrap; } // group flat manifest keys into UI sections for the token panel. function manifestGroups() { var groups = { colors: [], fonts: [], spacing: [], other: [] }; var tokens = state.manifest.tokens || {}; for (var key in tokens) { if (!Object.prototype.hasOwnProperty.call(tokens, key)) continue; var desc = tokens[key]; var g; if (/color|bg|primary|secondary|success|info|warning|danger|light|dark|link/.test(key)) g = "colors"; else if (/font|family|weight/.test(key)) g = "fonts"; else if (/size|line|radius|width|spac/.test(key)) g = "spacing"; else g = "other"; groups[g].push({ key: key, desc: desc }); } return [ { id: "colors", label: "Colors", items: groups.colors }, { id: "fonts", label: "Typography", items: groups.fonts }, { id: "spacing", label: "Spacing & borders", items: groups.spacing }, { id: "other", label: "Other", items: groups.other } ].filter(function (s) { return s.items.length; }); } function pickerKind(key, desc) { if (/color|bg$|^bg/.test(key) || (desc && desc.derive && desc.derive.length)) { // colors are hex pickers; but font/family keys never are if (!/font|family/.test(key)) return "color"; } if (/font|family/.test(key)) return "font"; return "text"; } function tokenInput(key, desc) { var o = state.open; var current = o.draftFlat[key]; if (current == null) current = (desc && desc.default != null) ? desc.default : ""; var kind = pickerKind(key, desc); var label = el("label", { "class": "tb-field" }); label.appendChild(el("span", { "class": "tb-field-label", text: prettyLabel(key) })); var row = el("div", { "class": "tb-field-row" }); var textInput = el("input", { type: "text", "class": "tb-input", value: current }); function commit(val) { setToken(key, val); if (textInput.value !== val) textInput.value = val; } if (kind === "color") { var hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(current) ? current : "#000000"; var colorInput = el("input", { type: "color", "class": "tb-color", value: hex }); colorInput.addEventListener("input", function () { commit(colorInput.value); }); row.appendChild(colorInput); textInput.addEventListener("input", function () { commit(textInput.value); if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(textInput.value)) colorInput.value = textInput.value; }); } else { textInput.addEventListener("input", function () { commit(textInput.value); }); } row.appendChild(textInput); if (desc && (desc.cssVar || desc.prop)) { var hint = desc.kind === "rule" ? (desc.selector + " { " + desc.prop + " }") : ("--bs-" + desc.cssVar); label.appendChild(el("span", { "class": "tb-field-hint", text: hint })); } label.appendChild(row); return label; } function prettyLabel(key) { return String(key).replace(/-/g, " ").replace(/\b\w/g, function (c) { return c.toUpperCase(); }); } function setToken(key, value) { var o = state.open; if (!o) return; if (value === "" || value == null) delete o.draftFlat[key]; else o.draftFlat[key] = value; o.dirty = true; applyPreview(); syncEditorChrome(); } // find the preset (if any) whose tree matches the open theme's current // layoutTree; returns the preset id, or null for custom/none. function matchedPresetId() { var o = state.open; if (!o || o.layoutTree == null) return null; for (var i = 0; i < state.layoutPresets.length; i++) { if (treesEqual(state.layoutPresets[i].tree, o.layoutTree)) return state.layoutPresets[i].id; } return null; } // Apply a preset to the OPEN theme: working layoutTree = deep copy of the // preset tree, mark dirty. Does NOT save or activate (8.3 / 8.7). function setLayoutPreset(presetId) { var o = state.open; if (!o) return; var preset = null; for (var i = 0; i < state.layoutPresets.length; i++) { if (state.layoutPresets[i].id === presetId) { preset = state.layoutPresets[i]; break; } } if (!preset) return; o.layoutTree = deepCopy(preset.tree); o.dirty = true; render(); setStatus("Layout set to \"" + preset.label + "\". Save to persist; the live site is unchanged."); } // a single panel-selector tab; switching panels keeps all working edits. function panelTab(id, label) { var active = state.panel === id; var attrs = { "class": "tb-tab" + (active ? " tb-tab-active" : ""), type: "button" }; if (active) attrs["aria-current"] = "true"; var b = el("button", attrs, label); b.addEventListener("click", function () { if (state.panel === id) return; state.panel = id; render(); }); return b; } // the Phase-1 token editor panel (color/font/spacing inputs from the manifest). function renderTokenPanel() { var o = state.open; var panel = el("div", { "class": "tb-token-panel" }); if (o.builtin) { panel.appendChild(el("div", { "class": "tb-readonly-note", text: "This is a built-in theme. Editing is allowed; your first Save creates an editable copy and leaves the original untouched." })); } manifestGroups().forEach(function (sec) { var fs = el("fieldset", { "class": "tb-section" }); fs.appendChild(el("legend", { text: sec.label })); sec.items.forEach(function (it) { fs.appendChild(tokenInput(it.key, it.desc)); }); panel.appendChild(fs); }); return panel; } // ---- structural region editor (layout mode) ----------------------------- // The region node types the admin may compose into Root.children, each with the // exact props the resolver understands (layoutTree.js schema). The order here is // the order offered in the "Add region" dropdown. var REGION_TYPES = ["Navbar", "Sidebar", "Content", "Footer"]; // The Bootstrap contextual colors a Navbar/Sidebar bg may use; "" = none (omit). var BG_COLORS = ["primary", "secondary", "success", "info", "warning", "danger", "light", "dark"]; // A sensible-default node for each region type (mirrors the preset defaults in // layoutTree.js). Returned fresh per call so each Add gets its own props object. function defaultRegion(type) { if (type === "Navbar") { return { type: "Navbar", props: { variant: "dark", bg: "primary", expand: "lg", brand: true, menu: true, fluid: false } }; } if (type === "Sidebar") { return { type: "Sidebar", props: { variant: "dark", bg: "dark", brand: true, menu: true, width: "240px" } }; } if (type === "Content") { return { type: "Content", props: { container: "container", grow: false } }; } if (type === "Footer") { return { type: "Footer", props: { text: "" } }; } return { type: type, props: {} }; } // The PROP CONTROLS each region type exposes, in render order. control is one of // "select" (with options), "check" (boolean), or "text". This single table drives // the inline settings UI so the rendering loop stays declarative. function regionFields(type) { if (type === "Navbar") { return [ { key: "variant", control: "select", options: ["light", "dark"] }, { key: "bg", control: "select", options: BG_COLORS, allowNone: true }, { key: "expand", control: "select", options: ["sm", "md", "lg", "xl", "xxl"] }, { key: "brand", control: "check" }, { key: "menu", control: "check" }, { key: "fluid", control: "check" } ]; } if (type === "Sidebar") { return [ { key: "variant", control: "select", options: ["light", "dark"] }, { key: "bg", control: "select", options: BG_COLORS, allowNone: true }, { key: "brand", control: "check" }, { key: "menu", control: "check" }, { key: "width", control: "text", placeholder: "240px" } ]; } if (type === "Content") { return [ { key: "container", control: "select", options: ["container", "fluid"] }, { key: "grow", control: "check" } ]; } if (type === "Footer") { return [ { key: "text", control: "text", placeholder: "Footer text" } ]; } return []; } // The working list of region nodes (Root.children) for the open theme, always an // array. Edits mutate these node objects in place; rebuildLayoutTree() then wraps // them back into a fresh Root with the derived className. function layoutChildren() { var o = state.open; if (!o || !o.layoutTree || !Array.isArray(o.layoutTree.children)) return []; return o.layoutTree.children; } // Count Content regions in the working children -- used to enforce exactly one. function contentCount() { var kids = layoutChildren(); var n = 0; for (var i = 0; i < kids.length; i++) { if (kids[i] && kids[i].type === "Content") n += 1; } return n; } // Rebuild open.layoutTree from a children array: a fresh Root whose className is // derived from the regions (a Sidebar => horizontal flex, else vertical column), // mark the editor dirty (same path setToken uses). Never saves/activates (8.7). function rebuildLayoutTree(children) { var o = state.open; if (!o) return; var hasSidebar = false; for (var i = 0; i < children.length; i++) { if (children[i] && children[i].type === "Sidebar") { hasSidebar = true; break; } } var className = hasSidebar ? "min-vh-100 d-flex" : "min-vh-100 d-flex flex-column"; o.layoutTree = { type: "Root", props: { className: className }, children: children }; o.dirty = true; } // Set one prop on a region node, then rebuild + re-render. Empty string clears // the prop (so an omitted bg / blank footer text drops out of the node). function setRegionProp(node, key, value) { if (!node.props || typeof node.props !== "object") node.props = {}; if (value === "" || value == null) delete node.props[key]; else node.props[key] = value; rebuildLayoutTree(layoutChildren()); render(); } // Move a region up/down within Root.children (dir = -1 or +1), then rebuild. function moveRegion(index, dir) { var kids = layoutChildren().slice(); var target = index + dir; if (target < 0 || target >= kids.length) return; var tmp = kids[index]; kids[index] = kids[target]; kids[target] = tmp; rebuildLayoutTree(kids); render(); } // Remove a region. Refuses to remove the last Content (the resolver requires // exactly one); warns instead, leaving the tree untouched. function removeRegion(index) { var kids = layoutChildren().slice(); var node = kids[index]; if (!node) return; if (node.type === "Content" && contentCount() <= 1) { setStatus("A layout needs exactly one Content region; it cannot be removed.", "error"); return; } kids.splice(index, 1); rebuildLayoutTree(kids); render(); } // Append a default region of the chosen type. A second Content is rejected (the // resolver requires exactly one); warns instead. function addRegion(type) { if (type === "Content" && contentCount() >= 1) { setStatus("Only one Content region is allowed.", "error"); return; } var kids = layoutChildren().slice(); kids.push(defaultRegion(type)); rebuildLayoutTree(kids); render(); } // one inline prop control (select / checkbox / text) for a region node. function regionPropControl(node, field) { var val = node.props ? node.props[field.key] : undefined; var labelText = prettyLabel(field.key); if (field.control === "check") { var wrap = el("label", { "class": "tb-region-check" }); var cb = el("input", { type: "checkbox", "class": "tb-region-checkbox" }); cb.checked = !!val; cb.addEventListener("change", function () { setRegionProp(node, field.key, cb.checked); }); wrap.appendChild(cb); wrap.appendChild(el("span", { "class": "tb-region-field-label", text: labelText })); return wrap; } var field2 = el("label", { "class": "tb-region-field" }); field2.appendChild(el("span", { "class": "tb-region-field-label", text: labelText })); if (field.control === "select") { var sel = el("select", { "class": "tb-region-select" }); if (field.allowNone) { var none = el("option", { value: "", text: "(none)" }); if (val == null || val === "") none.selected = true; sel.appendChild(none); } field.options.forEach(function (opt) { var o2 = el("option", { value: opt, text: opt }); if (val === opt) o2.selected = true; sel.appendChild(o2); }); sel.addEventListener("change", function () { setRegionProp(node, field.key, sel.value); }); field2.appendChild(sel); } else { var inp = el("input", { type: "text", "class": "tb-input", value: val == null ? "" : val }); if (field.placeholder) inp.setAttribute("placeholder", field.placeholder); inp.addEventListener("input", function () { setRegionProp(node, field.key, inp.value); }); field2.appendChild(inp); } return field2; } // one region row: header (type + Up/Down/Remove) and the inline settings grid. function regionRow(node, index, total) { var row = el("div", { "class": "tb-region" }); var head = el("div", { "class": "tb-region-head" }); head.appendChild(el("span", { "class": "tb-region-type", text: node.type })); var ctrls = el("div", { "class": "tb-region-ctrls" }); ctrls.appendChild(button("Move up", function () { moveRegion(index, -1); }, { disabled: index === 0, title: "Move up" })); ctrls.appendChild(button("Move down", function () { moveRegion(index, 1); }, { disabled: index === total - 1, title: "Move down" })); var lastContent = node.type === "Content" && contentCount() <= 1; ctrls.appendChild(button("Remove", function () { removeRegion(index); }, { danger: true, disabled: lastContent, title: lastContent ? "A layout needs exactly one Content region" : "Remove this region" })); head.appendChild(ctrls); row.appendChild(head); var fields = regionFields(node.type); if (fields.length) { var grid = el("div", { "class": "tb-region-fields" }); fields.forEach(function (f) { grid.appendChild(regionPropControl(node, f)); }); row.appendChild(grid); } return row; } // the Phase-2 layout panel: a VISUAL STRUCTURAL EDITOR. The admin starts from a // preset, then composes Root.children as an ordered list of regions -- each row // reorders, removes, and edits that region's props inline. Every change rebuilds // open.layoutTree (with the derived Root className) and marks the editor dirty; // it never saves or activates (8.7). If the open theme has no layoutTree yet, the // panel seeds the first preset (topnav) as a starting point WITHOUT mutating the // theme -- nothing persists until the user makes a change. function renderLayoutPanel() { var o = state.open; // seed a starting layout (first preset) for a theme that has none yet, so the // structural editor has something to show. This is display-only: o.dirty stays // false and nothing is saved until the user actually edits. if (o.layoutTree == null && state.layoutPresets.length) { o.layoutTree = deepCopy(state.layoutPresets[0].tree); } var panel = el("div", { "class": "tb-token-panel tb-layout-panel" }); if (o.builtin) { panel.appendChild(el("div", { "class": "tb-readonly-note", text: "This is a built-in theme. Editing the layout is allowed; your first Save creates an editable copy and leaves the original untouched." })); } // ---- start from preset (loads a deep copy into the working tree) ---- var presetFs = el("fieldset", { "class": "tb-section" }); presetFs.appendChild(el("legend", { text: "Start from preset" })); var matchId = matchedPresetId(); var currentLabel; if (matchId == null) { currentLabel = o.layoutTree == null ? "none" : "custom"; } else { currentLabel = "preset"; for (var i = 0; i < state.layoutPresets.length; i++) { if (state.layoutPresets[i].id === matchId) { currentLabel = state.layoutPresets[i].label; break; } } } presetFs.appendChild(el("div", { "class": "tb-field-hint tb-layout-current", text: "Current layout: " + currentLabel })); if (!state.layoutPresets.length) { presetFs.appendChild(el("div", { "class": "tb-empty", text: "No layout presets available." })); } else { var presetRow = el("div", { "class": "tb-region-add" }); var presetSel = el("select", { "class": "tb-region-select" }); state.layoutPresets.forEach(function (preset) { var opt = el("option", { value: preset.id, text: preset.label }); if (preset.id === matchId) opt.selected = true; presetSel.appendChild(opt); }); presetRow.appendChild(presetSel); presetRow.appendChild(button("Load preset", function () { if (presetSel.value) setLayoutPreset(presetSel.value); }, { title: "Replace the working layout with a copy of this preset" })); presetFs.appendChild(presetRow); } panel.appendChild(presetFs); // ---- regions: ordered list of Root.children ---- var regionsFs = el("fieldset", { "class": "tb-section" }); regionsFs.appendChild(el("legend", { text: "Regions" })); var kids = layoutChildren(); if (!kids.length) { regionsFs.appendChild(el("div", { "class": "tb-empty", text: "No regions yet. Add one below." })); } else { kids.forEach(function (node, idx) { regionsFs.appendChild(regionRow(node, idx, kids.length)); }); } // ---- add region ---- var addRow = el("div", { "class": "tb-region-add" }); var addSel = el("select", { "class": "tb-region-select" }); var haveContent = contentCount() >= 1; REGION_TYPES.forEach(function (type) { var attrs = { value: type, text: type }; // a second Content cannot be added -- disable the option so it is never picked. if (type === "Content" && haveContent) attrs.disabled = true; addSel.appendChild(el("option", attrs)); }); addRow.appendChild(addSel); addRow.appendChild(button("Add region", function () { addRegion(addSel.value); }, { title: "Append a region of the selected type" })); regionsFs.appendChild(addRow); if (haveContent) { regionsFs.appendChild(el("div", { "class": "tb-field-hint", text: "Exactly one Content region is required, so a second cannot be added." })); } panel.appendChild(regionsFs); panel.appendChild(el("div", { "class": "tb-field-hint", text: "Layout changes apply when the theme is Activated; the live preview shows token colors, not the page chrome. Edits update this theme only and are not published until you Save and Activate." })); return panel; } function renderEditor() { var o = state.open; var wrap = el("div", { "class": "tb-editor" }); // header bar var bar = el("div", { "class": "tb-toolbar" }); bar.appendChild(button("\u2190 Back", function () { if (o.dirty && !window.confirm("Discard unsaved edits?")) return; state.open = null; state.view = "manager"; refreshState(); })); var titleWrap = el("div", { "class": "tb-editor-title" }); titleWrap.appendChild(el("span", { "class": "tb-name", text: o.name })); if (o.builtin) titleWrap.appendChild(badge("read-only", "builtin")); state._dirtyBadge = badge("unsaved", "dirty"); if (!o.dirty) state._dirtyBadge.style.display = "none"; titleWrap.appendChild(state._dirtyBadge); bar.appendChild(titleWrap); bar.appendChild(el("div", { "class": "tb-spacer" })); // Save and Activate are SEPARATE buttons (1.4 / 8.3). bar.appendChild(button("Save", function () { doSave(false); }, { title: "Persist edits. Does NOT change the live site." })); bar.appendChild(button("Save & Activate", function () { doSave(true); }, { title: "Save, then publish to the live site." })); bar.appendChild(button("Activate", function () { doActivate(o.id); }, { primary: true, disabled: o.builtin, title: o.builtin ? "Save a copy first" : "Publish the SAVED theme to the live site" })); wrap.appendChild(bar); // two columns: token/layout panel + live preview var cols = el("div", { "class": "tb-cols" }); var panelWrap = el("div", { "class": "tb-panel-wrap" }); // Layout-mode only: tab strip to switch the left column between Tokens and Layout // (8.3). With layout mode off, the token panel shows alone, no tabs. var showLayout = state.layoutMode || state.caps.layoutMode; if (showLayout) { var tabs = el("div", { "class": "tb-tabs" }); tabs.appendChild(panelTab("tokens", "Tokens")); tabs.appendChild(panelTab("layout", "Layout")); panelWrap.appendChild(tabs); } else if (state.panel === "layout") { // never leave a stale "layout" selection active when phase drops to 1. state.panel = "tokens"; } panelWrap.appendChild(state.panel === "layout" && showLayout ? renderLayoutPanel() : renderTokenPanel()); cols.appendChild(panelWrap); var preview = el("div", { "class": "tb-preview-wrap" }); preview.appendChild(el("div", { "class": "tb-preview-label", text: "Live preview (not published until you Activate)" })); var frame = el("iframe", { "class": "tb-preview", sandbox: "allow-same-origin", title: "Theme preview" }); state._frame = frame; frame.addEventListener("load", applyPreview); preview.appendChild(frame); cols.appendChild(preview); wrap.appendChild(cols); // seed the preview document (srcdoc parses async -> applyPreview also on load) frame.srcdoc = previewSrcdoc(); return wrap; } // keep the dirty badge + chrome in sync without a full re-render on each keystroke. function syncEditorChrome() { if (state._dirtyBadge) state._dirtyBadge.style.display = state.open && state.open.dirty ? "" : "none"; } // Locate the live " + "" + "
Navbar
" + "

Heading 1

Heading 2

" + "

Body text with a sample link. The quick brown fox jumps over the lazy dog.

" + "
" + "PrimarySecondary" + "SuccessDanger" + "WarningInfo" + "
" + "
Card

A representative card surface.

" + "code sample
" + ""; } function render() { if (!root) return; clear(root); var shell = el("div", { "class": "tb-app" }); shell.appendChild(state.view === "editor" && state.open ? renderEditor() : renderManager()); statusBar = el("div", { "class": "tb-status" }); shell.appendChild(statusBar); root.appendChild(shell); } // ---- styles -------------------------------------------------------------- function injectStyles() { if (document.getElementById("tb-app-styles")) return; var s = document.createElement("style"); s.id = "tb-app-styles"; s.textContent = [ ".tb-app{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2330;height:100%;display:flex;flex-direction:column;}", ".tb-toolbar{display:flex;align-items:center;gap:.5rem;padding:.75rem 1rem;border-bottom:1px solid #e3e6ec;background:#f7f8fa;flex-wrap:wrap;}", ".tb-title{font-size:1.15rem;margin:0;}", ".tb-spacer{flex:1;}", ".tb-btn{font:inherit;padding:.35rem .7rem;border:1px solid #c7ccd6;border-radius:.35rem;background:#fff;cursor:pointer;color:#1f2330;text-decoration:none;display:inline-block;line-height:1.2;}", ".tb-btn:hover{background:#eef1f6;}", ".tb-btn[disabled]{opacity:.45;cursor:not-allowed;}", ".tb-btn-primary{background:#0d6efd;border-color:#0d6efd;color:#fff;}", ".tb-btn-primary:hover{background:#0b5ed7;}", ".tb-btn-danger{color:#b02a37;border-color:#e0aeb4;}", ".tb-btn-link{}", ".tb-file{display:none;}", ".tb-table{width:100%;border-collapse:collapse;}", ".tb-table th,.tb-table td{text-align:left;padding:.6rem .75rem;border-bottom:1px solid #eceef2;vertical-align:middle;}", ".tb-table th{font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:#6b7280;}", ".tb-row-active{background:#f1f7ff;}", ".tb-name{font-weight:600;margin-right:.5rem;}", ".tb-actions{display:flex;gap:.35rem;flex-wrap:wrap;}", ".tb-actions-col{width:1px;white-space:nowrap;}", ".tb-empty{color:#6b7280;text-align:center;padding:2rem;}", ".tb-badge{display:inline-block;font-size:.7rem;padding:.1rem .4rem;border-radius:.3rem;margin-right:.35rem;vertical-align:middle;}", ".tb-badge-active{background:#198754;color:#fff;}", ".tb-badge-builtin{background:#6c757d;color:#fff;}", ".tb-badge-dirty{background:#ffc107;color:#000;}", ".tb-cols{display:flex;flex:1;min-height:0;}", ".tb-panel-wrap{width:380px;max-width:45%;display:flex;flex-direction:column;min-height:0;border-right:1px solid #e3e6ec;}", ".tb-tabs{display:flex;gap:.25rem;padding:.5rem .75rem 0;background:#f7f8fa;border-bottom:1px solid #e3e6ec;}", ".tb-tab{font:inherit;padding:.4rem .8rem;border:1px solid #e3e6ec;border-bottom:0;border-radius:.35rem .35rem 0 0;background:#eef1f6;color:#445;cursor:pointer;}", ".tb-tab:hover{background:#e3e8f0;}", ".tb-tab-active{background:#fff;color:#1f2330;font-weight:600;margin-bottom:-1px;}", ".tb-panel-wrap .tb-token-panel{flex:1;border-right:0;}", ".tb-token-panel{width:380px;max-width:45%;overflow:auto;padding:1rem;border-right:1px solid #e3e6ec;}", ".tb-layout-current{margin-bottom:.6rem;color:#445;}", ".tb-layout-option{display:flex;align-items:center;gap:.5rem;padding:.45rem .5rem;margin:.3rem 0;border:1px solid #e3e6ec;border-radius:.4rem;cursor:pointer;}", ".tb-layout-option:hover{background:#f4f6fa;}", ".tb-layout-option-selected{border-color:#0d6efd;background:#f1f7ff;}", ".tb-layout-option-label{font-weight:600;}", ".tb-layout-radio{margin:0;}", ".tb-region{border:1px solid #e3e6ec;border-radius:.45rem;margin:.5rem 0;padding:.5rem .6rem;background:#fbfcfe;}", ".tb-region-head{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}", ".tb-region-type{font-weight:600;flex:1;}", ".tb-region-ctrls{display:flex;gap:.3rem;flex-wrap:wrap;}", ".tb-region-fields{display:flex;flex-direction:column;gap:.4rem;margin-top:.5rem;}", ".tb-region-field{display:flex;flex-direction:column;gap:.15rem;}", ".tb-region-field-label{font-size:.78rem;font-weight:600;color:#445;}", ".tb-region-check{display:flex;align-items:center;gap:.4rem;}", ".tb-region-checkbox{margin:0;}", ".tb-region-select{font:inherit;padding:.3rem .45rem;border:1px solid #c7ccd6;border-radius:.3rem;background:#fff;min-width:0;}", ".tb-region-add{display:flex;gap:.4rem;align-items:center;margin-top:.5rem;flex-wrap:wrap;}", ".tb-section{border:1px solid #e3e6ec;border-radius:.5rem;margin:0 0 1rem;padding:.5rem .75rem 1rem;}", ".tb-section legend{font-weight:600;padding:0 .35rem;font-size:.95rem;}", ".tb-field{display:block;margin:.6rem 0;}", ".tb-field-label{display:block;font-size:.85rem;font-weight:600;margin-bottom:.2rem;}", ".tb-field-hint{display:block;font-size:.72rem;color:#8a90a0;font-family:ui-monospace,Menlo,Consolas,monospace;margin-bottom:.2rem;}", ".tb-field-row{display:flex;gap:.4rem;align-items:center;}", ".tb-input{font:inherit;flex:1;padding:.3rem .45rem;border:1px solid #c7ccd6;border-radius:.3rem;min-width:0;}", ".tb-color{width:38px;height:32px;padding:0;border:1px solid #c7ccd6;border-radius:.3rem;background:#fff;cursor:pointer;}", ".tb-preview-wrap{flex:1;display:flex;flex-direction:column;min-width:0;}", ".tb-preview-label{padding:.5rem 1rem;font-size:.8rem;color:#6b7280;background:#f7f8fa;border-bottom:1px solid #e3e6ec;}", ".tb-preview{flex:1;width:100%;border:0;background:#fff;}", ".tb-editor{display:flex;flex-direction:column;flex:1;min-height:0;}", ".tb-editor-title{display:flex;align-items:center;gap:.4rem;}", ".tb-readonly-note{background:#fff8e1;border:1px solid #ffe7a0;border-radius:.4rem;padding:.6rem;margin-bottom:1rem;font-size:.85rem;}", ".tb-status{padding:.5rem 1rem;font-size:.85rem;color:#444;border-top:1px solid #e3e6ec;min-height:1.2rem;background:#fafbfc;}", ".tb-status-ok{color:#0f5132;background:#d1e7dd;}", ".tb-status-error{color:#842029;background:#f8d7da;}" ].join(""); document.head.appendChild(s); } // ---- boot ---------------------------------------------------------------- function mount(elementId) { root = document.getElementById(elementId || "theme-builder-root"); if (!root) return; injectStyles(); clear(root); root.appendChild(el("div", { "class": "tb-loading", text: "Loading themes..." })); refreshState().then(function () { if (TB.openThemeId) openTheme(TB.openThemeId); }); } // expose a small API (mirrors the Phase-2 themeBuilder.mount contract). window.themeBuilder = { mount: mount }; // auto-mount when the shell's div exists. if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { mount("theme-builder-root"); }); } else { mount("theme-builder-root"); } })();