public/agent-ui.js
// The "actionable UI" layer (§61): the agent never sends HTML. It sends
// { component, props } and this registry decides what may be rendered,
// validates every prop, and wires actions to app code it controls.
// h(): build DOM with createElement + text nodes only. Under the page's
// Trusted Types policy any innerHTML assignment would throw (§65).
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
else if (v === true) el.setAttribute(k, "");
else if (v !== false && v != null) el.setAttribute(k, String(v));
}
for (const c of children.flat()) {
if (c != null && c !== false) el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return el;
}
const money = (n) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
const orderSchema = { id: "string", item: "string", total: "number", status: "string" };
const registry = {
"order-card": {
schema: orderSchema,
render: (o) =>
h("article", { class: "order-card", "aria-label": `Order ${o.id}` },
h("strong", {}, o.id), " ", o.item,
h("div", { class: "meta" }, money(o.total), " · ", h("span", { class: "status" }, o.status))),
},
"refund-approval": {
schema: orderSchema,
render: (o, { actions }) => {
const status = h("p", { class: "status", role: "status" });
const approve = h("button", {
type: "button",
onclick: async () => {
approve.disabled = decline.disabled = true;
try {
await actions.requestRefund(o.id);
status.textContent = `Refund requested for ${o.id}.`;
} catch (err) {
status.textContent = `Refund failed: ${err.message}`;
approve.disabled = decline.disabled = false;
}
},
}, `Approve refund of ${money(o.total)}`);
const decline = h("button", {
type: "button", class: "secondary",
onclick: () => {
approve.disabled = decline.disabled = true;
status.textContent = "Refund declined. Nothing was changed.";
},
}, "Decline");
return h("article", { class: "order-card approval", "aria-label": `Approve refund for ${o.id}` },
h("strong", {}, o.id), " ", o.item, h("div", { class: "actions" }, approve, decline), status);
},
},
};
function validate(schema, props) {
if (typeof props !== "object" || props === null) return null;
const clean = {};
for (const [key, type] of Object.entries(schema)) {
if (typeof props[key] !== type) return null; // wrong or missing: refuse the whole component
clean[key] = props[key];
}
return clean; // unknown props are dropped
}
export function renderComponent({ component, props }, ctx) {
const def = Object.hasOwn(registry, component) ? registry[component] : null;
const clean = def && validate(def.schema, props);
if (!clean) {
console.warn("agent-ui: refused component", component);
return null;
}
return def.render(clean, ctx);
}