171 lines
11 KiB
JavaScript
171 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))");
|
|
// text that sits directly on the page background (header, hero, bullets, footer) must stay readable on
|
|
// gradients/patterns/images too: keep the theme colour only if it clears 4.5:1 against every background stop
|
|
const stops = (isObj(t.background) && Array.isArray(t.background.colors) ? t.background.colors.filter(okColor) : []);
|
|
const bgStops = stops.length ? stops.map(solid) : [solid(pal.bg)];
|
|
const minC = (ink) => Math.min(...bgStops.map((b) => contrast(ink, b)));
|
|
const pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff");
|
|
set("--page-ink", pageInk); set("--page-mute", minC(pal.muted) >= 4.5 ? pal.muted : pageInk);
|
|
// 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,
|
|
};
|
|
})();
|