agent-market/static/theme.js

164 lines
11 KiB
JavaScript

// Per-visitor theme engine. The theme is a validated JSON spec of design tokens stored in localStorage
// (never on the server), so one visitor's restyle never changes the page for anyone else.
(() => {
const KEY = "am_theme_v1";
const FONTS = {
system: ["system-ui,-apple-system,Segoe UI,Roboto,sans-serif"], serif: ["Georgia,'Times New Roman',serif"], mono: ["ui-monospace,Menlo,Consolas,monospace"],
Inter: ["'Inter',system-ui,sans-serif", "Inter:wght@400;600;700"], Poppins: ["'Poppins',system-ui,sans-serif", "Poppins:wght@400;600;700"],
Montserrat: ["'Montserrat',system-ui,sans-serif", "Montserrat:wght@400;600;700"], Nunito: ["'Nunito',system-ui,sans-serif", "Nunito:wght@400;700"],
"Space Grotesk": ["'Space Grotesk',system-ui,sans-serif", "Space+Grotesk:wght@400;700"], Lora: ["'Lora',Georgia,serif", "Lora:wght@400;700"],
Merriweather: ["'Merriweather',Georgia,serif", "Merriweather:wght@400;700"], "Playfair Display": ["'Playfair Display',Georgia,serif", "Playfair+Display:wght@400;700"],
"Roboto Slab": ["'Roboto Slab',Georgia,serif", "Roboto+Slab:wght@400;700"], "Fira Code": ["'Fira Code',ui-monospace,monospace", "Fira+Code:wght@400;600"],
"Space Mono": ["'Space Mono',ui-monospace,monospace", "Space+Mono:wght@400;700"], VT323: ["'VT323',ui-monospace,monospace", "VT323"],
"Press Start 2P": ["'Press Start 2P',ui-monospace,monospace", "Press+Start+2P"], "Bebas Neue": ["'Bebas Neue',Impact,sans-serif", "Bebas+Neue"],
Pacifico: ["'Pacifico',cursive", "Pacifico"], "Comic Neue": ["'Comic Neue','Comic Sans MS',cursive", "Comic+Neue:wght@400;700"],
};
const MODES = {
dark: { bg: "#0f1115", surface: "#171a21", card: "#1d212a", text: "#e9ecf1", muted: "#9aa3b2", accent: "#7c9cff", border: "#2a2f3a" },
light: { bg: "#f5f6fa", surface: "#ffffff", card: "#ffffff", text: "#151821", muted: "#5b6475", accent: "#3d5afe", border: "#dde1ea" },
};
const SECTIONS = ["hero", "chips", "bullets", "results", "footer"];
const IMAGE_HOSTS = ["images.unsplash.com", "picsum.photos", "upload.wikimedia.org"];
const isObj = (o) => o && typeof o === "object" && !Array.isArray(o);
const merge = (a, b) => { const o = { ...(a || {}) }; for (const [k, v] of Object.entries(b || {})) o[k] = isObj(v) && isObj(o[k]) ? merge(o[k], v) : v; return o; };
const okColor = (c) => typeof c === "string" && c.length < 60 && !/[;{}<>]/.test(c) && CSS.supports("color", c);
// ---- colour maths for guaranteed contrast on protected elements ----
const probe = document.createElement("span");
function rgb(c) {
probe.style.color = ""; probe.style.color = c; document.body.append(probe);
const m = getComputedStyle(probe).color.match(/[\d.]+/g) || [0, 0, 0, 1]; probe.remove();
return { r: +m[0], g: +m[1], b: +m[2], a: m[3] === undefined ? 1 : +m[3] };
}
const lum = ({ r, g, b }) => [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); })
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
const contrast = (a, b) => { const x = lum(rgb(a)), y = lum(rgb(b)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
const solid = (c) => { const v = rgb(c); return `rgb(${v.r},${v.g},${v.b})`; };
const bestInk = (bg) => (contrast(bg, "#111111") >= contrast(bg, "#ffffff") ? "#111111" : "#ffffff");
window.__amContrast = contrast;
function backgroundCss(bg, pal) {
const cols = (bg.colors || []).filter(okColor);
const c0 = cols[0] || pal.bg, c1 = cols[1] || pal.surface;
switch (bg.kind) {
case "gradient": return `linear-gradient(${Number(bg.angle ?? 135)}deg, ${(cols.length > 1 ? cols : [c0, c1]).join(", ")})`;
case "pattern": {
const p = bg.pattern || "dots";
const map = {
dots: `radial-gradient(${c1} 1.5px, transparent 1.6px) 0 0/18px 18px, ${c0}`,
grid: `linear-gradient(${c1} 1px, transparent 1px) 0 0/24px 24px, linear-gradient(90deg, ${c1} 1px, transparent 1px) 0 0/24px 24px, ${c0}`,
stripes: `repeating-linear-gradient(90deg, ${c0} 0 18px, ${c1} 18px 36px)`,
diagonal: `repeating-linear-gradient(45deg, ${c0} 0 14px, ${c1} 14px 28px)`,
checks: `conic-gradient(${c1} 25%, ${c0} 0 50%, ${c1} 0 75%, ${c0} 0) 0 0/40px 40px`,
waves: `radial-gradient(circle at 50% 0, transparent 18px, ${c1} 19px 21px, transparent 22px) 0 0/40px 24px, ${c0}`,
};
return map[p] || map.dots;
}
case "image": {
try { const u = new URL(bg.url); if (u.protocol === "https:" && IMAGE_HOSTS.includes(u.hostname)) return `center/cover no-repeat url("${u.href.replace(/["\\()]/g, "")}"), ${c0}`; } catch (e) {}
return c0;
}
default: return c0;
}
}
function loadFont(name) {
const f = FONTS[name]; if (!f) return FONTS.system[0];
if (f[1] && !document.querySelector(`link[data-font="${name}"]`)) {
const l = document.createElement("link"); l.rel = "stylesheet"; l.dataset.font = name;
l.href = `https://fonts.googleapis.com/css2?family=${f[1]}&display=swap`; document.head.append(l);
}
return f[0];
}
function scopeCss(css) {
if (typeof css !== "string" || /[<\\@]|expression\s*\(|javascript:|!\s*important|--[\w-]+\s*:/i.test(css)) return "";
const urlOk = [...css.matchAll(/url\(([^)]*)\)/gi)].every((m) => { try { return IMAGE_HOSTS.includes(new URL(m[1].replace(/["']/g, "").trim()).hostname); } catch (e) { return false; } });
if (!urlOk) return "";
return [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, sel, body]) =>
sel.split(",").map((s) => "#page " + s.trim()).join(", ") + "{" + body + "}").join("\n");
}
let theme = {};
try { theme = JSON.parse(localStorage.getItem(KEY) || "{}") || {}; } catch (e) { theme = {}; }
const defaults = { copy: {} };
function apply() {
const t = theme, root = document.documentElement.style, body = document.body;
const mode = t.mode === "light" ? "light" : "dark";
const pal = { ...MODES[mode] };
for (const [k, v] of Object.entries(t.palette || {})) if (k in pal && okColor(v)) pal[k] = v;
const set = (k, v) => root.setProperty(k, v);
set("--bg", pal.bg); set("--panel", pal.surface); set("--card", pal.card); set("--ink", pal.text);
set("--mute", pal.muted); set("--acc", pal.accent); set("--line", pal.border); set("--acc-ink", bestInk(pal.accent));
set("--page-bg", backgroundCss(isObj(t.background) ? t.background : { kind: "solid", colors: [pal.bg] }, pal));
set("--font", loadFont(FONTS[t.font] ? t.font : "system"));
set("--hfont", FONTS[t.headingFont] ? loadFont(t.headingFont) : "var(--font)");
set("--scale", String(Math.min(1.75, Math.max(0.75, Number(t.fontScale) || 1))));
set("--radius", Math.min(32, Math.max(0, Number(t.radius ?? 14))) + "px");
set("--space", String({ compact: 0.65, normal: 1, comfy: 1.4 }[t.density] || 1));
const cols = Math.min(6, Math.max(0, parseInt(t.columns) || 0));
set("--grid-cols", cols ? `repeat(${cols}, minmax(0, 1fr))` : "repeat(auto-fill, minmax(220px, 1fr))");
// protected panels: solid surface colour + whichever of black/white reads best (always >= 4.5:1)
const pbg = solid(pal.surface); set("--p-bg", pbg); set("--p-ink", bestInk(pbg));
// chat popup
const ch = isObj(t.chat) ? t.chat : {};
const cbg = okColor(ch.bg) ? ch.bg : pal.surface, cacc = okColor(ch.accent) ? ch.accent : pal.accent;
set("--chat-bg", solid(cbg)); set("--chat-ink", okColor(ch.text) && contrast(ch.text, solid(cbg)) >= 4.5 ? ch.text : bestInk(solid(cbg)));
set("--chat-acc", cacc); set("--chat-acc-ink", bestInk(cacc));
set("--chat-w", Math.min(600, Math.max(280, Number(ch.width) || 370)) + "px");
set("--chat-h", Math.min(820, Math.max(320, Number(ch.height) || 520)) + "px");
body.classList.toggle("chat-left", ch.position === "left");
for (const s of ["flat", "outlined", "elevated", "glass"]) body.classList.toggle("card-" + s, t.cardStyle === s);
body.classList.toggle("layout-list", t.layout === "list");
body.classList.toggle("no-anim", t.animations === false);
// copy
const c = { ...defaults.copy, ...(isObj(t.copy) ? t.copy : {}) };
document.getElementById("logoText").textContent = String(c.logo || "Agent Market").slice(0, 40);
document.getElementById("headline").textContent = String(c.headline).slice(0, 120);
document.getElementById("subhead").textContent = String(c.subhead).slice(0, 240);
const chips = document.getElementById("chips"); chips.innerHTML = "";
for (const chip of (Array.isArray(c.chips) ? c.chips : []).slice(0, 6)) {
const b = document.createElement("button"); b.className = "chip";
if (chip.event) b.dataset.event = chip.event; else b.dataset.say = String(chip.say || chip.label).slice(0, 120);
b.textContent = String(chip.label).slice(0, 40); chips.append(b);
}
const ul = document.getElementById("bullets"); ul.innerHTML = "";
for (const line of (Array.isArray(c.bullets) ? c.bullets : []).slice(0, 6)) { const li = document.createElement("li"); li.textContent = String(line).slice(0, 140); ul.append(li); }
if (t.copy && t.copy.footer) document.getElementById("footer").textContent = String(t.copy.footer).slice(0, 160);
else if (defaults.footerHtml) document.getElementById("footer").innerHTML = defaults.footerHtml;
// sections
const sec = isObj(t.sections) ? t.sections : {};
const order = Array.isArray(sec.order) ? sec.order.filter((x) => SECTIONS.includes(x)) : [];
const full = [...new Set([...order, ...SECTIONS])];
const hidden = Array.isArray(sec.hidden) ? sec.hidden : [];
for (const name of SECTIONS) {
const n = document.querySelector(`[data-section="${name}"]`);
n.style.order = String(full.indexOf(name));
n.classList.toggle("is-hidden", hidden.includes(name));
}
document.getElementById("user-css").textContent = t.customCss ? "@layer user {\n" + scopeCss(t.customCss) + "\n}" : "";
}
window.AMTheme = {
init(copyDefaults) { defaults.copy = copyDefaults; defaults.footerHtml = document.getElementById("footer").innerHTML; apply(); },
get: () => theme,
// action from the agent: {reset?, base?, patch}
applyAction(a) {
if (a.reset) theme = {};
if (a.base) theme = JSON.parse(JSON.stringify(a.base));
theme = merge(theme, a.patch || {});
localStorage.setItem(KEY, JSON.stringify(theme)); apply();
},
reset() { theme = {}; localStorage.removeItem(KEY); apply(); },
// a panel that must be visible (checkout etc.) un-hides the results section
ensureResultsVisible() {
if (theme.sections && Array.isArray(theme.sections.hidden) && theme.sections.hidden.includes("results")) {
theme.sections.hidden = theme.sections.hidden.filter((x) => x !== "results"); localStorage.setItem(KEY, JSON.stringify(theme)); apply(); return true;
}
return false;
},
contrast,
};
})();