diff --git a/.gitignore b/.gitignore
index c96d75d..87fa21b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ __pycache__/
node_modules/
screenshots/
.env
+static/dist/
diff --git a/deploy.sh b/deploy.sh
index 93b5b09..b56ade1 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -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 \
diff --git a/server.py b/server.py
index b6510e1..00930c4 100755
--- a/server.py
+++ b/server.py
@@ -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"):
diff --git a/static/app.js b/static/app.js
deleted file mode 100644
index 2ee48c8..0000000
--- a/static/app.js
+++ /dev/null
@@ -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.");
- })();
-})();
diff --git a/static/index.html b/static/index.html
deleted file mode 100644
index 126ab40..0000000
--- a/static/index.html
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-Agent Market (demo)
-
-
-
-
-
-TEST DEMO: all listings are fake and payments run in test mode. No real money moves.
-
- ◆ Agent Market
-
- Browse
- Sell
- For agents
- Reset look
- agent: …
-
-
-
-
-
- A marketplace you can just talk to.
- 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.
-
-
-
-
-
-
-
-💬 Ask the agent
-
-
-
-
-
-
diff --git a/static/theme.js b/static/theme.js
deleted file mode 100644
index 4c6ef6c..0000000
--- a/static/theme.js
+++ /dev/null
@@ -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,
- };
-})();
diff --git a/tests/e2e_http.py b/tests/e2e_http.py
index 869ff79..37da037 100755
--- a/tests/e2e_http.py
+++ b/tests/e2e_http.py
@@ -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 '' 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"]
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..80264c2
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
Agent Market (demo)
+
+
+
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..bf6d428
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1722 @@
+{
+ "name": "agent-market-web",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "agent-market-web",
+ "version": "0.2.0",
+ "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"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
+ "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz",
+ "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.9",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.2.tgz",
+ "integrity": "sha512-gjNDUwMswogkw3fNqV00u54uZlgmInuw5EU/1TeqQ6K0bEagoGvqjPdKzy112lKyhe7PkGudZ/kSQHsc9qroIg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.2.tgz",
+ "integrity": "sha512-YkAW8q7p5SCLSaGWaUkmsRnBEb3MGA1pB5nI/8k0lAK5fZsQKRBqkdR1h4UU9p8u3c7ZGdUF2Zcx7mK+E/pUKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.2.tgz",
+ "integrity": "sha512-hNmuIHv+LnrP/+q/44vsmIMWfoh7X2YXOp5FLtjGuvqN52VdtFpOpnshTc1k28tWv1wE/0D5gLjsozBUowEGbQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.2.tgz",
+ "integrity": "sha512-B0t5CoxgF2KL7R5FHAsyN8yVEQCSIkh4XVs8MCDjuBLNjEa8l3zd5tVKaFMv53LfUtHs9p2rnqeavPTOunV+Ww==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.2.tgz",
+ "integrity": "sha512-QQdYnfwrPmc1DoPlddjFvQPzUHFbStKXsHrG6EBYsWHhBVUCjsCRdcfGGXGAvhDpDtTzKK4vy1UrGFJ45rQaiw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.2.tgz",
+ "integrity": "sha512-1xRxtGWif6BB58VSfMavi0Yfs2M4x6CtRp4o7ScY4W7AGlgvf6GSPKdDNdw7TX3tMiQwZY5W/mvyrOJTq/7AKA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.2.tgz",
+ "integrity": "sha512-CXFLEt384BUwwOLR9DURWNHl1bQOVLd1ibOee+yKBJm/fZtvrSAOZ0JbxjAxszVMIovYMwFMS+oP+Q1aPcx8Eg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.2.tgz",
+ "integrity": "sha512-T1dorY0WHm5mMNpMHFl5rYRZ02gLSZcr6J45r66tYb3S3R3DyIRGiGWVdLtF+zOQjratNJaObl9jYumueRiL1A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.2.tgz",
+ "integrity": "sha512-ia6bfaCIc0ldp6bvs9oEkFrJVJKNUMoiDF0+LBZsQAZg+vE0znXnzGqH+ew/FYswE30cruAWPTO3dPw7keaVkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.2.tgz",
+ "integrity": "sha512-mB/gwfBfXtT0EBpbSssqKCqMFuX8b21Zy2TGoIhehSJEssZ5ksybxymwp1BkEM5Nczh/AAxpmh5q+MkhtundGg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.2.tgz",
+ "integrity": "sha512-VPd0a+XlHoNRI9jIPRHww2UKhDkD6XjbtcKYXZijAnhJS1jLLbiNtVl6a5LyEfNN+eyiyWcJOZXPzBo69oTsew==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.2.tgz",
+ "integrity": "sha512-10N1I6oBpERqzGtHKcCLrEpHlbXSBbMK9TURHvqgWbIFZuU3oJROxJ0H2nHfq96TVI0XJHQ9qzolJMFd2tz0LA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.2.tgz",
+ "integrity": "sha512-dhJrLVZ3wNNhwCmy9/VTg6Pq6J8a8eZ0n15dVRlbSWcDb+8V/1mDfbnvis+vt8W6Pbxzv6gUbTEl4piOT3bbUQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.2.tgz",
+ "integrity": "sha512-tPd2QuwxkTxgju5eARyb3RdgBWWi/O9dn/NFH0VkvGbLzoQeXrH6qMABtW8IODb5lUeBzB3sy2U7qmI4MtQ72g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.2.tgz",
+ "integrity": "sha512-njtDdPvSQWI9CDhIPPxsiEMeHayCfu7wwcbSQ7c2kYPhGVviL+WGVhq6Jfj/b+VuhSTkFuQ0z4ZFbFjkWwBFmg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.2.tgz",
+ "integrity": "sha512-EbOBo3dqCj9ecEV9Z7zvulB6AieMP2ztnKQYGVOSN0pzIyOu+3JH2YJZnLmbPeVvfn6yP6Qi7NbBM27EzGbj2w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.2.tgz",
+ "integrity": "sha512-8wLt2Q5n4qmfiE2Gk8V6KNLSf+oJuBX7Kdw5joFDtnZIZvirS0hLzBc79OiJjV71oYDmBUZLNy6pDgvylo4qiQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.2.tgz",
+ "integrity": "sha512-aIChbkhC4d33bxkVP0LSi2/2lUSGRXE6k4Qa0G7tgpgTOK7kxHTixmx5qC3SKUJ6yUMznHBtQoWaG4QMqLUnhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.2.tgz",
+ "integrity": "sha512-gQ+TkVZRfjZVcl/UOAxxfWw+sHjzIXoieNwxEyJlD3tRBmBhR8ZDq11w+QEgQDcpslEB+fhN/OEpaTsDoIIHhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.2.tgz",
+ "integrity": "sha512-sUaJpMKS2JBCvUk8fYimG7aMq2jHVT4kw/uVHxPolKLrMmkKy0KXirmZyZehdNpL7PffqAlPAWUELQcTiD8ChA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.2.tgz",
+ "integrity": "sha512-j1RdJWb0CrlSuwt12OPjLucPlwfLtA/KMOKVvh5rXSJEtl/WDoIGR3CSykK/jLGTo9RqNfW5Ub6kY5Z5ccYl9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.2.tgz",
+ "integrity": "sha512-i3r04aHCIY3eggTBLUp/4IxiBjDpAgQaK89Ue2nMJ2MKuIXX5JfoeZjHw+kPjGGNVDlhi8FT8xzuGMJZe3aehg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.2.tgz",
+ "integrity": "sha512-kCQ15h/uwNMGdN8gjXBZGf+aGE+ODzHfbi90/cAm4+Q89WoMiLODNoj8HoGZmYx87HPxfu5CBNPsZ+ZG4uIAoA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.2.tgz",
+ "integrity": "sha512-yYAuySSs+0X2gSMJPw/Q4jHsHZHyMq1dpAE+XnN790Jg1WuReUAD/+j+e4vuDvS4PXJ4PA4NCzTR4OYtDf18/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.2.tgz",
+ "integrity": "sha512-JY1rQdWOJrKutORqKO1uzrHXV86jFiQzfLFKx2s8S1CjLXL9fC5LOIvk0q7Z7PXJvsByt+sdoE1Q2a9gBr4gLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
+ "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
+ "dev": true,
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001815",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
+ "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.451",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.451.tgz",
+ "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.58",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
+ "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.2.tgz",
+ "integrity": "sha512-RxXm0XVVV1hFI3dYe12ruaOgsYQ725lUcUWeMSQqkF9tTxVErY+fy2Ets6vWhItL9MZMSNZGbvZFc7rH/VHp2Q==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.2",
+ "@rollup/rollup-android-arm64": "4.64.2",
+ "@rollup/rollup-darwin-arm64": "4.64.2",
+ "@rollup/rollup-darwin-x64": "4.64.2",
+ "@rollup/rollup-freebsd-arm64": "4.64.2",
+ "@rollup/rollup-freebsd-x64": "4.64.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.2",
+ "@rollup/rollup-linux-arm64-musl": "4.64.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.2",
+ "@rollup/rollup-linux-loong64-musl": "4.64.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.2",
+ "@rollup/rollup-linux-x64-gnu": "4.64.2",
+ "@rollup/rollup-linux-x64-musl": "4.64.2",
+ "@rollup/rollup-openbsd-x64": "4.64.2",
+ "@rollup/rollup-openharmony-arm64": "4.64.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.2",
+ "@rollup/rollup-win32-x64-gnu": "4.64.2",
+ "@rollup/rollup-win32-x64-msvc": "4.64.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+ "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.7",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
+ "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..5667bc5
--- /dev/null
+++ b/web/package.json
@@ -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"
+ }
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..d3d4490
--- /dev/null
+++ b/web/src/App.tsx
@@ -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
(loadTheme);
+ const themeRef = useRef(theme); themeRef.current = theme;
+ const [feeBps, setFeeBps] = useState(500);
+ const [panels, setPanels] = useState([]);
+ const [msgs, setMsgs] = useState([]);
+ const [chatOpen, setChatOpen] = useState(false);
+ const [chatMode, setChatMode] = useState("AI agent");
+ const [badge, setBadge] = useState({ text: "agent: …", title: "" });
+ const [toasts, setToasts] = useState([]);
+ const [compareIds, setCompareIds] = useState([]);
+ const history = useRef<{ role: string; content: string }[]>([]);
+ const stageRef = useRef(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("/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("/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("/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 (
+
+ {/* Protected: the test banner, the agent badge and the checkout/fee panels can't be restyled by themes. */}
+ TEST DEMO: all listings are fake and payments run in test mode. No real money moves.
+
+ ◆ {copy.logo}
+
+ talk({ message: "show me everything", echo: "show me everything" })}>Browse
+ talk({ event: { type: "sell" }, echo: "Sell" })}>Sell
+ For agents
+ { resetTheme(); toast("Look reset to default", "ok"); }}>Reset look
+ {badge.text}
+
+
+
+
+
+ {copy.headline}
+ {copy.subhead}
+
+
+ {copy.chips.map((c, i) => (
+ 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)}
+ ))}
+
+
+ {copy.bullets.map((b, i) => {b.slice(0, 140)} )}
+
+
+ {panels.map((p) => { const C = PANELS[p.type]; return ; })}
+
+
+
+
+ talk({ message: v, echo: v })} />
+ {toasts.map((t) =>
{t.text}
)}
+
+ {compareIds.length} selected
+ talk({ event: { type: "compare", listing_ids: compareIds }, echo: "Compare selected" })}>Compare
+
+
+ );
+}
diff --git a/web/src/api.ts b/web/src/api.ts
new file mode 100644
index 0000000..92be041
--- /dev/null
+++ b/web/src/api.ts
@@ -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(path: string, body?: unknown): Promise {
+ 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();
+}
diff --git a/web/src/components/Chat.tsx b/web/src/components/Chat.tsx
new file mode 100644
index 0000000..5105d86
--- /dev/null
+++ b/web/src/components/Chat.tsx
@@ -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(null);
+ useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [msgs, open]);
+ return (
+ <>
+ setOpen(true)}>💬 Ask the agent
+
+
+
+ {msgs.map((m) =>
{m.text}
)}
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/ListingForm.tsx b/web/src/components/ListingForm.tsx
new file mode 100644
index 0000000..4e2fe2e
--- /dev/null
+++ b/web/src/components/ListingForm.tsx
@@ -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) => {
+ 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 (
+
+ List something
+ Form opened by the agent
+
+
+ );
+}
diff --git a/web/src/components/Listings.tsx b/web/src/components/Listings.tsx
new file mode 100644
index 0000000..61c07dc
--- /dev/null
+++ b/web/src/components/Listings.tsx
@@ -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 Sold ;
+ return (
+
+ {opts.includes("online") && (
+ talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` })}>Buy online
+ )}
+ {opts.includes("cash") && (
+ talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` })}>Cash meetup
+ )}
+
+ );
+}
+
+export function ListingCard({ x }: { x: Listing }) {
+ const { compare } = useAgent();
+ const [checked, setChecked] = useState(compare.has(x.id));
+ return (
+
+
{x.emoji || "📦"}
+
{x.title}
+
{money(x.price)}{x.kind === "service" ? " / session" : ""}
+
+ {x.kind}
+ {x.seller_type === "agent" && sold by an agent }
+ test listing
+
+
{x.area} · {x.seller}
+
+ { setChecked(e.target.checked); compare.toggle(x.id, e.target.checked); }} /> compare
+
+
+
+ );
+}
+
+/** 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 (
+
+ {payload.heading || "Listings"}
+ Rendered by the agent
+ {(payload.listings || []).map((x) => )}
+
+ );
+}
+
+/** 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) => (
+ {label} {L.map((x, i) => {f(x, Q[i] || {})} )}
+ );
+ return (
+
+ {payload.heading || "Compare"}
+
+ {L.map((x) => {x.emoji} {x.title} )}
+ {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) => )}
+
+
+ );
+}
+
+/** show_hero — welcome panel inside the results area. */
+export function HeroPanel({ payload }: { payload: { title?: string; subtitle?: string; bullets?: string[] } }) {
+ return (
+
+ {payload.title || "Agent Market"}
+ {payload.subtitle || ""}
+ {(payload.bullets || []).map((b, i) => {b} )}
+
+ );
+}
diff --git a/web/src/components/Payments.tsx b/web/src/components/Payments.tsx
new file mode 100644
index 0000000..f050671
--- /dev/null
+++ b/web/src/components/Payments.tsx
@@ -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 (
+
+ );
+}
+
+/** 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 (
+
+ Cash in person: no fee
+ {L?.emoji || ""} {d.title} · seller {d.seller}
+
+ Price (pay the seller directly) {money(d.price_cents)}
+ Platform fee {money(d.fee_cents)}
+ Total {money(d.price_cents)}
+
+ Suggested meetup: {d.place} · {d.time}
+ We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)
+ Deal {d.id} · status {d.status}
+
+ { setDone(true); talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); }}>Mark paid in cash
+
+
+ );
+}
+
+/** show_receipt */
+export function Receipt({ payload }: { payload: { order: Order } }) {
+ const o = payload.order;
+ return (
+
+ {o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"}
+
+ {o.title} {money(o.subtotal_cents)}
+ Facilitation fee {money(o.fee_cents)}
+ Total {money(o.total_cents)}
+ Seller receives {money(o.seller_receives_cents)}
+
+ Order {o.id} · {o.method}{o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · {o.status}
+
+ );
+}
diff --git a/web/src/components/registry.tsx b/web/src/components/registry.tsx
new file mode 100644
index 0000000..f35c61e
--- /dev/null
+++ b/web/src/components/registry.tsx
@@ -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> = {
+ 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 {
+ applyTheme: (a: ThemeAction) => void;
+ clearStage: () => void;
+}
+export const EFFECTS: Record 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(),
+};
diff --git a/web/src/context.ts b/web/src/context.ts
new file mode 100644
index 0000000..88d17b5
--- /dev/null
+++ b/web/src/context.ts
@@ -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;
+ 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(null);
+export const useAgent = () => {
+ const c = useContext(Ctx);
+ if (!c) throw new Error("useAgent outside provider");
+ return c;
+};
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..b1f4cf8
--- /dev/null
+++ b/web/src/main.tsx
@@ -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( );
diff --git a/static/styles.css b/web/src/styles.css
similarity index 100%
rename from static/styles.css
rename to web/src/styles.css
diff --git a/web/src/theme/engine.ts b/web/src/theme/engine.ts
new file mode 100644
index 0000000..28540be
--- /dev/null
+++ b/web/src/theme/engine.ts
@@ -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;
+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 = {
+ 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> = {
+ 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 => !!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) {
+ 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 = {
+ 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 / (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)[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; hidden: Set } {
+ 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((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, hidden };
+}
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..50271cc
--- /dev/null
+++ b/web/src/types.ts
@@ -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);
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..8928424
--- /dev/null
+++ b/web/tsconfig.json
@@ -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"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..cda309e
--- /dev/null
+++ b/web/vite.config.ts
@@ -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" } },
+});