open-standards/wai/wai-app/web/renderer.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

112 lines
4.1 KiB
JavaScript

// The streaming renderer: each snapshot of a program (joule-ui's line language, a tree
// every widget of which the registry accepted) reconciled into the page by key. A widget
// keeps its instance, and so its state (a room joined, a chat typed), across snapshots
// while its key and its widget stay the same; only its props are updated. A widget that
// appears is created (and marked, so the page shows it arriving); one that is gone is
// destroyed.
//
// A component is `(ctx, props, node) => ({ el, slot?, update?(props), destroy?() })`:
// `el` its element, `slot` where its children go (it holds nothing else).
export class Renderer {
constructor(container, components, ctx) {
this.container = container;
this.components = components;
this.ctx = ctx;
this.inst = new Map();
this.created = 0;
this.renders = 0;
}
render(snap) {
this.renders++;
const tree = snap && snap.tree;
if (!tree) {
for (const [k, i] of [...this.inst]) this.destroy(k, i);
this.container.replaceChildren();
return;
}
const nodes = snap.nodes || [];
let idx = 0;
const seen = new Set();
const walk = (w) => {
const node = nodes[idx++] || { key: `#${idx}`, line: 0, statement: "" };
const key = node.key;
seen.add(key);
let inst = this.inst.get(key);
if (inst && inst.name !== w.name) {
this.destroy(key, inst);
inst = null;
}
const json = JSON.stringify(w.props || {});
const props = plainProps(w.props);
if (!inst) {
const make = this.components[w.name] || unknown(w.name);
const comp = make(this.ctx, props, node);
comp.el.classList.add("wai-w", "wai-new");
comp.el.dataset.key = key;
comp.el.dataset.widget = w.name;
comp.el.dataset.line = String(node.line);
setTimeout(() => comp.el.classList.remove("wai-new"), 1400);
inst = { name: w.name, comp, json };
this.inst.set(key, inst);
this.created++;
} else if (inst.json !== json) {
if (inst.comp.update) inst.comp.update(props);
inst.json = json;
}
inst.comp.el.dataset.line = String(node.line);
const kids = (w.children || []).map(walk);
if (inst.comp.slot) place(inst.comp.slot, kids.map((k) => k.comp.el));
return inst;
};
const root = walk(tree);
for (const [k, i] of [...this.inst]) if (!seen.has(k)) this.destroy(k, i);
if (this.container.children.length !== 1 || this.container.firstElementChild !== root.comp.el) this.container.replaceChildren(root.comp.el);
}
destroy(key, inst) {
try { if (inst.comp.destroy) inst.comp.destroy(); } catch { /* the component's own fault */ }
inst.comp.el.remove();
this.inst.delete(key);
}
// The widgets on the page now, by key.
keys() { return [...this.inst.keys()]; }
}
// A prop value in joule-ui's wire form (`{type, value}`) as a plain value: text, number,
// boolean, null, an array, an object, or an action `{ action, payload }`.
export function plain(v) {
if (!v || typeof v !== "object") return v;
switch (v.type) {
case "null": return null;
case "list": return (v.value || []).map(plain);
case "object": return Object.fromEntries(Object.entries(v.value || {}).map(([k, x]) => [k, plain(x)]));
case "action": return { action: v.value.id, payload: v.value.payload == null ? null : plain(v.value.payload) };
default: return v.value;
}
}
export function plainProps(props) {
return Object.fromEntries(Object.entries(props || {}).map(([k, v]) => [k, plain(v)]));
}
// Put `els` in `slot` in order, moving only what is out of place (an iframe moved in the
// document reloads, so nothing in place is touched).
export function place(slot, els) {
for (const c of [...slot.children]) if (!els.includes(c)) c.remove();
for (let i = 0; i < els.length; i++) {
const at = slot.children[i];
if (at !== els[i]) slot.insertBefore(els[i], at || null);
}
}
function unknown(name) {
return () => {
const el = document.createElement("div");
el.className = "wai-unknown";
el.textContent = `(${name}: no component for this widget on this page)`;
return { el };
};
}