// 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, }; })();