sc-theme-builder/public/builderApp.js
2026-07-01 20:07:28 -05:00

1346 lines
58 KiB
JavaScript

/* 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[<kebabKey>] = { 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-<cssVar>:v}
// (+ --bs-<cssVar>-rgb companion when the token derives and the value is a
// hex color), rule -> <selector>{<prop>: 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 <style id="tb-overlay"> inside the preview iframe, or null
// when the frame/document/overlay is not ready yet.
function overlayStyleNode() {
var frame = state._frame;
if (!frame) return null;
var doc = frame.contentDocument;
if (!doc) return null;
return doc.getElementById("tb-overlay") || null;
}
// Debounce + stale-guard state for the server-compiled preview. _previewSeq is
// bumped per request; a response is only applied when its captured sequence is
// still the latest, so an out-of-order (slow) response can never clobber a
// newer one.
var PREVIEW_DEBOUNCE_MS = 150;
state._previewTimer = null;
state._previewSeq = 0;
// write the instant client-side overlay into the preview iframe -- snappy
// feedback that covers --bs-* custom properties (production parity for those),
// then kick the debounced server compile for FULL fidelity (button rules etc).
function applyPreview() {
var o = state.open;
var style = overlayStyleNode();
if (!o || !style) return;
style.textContent = overlayCss(o.draftFlat, state.manifest);
schedulePreviewCss();
}
// Debounced server-compiled preview. ~150ms after the last token change, POST
// the current draft (nested) tokens to /preview-css and inject the returned
// CSS into the overlay <style> -- giving WYSIWYG parity with production,
// including the deep button rules client overlayCss() cannot emit. Stale
// responses are ignored (sequence check); any fetch/compile failure is
// swallowed, leaving the instant --bs-* overlay in place. Never throws.
function schedulePreviewCss() {
if (state._previewTimer) clearTimeout(state._previewTimer);
state._previewTimer = setTimeout(runPreviewCss, PREVIEW_DEBOUNCE_MS);
}
// Fire one server compile immediately (used by the debounce + initial open).
function runPreviewCss() {
state._previewTimer = null;
var o = state.open;
if (!o) return;
var seq = ++state._previewSeq;
var nestedTokens = nest(o.draftFlat, o.baseTokens, state.manifest);
previewCss(nestedTokens).then(function (css) {
// ignore a stale response if a newer request started since this one.
if (seq !== state._previewSeq) return;
var style = overlayStyleNode();
if (!style) return;
if (typeof css === "string") style.textContent = css;
}).catch(function () {
// fall back to the instant --bs-* overlay already in place; never throw.
});
}
// the sample page loaded into the preview iframe. Loads the live theme.css so
// the baseline matches production, plus an empty <style id="tb-overlay"> we
// rewrite on every edit. No allow-scripts: the preview cannot run code.
function previewSrcdoc() {
var css = CSS_ROUTE;
return "" +
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">" +
"<link rel=\"stylesheet\" href=\"" + css + "\">" +
"<style id=\"tb-overlay\"></style>" +
"<style>body{padding:1rem;background:var(--bs-body-bg,#fff);color:var(--bs-body-color,#212529);" +
"font-family:var(--bs-body-font-family,system-ui),sans-serif;font-size:var(--bs-body-font-size,1rem);" +
"line-height:var(--bs-body-line-height,1.5);}" +
".sw{border:var(--bs-border-width,1px) solid var(--bs-border-color,#dee2e6);" +
"border-radius:var(--bs-border-radius,.375rem);padding:1rem;margin-bottom:1rem;background:var(--bs-card-bg,#fff);}" +
".bar{padding:.5rem 1rem;margin-bottom:1rem;border-radius:var(--bs-border-radius,.375rem);" +
"background:var(--bs-navbar-bg,var(--bs-primary,#0d6efd));color:#fff;}" +
".btn{display:inline-block;padding:.375rem .75rem;margin:.25rem;border-radius:var(--bs-border-radius,.375rem);" +
"border:var(--bs-border-width,1px) solid transparent;color:#fff;}" +
".btn-p{background:var(--bs-primary,#0d6efd);}.btn-s{background:var(--bs-secondary,#6c757d);}" +
".btn-su{background:var(--bs-success,#198754);}.btn-d{background:var(--bs-danger,#dc3545);}" +
".btn-w{background:var(--bs-warning,#ffc107);color:#000;}.btn-i{background:var(--bs-info,#0dcaf0);color:#000;}" +
"a{color:var(--bs-link-color,var(--bs-primary,#0d6efd));}" +
"h1,h2,h3{font-family:var(--bs-headings-font-family,inherit);}</style></head><body>" +
"<div class=\"bar\">Navbar</div>" +
"<h1>Heading 1</h1><h2>Heading 2</h2>" +
"<p>Body text with a <a href=\"#\">sample link</a>. The quick brown fox jumps over the lazy dog.</p>" +
"<div>" +
"<span class=\"btn btn-p\">Primary</span><span class=\"btn btn-s\">Secondary</span>" +
"<span class=\"btn btn-su\">Success</span><span class=\"btn btn-d\">Danger</span>" +
"<span class=\"btn btn-w\">Warning</span><span class=\"btn btn-i\">Info</span>" +
"</div>" +
"<div class=\"sw\"><strong>Card</strong><p>A representative card surface.</p>" +
"<code>code sample</code></div>" +
"</body></html>";
}
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");
}
})();