open-standards/wai/wai-app/web/components.js
Transaction Science 608cd843e9 wai-app: Create's crate, a library with no binary, whose export wai create writes
wai/wai-ui becomes wai/wai-app, the library `wai_app`, with no binary and no `cli`
feature: `wai`, built with its feature `app`, is its command line and its MCP
server. Its served files are named wai-app-* (the WebAssembly package wai_app),
the recordings wai-app-*.json.

- export.rs gives the files of a starter project for a program and the facts its
  project file holds (title, summary, `wai dev`'s mode for the app's room, a watch
  room's file, a show's audience); `wai create --from app.oui` writes them, the
  project file and the licence. The export no longer writes app.json, room.toml,
  relay.toml, package.json, check.mjs or serve.mjs: wai.json names the room, `wai
  dev` runs it and `wai app check` checks the project. Its README and AGENTS.md
  say so, and the README its licence.
- runtime.rs reads a Create runtime by its listing (create/wai-app-runtime.json),
  checking each file's SHA-256.
- bridge.rs keeps the bridge on 127.0.0.1 a coding agent streams a program into
  the page through, with a stream helper; the stdio server, its tools and the
  binary's test go (`wai mcp --toolset app` serves them).
- The page's Export hands over app.oui, the program in its canonical form, and the
  command that makes it a project; the ZIP writer and the page's export in
  WebAssembly go. The page names `wai mcp --toolset app` for the coding agent.
- joule-ui/docs/create.md §4 and §5: the export as `wai create --from app.oui`, and
  the agent's tools app_guide, app_check, app_stream and app_export.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:13:27 -04:00

461 lines
22 KiB
JavaScript

// The WAI widgets of wai-app's registry, as components of the streaming renderer.
//
// A room widget (Room, WatchTogether, Show) runs the room client of joule-moq-web in a frame
// of this page's own origin (room-frame.html): the page that makes its own key, asks the
// room service for a grant, proves the key to the relay, checks every audio and video
// object against its signed receipt before it is shown, keeps a watch room in sync and
// follows a show's polls. The widgets inside the room drive that client through its own
// interface (`roomTest`), from this page: every action a person takes (join, watch, leave,
// send a chat, open a poll, vote, play, pause) goes through the page's session and is
// sealed into its log (session.js).
const h = (tag, attrs = {}, ...kids) => {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "text") el.textContent = v;
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
else el.setAttribute(k, v === true ? "" : String(v));
}
for (const c of kids) if (c != null) el.append(c);
return el;
};
const short = (p) => (p ? `${p.slice(0, 8)}…` : "");
const text = (props, k, d = "") => (typeof props[k] === "string" ? props[k] : d);
// ---------------------------------------------------------------------------- the room
// The page's one room: the widget that holds it sets it, the widgets inside it read it.
export class RoomSlot {
constructor() { this.current = null; this.subs = new Set(); }
set(handle) { this.current = handle; for (const f of this.subs) f(handle); }
clear(handle) { if (this.current === handle) this.set(null); }
on(f) { this.subs.add(f); if (this.current) f(this.current); return () => this.subs.delete(f); }
}
// The room client in its frame, and what the widgets ask of it.
export class RoomHandle {
constructor(kind, frame) {
this.kind = kind;
this.frame = frame;
this.subs = new Set();
this.last = null;
this.timer = setInterval(() => this.tick(), 600);
}
get t() {
try { return this.frame.contentWindow && this.frame.contentWindow.roomTest; } catch { return null; }
}
// Ready to enter: the client has read its room service and waits for a role.
async ready(ms = 30000) {
const end = Date.now() + ms;
for (;;) {
const t = this.t;
if (t && t.enter && t.state && t.state.phase === "idle") return t;
if (t && t.state && t.state.phase === "refused") throw new Error("the room client refused its endpoints");
if (Date.now() > end) throw new Error("the room client did not load");
await new Promise((r) => setTimeout(r, 150));
}
}
async enter(role) {
const t = await this.ready();
const phase = await t.enter(role);
this.tick();
return { phase, principal: this.me() };
}
async leave() { const t = this.t; if (t && t.leave) await t.leave(); this.tick(); return "left"; }
state() { const t = this.t; return (t && t.state) || null; }
phase() { const s = this.state(); return s ? s.phase : "loading"; }
me() { return (this.last && this.last.me && this.last.me.principal) || null; }
role() { const t = this.t; return t && t.role; }
snapshot() { try { return this.t && this.t.snapshot ? this.t.snapshot() : null; } catch { return null; } }
chats() {
const s = this.state();
return ((s && s.timeline) || []).filter((e) => e.kind === "chat").map((e) => ({ t: e.t, who: e.who, text: e.payload && e.payload.text, digest: e.digest }));
}
async sendChat(text) { const v = await this.t.sendChat(text); this.tick(); return { t: v && v.t, digest: v && v.digest }; }
co() { try { return this.t && this.t.coState ? this.t.coState() : null; } catch { return null; } }
async openPoll(question, options, seconds) {
const r = await this.t.coRequest("/co/window", { rule: "plurality", options, k: 1, seconds, question });
if (r.status !== 200) throw new Error(`the room's clock answered ${r.status}: ${JSON.stringify(r.body)}`);
return { status: r.status };
}
async vote(window, option) { const v = await this.t.coInput({ window, type: "vote", option }); return { window, option, t: v && v.t }; }
async watch(name, arg) { const at = await this.t.watchControl(name, arg); return { name, arg, at }; }
// The watch room's player position, in microseconds.
position() {
try { const m = this.frame.contentDocument.getElementById("cowatch-media"); return Math.round((m.currentTime || 0) * 1e6); } catch { return 0; }
}
// Objects received and checked against their receipts, over every participant and track.
checked() {
const s = this.state();
const out = { received: 0, verified: 0, refused: 0 };
for (const sub of Object.values((s && s.subs) || {})) {
for (const m of ["audio", "video", "screen"]) {
const x = sub[m];
if (!x) continue;
out.received += x.received || 0;
out.verified += x.verified || 0;
out.refused += x.refused || 0;
}
}
return out;
}
present() {
const s = this.state();
return ((s && s.room && s.room.present) || []).map((p) => p.principal);
}
setView(view) {
try { this.frame.contentDocument.body.dataset.view = view; } catch { /* not loaded yet: the query has it */ }
}
tick() {
this.last = this.snapshot();
for (const f of this.subs) { try { f(this); } catch { /* a view's own fault */ } }
}
on(f) { this.subs.add(f); f(this); return () => this.subs.delete(f); }
destroy() {
clearInterval(this.timer);
this.subs.clear();
try { if (this.t && this.t.leave && ["publishing", "watching"].includes(this.phase())) this.t.leave(); } catch { /* gone */ }
}
}
const KIND = {
Room: { kind: "live", label: "Live room", join: "Join with camera and microphone", watch: "Watch", view: "none" },
WatchTogether: { kind: "watch", label: "Watching together", join: null, watch: "Watch together", view: "watch" },
Show: { kind: "show", label: "Show", join: "Join as a host", watch: "Watch", view: "none" },
};
const PHASE = {
loading: "The room client is loading…",
idle: "Not in the room yet.",
entering: "Entering…",
publishing: "In the room, on camera and microphone.",
watching: "Watching.",
full: "The room is full: you can watch.",
left: "You left the room.",
refused: "This page cannot reach the room service.",
};
function roomWidget(name) {
const k = KIND[name];
return (ctx, props) => {
const title = h("h2", { class: "wai-room-title", text: text(props, "title", k.label) });
const status = h("span", { class: "wai-room-status", role: "status", "aria-live": "polite", text: PHASE.loading });
const join = k.join ? h("button", { type: "button", class: "wai-btn wai-primary", "data-act": "join", text: k.join }) : null;
const watch = h("button", { type: "button", class: "wai-btn", "data-act": "watch", text: k.watch });
const leave = h("button", { type: "button", class: "wai-btn", "data-act": "leave", hidden: true, text: "Leave" });
const frame = h("iframe", { class: "wai-frame", title: `${k.label}: the room`, allow: "camera; microphone; autoplay", loading: "eager" });
const stage = h("div", { class: "wai-room-stage", "data-view": k.view }, frame);
const slot = h("div", { class: "wai-room-children" });
const el = h("section", { class: "wai-room", "data-kind": k.kind },
h("header", { class: "wai-room-head" }, title, h("span", { class: "wai-kind", text: k.label })),
h("div", { class: "wai-room-controls" }, join, watch, leave, status),
stage, slot);
const u = new URL(ctx.config.frame, location.href);
u.searchParams.set("service", (ctx.config.services && ctx.config.services[k.kind]) || ctx.config.service);
u.searchParams.set("view", k.view);
frame.src = u.toString();
const handle = new RoomHandle(k.kind, frame);
handle.stage = stage;
handle.views = new Set();
handle.showVideo = (on) => {
if (on) handle.views.add("video"); else handle.views.delete("video");
const view = k.view === "watch" ? "watch" : handle.views.size ? "stage" : "none";
stage.dataset.view = view;
handle.setView(view);
};
// The frame's height follows its content.
const fit = () => {
try {
const d = frame.contentDocument;
if (d && d.body) frame.style.height = `${Math.max(0, d.body.scrollHeight)}px`;
} catch { /* not ours yet */ }
};
frame.addEventListener("load", () => {
handle.setView(stage.dataset.view);
try { new ResizeObserver(fit).observe(frame.contentDocument.body); } catch { /* no observer */ }
fit();
});
const busy = (b) => { for (const x of [join, watch, leave]) if (x) x.disabled = b; };
const act = (action, role) => async () => {
busy(true);
try {
if (role) await ctx.session.fire(action, { room: k.kind, role }, () => handle.enter(role));
else await ctx.session.fire(action, { room: k.kind }, () => handle.leave());
} catch (e) {
status.textContent = `${PHASE[handle.phase()] || ""} ${e.message || e}`;
}
busy(false);
render();
};
if (join) join.addEventListener("click", act("room.join", "publisher"));
watch.addEventListener("click", act("room.watch", "viewer"));
leave.addEventListener("click", act("room.leave", null));
const render = () => {
const p = handle.phase();
const inRoom = p === "publishing" || p === "watching";
status.textContent = PHASE[p] || p;
if (join) join.hidden = inRoom || p === "left";
watch.hidden = inRoom || p === "left";
leave.hidden = !inRoom;
el.dataset.phase = p;
};
handle.on(render);
ctx.room.set(handle);
return {
el,
slot,
handle,
update(next) { title.textContent = text(next, "title", k.label); },
destroy() { ctx.room.clear(handle); handle.destroy(); },
};
};
}
// The widgets inside a room find it through the page's room slot.
function inRoom(ctx, draw) {
let handle = null;
let off = null;
const offSlot = ctx.room.on((hd) => {
if (off) off();
handle = hd;
off = hd ? hd.on(() => draw(hd)) : null;
if (!hd) draw(null);
});
return { get handle() { return handle; }, stop() { if (off) off(); offSlot(); } };
}
// ---------------------------------------------------------------------------- the widgets
function App(ctx, props) {
const title = h("h1", { class: "wai-app-title", text: text(props, "title") });
const slot = h("div", { class: "wai-app-children" });
const el = h("div", { class: "wai-app-shell" }, title, slot);
return { el, slot, update(p) { title.textContent = text(p, "title"); } };
}
function Text(ctx, props) {
const el = h("p", { class: "wai-text", text: text(props, "text") });
return { el, update(p) { el.textContent = text(p, "text"); } };
}
function Player(ctx, props) {
const el = h("div", { class: "wai-player", text: text(props, "title", "The room's video") });
const r = inRoom(ctx, () => {});
const on = ctx.room.on((hd) => { if (hd && hd.showVideo) hd.showVideo(true); });
return {
el,
update(p) { el.textContent = text(p, "title", "The room's video"); },
destroy() { on(); const hd = r.handle; if (hd && hd.showVideo) hd.showVideo(false); r.stop(); },
};
}
function Chat(ctx, props) {
const head = h("h3", { class: "wai-h", text: text(props, "title", "Chat") });
const list = h("ul", { class: "wai-chat-list", "aria-live": "polite" });
const input = h("input", { class: "wai-input", maxlength: "500", autocomplete: "off", "aria-label": "Your message" });
const send = h("button", { type: "submit", class: "wai-btn", text: "Send" });
const note = h("p", { class: "wai-note" });
const form = h("form", { class: "wai-chat-form" }, input, send);
const el = h("section", { class: "wai-chat" }, head, list, form, note);
let shown = 0;
const r = inRoom(ctx, (hd) => {
const chats = hd ? hd.chats() : [];
if (chats.length !== shown) {
list.replaceChildren(...chats.map((c) => h("li", { "data-digest": c.digest || "" }, h("b", { text: `${c.who}: ` }), c.text || "")));
shown = chats.length;
list.scrollTop = list.scrollHeight;
}
const p = hd ? hd.phase() : "loading";
const can = p === "publishing";
input.disabled = send.disabled = !can;
note.textContent = can ? "Each message is signed by your key and checked by everyone before it shows." : p === "watching" ? "Viewers read the chat; those who join write it." : "Join the room to write.";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const t = input.value.trim();
const hd = r.handle;
if (!t || !hd) return;
input.value = "";
try { await ctx.session.fire("chat.send", { text: t }, () => hd.sendChat(t)); } catch (err) { note.textContent = String(err.message || err); }
});
return { el, update(p) { head.textContent = text(p, "title", "Chat"); }, destroy() { r.stop(); } };
}
function Poll(ctx, props) {
let p = props;
const q = h("h3", { class: "wai-h wai-poll-q" });
const opts = h("div", { class: "wai-poll-options" });
const open = h("button", { type: "button", class: "wai-btn wai-primary", hidden: true, text: "Open the poll" });
const result = h("p", { class: "wai-poll-result", role: "status", "aria-live": "polite" });
const note = h("p", { class: "wai-note" });
const el = h("section", { class: "wai-poll" }, q, opts, open, result, note);
const options = () => (Array.isArray(p.options) ? p.options : []).map(String);
const question = () => text(p, "question");
const seconds = () => (typeof p.seconds === "number" ? p.seconds : 30);
let voted = null;
const draw = (hd) => {
q.textContent = question();
const co = hd ? hd.co() : null;
const labels = options();
const windows = co ? co.windows.filter((w) => w.question === question()) : [];
const w = windows[windows.length - 1] || null;
const now = co ? co.now : 0;
const isOpen = !!w && w.close > now;
const audience = co && co.audience && co.audience.limit > 0;
open.hidden = !(co && co.lead) || isOpen;
open.disabled = false;
if (opts.dataset.labels !== JSON.stringify(labels) || opts.dataset.open !== String(isOpen && audience)) {
opts.dataset.labels = JSON.stringify(labels);
opts.dataset.open = String(isOpen && audience);
opts.replaceChildren(...labels.map((label, i) => {
const b = h("button", { type: "button", class: "wai-btn wai-option", "data-option": String(i), text: label, disabled: !(isOpen && audience) });
b.addEventListener("click", async () => {
const ww = w;
if (!ww || !hd) return;
for (const x of opts.querySelectorAll("button")) x.disabled = true;
try { await ctx.session.fire("poll.vote", { question: question(), option: i, label }, () => hd.vote(ww.digest, i)); voted = i; } catch (e) { note.textContent = String(e.message || e); }
});
return b;
}));
}
if (w && w.selection) {
const pick = w.selection.picks[0];
const credit = w.selection.credits[0];
result.textContent = pick && pick.option != null ? `The room chose “${labels[pick.option] || `option ${pick.option + 1}`}”, with ${credit ? credit.count : 0} vote${credit && credit.count === 1 ? "" : "s"}; the room's clock signed the result.` : "The poll closed with no vote.";
el.dataset.state = "closed";
} else if (isOpen) {
result.textContent = `Open: closes in ${Math.max(0, Math.round((w.close - now) / 1e6))} s · ${w.inputs} vote${w.inputs === 1 ? "" : "s"} so far${voted != null ? ` · you chose “${labels[voted]}”` : ""}`;
el.dataset.state = "open";
} else if (w) {
result.textContent = "Closed: the room's clock is counting.";
el.dataset.state = "counting";
} else {
result.textContent = co ? (co.lead ? "Open it when you are ready." : "The host opens the poll.") : "Join or watch the show to take part.";
el.dataset.state = "waiting";
}
note.textContent = audience ? "Your vote is signed by your key; the room's clock selects and signs the result." : co && co.lead ? "You lead this show: you open its polls." : "";
};
const r = inRoom(ctx, draw);
open.addEventListener("click", async () => {
const hd = r.handle;
if (!hd) return;
open.disabled = true;
try { await ctx.session.fire("poll.open", { question: question(), options: options() }, () => hd.openPoll(question(), options().length, seconds())); } catch (e) { note.textContent = String(e.message || e); }
});
draw(null);
return { el, update(next) { p = next; draw(r.handle); }, destroy() { r.stop(); } };
}
function Participants(ctx, props) {
const head = h("h3", { class: "wai-h", text: text(props, "title", "In the room") });
const list = h("ul", { class: "wai-people" });
const el = h("section", { class: "wai-participants" }, head, list);
const r = inRoom(ctx, (hd) => {
const ps = hd ? hd.present() : [];
const me = hd ? hd.me() : null;
const json = JSON.stringify([ps, me]);
if (list.dataset.v === json) return;
list.dataset.v = json;
list.replaceChildren(...(ps.length ? ps.map((p) => h("li", { "data-principal": p, text: p === me ? `you (${short(p)})` : short(p) })) : [h("li", { class: "wai-note", text: "No one yet." })]));
});
return { el, update(p) { head.textContent = text(p, "title", "In the room"); }, destroy() { r.stop(); } };
}
function Receipts(ctx, props) {
const head = h("h3", { class: "wai-h", text: text(props, "title", "Receipts") });
const verdict = h("p", { class: "wai-verdict", role: "status" });
const table = h("table", { class: "wai-log" }, h("thead", {}, h("tr", {}, ...["#", "Action", "Verdict", "Energy", "Link"].map((t) => h("th", { text: t })))));
const body = h("tbody");
table.append(body);
const room = h("p", { class: "wai-note" });
const el = h("section", { class: "wai-receipts" }, head, verdict, h("div", { class: "wai-scroll" }, table), room);
const draw = () => {
const log = ctx.session.log();
const v = ctx.session.verify();
verdict.textContent = v.verified != null
? `${v.verified} action${v.verified === 1 ? "" : "s"} sealed by this page's key (${short(ctx.session.actor.replace("did:key:", ""))}), the chain verified link by link.`
: `The chain breaks at link ${v.broken_at}.`;
verdict.dataset.ok = String(v.verified != null);
body.replaceChildren(...log.slice(-12).map((e) => h("tr", { "data-seq": String(e.seq) },
h("td", { text: String(e.seq) }), h("td", { text: e.action }), h("td", { text: e.verdict }),
h("td", { text: `${e.energy.estimated_uj} µJ (${e.energy.provenance})` }), h("td", { class: "wai-mono", text: e.id.slice(3, 15) }))));
};
const off = ctx.session.on(draw);
const r = inRoom(ctx, (hd) => {
if (!hd) { room.textContent = ""; return; }
const c = hd.checked();
room.textContent = `The room: ${c.verified} of ${c.received} objects received checked against their receipts${c.refused ? `, ${c.refused} refused` : ""}.`;
});
draw();
return { el, update(p) { head.textContent = text(p, "title", "Receipts"); }, destroy() { off(); r.stop(); } };
}
function EnergyMeter(ctx, props) {
const head = h("h3", { class: "wai-h", text: text(props, "title", "Energy") });
const total = h("p", { class: "wai-energy-total" });
const list = h("ul", { class: "wai-energy" });
const note = h("p", { class: "wai-note", text: "A page has no energy meter: each figure is an estimate (the action's time on this page at 20 W), labelled Estimator, never a reading." });
const el = h("section", { class: "wai-energymeter" }, head, total, list, note);
const draw = () => {
const log = ctx.session.log();
const by = new Map();
for (const e of log) if (e.verdict === "allow") by.set(e.action, (by.get(e.action) || 0) + e.energy.estimated_uj);
const sum = ctx.session.totalUj();
total.textContent = `${(sum / 1e6).toFixed(3)} J over ${log.length} action${log.length === 1 ? "" : "s"} (Estimator)`;
list.replaceChildren(...[...by.entries()].map(([a, uj]) => h("li", { text: `${a}: ${(uj / 1e3).toFixed(1)} mJ` })));
};
const off = ctx.session.on(draw);
draw();
return { el, update(p) { head.textContent = text(p, "title", "Energy"); }, destroy() { off(); } };
}
function WatchTogetherControls(ctx) {
// The shared player's controls, each a sealed action: they apply to everyone in the room.
const mk = (label, act) => h("button", { type: "button", class: "wai-btn", "data-act": act, text: label });
const play = mk("Play", "play"), pause = mk("Pause", "pause"), back = mk("Back 10 s", "back"), fwd = mk("Ahead 10 s", "ahead");
const el = h("div", { class: "wai-watch-controls", hidden: true }, play, pause, back, fwd);
const r = inRoom(ctx, (hd) => { el.hidden = !hd || hd.phase() !== "watching"; });
const go = (name, arg) => async () => {
const hd = r.handle;
if (!hd) return;
const a = typeof arg === "function" ? arg(hd) : arg;
try { await ctx.session.fire(`watch.${name}`, { arg: a }, () => hd.watch(name, a)); } catch { /* shown by the room */ }
};
play.addEventListener("click", go("play", 0));
pause.addEventListener("click", go("pause", 0));
back.addEventListener("click", go("seek", (hd) => Math.max(0, hd.position() - 10e6)));
fwd.addEventListener("click", go("seek", (hd) => hd.position() + 10e6));
return { el, stop: () => r.stop() };
}
function WatchTogether(ctx, props, node) {
const c = roomWidget("WatchTogether")(ctx, props, node);
const controls = WatchTogetherControls(ctx);
c.el.insertBefore(controls.el, c.slot);
const destroy = c.destroy;
c.destroy = () => { controls.stop(); destroy(); };
return c;
}
export const components = {
App,
Text,
Room: roomWidget("Room"),
WatchTogether,
Show: roomWidget("Show"),
Player,
Chat,
Poll,
Participants,
Receipts,
EnergyMeter,
};
// The context the components share: the page's session, its one room, and where the room
// service (`service`, or one for each kind of room, `services`) and the room client's frame
// are, each on this page's origin.
export function context({ session, service, services = {}, frame }) {
return { session, room: new RoomSlot(), config: { service, services, frame } };
}