React + Vite frontend (WIP): component registry, theme engine port, server serves static/dist, deploy builds

This commit is contained in:
leora 2026-10-08 19:46:14 +08:00
parent c3d6871407
commit e62c9af423
24 changed files with 2497 additions and 467 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ __pycache__/
node_modules/
screenshots/
.env
static/dist/

View File

@ -22,6 +22,11 @@ set -a
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env"
[ -f "$CONF/$ENVN.env" ] && . "$CONF/$ENVN.env"
set +a
# Build the React frontend (web/ -> static/dist) BEFORE stopping the running server, so a failed build
# leaves the current version up. Reproducible: exact versions from web/package-lock.json via `npm ci`.
command -v npm >/dev/null || { echo "npm not found: Node 20+ is needed to build the frontend"; exit 1; }
( cd web && npm ci --no-audit --no-fund --loglevel=error && npm run build --silent ) || { echo "frontend build failed; still running the previous version"; exit 1; }
[ -f static/dist/index.html ] || { echo "build produced no static/dist/index.html"; exit 1; }
PID=$(ss -ltnp 2>/dev/null | grep ":$PORT " | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)
[ -n "$PID" ] && kill "$PID" && sleep 0.5
AGENT_MARKET_PORT=$PORT AGENT_MARKET_ENV=$ENVN AGENT_MARKET_DATA=$ROOT/data \

View File

@ -14,7 +14,9 @@ HOST = os.environ.get("AGENT_MARKET_HOST", "127.0.0.1")
PORT = int(os.environ.get("AGENT_MARKET_PORT", "8796"))
DATA_DIR = os.environ.get("AGENT_MARKET_DATA", os.path.join(os.path.dirname(os.path.abspath(__file__)), "data"))
ENV_LABEL = os.environ.get("AGENT_MARKET_ENV", "demo")
# Frontend: the React/Vite build in static/dist (built from web/ by deploy.sh; see README "Frontend").
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
INDEX = os.path.join("dist", "index.html")
WEBHOOK_SECRET = os.environ.get("AGENT_MARKET_WEBHOOK_SECRET") or secrets.token_hex(16) # per-process if unset
MAX_BODY = 64 * 1024
CHAT_RPM = int(os.environ.get("AGENT_MARKET_CHAT_RPM", "12")) # per-IP chat requests per minute
@ -264,10 +266,13 @@ class H(BaseHTTPRequestHandler):
def _static(self, rel):
path = os.path.normpath(os.path.join(STATIC, rel))
if not path.startswith(STATIC) or not os.path.isfile(path):
if not path.startswith(STATIC + os.sep) or not os.path.isfile(path):
if rel == INDEX:
return self._json(503, {"error": "frontend_not_built", "hint": "cd web && npm ci && npm run build"})
return self._json(404, {"error": "not_found"})
ctype = {".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css",
".svg": "image/svg+xml", ".png": "image/png"}.get(os.path.splitext(path)[1], "application/octet-stream")
".svg": "image/svg+xml", ".png": "image/png", ".json": "application/json",
".ico": "image/x-icon", ".woff2": "font/woff2"}.get(os.path.splitext(path)[1], "application/octet-stream")
with open(path, "rb") as f:
b = f.read()
self.send_response(200)
@ -281,7 +286,7 @@ class H(BaseHTTPRequestHandler):
u = urllib.parse.urlparse(self.path)
path, qs = u.path, dict(urllib.parse.parse_qsl(u.query))
if path in ("/", "/index.html"):
return self._static("index.html")
return self._static(INDEX)
if path.startswith("/static/"):
return self._static(path[len("/static/"):])
if path in ("/agent.json", "/.well-known/agent.json"):

View File

@ -1,238 +0,0 @@
// Agent Market frontend: renders UI actions chosen by the agent. Card data goes only to the test payment endpoint.
(() => {
const $ = (s) => document.querySelector(s);
const stage = $("#stage"), log = $("#log");
const actor = localStorage.getItem("am_actor") || ("guest-" + Math.random().toString(36).slice(2, 7));
localStorage.setItem("am_actor", actor);
const history = [];
let feeBps = 500;
const compareSet = new Set();
const money = (c) => "$" + (c / 100).toFixed(2);
function el(tag, attrs = {}, ...kids) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") n.className = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else if (v !== undefined && v !== null) n.setAttribute(k, v);
}
for (const k of kids.flat()) if (k !== null && k !== undefined) n.append(k.nodeType ? k : document.createTextNode(String(k)));
return n;
}
function toast(text, level = "info") {
const t = el("div", { class: "toast " + level }, text);
$("#toasts").append(t); setTimeout(() => t.remove(), 3500);
}
function say(role, text) {
log.append(el("div", { class: "msg " + role }, text)); log.scrollTop = log.scrollHeight;
}
function openChat() { $("#chat").classList.remove("hidden"); $("#chatLauncher").style.display = "none"; }
async function api(path, body) {
const r = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Id": actor }, body: JSON.stringify(body) } : { headers: { "X-Agent-Id": actor } });
return r.json();
}
function setModel(model, ms) {
const short = (model || "").replace(/:free$/, "");
$("#agentBadge").textContent = "AI: " + short;
$("#agentBadge").title = (model || "") + " via OpenRouter (free tier)";
$("#chatMode").textContent = `AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`;
}
async function talk({ message, event, echo }) {
openChat();
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(-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) });
history.push({ role: "assistant", content: out.reply || "" });
say("bot", out.reply || "(no reply)");
if (out.agent === "llm") setModel(out.model, out.latency_ms);
else if (out.agent === "fallback") {
$("#chatMode").textContent = "built-in agent answered (AI model unavailable)";
$("#agentBadge").textContent = "agent: built-in (AI unavailable)"; $("#agentBadge").title = "The hosted model is rate-limited or out of free quota";
}
else if (out.agent === "rate-limit") toast(out.reply, "warn");
render(out.actions || []);
} catch (e) { thinking.remove(); say("sys", "Agent error: " + e.message); }
}
// ---------- component renderers ----------
const R = {
show_hero(p) {
return el("section", { class: "panel hero" }, el("h2", {}, p.title || "Agent Market"),
el("p", {}, p.subtitle || ""), el("ul", {}, (p.bullets || []).map((b) => el("li", {}, b))));
},
show_cards(p) {
return el("section", { class: "panel", "data-component": "cards" }, el("h2", {}, p.heading || "Listings"),
el("div", { class: "by" }, "Rendered by the agent"),
el("div", { class: "grid" }, (p.listings || []).map(card)));
},
show_compare(p) {
const L = p.listings || [], Q = p.quotes || [];
const row = (label, f) => el("tr", {}, el("th", {}, label), L.map((x, i) => el("td", {}, f(x, Q[i] || {}))));
return el("section", { class: "panel", "data-component": "compare" }, el("h2", {}, p.heading || "Compare"),
el("table", { class: "cmp" },
el("tr", {}, el("th", {}, ""), L.map((x) => el("th", {}, x.emoji + " " + x.title))),
row("Type", (x) => x.kind), row("Price", (x) => money(x.price)),
row("Pay online (test)", (x, q) => q.online && q.online.available ? `${money(q.online.total_cents)} incl. ${money(q.online.fee_cents)} fee` : "not offered"),
row("Cash in person", (x, q) => q.cash_in_person && q.cash_in_person.available ? `${money(q.cash_in_person.total_cents)} · $0 fee` : "not offered"),
row("Seller", (x) => x.seller + (x.seller_type === "agent" ? " 🤖" : "")),
row("Area", (x) => x.area), row("", (x) => buyButtons(x))));
},
show_listing_form(p) {
const d = p.defaults || {};
const payOpts = Array.isArray(d.payment_options) && d.payment_options.length ? d.payment_options : ["online", "cash"];
const f = el("form", { class: "form", "data-component": "listing-form" },
el("label", { class: "full" }, "Title", el("input", { name: "title", required: "", value: d.title || "", placeholder: "e.g. Vintage desk chair" })),
el("label", {}, "Type", (() => { const s = el("select", { name: "kind" }, el("option", { value: "product" }, "Product"), el("option", { value: "service" }, "Service")); s.value = d.kind || "product"; return s; })()),
el("label", {}, "Price (USD)", el("input", { name: "price", type: "number", step: "0.01", min: "0", required: "", value: d.price ? (d.price / 100).toFixed(2) : "" })),
el("label", {}, "Category", el("input", { name: "category", value: d.category || "" })),
el("label", {}, "Area (approximate)", el("input", { name: "area", value: d.area || "Demo Town" })),
el("label", { class: "full" }, "Description", el("textarea", { name: "description", rows: "3", required: "" }, d.description || "")),
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "online", checked: payOpts.includes("online") ? "" : null }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)),
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: payOpts.includes("cash") ? "" : null }), " Accept cash in person (free)")),
el("div", { class: "full" }, el("button", { class: "btn", type: "submit" }, "Publish test listing")));
f.addEventListener("submit", (e) => {
e.preventDefault();
const fd = new FormData(f);
const fields = { title: fd.get("title"), kind: fd.get("kind"), price: fd.get("price"), category: fd.get("category"),
area: fd.get("area"), description: fd.get("description"),
payment_options: [fd.get("online") && "online", fd.get("cash") && "cash"].filter(Boolean) };
talk({ event: { type: "submit_listing", fields }, echo: `Publish “${fields.title}”` });
});
return el("section", { class: "panel" }, el("h2", {}, "List something"), el("div", { class: "by" }, "Form opened by the agent"), f);
},
show_checkout(p) {
const it = p.intent, L = p.listing || {};
const status = el("div", { class: "meta" });
const card = el("input", { name: "card", value: "4242 4242 4242 4242", inputmode: "numeric", "aria-label": "Test card number" });
const exp = el("input", { value: "12/34", "aria-label": "Expiry" }), cvc = el("input", { value: "123", "aria-label": "CVC" });
const payBtn = el("button", { class: "btn", type: "submit" }, `Pay ${money(it.total_cents)} (test)`);
const form = el("form", {}, el("div", { class: "testcard" }, "TEST MODE: no real charge. Success: 4242 4242 4242 4242 · Decline: 4000 0000 0000 0002. Other numbers are refused."),
el("div", { class: "cardform" }, card, exp, cvc), el("div", { style: "margin-top:10px" }, payBtn), status);
form.addEventListener("submit", async (e) => {
e.preventDefault(); payBtn.disabled = true; status.textContent = "Contacting test payment provider…";
const r = await api(`/api/payments/${it.id}/confirm`, { card_number: card.value });
status.textContent = r.status === "succeeded" ? "Paid (test)." : "Declined: " + (r.error || "unknown");
status.className = "meta " + (r.status === "succeeded" ? "ok" : "warn");
payBtn.disabled = r.status === "succeeded";
talk({ event: { type: "payment_result", intent_id: it.id, status: r.status, order_id: r.order && r.order.id, error: r.error } });
});
return el("section", { class: "panel", "data-component": "checkout" }, el("h2", {}, "Checkout"),
el("div", { class: "by" }, `${L.emoji || ""} ${it.title} · sold by ${L.seller || "?"}`),
el("table", { class: "breakdown" },
el("tr", {}, el("td", {}, `Price${it.quantity > 1 ? " × " + it.quantity : ""}`), el("td", {}, money(it.subtotal_cents))),
el("tr", {}, el("td", {}, `Facilitation fee (${(it.fee_bps / 100).toFixed(1)}%, online payments only)`), el("td", { "data-testid": "fee" }, money(it.fee_cents))),
el("tr", { class: "total" }, el("td", {}, "You pay"), el("td", { "data-testid": "total" }, money(it.total_cents))),
el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(it.subtotal_cents)))),
form,
"cash" in Object.fromEntries((L.payment_options || []).map((x) => [x, 1])) ?
el("p", { class: "meta" }, "Prefer to skip the fee? ", el("a", { href: "#", onclick: (e) => { e.preventDefault(); talk({ event: { type: "buy_cash", listing_id: L.id }, echo: "Pay cash in person instead" }); } }, "Meet and pay cash in person ($0 fee)")) : null);
},
show_cash_meetup(p) {
const d = p.deal, L = p.listing || {};
const done = el("button", { class: "btn cash" }, "Mark paid in cash");
done.addEventListener("click", () => { done.disabled = true; talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); });
return el("section", { class: "panel", "data-component": "cash-meetup" }, el("h2", {}, "Cash in person: no fee"),
el("div", { class: "by" }, `${L.emoji || ""} ${d.title} · seller ${d.seller}`),
el("table", { class: "breakdown" },
el("tr", {}, el("td", {}, "Price (pay the seller directly)"), el("td", {}, money(d.price_cents))),
el("tr", {}, el("td", {}, "Platform fee"), el("td", { "data-testid": "cash-fee" }, money(d.fee_cents))),
el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(d.price_cents)))),
el("p", {}, `Suggested meetup: ${d.place} · ${d.time}`),
el("p", { class: "meta" }, "We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)"),
el("div", { class: "meta" }, "Deal " + d.id + " · status " + d.status), el("div", { style: "margin-top:10px" }, done));
},
show_receipt(p) {
const o = p.order;
return el("section", { class: "panel", "data-component": "receipt" }, el("h2", { class: "ok" }, o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"),
el("table", { class: "breakdown" },
el("tr", {}, el("td", {}, o.title), el("td", {}, money(o.subtotal_cents))),
el("tr", {}, el("td", {}, "Facilitation fee"), el("td", { "data-testid": "receipt-fee" }, money(o.fee_cents))),
el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(o.total_cents))),
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; },
};
function buyButtons(x) {
const opts = x.payment_options || [];
if (x.status !== "active") return el("span", { class: "meta" }, "Sold");
return el("div", { class: "actions" },
opts.includes("online") ? el("button", { class: "btn", onclick: () => talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` }) }, "Buy online") : null,
opts.includes("cash") ? el("button", { class: "btn cash", onclick: () => talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` }) }, "Cash meetup") : null);
}
function card(x) {
const cb = el("input", { type: "checkbox", "aria-label": "compare" });
cb.checked = compareSet.has(x.id);
cb.addEventListener("change", () => { cb.checked ? compareSet.add(x.id) : compareSet.delete(x.id); updateCompareBar(); });
return el("div", { class: "card" + (x.status !== "active" ? " sold" : ""), "data-listing": x.id },
el("div", { class: "emoji" }, x.emoji || "📦"), el("h3", {}, x.title),
el("div", { class: "price" }, money(x.price), el("span", { class: "meta" }, x.kind === "service" ? " / session" : "")),
el("div", {}, el("span", { class: "tag" }, x.kind), x.seller_type === "agent" ? el("span", { class: "tag agent-tag" }, "sold by an agent") : null,
el("span", { class: "tag" }, "test listing")),
el("div", { class: "meta" }, x.area + " · " + x.seller),
el("label", { class: "meta" }, cb, " compare"), buyButtons(x));
}
const cmpbar = el("div", { class: "cmpbar" }); document.body.append(cmpbar);
function updateCompareBar() {
cmpbar.innerHTML = ""; cmpbar.style.display = compareSet.size ? "flex" : "none";
cmpbar.append(`${compareSet.size} selected`, el("button", { class: "btn", onclick: () => talk({ event: { type: "compare", listing_ids: [...compareSet] }, echo: "Compare selected" }) }, "Compare"));
}
function render(actions) {
let first = true;
for (const a of actions) {
const fn = R[a.type]; if (!fn) continue;
const node = fn(a.payload || {});
if (!node) continue;
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" });
}
// ---------- wiring ----------
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-say],[data-event]"); if (!b) return;
if (b.dataset.say) talk({ message: b.dataset.say, echo: b.dataset.say });
else talk({ event: { type: b.dataset.event }, echo: b.textContent.trim() });
});
$("#chatLauncher").addEventListener("click", openChat);
$("#chatClose").addEventListener("click", () => { $("#chat").classList.add("hidden"); $("#chatLauncher").style.display = ""; });
$("#chatForm").addEventListener("submit", (e) => {
e.preventDefault(); const v = $("#chatInput").value.trim(); if (!v) return;
$("#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; AMTheme.init(copyDefaults(feeBps));
if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc) { $("#agentBadge").textContent = "agent: built-in (AI quota used up)"; $("#agentBadge").title = "Hosted model free quota resets " + s.agent.daily_quota_exhausted_until_utc; }
else 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 || ""; }
});
// Landing: show featured listings via the agent.
(async () => {
const out = await api("/api/chat", { event: { type: "landing" }, actor });
render(out.actions || []);
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
})();
})();

View File

@ -1,51 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<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>
<!-- 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> <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>
<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 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>
<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>
<aside id="chat" class="chat hidden" aria-label="Market agent chat">
<div class="chat-head">
<div><b>Market Guide</b><div class="sub" id="chatMode">AI agent</div></div>
<button id="chatClose" aria-label="Close">✕</button>
</div>
<div id="log" class="log"></div>
<form id="chatForm" class="chat-form">
<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,174 +0,0 @@
// 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)));
let pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff");
// busy gradients (light and dark stops) can't be fixed by text colour alone: put a readability scrim behind page text
const scrim = minC(pageInk) < 4.5;
body.classList.toggle("scrim", scrim);
if (scrim) { set("--scrim", solid(pal.surface)); pageInk = bestInk(solid(pal.surface)); }
set("--page-ink", pageInk); set("--page-mute", !scrim && 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,
};
})();

View File

@ -28,7 +28,12 @@ def check(name, cond, detail=""):
s, html = call("GET", "/", raw=True)
check("landing page loads", s == 200 and "Agent Market" in html and "chatLauncher" in html)
import re
check("landing page loads (React SPA shell)", s == 200 and "Agent Market" in html and '<div id="root">' in html)
assets = re.findall(r'(?:src|href)="(/static/dist/assets/[^"]+)"', html)
js = [a for a in assets if a.endswith(".js")]
bundle = call("GET", js[0], raw=True) if js else (0, "")
check("built JS bundle served", bundle[0] == 200 and "chatLauncher" in bundle[1] and "show_checkout" in bundle[1], f"assets={len(assets)}")
s, st = call("GET", "/api/status")
check("status endpoint", s == 200 and st["payments"]["mode"] == "test", f"agent={st['agent']['mode']} fee_bps={st['fee_bps']}")
fee_bps = st["fee_bps"]

16
web/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Market (demo)</title>
<!-- Cascade-layer order must be declared before any layered CSS: base < user (customCss) < protected (banner, badge, fee panels). -->
<style>@layer base, user, protected;</style>
<!-- Sanitised, scoped per-visitor customCss lands here inside @layer user (see src/theme/engine.ts). -->
<style id="user-css"></style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1722
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "agent-market-web",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "5.9",
"vite": "^7.3.7"
}
}

178
web/src/App.tsx Normal file
View File

@ -0,0 +1,178 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { actor, api } from "./api";
import { Ctx, type AgentCtx, type Talk } from "./context";
import { EFFECTS, PANELS } from "./components/registry";
import { Chat, type Msg } from "./components/Chat";
import {
applyThemeToDocument, contrast, loadTheme, reduceThemeAction, resolveCopy, resolveSections, saveTheme,
type Copy, type Section, type Theme, type ThemeAction,
} from "./theme/engine";
import type { ChatReply, Status, UIAction } from "./types";
declare global { interface Window { __amContrast: typeof contrast } }
window.__amContrast = contrast; // used by the guardrail tests
const copyDefaults = (bps: number): Copy => ({
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)"],
});
interface Panel { key: string; type: string; payload: any }
interface Toast { id: number; text: string; level: string }
let seq = 0;
export default function App() {
const [theme, setTheme] = useState<Theme>(loadTheme);
const themeRef = useRef(theme); themeRef.current = theme;
const [feeBps, setFeeBps] = useState(500);
const [panels, setPanels] = useState<Panel[]>([]);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [chatOpen, setChatOpen] = useState(false);
const [chatMode, setChatMode] = useState("AI agent");
const [badge, setBadge] = useState({ text: "agent: …", title: "" });
const [toasts, setToasts] = useState<Toast[]>([]);
const [compareIds, setCompareIds] = useState<string[]>([]);
const history = useRef<{ role: string; content: string }[]>([]);
const stageRef = useRef<HTMLElement>(null);
// Theme: tokens/classes/customCss go onto the document; copy + sections are rendered below.
useLayoutEffect(() => { applyThemeToDocument(theme); saveTheme(theme); }, [theme]);
const resetTheme = useCallback(() => { themeRef.current = {}; setTheme({}); }, []);
const toast = useCallback((text: string, level: "info" | "ok" | "warn" = "info") => {
const id = ++seq; setToasts((ts) => [...ts, { id, text, level }]);
setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 3500);
}, []);
const say = useCallback((role: Msg["role"], text: string) => { const id = ++seq; setMsgs((m) => [...m, { id, role, text }]); return id; }, []);
const unsay = useCallback((id: number) => setMsgs((m) => m.filter((x) => x.id !== id)), []);
const setModel = useCallback((model?: string, ms?: number) => {
const short = (model || "").replace(/:free$/, "");
setBadge({ text: "AI: " + short, title: (model || "") + " via OpenRouter (free tier)" });
setChatMode(`AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`);
}, []);
// Render agent actions through the registry: effects run, panels replace the results area.
const render = useCallback((actions: UIAction[]) => {
// Theme changes in this batch are folded locally so a panel later in the same batch sees them
// (e.g. a set_theme that hides "results" followed by a checkout un-hides it again, like before).
let t = themeRef.current;
const fx = { toast, applyTheme: (a: ThemeAction) => { t = reduceThemeAction(t, a); }, clearStage: () => setPanels([]) };
const batch: Panel[] = [];
for (const a of actions) {
if (EFFECTS[a.type]) EFFECTS[a.type](a.payload || {}, fx);
else if (PANELS[a.type]) batch.push({ key: `${++seq}`, type: a.type, payload: a.payload || {} });
}
if (batch.length) {
const hidden = t.sections?.hidden;
if (Array.isArray(hidden) && hidden.includes("results")) {
t = { ...t, sections: { ...t.sections, hidden: hidden.filter((x: string) => x !== "results") } };
toast("Results section shown again so you can see this", "info");
}
}
if (t !== themeRef.current) { themeRef.current = t; setTheme(t); }
if (!batch.length) return;
setPanels(batch);
setTimeout(() => stageRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 0);
}, [toast]);
const talk = useCallback(async ({ message, event, echo }: Talk) => {
setChatOpen(true);
if (echo) say("user", echo);
const thinking = say("sys", "agent is thinking…");
try {
const out = await api<ChatReply>("/api/chat", { message, event, history: history.current.slice(-6), actor, theme: themeRef.current });
unsay(thinking);
if (message) history.current.push({ role: "user", content: message });
if (event) history.current.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) });
history.current.push({ role: "assistant", content: out.reply || "" });
say("bot", out.reply || "(no reply)");
if (out.agent === "llm") setModel(out.model, out.latency_ms);
else if (out.agent === "fallback") {
setChatMode("built-in agent answered (AI model unavailable)");
setBadge({ text: "agent: built-in (AI unavailable)", title: "The hosted model is rate-limited or out of free quota" });
} else if (out.agent === "rate-limit") toast(out.reply || "", "warn");
render(out.actions || []);
} catch (e) { unsay(thinking); say("sys", "Agent error: " + (e as Error).message); }
}, [say, unsay, setModel, toast, render]);
// Boot: status (fee + badge), then the deterministic landing view and greeting.
useEffect(() => {
api<Status>("/api/status").then((s) => {
setFeeBps(s.fee_bps);
if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc)
setBadge({ text: "agent: built-in (AI quota used up)", title: "Hosted model free quota resets " + s.agent.daily_quota_exhausted_until_utc });
else if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model);
else setBadge({ text: "agent: rule-based fallback", title: s.agent.note || "" });
});
api<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => {
render(out.actions || []);
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ctx: AgentCtx = useMemo(() => ({
talk, feeBps, toast,
compare: {
has: (id) => compareIds.includes(id),
toggle: (id, on) => setCompareIds((ids) => (on ? [...new Set([...ids, id])] : ids.filter((x) => x !== id))),
},
}), [talk, feeBps, toast, compareIds]);
const copy = resolveCopy(theme, copyDefaults(feeBps));
const { order, hidden } = resolveSections(theme);
const sec = (s: Section) => ({ "data-section": s, style: { order: order[s] }, className: hidden.has(s) ? "is-hidden" : undefined });
const cls = (base: string, s: Section) => [base, hidden.has(s) ? "is-hidden" : ""].filter(Boolean).join(" ");
return (
<Ctx.Provider value={ctx}>
{/* Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. */}
<div className="testbar" data-protected>TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<header className="top">
<div className="brand"><span className="logo">◆</span> <span id="logoText">{copy.logo}</span></div>
<nav>
<button className="link" data-say="show me everything" onClick={() => talk({ message: "show me everything", echo: "show me everything" })}>Browse</button>
<button className="link" data-event="sell" onClick={() => talk({ event: { type: "sell" }, echo: "Sell" })}>Sell</button>
<a className="link" href="/agent.json" target="_blank">For agents</a>
<button className="link" id="resetLook" title="Restore the default look" onClick={() => { resetTheme(); toast("Look reset to default", "ok"); }}>Reset look</button>
<span id="agentBadge" className="badge" data-protected title={badge.title}>{badge.text}</span>
</nav>
</header>
<main id="page">
<section {...sec("hero")} className={cls("hero-sec", "hero")}>
<h1 id="headline">{copy.headline}</h1>
<p id="subhead">{copy.subhead}</p>
</section>
<div {...sec("chips")} className={cls("chips", "chips")} id="chips">
{copy.chips.map((c, i) => (
<button key={i} className="chip" data-say={c.event ? undefined : String(c.say || c.label).slice(0, 120)} data-event={c.event}
onClick={() => c.event ? talk({ event: { type: c.event }, echo: c.label.slice(0, 40) }) : talk({ message: String(c.say || c.label).slice(0, 120), echo: String(c.say || c.label).slice(0, 120) })}>
{c.label.slice(0, 40)}</button>
))}
</div>
<ul {...sec("bullets")} className={cls("facts", "bullets")} id="bullets">
{copy.bullets.map((b, i) => <li key={i}>{b.slice(0, 140)}</li>)}
</ul>
<section {...sec("results")} className={hidden.has("results") ? "is-hidden" : undefined} id="stage" ref={stageRef} aria-live="polite">
{panels.map((p) => { const C = PANELS[p.type]; return <C key={p.key} payload={p.payload} />; })}
</section>
<footer {...sec("footer")} className={cls("foot", "footer")} id="footer">
{copy.footer || <>Agent Market demo · fake test data · test-mode payments · <a href="/openapi.json" target="_blank">open API</a></>}
</footer>
</main>
<Chat open={chatOpen} setOpen={setChatOpen} mode={chatMode} msgs={msgs}
onSend={(v) => talk({ message: v, echo: v })} />
<div id="toasts">{toasts.map((t) => <div key={t.id} className={"toast " + t.level}>{t.text}</div>)}</div>
<div className="cmpbar" style={{ display: compareIds.length ? "flex" : "none" }}>
{compareIds.length} selected
<button className="btn" onClick={() => talk({ event: { type: "compare", listing_ids: compareIds }, echo: "Compare selected" })}>Compare</button>
</div>
</Ctx.Provider>
);
}

13
web/src/api.ts Normal file
View File

@ -0,0 +1,13 @@
// Thin fetch wrapper. X-Agent-Id identifies this browser's demo actor (same as the pre-React app).
export const actor: string = (() => {
const a = localStorage.getItem("am_actor") || "guest-" + Math.random().toString(36).slice(2, 7);
localStorage.setItem("am_actor", a);
return a;
})();
export async function api<T = any>(path: string, body?: unknown): Promise<T> {
const r = await fetch(path, body !== undefined
? { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Id": actor }, body: JSON.stringify(body) }
: { headers: { "X-Agent-Id": actor } });
return r.json();
}

View File

@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from "react";
export interface Msg { id: number; role: "user" | "bot" | "sys"; text: string }
/** Chat popup. Typed messages go to the model via /api/chat; position/size/colours come from the theme. */
export function Chat({ open, setOpen, mode, msgs, onSend }: {
open: boolean; setOpen: (o: boolean) => void; mode: string; msgs: Msg[]; onSend: (text: string) => void;
}) {
const [value, setValue] = useState("");
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [msgs, open]);
return (
<>
<button id="chatLauncher" className="launcher" aria-label="Open the market agent" style={{ display: open ? "none" : undefined }}
onClick={() => setOpen(true)}>💬 Ask the agent</button>
<aside id="chat" className={"chat" + (open ? "" : " hidden")} aria-label="Market agent chat">
<div className="chat-head">
<div><b>Market Guide</b><div className="sub" id="chatMode">{mode}</div></div>
<button id="chatClose" aria-label="Close" onClick={() => setOpen(false)}>✕</button>
</div>
<div id="log" className="log" ref={logRef}>
{msgs.map((m) => <div key={m.id} className={"msg " + m.role}>{m.text}</div>)}
</div>
<form id="chatForm" className="chat-form" onSubmit={(e) => { e.preventDefault(); const v = value.trim(); if (!v) return; setValue(""); onSend(v); }}>
<input id="chatInput" autoComplete="off" placeholder="e.g. find a bike, or: make it look retro" value={value} onChange={(e) => setValue(e.target.value)} />
<button>Send</button>
</form>
</aside>
</>
);
}

View File

@ -0,0 +1,37 @@
import { useAgent } from "../context";
interface Defaults { title?: string; kind?: string; price?: number; category?: string; area?: string; description?: string; payment_options?: string[] }
/** show_listing_form — prefilled by the agent; Publish sends a deterministic submit_listing event. */
export function ListingForm({ payload }: { payload: { defaults?: Defaults } }) {
const { talk, feeBps } = useAgent();
const d = payload.defaults || {};
const pay = Array.isArray(d.payment_options) && d.payment_options.length ? d.payment_options : ["online", "cash"];
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const fields = {
title: fd.get("title"), kind: fd.get("kind"), price: fd.get("price"), category: fd.get("category"),
area: fd.get("area"), description: fd.get("description"),
payment_options: [fd.get("online") && "online", fd.get("cash") && "cash"].filter(Boolean),
};
talk({ event: { type: "submit_listing", fields }, echo: `Publish “${fields.title}”` });
};
return (
<section className="panel">
<h2>List something</h2>
<div className="by">Form opened by the agent</div>
<form className="form" data-component="listing-form" onSubmit={submit}>
<label className="full">Title<input name="title" required defaultValue={d.title || ""} placeholder="e.g. Vintage desk chair" /></label>
<label>Type<select name="kind" defaultValue={d.kind === "service" ? "service" : "product"}><option value="product">Product</option><option value="service">Service</option></select></label>
<label>Price (USD)<input name="price" type="number" step="0.01" min="0" required defaultValue={d.price ? (d.price / 100).toFixed(2) : ""} /></label>
<label>Category<input name="category" defaultValue={d.category || ""} /></label>
<label>Area (approximate)<input name="area" defaultValue={d.area || "Demo Town"} /></label>
<label className="full">Description<textarea name="description" rows={3} required defaultValue={d.description || ""} /></label>
<label><span><input type="checkbox" name="online" defaultChecked={pay.includes("online")} /> Accept online payment ({feeBps / 100}% fee, paid by buyer)</span></label>
<label><span><input type="checkbox" name="cash" defaultChecked={pay.includes("cash")} /> Accept cash in person (free)</span></label>
<div className="full"><button className="btn" type="submit">Publish test listing</button></div>
</form>
</section>
);
}

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { useAgent } from "../context";
import { money, type Listing, type Quote } from "../types";
export function BuyButtons({ x }: { x: Listing }) {
const { talk } = useAgent();
const opts = x.payment_options || [];
if (x.status !== "active") return <span className="meta">Sold</span>;
return (
<div className="actions">
{opts.includes("online") && (
<button className="btn" onClick={() => talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` })}>Buy online</button>
)}
{opts.includes("cash") && (
<button className="btn cash" onClick={() => talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` })}>Cash meetup</button>
)}
</div>
);
}
export function ListingCard({ x }: { x: Listing }) {
const { compare } = useAgent();
const [checked, setChecked] = useState(compare.has(x.id));
return (
<div className={"card" + (x.status !== "active" ? " sold" : "")} data-listing={x.id}>
<div className="emoji">{x.emoji || "📦"}</div>
<h3>{x.title}</h3>
<div className="price">{money(x.price)}<span className="meta">{x.kind === "service" ? " / session" : ""}</span></div>
<div>
<span className="tag">{x.kind}</span>
{x.seller_type === "agent" && <span className="tag agent-tag">sold by an agent</span>}
<span className="tag">test listing</span>
</div>
<div className="meta">{x.area} · {x.seller}</div>
<label className="meta">
<input type="checkbox" aria-label="compare" checked={checked}
onChange={(e) => { setChecked(e.target.checked); compare.toggle(x.id, e.target.checked); }} /> compare
</label>
<BuyButtons x={x} />
</div>
);
}
/** show_cards — results grid. The theme's `layout: "list"` turns the same grid into rows via CSS. */
export function Results({ payload }: { payload: { heading?: string; listings?: Listing[] } }) {
return (
<section className="panel" data-component="cards">
<h2>{payload.heading || "Listings"}</h2>
<div className="by">Rendered by the agent</div>
<div className="grid">{(payload.listings || []).map((x) => <ListingCard key={x.id} x={x} />)}</div>
</section>
);
}
/** show_compare — side-by-side table with online-vs-cash pricing. */
export function Compare({ payload }: { payload: { heading?: string; listings?: Listing[]; quotes?: Quote[] } }) {
const L = payload.listings || [], Q = payload.quotes || [];
const row = (label: string, f: (x: Listing, q: Quote) => React.ReactNode) => (
<tr><th>{label}</th>{L.map((x, i) => <td key={x.id}>{f(x, Q[i] || {})}</td>)}</tr>
);
return (
<section className="panel" data-component="compare">
<h2>{payload.heading || "Compare"}</h2>
<table className="cmp"><tbody>
<tr><th></th>{L.map((x) => <th key={x.id}>{x.emoji} {x.title}</th>)}</tr>
{row("Type", (x) => x.kind)}
{row("Price", (x) => money(x.price))}
{row("Pay online (test)", (_, q) => q.online?.available ? `${money(q.online.total_cents)} incl. ${money(q.online.fee_cents)} fee` : "not offered")}
{row("Cash in person", (_, q) => q.cash_in_person?.available ? `${money(q.cash_in_person.total_cents)} · $0 fee` : "not offered")}
{row("Seller", (x) => x.seller + (x.seller_type === "agent" ? " 🤖" : ""))}
{row("Area", (x) => x.area)}
{row("", (x) => <BuyButtons x={x} />)}
</tbody></table>
</section>
);
}
/** show_hero — welcome panel inside the results area. */
export function HeroPanel({ payload }: { payload: { title?: string; subtitle?: string; bullets?: string[] } }) {
return (
<section className="panel hero" data-component="hero">
<h2>{payload.title || "Agent Market"}</h2>
<p>{payload.subtitle || ""}</p>
<ul>{(payload.bullets || []).map((b, i) => <li key={i}>{b}</li>)}</ul>
</section>
);
}

View File

@ -0,0 +1,93 @@
// PROTECTED components: checkout, cash meetup and receipt. Their fee lines, totals, test-card notice and
// card inputs sit in the `protected` CSS layer (styles.css) and use --p-bg/--p-ink, which the theme engine
// always sets to a solid colour + black/white ink (>= 4.5:1). Keep the data-component / data-testid hooks.
import { useState } from "react";
import { api } from "../api";
import { useAgent } from "../context";
import { money, type Deal, type Intent, type Listing, type Order } from "../types";
/** show_checkout — card details go straight to the test payment endpoint, never to the agent/LLM. */
export function Checkout({ payload }: { payload: { intent: Intent; listing?: Listing } }) {
const { talk } = useAgent();
const it = payload.intent, L = payload.listing;
const [card, setCard] = useState("4242 4242 4242 4242");
const [status, setStatus] = useState<{ text: string; cls: string }>({ text: "", cls: "meta" });
const [busy, setBusy] = useState(false);
const pay = async (e: React.FormEvent) => {
e.preventDefault(); setBusy(true); setStatus({ text: "Contacting test payment provider…", cls: "meta" });
const r = await api(`/api/payments/${it.id}/confirm`, { card_number: card });
const ok = r.status === "succeeded";
setStatus({ text: ok ? "Paid (test)." : "Declined: " + (r.error || "unknown"), cls: "meta " + (ok ? "ok" : "warn") });
setBusy(ok);
talk({ event: { type: "payment_result", intent_id: it.id, status: r.status, order_id: r.order?.id, error: r.error } });
};
return (
<section className="panel" data-component="checkout">
<h2>Checkout</h2>
<div className="by">{L?.emoji || ""} {it.title} · sold by {L?.seller || "?"}</div>
<table className="breakdown"><tbody>
<tr><td>Price{it.quantity > 1 ? " × " + it.quantity : ""}</td><td>{money(it.subtotal_cents)}</td></tr>
<tr><td>Facilitation fee ({(it.fee_bps / 100).toFixed(1)}%, online payments only)</td><td data-testid="fee">{money(it.fee_cents)}</td></tr>
<tr className="total"><td>You pay</td><td data-testid="total">{money(it.total_cents)}</td></tr>
<tr><td className="meta">Seller receives</td><td className="meta">{money(it.subtotal_cents)}</td></tr>
</tbody></table>
<form onSubmit={pay}>
<div className="testcard">TEST MODE: no real charge. Success: 4242 4242 4242 4242 · Decline: 4000 0000 0000 0002. Other numbers are refused.</div>
<div className="cardform">
<input name="card" value={card} onChange={(e) => setCard(e.target.value)} inputMode="numeric" aria-label="Test card number" />
<input defaultValue="12/34" aria-label="Expiry" />
<input defaultValue="123" aria-label="CVC" />
</div>
<div style={{ marginTop: 10 }}><button className="btn" type="submit" disabled={busy}>Pay {money(it.total_cents)} (test)</button></div>
<div className={status.cls}>{status.text}</div>
</form>
{L?.payment_options?.includes("cash") && (
<p className="meta">Prefer to skip the fee?{" "}
<a href="#" onClick={(e) => { e.preventDefault(); talk({ event: { type: "buy_cash", listing_id: L.id }, echo: "Pay cash in person instead" }); }}>
Meet and pay cash in person ($0 fee)</a></p>
)}
</section>
);
}
/** show_cash_meetup — $0 platform fee; the site only connects buyer and seller. */
export function CashMeetup({ payload }: { payload: { deal: Deal; listing?: Listing } }) {
const { talk } = useAgent();
const d = payload.deal, L = payload.listing;
const [done, setDone] = useState(false);
return (
<section className="panel" data-component="cash-meetup">
<h2>Cash in person: no fee</h2>
<div className="by">{L?.emoji || ""} {d.title} · seller {d.seller}</div>
<table className="breakdown"><tbody>
<tr><td>Price (pay the seller directly)</td><td>{money(d.price_cents)}</td></tr>
<tr><td>Platform fee</td><td data-testid="cash-fee">{money(d.fee_cents)}</td></tr>
<tr className="total"><td>Total</td><td>{money(d.price_cents)}</td></tr>
</tbody></table>
<p>Suggested meetup: {d.place} · {d.time}</p>
<p className="meta">We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)</p>
<div className="meta">Deal {d.id} · status {d.status}</div>
<div style={{ marginTop: 10 }}>
<button className="btn cash" disabled={done}
onClick={() => { setDone(true); talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); }}>Mark paid in cash</button>
</div>
</section>
);
}
/** show_receipt */
export function Receipt({ payload }: { payload: { order: Order } }) {
const o = payload.order;
return (
<section className="panel" data-component="receipt">
<h2 className="ok">{o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"}</h2>
<table className="breakdown"><tbody>
<tr><td>{o.title}</td><td>{money(o.subtotal_cents)}</td></tr>
<tr><td>Facilitation fee</td><td data-testid="receipt-fee">{money(o.fee_cents)}</td></tr>
<tr className="total"><td>Total</td><td>{money(o.total_cents)}</td></tr>
<tr><td className="meta">Seller receives</td><td className="meta">{money(o.seller_receives_cents)}</td></tr>
</tbody></table>
<div className="meta">Order {o.id} · {o.method}{o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · {o.status}</div>
</section>
);
}

View File

@ -0,0 +1,33 @@
// Component registry: every UI action type the agent can send maps to either
// - a PANEL component, rendered into the results area (#stage), or
// - an EFFECT, a side effect with no panel (theme change, toast, clear).
// To add a component, see "Adding a UI component" in README.md.
import type { ComponentType } from "react";
import type { AgentCtx } from "../context";
import type { ThemeAction } from "../theme/engine";
import { Compare, HeroPanel, Results } from "./Listings";
import { ListingForm } from "./ListingForm";
import { CashMeetup, Checkout, Receipt } from "./Payments";
export const PANELS: Record<string, ComponentType<{ payload: any }>> = {
show_hero: HeroPanel,
show_cards: Results,
show_compare: Compare,
show_listing_form: ListingForm,
show_checkout: Checkout,
show_cash_meetup: CashMeetup,
show_receipt: Receipt,
};
export interface EffectApi extends Pick<AgentCtx, "toast"> {
applyTheme: (a: ThemeAction) => void;
clearStage: () => void;
}
export const EFFECTS: Record<string, (payload: any, fx: EffectApi) => void> = {
set_theme: (p: ThemeAction, fx) => {
fx.applyTheme(p);
fx.toast(p.reset && !p.base && !Object.keys(p.patch || {}).length ? "Look reset to default" : "Look updated", "ok");
},
toast: (p, fx) => fx.toast(p.text || "", p.level || "info"),
clear_panel: (_p, fx) => fx.clearStage(),
};

16
web/src/context.ts Normal file
View File

@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
import type { ChatEvent } from "./types";
export interface Talk { message?: string; event?: ChatEvent; echo?: string }
export interface AgentCtx {
talk: (t: Talk) => Promise<void>;
feeBps: number;
compare: { has: (id: string) => boolean; toggle: (id: string, on: boolean) => void };
toast: (text: string, level?: "info" | "ok" | "warn") => void;
}
export const Ctx = createContext<AgentCtx | null>(null);
export const useAgent = () => {
const c = useContext(Ctx);
if (!c) throw new Error("useAgent outside provider");
return c;
};

6
web/src/main.tsx Normal file
View File

@ -0,0 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);

170
web/src/theme/engine.ts Normal file
View File

@ -0,0 +1,170 @@
// Per-visitor theme engine (port of the pre-React static/theme.js, same behaviour).
// 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. The server validates every
// set_theme action first (market/theme.py); everything here re-validates defensively.
export type Theme = Record<string, any>;
export interface ThemeAction { reset?: boolean; base?: Theme; patch?: Theme; preset?: string }
export interface Copy { logo: string; headline: string; subhead: string; footer?: string; chips: Chip[]; bullets: string[] }
export interface Chip { label: string; say?: string; event?: string }
export const THEME_KEY = "am_theme_v1";
export const SECTIONS = ["hero", "chips", "bullets", "results", "footer"] as const;
export type Section = (typeof SECTIONS)[number];
const FONTS: Record<string, [string, string?]> = {
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: Record<"dark" | "light", Record<string, string>> = {
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 IMAGE_HOSTS = ["images.unsplash.com", "picsum.photos", "upload.wikimedia.org"];
export const isObj = (o: unknown): o is Record<string, any> => !!o && typeof o === "object" && !Array.isArray(o);
export const merge = (a: Theme, b: Theme): Theme => {
const o: Theme = { ...(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: unknown): c is string => typeof c === "string" && c.length < 60 && !/[;{}<>]/.test(c) && CSS.supports("color", c);
// ---- colour maths for guaranteed contrast on protected elements ----
function rgb(c: string) {
const probe = document.createElement("span");
probe.style.color = c; document.body.append(probe);
const m = (getComputedStyle(probe).color.match(/[\d.]+/g) || ["0", "0", "0", "1"]).map(Number); 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: number; g: number; b: number }) => [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);
export const contrast = (a: string, b: string) => { 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: string) => { const v = rgb(c); return `rgb(${v.r},${v.g},${v.b})`; };
const bestInk = (bg: string) => (contrast(bg, "#111111") >= contrast(bg, "#ffffff") ? "#111111" : "#ffffff");
function backgroundCss(bg: Theme, pal: Record<string, string>) {
const cols = (Array.isArray(bg.colors) ? 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 map: Record<string, string> = {
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[bg.pattern] || 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 { /* invalid URL */ }
return c0;
}
default: return c0;
}
}
function loadFont(name: string) {
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];
}
export function scopeCss(css: unknown) {
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 { return false; } });
if (!urlOk) return "";
return [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, sel, body]) =>
sel.split(",").map((s) => "#page " + s.trim()).join(", ") + "{" + body + "}").join("\n");
}
export function loadTheme(): Theme {
try { const t = JSON.parse(localStorage.getItem(THEME_KEY) || "{}"); return isObj(t) ? t : {}; } catch { return {}; }
}
export function saveTheme(t: Theme) {
if (Object.keys(t).length) localStorage.setItem(THEME_KEY, JSON.stringify(t)); else localStorage.removeItem(THEME_KEY);
}
export function reduceThemeAction(current: Theme, a: ThemeAction): Theme {
let t = current;
if (a.reset) t = {};
if (a.base) t = JSON.parse(JSON.stringify(a.base));
return merge(t, a.patch || {});
}
/** Apply design tokens to <html>/<body> (CSS variables, classes, fonts, scoped customCss). Copy and
* section order/visibility are rendered by React from the same theme (see resolveCopy/resolveSections). */
export function applyThemeToDocument(t: Theme) {
const 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(isObj(t.palette) ? t.palette : {})) if (k in pal && okColor(v)) pal[k] = v;
const set = (k: string, v: string) => 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 } as Record<string, number>)[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 sitting directly on the page background must clear 4.5:1 against every background stop; if no
// ink can (busy gradients), a readability scrim goes behind it.
const stops = isObj(t.background) && Array.isArray(t.background.colors) ? t.background.colors.filter(okColor) : [];
const bgStops: string[] = stops.length ? stops.map(solid) : [solid(pal.bg)];
const minC = (ink: string) => Math.min(...bgStops.map((b) => contrast(ink, b)));
let pageInk = minC(pal.text) >= 4.5 ? pal.text : (minC("#111111") >= minC("#ffffff") ? "#111111" : "#ffffff");
const scrim = minC(pageInk) < 4.5;
body.classList.toggle("scrim", scrim);
if (scrim) { set("--scrim", solid(pal.surface)); pageInk = bestInk(solid(pal.surface)); }
set("--page-ink", pageInk); set("--page-mute", !scrim && minC(pal.muted) >= 4.5 ? pal.muted : pageInk);
// Protected panels (checkout / cash / receipt): solid surface colour + black or white ink, 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);
const userCss = document.getElementById("user-css");
if (userCss) userCss.textContent = t.customCss ? "@layer user {\n" + scopeCss(t.customCss) + "\n}" : "";
}
export function resolveCopy(t: Theme, defaults: Copy): Copy {
const c = isObj(t.copy) ? t.copy : {};
const str = (v: unknown, d: string, n: number) => (typeof v === "string" && v.trim() ? v : d).slice(0, n);
return {
logo: str(c.logo, defaults.logo, 40), headline: str(c.headline, defaults.headline, 120), subhead: str(c.subhead, defaults.subhead, 240),
footer: typeof c.footer === "string" ? c.footer.slice(0, 160) : undefined,
chips: (Array.isArray(c.chips) ? c.chips : defaults.chips).filter((x: any) => isObj(x) && typeof x.label === "string").slice(0, 6),
bullets: (Array.isArray(c.bullets) ? c.bullets : defaults.bullets).filter((x: any) => typeof x === "string").slice(0, 6),
};
}
export function resolveSections(t: Theme): { order: Record<Section, number>; hidden: Set<Section> } {
const sec = isObj(t.sections) ? t.sections : {};
const order = Array.isArray(sec.order) ? sec.order.filter((x: any) => (SECTIONS as readonly string[]).includes(x)) : [];
const full = [...new Set([...order, ...SECTIONS])] as Section[];
const hidden = new Set<Section>((Array.isArray(sec.hidden) ? sec.hidden : []).filter((x: any) => (SECTIONS as readonly string[]).includes(x)));
return { order: Object.fromEntries(full.map((s, i) => [s, i])) as Record<Section, number>, hidden };
}

26
web/src/types.ts Normal file
View File

@ -0,0 +1,26 @@
export interface Listing {
id: string; title: string; kind: "product" | "service"; price: number; currency?: string; description?: string;
category?: string; tags?: string[]; emoji?: string; seller: string; seller_type?: "human" | "agent"; area?: string;
payment_options?: ("online" | "cash")[]; status: string;
}
export interface Quote {
online?: { available: boolean; total_cents: number; fee_cents: number };
cash_in_person?: { available: boolean; total_cents: number };
}
export interface Intent { id: string; title: string; quantity: number; subtotal_cents: number; fee_cents: number; fee_bps: number; total_cents: number }
export interface Deal { id: string; title: string; seller: string; price_cents: number; fee_cents: number; place: string; time: string; status: string }
export interface Order {
id: string; title: string; method: string; status: string; subtotal_cents: number; fee_cents: number; total_cents: number;
seller_receives_cents: number; card_last4?: string;
}
export interface UIAction { type: string; payload?: any }
export interface ChatReply {
reply?: string; actions?: UIAction[]; agent?: "llm" | "fallback" | "ui-event" | "rate-limit"; model?: string; latency_ms?: number;
}
export interface ChatEvent { type: string; [k: string]: unknown }
export interface Status {
env: string; fee_bps: number;
agent: { mode: "llm" | "fallback"; model?: string; last_model?: string; note?: string; daily_quota_exhausted_until_utc?: string | null };
}
export const money = (c: number) => "$" + (c / 100).toFixed(2);

17
web/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

10
web/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// The Python server serves the build from /static/dist/ (see server.py and deploy.sh).
export default defineConfig({
plugins: [react()],
base: "/static/dist/",
build: { outDir: "../static/dist", emptyOutDir: true, sourcemap: false },
server: { proxy: { "/api": "http://127.0.0.1:8795", "/agent.json": "http://127.0.0.1:8795", "/openapi.json": "http://127.0.0.1:8795" } },
});