Agent controls the whole look: validated theme spec, presets, layout, copy, chat, scoped CSS, protected fee/banner #4

Merged
leora merged 6 commits from feat/agent-theming into main 2026-10-08 19:41:37 +08:00
9 changed files with 802 additions and 69 deletions
Showing only changes of commit 2e10619b95 - Show all commits

View File

@ -30,7 +30,34 @@ Allowed actions (use only these types; listing ids must come from CATALOG below,
- {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} welcome / help
- {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}}
- {"type":"set_theme","payload":{...}} change how the page LOOKS (see LOOK & LAYOUT). Applies instantly, only for this visitor.
LOOK & LAYOUT: you control the entire look of the page. Never say you can't change something visual; use set_theme.
set_theme payload = any subset of this spec (it is merged into the current look):
{"preset":"retro|minimal|neon|pastel|high contrast|sunset|ocean", // starts from that preset, other keys override
"reset":true, // back to the default look
"mode":"dark|light",
"background":{"kind":"solid|gradient|pattern|image","colors":["#hex",...],"angle":0-360,
"pattern":"dots|grid|stripes|diagonal|checks|waves","url":"https://images.unsplash.com/... or picsum.photos"},
"palette":{"bg","surface","card","text","muted","accent","border"}, // CSS colours (#hex, rgb(), names)
"font":"FONT","headingFont":"FONT","fontScale":0.75-1.75 (1=default),
"radius":0-32 (0=sharp corners, 24+=pill-ish),"density":"compact|normal|comfy",
"cardStyle":"flat|outlined|elevated|glass","layout":"grid|list","columns":0-6 (0=auto),
"copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]},
"sections":{"order":["hero","chips","bullets","results","footer"],"hidden":["bullets",...]},
"chat":{"position":"left|right","width":280-600,"height":320-820,"bg","accent","text"},
"animations":true|false,
"customCss":"<plain CSS rules for anything else, e.g. .card{border-width:3px} h1{letter-spacing:4px}>"}
FONT = system | serif | mono | Inter | Poppins | Montserrat | Nunito | Space Grotesk | Lora | Merriweather |
Playfair Display | Roboto Slab | Fira Code | Space Mono | VT323 | Press Start 2P | Bebas Neue | Pacifico | Comic Neue
Relative requests use CURRENT_LOOK (e.g. "bigger text" => raise fontScale from its current value, default 1).
Light mode usually needs only {"mode":"light"} plus any colours asked for; "green accent" => palette.accent.
Out of bounds (say so briefly and offer the closest option): the TEST DEMO banner, the agent badge, checkout fee
lines/amounts and the payment form are fixed for safety; no scripts or HTML; images only from the hosts above.
For a purely visual request return ONLY set_theme (do not re-show listings), and confirm what changed in "reply".
Rules:
- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title.
- Interpret needs, not just keywords: e.g. "get my kid to school" => a kids' bike; "garden" => lawn/yard services.
- Respect budgets (prices in CATALOG are integer cents: 5500 = $55.00).
- If nothing fits, say so and show the closest options with show_cards.
@ -42,7 +69,8 @@ Rules:
ALLOWED_ACTIONS = {"show_hero", "show_cards", "show_listing_form", "show_compare",
"show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast"}
"show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast", "set_theme"}
ID_RE = re.compile(r"\s*\(?\b(?:lst|pi|cash|ord|evt)_[0-9a-f]{6,}\b\)?")
def money(cents: int) -> str:
@ -246,12 +274,45 @@ def fallback_event(store, ev: dict, actor: str):
return {"reply": "I didn't recognise that action.", "actions": []}
def fallback_theme(low):
"""Basic look commands for when the LLM is unavailable."""
from .theme import PRESETS, PRESET_ALIASES
if re.search(r"\breset\b.*\b(look|theme|style|design|page)\b|\bdefault (look|theme)\b", low):
return {"reply": "Done: the page is back to its default look.", "actions": [{"type": "set_theme", "payload": {"reset": True}}]}
for name in list(PRESETS) + list(PRESET_ALIASES):
if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|mode|preset|vibe", low):
real = PRESET_ALIASES.get(name, name)
return {"reply": f"Applied the {real} look.", "actions": [{"type": "set_theme", "payload": {"preset": real}}]}
patch, said = {}, []
if re.search(r"\blight (mode|theme)\b", low):
patch["mode"] = "light"; said.append("light mode")
elif re.search(r"\bdark (mode|theme)\b", low):
patch["mode"] = "dark"; said.append("dark mode")
if re.search(r"as a list|list (view|layout)|not cards", low):
patch["layout"] = "list"; said.append("list layout")
elif re.search(r"grid (view|layout)|as cards", low):
patch["layout"] = "grid"; said.append("grid layout")
if re.search(r"chat.*\bleft\b", low):
patch["chat"] = {"position": "left"}; said.append("chat on the left")
elif re.search(r"chat.*\bright\b", low):
patch["chat"] = {"position": "right"}; said.append("chat on the right")
if re.search(r"(no|stop|turn off|disable) animation", low):
patch["animations"] = False; said.append("animations off")
if not patch:
return None
return {"reply": "Done: " + ", ".join(said) + ".", "actions": [{"type": "set_theme", "payload": patch}]}
def fallback_agent(store, message: str, history, actor: str = "demo-buyer"):
m = (message or "").strip()
low = m.lower()
actions = []
reply = ""
th = fallback_theme(low)
if th:
return th
# welcome / help
if not m or low in ("hi", "hello", "hey", "help", "start"):
reply = ("Welcome to Agent Market (TEST DEMO). I drive the page: ask me to search, "
@ -437,7 +498,7 @@ def _llm_config():
def _call_llm(cfg, model, messages, json_mode=True):
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 900}
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
if json_mode:
payload["response_format"] = {"type": "json_object"}
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={
@ -492,8 +553,9 @@ def _catalog(store):
for L in store.all("listings") if L.get("status") == "active"][:60]
def _llm_once(store, cfg, model, message, history):
sys_prompt = UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000]
def _llm_once(store, cfg, model, message, history, look=""):
sys_prompt = (UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}"))
msgs = [{"role": "system", "content": sys_prompt}]
for h in (history or [])[-6:]:
if h.get("role") in ("user", "assistant") and h.get("content"):
@ -513,10 +575,11 @@ def _llm_once(store, cfg, model, message, history):
reply = str(plan.get("reply") or "").strip()
if not reply and not actions:
raise LLMError("empty_plan", "")
return {"reply": (reply or "Here you go.")[:600], "actions": actions, "tools": tool_results}
reply = ID_RE.sub("", reply).strip()
return {"reply": (reply or "Done.")[:600], "actions": actions, "tools": tool_results}
def llm_agent(store, message, history, cfg):
def llm_agent(store, message, history, cfg, look=""):
"""Try each model in the chain; skip models cooling down after a 429. Raises if all fail."""
errors = []
now_t = time.time()
@ -529,7 +592,7 @@ def llm_agent(store, message, history, cfg):
errors.append({"model": model, "error": "skipped_budget", "ms": 0})
continue
try:
fut = _pool.submit(_llm_once, store, cfg, model, message, history)
fut = _pool.submit(_llm_once, store, cfg, model, message, history, look)
try:
out = fut.result(timeout=min(LLM_TIMEOUT, remaining))
except concurrent.futures.TimeoutError:
@ -554,7 +617,7 @@ def llm_agent(store, message, history, cfg):
LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1"
def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer"):
def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""):
actor = (actor or "demo-buyer")[:60]
t0 = time.time()
if event and (event.get("type") == "landing" or not LLM_EVENTS):
@ -568,7 +631,7 @@ def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer
if cfg:
text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
try:
return llm_agent(store, text, history or [], cfg)
return llm_agent(store, text, history or [], cfg, look)
except Exception as e:
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor)
out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__),

280
market/theme.py Normal file
View File

@ -0,0 +1,280 @@
"""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]

View File

@ -8,6 +8,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from market.store import Store, now, new_id
from market import agent as A
from market import payments
from market import theme as TH
HOST = os.environ.get("AGENT_MARKET_HOST", "127.0.0.1")
PORT = int(os.environ.get("AGENT_MARKET_PORT", "8796"))
@ -144,6 +145,11 @@ def hydrate(actions, actor):
continue
deal = r["deal"]
p.update(deal=deal, listing=store.get("listings", deal["listing_id"]), deal_id=deal["id"])
elif t == "set_theme":
act = TH.build_theme_action(p)
if not act:
continue
p = act
elif t == "show_receipt":
o = store.get("orders", p.get("order_id") or "")
if not o:
@ -322,7 +328,8 @@ class H(BaseHTTPRequestHandler):
msg = str(body.get("message") or "")[:2000]
ev = body.get("event") if isinstance(body.get("event"), dict) else None
hist = body.get("history") if isinstance(body.get("history"), list) else []
out = A.handle_chat(store, msg, hist, ev, actor)
look = TH.compact_current(body.get("theme")) if isinstance(body.get("theme"), dict) else ""
out = A.handle_chat(store, msg, hist, ev, actor, look)
out["actions"] = hydrate(out.get("actions"), actor)
out.pop("tools", None)
return self._json(200, out)

View File

@ -44,7 +44,7 @@
if (echo) say("user", echo);
const thinking = el("div", { class: "msg sys" }, "agent is thinking…"); log.append(thinking);
try {
const out = await api("/api/chat", { message, event, history: history.slice(-8), actor });
const out = await api("/api/chat", { message, event, history: history.slice(-6), actor, theme: AMTheme.get() });
thinking.remove();
if (message) history.push({ role: "user", content: message });
if (event) history.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) });
@ -154,6 +154,7 @@
el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(o.seller_receives_cents)))),
el("div", { class: "meta" }, `Order ${o.id} · ${o.method}${o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · ${o.status}`));
},
set_theme(p) { AMTheme.applyAction(p); toast(p.reset && !p.base && !Object.keys(p.patch || {}).length ? "Look reset to default" : "Look updated", "ok"); return null; },
clear_panel() { stage.innerHTML = ""; return null; },
toast(p) { toast(p.text || "", p.level || "info"); return null; },
};
@ -189,7 +190,7 @@
const fn = R[a.type]; if (!fn) continue;
const node = fn(a.payload || {});
if (!node) continue;
if (first) { stage.innerHTML = ""; first = false; }
if (first) { stage.innerHTML = ""; first = false; if (AMTheme.ensureResultsVisible()) toast("Results section shown again so you can see this", "info"); }
stage.append(node);
}
if (!first) stage.scrollIntoView({ behavior: "smooth", block: "start" });
@ -208,8 +209,19 @@
$("#chatInput").value = ""; talk({ message: v, echo: v });
});
const copyDefaults = (bps) => ({
logo: "Agent Market", headline: "A marketplace you can just talk to.",
subhead: "Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page. You can also restyle the whole page from the chat.",
chips: [{ label: "🚲 Show me bikes", say: "show me bikes" }, { label: "📐 Tutoring", say: "find tutoring services" },
{ label: "⚖️ Compare tutoring", say: "compare the two tutoring options" }, { label: "➕ Sell something", event: "sell" },
{ label: "🎨 Make it look retro", say: "make it look retro" }],
bullets: [`${(bps / 100).toFixed(bps % 100 ? 1 : 0)}% facilitation fee when you pay online (test mode)`, "$0 fee when you meet and pay cash in person",
"Agents welcome: other AI agents buy and sell through the open API (see For agents)"],
});
AMTheme.init(copyDefaults(feeBps));
$("#resetLook").addEventListener("click", () => { AMTheme.reset(); toast("Look reset to default", "ok"); });
api("/api/status").then((s) => {
feeBps = s.fee_bps; $("#feePct").textContent = (feeBps / 100).toFixed(feeBps % 100 ? 1 : 0) + "%";
feeBps = s.fee_bps; AMTheme.init(copyDefaults(feeBps));
if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model);
else { $("#agentBadge").textContent = "agent: rule-based fallback"; $("#agentBadge").title = s.agent.note || ""; }
});

View File

@ -5,39 +5,31 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Market (demo)</title>
<link rel="stylesheet" href="/static/styles.css">
<style id="user-css"></style>
</head>
<body>
<div class="testbar">TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<!-- Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. -->
<div class="testbar" data-protected>TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<header class="top">
<div class="brand"><span class="logo">◆</span> Agent Market</div>
<div class="brand"><span class="logo">◆</span> <span id="logoText">Agent Market</span></div>
<nav>
<button class="link" data-say="show me everything">Browse</button>
<button class="link" data-event="sell">Sell</button>
<a class="link" href="/agent.json" target="_blank">For agents</a>
<span id="agentBadge" class="badge">agent: …</span>
<button class="link" id="resetLook" title="Restore the default look">Reset look</button>
<span id="agentBadge" class="badge" data-protected>agent: …</span>
</nav>
</header>
<main>
<section class="landing" id="landing">
<div class="landing-copy">
<h1>A marketplace you can just talk to.</h1>
<p>Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page.</p>
<div class="chips">
<button class="chip" data-say="show me bikes">🚲 Show me bikes</button>
<button class="chip" data-say="find tutoring services">📐 Tutoring</button>
<button class="chip" data-say="compare tutoring">⚖️ Compare tutoring</button>
<button class="chip" data-event="sell">➕ Sell something</button>
</div>
<ul class="facts">
<li><b id="feePct">5%</b> facilitation fee when you pay online (test mode)</li>
<li><b>$0</b> fee when you meet and pay cash in person</li>
<li><b>Agents welcome:</b> other AI agents buy and sell through the <a href="/openapi.json" target="_blank">open API</a></li>
</ul>
</div>
<main id="page">
<section class="hero-sec" data-section="hero">
<h1 id="headline">A marketplace you can just talk to.</h1>
<p id="subhead">Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page. You can also restyle the whole page from the chat.</p>
</section>
<section id="stage" aria-live="polite"></section>
<div class="chips" data-section="chips" id="chips"></div>
<ul class="facts" data-section="bullets" id="bullets"></ul>
<section id="stage" data-section="results" aria-live="polite"></section>
<footer class="foot" data-section="footer" id="footer">Agent Market demo · fake test data · test-mode payments · <a href="/openapi.json" target="_blank">open API</a></footer>
</main>
<button id="chatLauncher" class="launcher" aria-label="Open the market agent">💬 Ask the agent</button>
@ -48,11 +40,12 @@
</div>
<div id="log" class="log"></div>
<form id="chatForm" class="chat-form">
<input id="chatInput" autocomplete="off" placeholder="e.g. find a bike under $200">
<input id="chatInput" autocomplete="off" placeholder="e.g. find a bike, or: make it look retro">
<button>Send</button>
</form>
</aside>
<div id="toasts"></div>
<script src="/static/theme.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -1,46 +1,91 @@
:root{--bg:#0f1115;--panel:#171a21;--card:#1d212a;--ink:#e9ecf1;--mute:#9aa3b2;--acc:#7c9cff;--ok:#38c172;--warn:#f6ad55;--line:#2a2f3a}
*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)}
/* Layer order: base (themeable via CSS variables) < user (agent's sanitised customCss) < protected.
customCss is stripped of !important and custom properties, so it can never beat the protected layer. */
@layer base, user, protected;
@layer base {
:root{--bg:#0f1115;--page-bg:#0f1115;--panel:#171a21;--card:#1d212a;--ink:#e9ecf1;--mute:#9aa3b2;--acc:#7c9cff;--acc-ink:#0b0e16;--line:#2a2f3a;--ok:#38c172;--warn:#f6ad55;
--font:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;--hfont:var(--font);--scale:1;--radius:14px;--space:1;--cols:auto-fill;
--grid-cols:repeat(auto-fill,minmax(220px,1fr));--chat-bg:var(--panel);--chat-ink:var(--ink);--chat-acc:var(--acc);--chat-acc-ink:var(--acc-ink);--chat-w:370px;--chat-h:520px;
--p-bg:#171a21;--p-ink:#ffffff}
*{box-sizing:border-box}
body{margin:0;font-family:var(--font);font-size:calc(15px * var(--scale));line-height:1.5;background:var(--page-bg);background-attachment:fixed;color:var(--ink);min-height:100vh}
h1,h2,h3{font-family:var(--hfont)}
a{color:var(--acc)}button{font:inherit;cursor:pointer}
.testbar{background:#3b2f0b;color:#ffd970;text-align:center;font-size:13px;padding:6px}
.top{display:flex;justify-content:space-between;align-items:center;padding:14px 28px;border-bottom:1px solid var(--line)}
.brand{font-weight:700;font-size:18px}.logo{color:var(--acc)}
.top{display:flex;justify-content:space-between;align-items:center;padding:calc(14px * var(--space)) 28px;border-bottom:1px solid var(--line)}
.brand{font-weight:700;font-size:1.2em}.logo{color:var(--acc)}
nav{display:flex;gap:14px;align-items:center}.link{background:none;border:0;color:var(--ink);text-decoration:none;padding:4px}
.badge{font-size:12px;border:1px solid var(--line);border-radius:999px;padding:3px 10px;color:var(--mute)}
main{max-width:1100px;margin:0 auto;padding:28px}
.landing{padding:28px 0 8px}.landing h1{font-size:40px;line-height:1.1;margin:0 0 10px}.landing p{color:var(--mute);max-width:640px}
.chips{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0}.chip{background:var(--card);border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:999px}
.chip:hover{border-color:var(--acc)}.facts{color:var(--mute);padding-left:18px}
#stage{margin-top:22px;display:flex;flex-direction:column;gap:18px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;animation:in .25s ease}
main{max-width:1100px;margin:0 auto;padding:calc(28px * var(--space)) 28px;display:flex;flex-direction:column;gap:calc(14px * var(--space))}
.hero-sec h1{font-size:2.65em;line-height:1.1;margin:calc(20px * var(--space)) 0 10px}.hero-sec p{color:var(--mute);max-width:660px;margin:0}
.chips{display:flex;flex-wrap:wrap;gap:10px}.chip{background:var(--card);border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:999px}
.chip:hover{border-color:var(--acc)}.facts{color:var(--mute);padding-left:18px;margin:0}
.foot{color:var(--mute);font-size:.85em;border-top:1px solid var(--line);padding-top:calc(14px * var(--space));margin-top:calc(20px * var(--space))}
.is-hidden{display:none !important}
#stage{display:flex;flex-direction:column;gap:calc(18px * var(--space))}
#stage:empty{display:none}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:calc(20px * var(--space));animation:in .25s ease}
@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1}}
.panel h2{margin:0 0 12px;font-size:20px}.panel .by{font-size:12px;color:var(--mute);margin-bottom:10px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:6px}
.card .emoji{font-size:30px}.card h3{margin:0;font-size:16px}.price{font-weight:700;font-size:18px}
.meta{font-size:12px;color:var(--mute)}.tag{display:inline-block;font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-right:4px}
.panel h2{margin:0 0 12px;font-size:1.35em}.panel .by{font-size:.8em;color:var(--mute);margin-bottom:10px}
.grid{display:grid;grid-template-columns:var(--grid-cols);gap:calc(14px * var(--space))}
.card{background:var(--card);border:1px solid var(--line);border-radius:calc(var(--radius) * .85);padding:calc(14px * var(--space));display:flex;flex-direction:column;gap:6px}
.card .emoji{font-size:2em}.card h3{margin:0;font-size:1.07em}.price{font-weight:700;font-size:1.2em}
body.card-flat .card{border-color:transparent;background:var(--card)}
body.card-outlined .card{background:transparent;border:2px solid var(--line)}
body.card-elevated .card{border-color:transparent;box-shadow:0 10px 28px rgba(0,0,0,.25)}
body.card-glass .card,body.card-glass .panel{background:color-mix(in srgb,var(--card) 55%,transparent);backdrop-filter:blur(10px);border-color:color-mix(in srgb,var(--line) 70%,transparent)}
body.layout-list .grid{grid-template-columns:1fr}
body.layout-list .card{flex-direction:row;align-items:center;gap:16px;flex-wrap:wrap}
body.layout-list .card h3{flex:1 1 220px}body.layout-list .card .actions{margin-top:0}
.meta{font-size:.8em;color:var(--mute)}.tag{display:inline-block;font-size:.73em;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-right:4px}
.agent-tag{border-color:#5a4bd1;color:#b7adff}.sold{opacity:.55}
.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto}
.btn{background:var(--acc);color:#0b0e16;border:0;border-radius:8px;padding:7px 11px;font-weight:600}
.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.cash{background:var(--ok)}
.hero{background:linear-gradient(135deg,#1c2340,#171a21)}.hero ul{color:var(--mute)}
.btn{background:var(--acc);color:var(--acc-ink);border:0;border-radius:calc(var(--radius) * .55);padding:7px 11px;font-weight:600}
.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.cash{background:var(--ok);color:#06210f}
.hero{background:linear-gradient(135deg,color-mix(in srgb,var(--acc) 22%,var(--panel)),var(--panel))}.hero ul{color:var(--mute)}
table.cmp{width:100%;border-collapse:collapse}table.cmp td,table.cmp th{border-bottom:1px solid var(--line);padding:8px;text-align:left;vertical-align:top}
.form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mute)}
.form .full{grid-column:1/-1}input,textarea,select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:9px;font:inherit}
.form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.form label{display:flex;flex-direction:column;gap:4px;font-size:.87em;color:var(--mute)}
.form .full{grid-column:1/-1}input,textarea,select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:calc(var(--radius) * .55);padding:9px;font:inherit}
.breakdown{width:100%;max-width:440px;border-collapse:collapse;margin:8px 0 14px}.breakdown td{padding:5px 0}.breakdown td:last-child{text-align:right}
.breakdown tr.total td{border-top:1px solid var(--line);font-weight:700;padding-top:8px}
.testcard{background:#2a230c;border:1px dashed #a88a2a;color:#ffd970;border-radius:8px;padding:8px 10px;font-size:13px;margin-bottom:10px}
.breakdown tr.total td{border-top:1px solid currentColor;font-weight:700;padding-top:8px}
.cardform{display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;max-width:440px}
.ok{color:var(--ok)}.warn{color:var(--warn)}
.launcher{position:fixed;right:22px;bottom:22px;background:var(--acc);color:#0b0e16;border:0;border-radius:999px;padding:12px 18px;font-weight:700;box-shadow:0 8px 30px #0008}
.chat{position:fixed;right:22px;bottom:22px;width:370px;max-width:calc(100vw - 30px);height:520px;max-height:calc(100vh - 60px);background:var(--panel);border:1px solid var(--line);border-radius:16px;display:flex;flex-direction:column;box-shadow:0 12px 40px #000a}
.launcher{position:fixed;right:22px;bottom:22px;background:var(--chat-acc);color:var(--chat-acc-ink);border:0;border-radius:999px;padding:12px 18px;font-weight:700;box-shadow:0 8px 30px #0008}
.chat{position:fixed;right:22px;bottom:22px;width:var(--chat-w);max-width:calc(100vw - 30px);height:var(--chat-h);max-height:calc(100vh - 60px);background:var(--chat-bg);color:var(--chat-ink);border:1px solid var(--line);border-radius:16px;display:flex;flex-direction:column;box-shadow:0 12px 40px #000a;z-index:5}
body.chat-left .chat,body.chat-left .launcher{right:auto;left:22px}
body.chat-left .cmpbar{left:auto;right:22px}
.chat.hidden{display:none}.chat-head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line)}
.chat-head button{background:none;border:0;color:var(--mute);font-size:16px}.sub{font-size:11px;color:var(--mute)}
.chat-head button{background:none;border:0;color:inherit;opacity:.7;font-size:16px}.sub{font-size:11px;opacity:.75}
.log{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
.msg{padding:8px 11px;border-radius:12px;max-width:85%;white-space:pre-wrap}.msg.user{align-self:flex-end;background:var(--acc);color:#0b0e16}
.msg.bot{align-self:flex-start;background:var(--card)}.msg.sys{align-self:center;font-size:12px;color:var(--mute)}
.chat-form{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line)}.chat-form input{flex:1}
.chat-form button{background:var(--acc);border:0;border-radius:8px;padding:0 14px;font-weight:600}
.msg{padding:8px 11px;border-radius:12px;max-width:85%;white-space:pre-wrap}.msg.user{align-self:flex-end;background:var(--chat-acc);color:var(--chat-acc-ink)}
.msg.bot{align-self:flex-start;background:color-mix(in srgb,var(--chat-ink) 10%,var(--chat-bg));color:var(--chat-ink)}.msg.sys{align-self:center;font-size:12px;opacity:.75}
.chat-form{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line)}.chat-form input{flex:1;background:color-mix(in srgb,var(--chat-ink) 6%,var(--chat-bg));color:var(--chat-ink)}
.chat-form button{background:var(--chat-acc);color:var(--chat-acc-ink);border:0;border-radius:8px;padding:0 14px;font-weight:600}
#toasts{position:fixed;left:50%;top:50px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;z-index:9}
.toast{background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px}.toast.ok{border-color:var(--ok)}.toast.warn{border-color:var(--warn)}
.toast{background:var(--card);color:var(--ink);border:1px solid var(--line);padding:8px 14px;border-radius:8px}.toast.ok{border-color:var(--ok)}.toast.warn{border-color:var(--warn)}
.cmpbar{position:fixed;left:22px;bottom:22px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px 12px;display:none;gap:8px;align-items:center}
@media(max-width:640px){.landing h1{font-size:28px}.form{grid-template-columns:1fr}.top{padding:12px}main{padding:16px}}
body.no-anim *,body.no-anim *::before,body.no-anim *::after{animation:none !important;transition:none !important}
@media(max-width:640px){.hero-sec h1{font-size:1.9em}.form{grid-template-columns:1fr}.top{padding:12px}main{padding:16px}}
}
@layer protected {
/* Guardrails: fixed, high-contrast colours; nothing in a theme or customCss can change these. */
.testbar{display:block;visibility:visible;opacity:1;filter:none;background:#3b2f0b;color:#ffd970;text-align:center;
font:600 13px/1.4 system-ui,sans-serif;padding:6px;letter-spacing:normal;text-transform:none;text-shadow:none;margin:0;border:0}
.badge{display:inline-block;visibility:visible;opacity:1;filter:none;background:#10131a;color:#ffffff;border:1px solid #5b6475;
border-radius:999px;padding:3px 10px;font:500 12px/1.4 system-ui,sans-serif;text-shadow:none;letter-spacing:normal;text-transform:none}
#stage{visibility:visible;opacity:1;filter:none;max-height:none;overflow:visible}
[data-component=checkout],[data-component=receipt],[data-component=cash-meetup]{
display:block;visibility:visible;opacity:1;filter:none;backdrop-filter:none;max-height:none;height:auto;overflow:visible;
background:var(--p-bg);color:var(--p-ink);border:2px solid var(--p-ink);text-shadow:none;letter-spacing:normal;
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;margin:0}
[data-component=checkout] *,[data-component=receipt] *,[data-component=cash-meetup] *{
visibility:visible;opacity:1;filter:none;text-shadow:none;letter-spacing:normal;text-transform:none;font-family:inherit;color:inherit}
[data-component=checkout] .meta,[data-component=receipt] .meta,[data-component=cash-meetup] .meta{color:var(--p-ink);font-size:13px}
[data-component=checkout] .breakdown,[data-component=receipt] .breakdown,[data-component=cash-meetup] .breakdown{display:table;font-size:15px;background:transparent}
.breakdown tr{display:table-row}.breakdown td{display:table-cell;font-size:inherit;color:var(--p-ink);background:transparent}
[data-component=checkout] .testcard{display:block;background:#2a230c;color:#ffd970;border:1px dashed #a88a2a;font-size:13px}
[data-component=checkout] .cardform{display:grid}
[data-component=checkout] input{display:block;background:#ffffff;color:#111111;border:1px solid #555555;font-size:15px}
[data-component=checkout] .btn,[data-component=cash-meetup] .btn{display:inline-block;background:var(--p-ink);color:var(--p-bg);border:0}
[data-component=checkout] a,[data-component=cash-meetup] a{color:var(--p-ink);text-decoration:underline}
[data-component=receipt] h2.ok{color:var(--p-ink)}
}

163
static/theme.js Normal file
View File

@ -0,0 +1,163 @@
// 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,
};
})();

44
tests/test_theme.py Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Unit checks for theme validation and the customCss sanitiser."""
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from market.theme import sanitize_css, sanitize_patch, build_theme_action, PRESETS
fails = 0
def check(name, cond):
global fails
fails += not cond
print(("PASS " if cond else "FAIL ") + name)
evil = ("@import url(https://evil.example/x.css); .card{color:red!important;position:fixed;z-index:99;--p-ink:transparent;"
"background:url(https://evil.example/t.png)} body{display:none} :root{--acc:red} h1{background:url(https://images.unsplash.com/a.jpg);margin:-500px}"
" <script>alert(1)</script> .x{color:expression(alert(1))} .y{behavior:url(x.htc)} .z::before{content:'x'}"
" .t{transform:scale(0)} .k{color:blue}")
out = sanitize_css(evil)
check("css: @import stripped", "@" not in out and "evil.example/x.css" not in out)
check("css: !important stripped", "important" not in out)
check("css: position/z-index/content/transform dropped", not any(k in out for k in ("position", "z-index", "content", "transform")))
check("css: custom properties dropped", "--" not in out)
check("css: non-allowlisted url() neutralised", "evil.example" not in out and "url(https://images.unsplash.com/a.jpg)" in out)
check("css: body/:root selectors dropped", "body" not in out and ":root" not in out)
check("css: script/expression/behavior gone", not any(k in out.lower() for k in ("<script", "expression", "behavior", "alert(1)")))
check("css: negative margins dropped", "-500px" not in out)
check("css: harmless rule kept", ".k{color:blue}" in out)
p = sanitize_patch({"mode": "light", "palette": {"accent": "green", "text": "red;}body{x"}, "font": "comic sans", "fontScale": 9,
"radius": -5, "columns": 12, "layout": "list", "chat": {"position": "left", "width": 5000},
"copy": {"headline": "<b>Hi</b> there", "chips": ["Bikes", {"label": "Garden", "say": "garden stuff"}]},
"sections": {"hidden": ["bullets", "nope"], "order": ["results", "hero"]}, "evil": "x",
"background": {"kind": "image", "url": "https://evil.example/bg.jpg"}})
check("patch: bad colour dropped, good kept", p["palette"] == {"accent": "green"})
check("patch: font alias resolved", p["font"] == "Comic Neue")
check("patch: numbers clamped", p["fontScale"] == 1.75 and p["radius"] == 0 and p["columns"] == 6 and p["chat"]["width"] == 600)
check("patch: html stripped from copy", "<" not in p["copy"]["headline"])
check("patch: sections validated", p["sections"]["hidden"] == ["bullets"] and p["sections"]["order"][:2] == ["results", "hero"])
check("patch: unknown keys and non-allowlisted image dropped", "evil" not in p and "background" not in p)
a = build_theme_action({"preset": "Retro"})
check("preset: retro expands", a["preset"] == "retro" and a["base"]["font"] == "VT323")
check("preset: alias", build_theme_action({"preset": "cyberpunk"})["preset"] == "neon")
check("reset action", build_theme_action({"reset": True})["reset"] is True)
check("all presets validate cleanly", all(sanitize_patch(v) for v in PRESETS.values()))
sys.exit(1 if fails else 0)

126
tests/theme_check.js Normal file
View File

@ -0,0 +1,126 @@
// Chat-driven restyle walkthrough + guardrail checks. Usage:
// NODE_PATH=<dir with puppeteer-core> node tests/theme_check.js BASE_URL [screenshot_dir]
const puppeteer = require("puppeteer-core");
const BASE = (process.argv[2] || "http://127.0.0.1:8795").replace(/\/$/, "");
const SHOTS = process.argv[3] || "";
const results = []; const replies = [];
const check = (name, ok, d = "") => { results.push(!!ok); console.log((ok ? "PASS " : "FAIL ") + name + (d ? ` [${d}]` : "")); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({ executablePath: process.env.CHROME || "/usr/bin/google-chrome", headless: "new",
args: ["--no-sandbox", "--disable-gpu", "--user-data-dir=/tmp/am-theme-profile-" + Date.now()] });
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
await page.setViewport({ width: 1280, height: 860 });
const errors = []; page.on("pageerror", (e) => errors.push(e.message));
await page.goto(BASE + "/", { waitUntil: "networkidle0" });
await page.evaluate(() => localStorage.removeItem("am_theme_v1"));
await page.reload({ waitUntil: "networkidle0" });
await page.waitForSelector(".card", { timeout: 20000 });
await page.click("#chatLauncher");
const ask = async (text) => {
for (let attempt = 0; attempt < 4; attempt++) {
const n = (await page.$$(".msg.bot")).length; const t0 = Date.now();
await page.type("#chatInput", text); await page.keyboard.press("Enter");
await page.waitForFunction((n) => document.querySelectorAll(".msg.bot").length > n, { timeout: 60000 }, n);
await sleep(500);
const last = await page.$$eval(".msg.bot", (ns) => ns[ns.length - 1].textContent);
if (/sending messages quickly/.test(last)) { const s = +(last.match(/(\d+)s/) || [0, 6])[1]; await sleep((s + 1) * 1000); continue; }
const mode = await page.$eval("#chatMode", (n) => n.textContent);
replies.push({ text, reply: last, ms: Date.now() - t0, mode });
console.log(` > ${text}\n ${mode} · ${Date.now() - t0}ms · "${last.slice(0, 160)}"`);
return last;
}
};
const theme = () => page.evaluate(() => JSON.parse(localStorage.getItem("am_theme_v1") || "{}"));
const cs = (sel, prop) => page.$eval(sel, (n, p) => getComputedStyle(n)[p], prop);
// Guardrails: banner, badge and checkout fee lines must stay visible and >= 4.5:1 contrast.
async function guardrails(label) {
// open a checkout for the first online-payable item via the deterministic click path
await page.evaluate(() => { const b = [...document.querySelectorAll("#stage .card button.btn:not(.cash)")][0]; if (b) b.click(); });
const hasCheckout = await page.waitForSelector("[data-component=checkout]", { timeout: 15000 }).then(() => true, () => false);
const r = await page.evaluate(() => {
const eff = (n) => { while (n) { const c = getComputedStyle(n).backgroundColor; if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c; n = n.parentElement; } return "rgb(255,255,255)"; };
const vis = (n) => { const s = getComputedStyle(n); const r = n.getBoundingClientRect(); return s.display !== "none" && s.visibility === "visible" && +s.opacity === 1 && r.width > 0 && r.height > 0; };
const out = {};
for (const [k, sel] of [["banner", ".testbar"], ["badge", "#agentBadge"], ["fee", "[data-testid=fee]"], ["total", "[data-testid=total]"], ["testcard", ".testcard"]]) {
const n = document.querySelector(sel); if (!n) { out[k] = null; continue; }
out[k] = { c: window.__amContrast(getComputedStyle(n).color, eff(n)), vis: vis(n), text: n.textContent.trim().slice(0, 40), fs: parseFloat(getComputedStyle(n).fontSize) };
}
out.input = (() => { const i = document.querySelector("[data-component=checkout] input"); return i && { vis: vis(i), c: window.__amContrast(getComputedStyle(i).color, getComputedStyle(i).backgroundColor) }; })();
return out;
});
const ok = hasCheckout && ["banner", "badge", "fee", "total", "testcard"].every((k) => r[k] && r[k].vis && r[k].c >= 4.5 && r[k].fs >= 12) && r.input && r.input.vis && r.input.c >= 4.5;
check(`guardrails intact under ${label}`, ok, ["banner", "badge", "fee", "total"].map((k) => r[k] ? `${k} ${r[k].c.toFixed(1)}:1` : `${k} missing`).join(", ") + (r.fee ? ` fee="${r.fee.text}"` : ""));
const feeOk = r.fee && r.total && /^\$\d+\.\d\d$/.test(r.fee.text) && r.banner && /TEST DEMO/.test(r.banner.text);
check(`fee amount + banner text unchanged under ${label}`, feeOk);
}
await ask("show me bikes");
await page.waitForSelector("#stage .card", { timeout: 15000 });
await ask("make the background a sunset gradient");
const bg1 = await cs("body", "backgroundImage");
check("sunset gradient background", /gradient/.test(bg1), bg1.slice(0, 90));
if (SHOTS) await page.screenshot({ path: SHOTS + "/6-theme-sunset-gradient.png" });
await guardrails("sunset gradient");
await ask("show me everything");
await ask("switch to light mode with a green accent");
const t2 = await theme();
const acc = await page.evaluate(() => { const s = document.createElement("i"); s.style.color = getComputedStyle(document.documentElement).getPropertyValue("--acc"); document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; });
const [r, g, b] = acc.match(/\d+/g).map(Number);
check("light mode with green accent", t2.mode === "light" && g > r && g > b, `mode=${t2.mode} accent=${acc}`);
if (SHOTS) await page.screenshot({ path: SHOTS + "/7-theme-light-green.png" });
await guardrails("light + green");
await ask("use a serif font and make the text bigger");
const ff = await cs("body", "fontFamily"); const t3 = await theme();
check("serif font + bigger text", /Georgia|serif|Lora|Merriweather|Playfair|Roboto Slab/i.test(ff) && (t3.fontScale || 1) > 1, `${ff.slice(0, 40)} scale=${t3.fontScale}`);
await ask("show results as a list, not cards");
await ask("show me everything");
const cols = await page.$eval("#stage .grid", (n) => getComputedStyle(n).gridTemplateColumns.split(" ").length).catch(() => 0);
check("results as a list", (await page.$eval("body", (n) => n.classList.contains("layout-list"))) && cols === 1, `columns=${cols}`);
await ask("move the chat to the left");
const left = await page.$eval("#chat", (n) => n.getBoundingClientRect().left);
check("chat moved to the left", left < 100, `left=${left}`);
await ask("make it look retro");
const ff2 = await cs("body", "fontFamily"); const t4 = await theme();
check("retro preset applied", /VT323|Press Start|Space Mono|mono|Courier/i.test(ff2) || /retro/i.test(JSON.stringify(t4)), ff2.slice(0, 50));
if (SHOTS) { await ask("show me everything"); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: SHOTS + "/8-theme-retro.png" }); }
await guardrails("retro");
await ask("hide the bullet points");
check("bullets hidden", (await cs("#bullets", "display")) === "none");
await ask("change the headline to Buy and sell anything nearby");
const h = await page.$eval("#headline", (n) => n.textContent);
check("headline changed", /buy and sell anything nearby/i.test(h), h);
// A second, fresh session must still see the default look.
const ctx2 = await browser.createBrowserContext(); const p2 = await ctx2.newPage();
await p2.goto(BASE + "/", { waitUntil: "networkidle0" });
const fresh = await p2.evaluate(() => ({ h: document.getElementById("headline").textContent, bg: getComputedStyle(document.body).backgroundImage, bul: getComputedStyle(document.getElementById("bullets")).display, ls: localStorage.getItem("am_theme_v1") }));
check("fresh session sees default look", /just talk to/.test(fresh.h) && fresh.bg === "none" && fresh.bul !== "none" && !fresh.ls, JSON.stringify(fresh).slice(0, 120));
await ctx2.close();
await ask("reset the look");
const t5 = await theme(); const h2 = await page.$eval("#headline", (n) => n.textContent);
check("reset the look", Object.keys(t5).length === 0 && /just talk to/.test(h2), JSON.stringify(t5));
check("no internal ids in replies", replies.every((x) => !/\b(lst|pi|cash|ord)_[0-9a-f]{6,}/.test(x.reply)));
check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), [...new Set(replies.map((x) => x.mode.split(" · ")[1]))].join(","));
check("no page JS errors", errors.length === 0, errors.join("; "));
const ms = replies.map((x) => x.ms).sort((a, b) => a - b);
console.log(`\nmodel replies: median ${(ms[ms.length >> 1] / 1000).toFixed(1)}s, max ${(ms[ms.length - 1] / 1000).toFixed(1)}s`);
await browser.close();
const passed = results.filter(Boolean).length;
console.log(`${passed}/${results.length} passed`);
process.exit(passed === results.length ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(2); });