"""Per-visitor look & feel: a validated JSON theme spec (design tokens) the agent can patch. The server never stores themes; it only validates what the model proposes. The browser keeps the theme in localStorage, so one visitor's restyle never affects anyone else. No raw HTML/JS is ever accepted; the optional customCss escape hatch is sanitised and scoped (see sanitize_css).""" import re COLOR_RE = re.compile(r"^(#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|" r"rgba?\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(,\s*[\d.]+%?\s*)?\)|" r"hsla?\(\s*[\d.]+(deg)?\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*(,\s*[\d.]+%?\s*)?\)|" r"[a-zA-Z]{3,24})$") FONTS = { # name -> (css stack, google family or None) "system": ("system-ui,-apple-system,Segoe UI,Roboto,sans-serif", None), "serif": ("Georgia,'Times New Roman',serif", None), "mono": ("ui-monospace,Menlo,Consolas,monospace", None), "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"), } _FONT_ALIASES = {"sans": "system", "sans-serif": "system", "default": "system", "georgia": "serif", "times": "serif", "monospace": "mono", "courier": "mono", "pixel": "Press Start 2P", "retro": "VT323", "script": "Pacifico", "comic": "Comic Neue", "comic sans": "Comic Neue", "playfair": "Playfair Display"} PATTERNS = ["dots", "grid", "stripes", "diagonal", "checks", "waves"] IMAGE_HOSTS = {"images.unsplash.com", "picsum.photos", "upload.wikimedia.org"} SECTIONS = ["hero", "chips", "bullets", "results", "footer"] PALETTE_KEYS = ["bg", "surface", "card", "text", "muted", "accent", "border"] PRESETS = { "retro": {"mode": "light", "background": {"kind": "pattern", "pattern": "checks", "colors": ["#f4e3c1", "#ead2a6"]}, "palette": {"surface": "#fff6e3", "card": "#fffaf0", "text": "#3a2a1a", "muted": "#7a5c3e", "accent": "#d9480f", "border": "#3a2a1a"}, "font": "VT323", "headingFont": "Press Start 2P", "fontScale": 1.15, "radius": 0, "cardStyle": "outlined", "density": "normal"}, "minimal": {"mode": "light", "background": {"kind": "solid", "colors": ["#ffffff"]}, "palette": {"surface": "#ffffff", "card": "#ffffff", "text": "#111111", "muted": "#666666", "accent": "#111111", "border": "#e6e6e6"}, "font": "Inter", "headingFont": "Inter", "radius": 4, "cardStyle": "flat", "density": "comfy", "animations": False}, "neon": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#05010f", "#1a0033", "#00111f"], "angle": 160}, "palette": {"surface": "#0d0620", "card": "#130a2b", "text": "#f5f0ff", "muted": "#b9a6ff", "accent": "#39ff14", "border": "#ff00e6"}, "font": "Space Grotesk", "headingFont": "Bebas Neue", "radius": 12, "cardStyle": "glass", "customCss": ".card{box-shadow:0 0 12px #ff00e6aa} h1{text-shadow:0 0 18px #39ff14}"}, "pastel": {"mode": "light", "background": {"kind": "gradient", "colors": ["#fde2e4", "#e2ece9", "#dfe7fd"], "angle": 135}, "palette": {"surface": "#fffafc", "card": "#ffffff", "text": "#3d3a4b", "muted": "#7d7a8c", "accent": "#b388eb", "border": "#ecdff5"}, "font": "Nunito", "headingFont": "Nunito", "radius": 22, "cardStyle": "elevated", "density": "comfy"}, "high contrast": {"mode": "dark", "background": {"kind": "solid", "colors": ["#000000"]}, "palette": {"surface": "#000000", "card": "#000000", "text": "#ffffff", "muted": "#ffffff", "accent": "#ffff00", "border": "#ffffff"}, "font": "system", "fontScale": 1.2, "radius": 2, "cardStyle": "outlined", "animations": False}, "sunset": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#ff7e5f", "#feb47b", "#6a3093"], "angle": 180}, "palette": {"surface": "#2b1531", "card": "#3a1d40", "text": "#fff4ec", "muted": "#ffd2b8", "accent": "#ff9966", "border": "#6a3093"}, "radius": 16, "cardStyle": "glass"}, "ocean": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#0f2027", "#203a43", "#2c5364"], "angle": 180}, "palette": {"surface": "#10262f", "card": "#163542", "text": "#e6f6ff", "muted": "#9cc7d9", "accent": "#4fd1c5", "border": "#2c5364"}, "font": "Montserrat", "radius": 14}, } PRESET_ALIASES = {"high-contrast": "high contrast", "contrast": "high contrast", "accessible": "high contrast", "80s": "retro", "vintage": "retro", "clean": "minimal", "cyberpunk": "neon", "soft": "pastel"} def _color(v): return v.strip() if isinstance(v, str) and COLOR_RE.match(v.strip()) else None def _num(v, lo, hi, cast=float): try: x = cast(v) except (TypeError, ValueError): return None return max(lo, min(hi, x)) def _text(v, n): if not isinstance(v, str): return None v = re.sub(r"[<>\x00-\x1f]", "", v).strip() return v[:n] if v else None def _font(v): if not isinstance(v, str): return None k = v.strip() for name in FONTS: if name.lower() == k.lower(): return name return _FONT_ALIASES.get(k.lower()) CSS_PROPS = { # allow-list for the customCss escape hatch (no positioning, transforms, content or variables) "color", "background", "background-color", "background-image", "background-size", "background-position", "background-repeat", "background-attachment", "border", "border-color", "border-width", "border-style", "border-top", "border-bottom", "border-left", "border-right", "border-radius", "box-shadow", "outline", "outline-color", "font", "font-family", "font-size", "font-weight", "font-style", "letter-spacing", "line-height", "text-align", "text-transform", "text-decoration", "text-shadow", "padding", "padding-top", "padding-bottom", "padding-left", "padding-right", "margin", "margin-top", "margin-bottom", "margin-left", "margin-right", "gap", "opacity", "filter", "backdrop-filter", "width", "max-width", "min-width", "min-height", "display", "flex-direction", "flex-wrap", "justify-content", "align-items", "grid-template-columns", "cursor", "transition", "accent-color", "caret-color", "word-spacing", "font-variant", } def _url_ok(m): host = re.match(r"https://([^/'\")\s]+)", m.group(1).strip("'\" ")) return m.group(0) if host and host.group(1).lower() in IMAGE_HOSTS else "none" def sanitize_css(css): """Return sanitised CSS rules (selectors unscoped; the browser prefixes them with #page).""" if not isinstance(css, str): return "" css = css[:3000] css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) if re.search(r"<|>\s*<|\\|@|expression\s*\(|javascript:|behavior\s*:|-moz-binding|vbscript:", css, re.I): css = re.sub(r"<[^>]*>?|\\|@[^;{}]*(;|\{[^}]*\})?|expression\s*\([^)]*\)|javascript:|vbscript:|behavior\s*:[^;}]*|-moz-binding[^;}]*", "", css, flags=re.I) out = [] for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): sel = sel.strip() if not sel or len(sel) > 200 or re.search(r"[<>@\\]|^\s*(html|body|:root)\b", sel, re.I): continue decls = [] for d in body.split(";"): if ":" not in d: continue prop, val = d.split(":", 1) prop, val = prop.strip().lower(), val.strip() if prop not in CSS_PROPS or not val or len(val) > 300: continue val = re.sub(r"!\s*important", "", val, flags=re.I).strip() val = re.sub(r"url\(([^)]*)\)", _url_ok, val, flags=re.I) if re.search(r"var\(|attr\(|expression|javascript", val, re.I) or val.count("(") != val.count(")"): continue if prop.startswith("margin") and "-" in val: continue # no negative margins (could overlap protected panels) decls.append(f"{prop}:{val}") if decls: out.append(f"{sel}{{{';'.join(decls)}}}") return "\n".join(out)[:3000] def sanitize_patch(p, depth=0): """Validate a theme patch from the model. Unknown keys/values are dropped silently.""" if not isinstance(p, dict): return {} out = {} if p.get("mode") in ("light", "dark"): out["mode"] = p["mode"] bg = p.get("background") if isinstance(bg, str): bg = {"kind": "solid", "colors": [bg]} if isinstance(bg, dict): b = {} kind = bg.get("kind") or bg.get("type") if kind in ("solid", "gradient", "pattern", "image"): b["kind"] = kind cols = bg.get("colors") or ([bg["color"]] if bg.get("color") else []) cols = [c for c in (_color(c) for c in (cols if isinstance(cols, list) else [])) if c][:5] if cols: b["colors"] = cols a = _num(bg.get("angle"), 0, 360, int) if a is not None: b["angle"] = a if bg.get("pattern") in PATTERNS: b["pattern"] = bg["pattern"] url = bg.get("url") or bg.get("image") if isinstance(url, str): m = re.match(r"^https://([^/\s'\"()]+)/[^\s'\"()<>]*$", url.strip()) if m and m.group(1).lower() in IMAGE_HOSTS: b["url"] = url.strip() if b.get("kind") == "image" and "url" not in b: b.pop("kind") if b: out["background"] = b pal = p.get("palette") if isinstance(pal, dict): aliases = {"background": "bg", "panel": "surface", "ink": "text", "foreground": "text", "mute": "muted", "line": "border"} clean = {} for k, v in pal.items(): k = aliases.get(k, k) if k in PALETTE_KEYS and _color(v): clean[k] = _color(v) if clean: out["palette"] = clean for k in ("font", "headingFont"): f = _font(p.get(k)) if f: out[k] = f for k, lo, hi, cast in (("fontScale", 0.75, 1.75, float), ("radius", 0, 32, int), ("columns", 0, 6, int)): v = _num(p.get(k), lo, hi, cast) if v is not None and p.get(k) is not None: out[k] = round(v, 2) if cast is float else v for k, allowed in (("density", ("compact", "normal", "comfy")), ("cardStyle", ("flat", "outlined", "elevated", "glass")), ("layout", ("grid", "list"))): if p.get(k) in allowed: out[k] = p[k] if isinstance(p.get("animations"), bool): out["animations"] = p["animations"] copy = p.get("copy") if isinstance(copy, dict): c = {} for k, n in (("logo", 40), ("headline", 120), ("subhead", 240), ("footer", 160)): t = _text(copy.get(k), n) if t: c[k] = t if isinstance(copy.get("chips"), list): chips = [] for ch in copy["chips"][:6]: if isinstance(ch, str): ch = {"label": ch, "say": ch} if isinstance(ch, dict) and _text(ch.get("label"), 40): chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)}) c["chips"] = chips if isinstance(copy.get("bullets"), list): c["bullets"] = [t for t in (_text(b, 140) for b in copy["bullets"][:6]) if t] if c: out["copy"] = c sec = p.get("sections") if isinstance(sec, dict): s = {} if isinstance(sec.get("order"), list): order = [x for x in sec["order"] if x in SECTIONS] s["order"] = list(dict.fromkeys(order + [x for x in SECTIONS if x not in order])) if isinstance(sec.get("hidden"), list): s["hidden"] = [x for x in dict.fromkeys(sec["hidden"]) if x in SECTIONS] if s: out["sections"] = s chat = p.get("chat") if isinstance(chat, dict): c = {} if chat.get("position") in ("left", "right"): c["position"] = chat["position"] for k, lo, hi in (("width", 280, 600), ("height", 320, 820)): v = _num(chat.get(k), lo, hi, int) if v is not None: c[k] = v for k in ("bg", "accent", "text"): if _color(chat.get(k)): c[k] = _color(chat[k]) if c: out["chat"] = c if p.get("customCss"): css = sanitize_css(p["customCss"]) if css: out["customCss"] = css return out def build_theme_action(payload): """Turn a model's set_theme payload into a client action: {reset?, base?, patch}.""" if not isinstance(payload, dict): return None if payload.get("reset"): return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})} name = str(payload.get("preset") or "").strip().lower() name = PRESET_ALIASES.get(name, name) patch = payload.get("patch") if isinstance(payload.get("patch"), dict) else {k: v for k, v in payload.items() if k not in ("preset", "reset")} out = {"patch": sanitize_patch(patch)} if name in PRESETS: out["preset"] = name out["base"] = sanitize_patch(PRESETS[name]) if not out["patch"] and "base" not in out: return None return out def compact_current(theme): """Short current-look summary for the prompt (keeps free-tier calls lean).""" import json t = sanitize_patch(theme) if isinstance(theme, dict) else {} t.pop("customCss", None) s = json.dumps(t, separators=(",", ":")) return s if len(s) <= 900 else s[:900]