Decision records
ADR 0127 — @loom/plugin-renderer-dom: a framework-free DOM renderer for the Blueprint, in loom
- Status: Accepted (on merge)
- Date: 2026-10-10
- Closes: U21 (loom-ui readiness audit, 2026-10-05): “move renderer-lit’s generic DOM adapter into loom as
plugin-renderer-dom, so a loom app gets a web renderer without loom-ui”. - Builds on: ADR 0043 (1P plugins live in
plugins/), ADR 0088 (a surface must cover the catalogue), ADR 0096 and ADR 0097 (the status matrix every renderer applies), ADR 0098 (placement, titles), ADR 0101 (container ids). - Placement: 1P plugin,
plugins/renderer-dom. Not kernel, not an SPI.KERNEL_ABI,EVENT_SCHEMAand every SPI are unchanged; the only dependencies are@loom/blueprint-runtimeand@loom/projection-runtime, the two the terminal renderer already has.
Context
loom ships two renderers over the Blueprint: React (a peer dependency on React 18) and terminal (strings). A loom app that wants a web page without React has had to adopt loom-ui, whose renderer-lit carries a DOM adapter, or write its own. loom-ui is optional by design (it depends on loom, never the reverse), so loom itself must offer a way to put a Blueprint on a page.
Is renderer-lit’s adapter separable? Partly, and not cheaply. renderer-lit/src/dom is ~1,500 lines of source (tree, reconcile, containers, registry) plus ~5,500 of tests. Its mechanics are generic: keyed reconcile by widget id and layout id, a status matrix, a projection subscription per widget. But it is bound to loom-ui in three ways that cannot cross the boundary: every action is stamped with viewedVersion from @loom-ui/core (the Experience layer); its widget contract is “mount into a host, return a handle with patch/dispose”, shaped for Lit custom elements; and its strings come from loom-ui’s i18n catalogue (say). Its container-role registry (DomContainerRenderer) exists for loom-ui’s Experience containers (tabs, stepper). Moving it would either drag those in or hollow it out. This ADR therefore designs the renderer from loom’s Blueprint and its two existing renderers (the terminal’s RendererRegistry + status matrix, React’s WidgetFrame and eight widgets), and borrows renderer-lit’s ideas (keyed reconcile; focus-safe minimal DOM moves; never throw from update), not its code. loom-ui may later delete its own adapter in favour of this one; that is loom-ui’s call and not a precondition.
Decision
D1 — Placement: a 1P plugin, @loom/plugin-renderer-dom in plugins/renderer-dom. ADR 0043’s rule: a renderer is an adapter over a seam (RendererRegistry, ProjectionRuntime), so it is a plugin. Second consumers (ADR 0083’s outward gate): the a2ui-dashboard and db-observability examples in loom-examples (a web surface today means React or a hand-written adapter), and any loom app serving a page without a framework; the third is loom-ui itself, which can render its widgets through the same DomWidgetRenderer contract. It is not added to the @loom/sdk barrel: the helper names it exports for custom renderers (h, display, token) are too generic for a barrel that export *s every plugin, and the React renderer is outside the barrel for the same reason of weight.
D2 — API: the terminal renderer’s shape, plus what a live DOM needs.
renderBlueprintToDom(blueprint, stateMap, options?) -> HTMLElement: the one-shot sibling ofrenderBlueprintToString. Not live.mountBlueprint(host, blueprint, { projectionRuntime, executionId, ...options }) -> DomHandle: appends intohost, subscribes each bound projection, and on a state change re-draws only the widgets bound to that projection whose resolved value or status changed (deep equality).handle.update(next)lands a revised Blueprint incrementally (below) and returns{ identity, remounted }(loom’s ownpreservesBlueprintNodesreport, plus the ids that had to be rebuilt).handle.dispose()unsubscribes and removes the element.defaultDomRegistry: RendererRegistry<DomWidgetRenderer>for the eight built-in kinds;DomWidgetRenderer = (ctx: { doc, widget, value, status?, onAction? }) => HTMLElementreturns the widget’s body. Merge your own with.merge(); do not mutate the shared one (as the terminal and React registries).- Options:
registry,check(the value-contract slot),onAction,title: "heading" | "none",document. - “Surface patches”: the agent-surface patch protocol (
@loom/agent-surface) already folds into a projection; a widget bound to that projection is re-drawn by the subscription above. The renderer does not interpret patches itself, so it works for any projection, not only surfaces.
D3 — Incremental update is a keyed reconcile. Widgets are keyed by id (kind change remounts); containers by layout id (IR 2.4.0) or, without one, by ordinal among the id-less siblings plus type. A matched entry keeps its DOM node; unmatched ones are created or swept; the children are put in order by moving a node only when it is not already in place, so focus and scroll survive an insert or reorder. A widget that moves to a different container is remounted (reported in remounted). Widgets are re-drawn by replacing their own subtree: a renderer’s body holds no state the renderer must preserve (the built-ins are display widgets and two buttons); a custom widget that owns user-entered state must hold it outside its body or accept the re-draw. This is a deliberate simplification of renderer-lit’s patch(widget) handle, which exists for Lit elements that own input state.
D4 — Coverage: the same eight kinds, the same matrix, asserted. MetricCard, Timeline, ApprovalCard, StatusBadge, StreamingText, ProgressBar, DataTable, AlertCard. The canonical binding-status matrix (error, empty, contract, stale, pending, loading, ready) is applied by the adapter around any widget, with the React renderer’s markers and roles (role="alert" for error, role="status" for empty), data-binding-state, data-source-* and the degraded-from chain. IR 2.1.0 titled layouts are <section aria-label><h2>, placed grid cells get grid-column/grid-row on the child itself (no wrapper element), and Layout.role is ignored as React and terminal ignore it (ADR 0099): there is no container-renderer registry here, because that axis is loom-ui’s Experience layer. An unregistered kind draws a visible [No renderer for X] placeholder; a throwing renderer is contained to its widget and its message is not shown. src/parity.test.ts asserts that the DOM, React and terminal registries each cover builtinCatalog and register exactly the same kinds, and that one Blueprint shows the same facts on the DOM and terminal surfaces. A kind added to the catalogue without a renderer on any of the three fails there.
D5 — Security: the renderer cannot be made to parse data.
- Every element is built through
h(doc, tag, attrs, ...children). There is noinnerHTML,outerHTML,insertAdjacentHTML,document.writeorsrcdocin the package; a string child is a text node. A data string such as<img src=x onerror=...>is displayed, not parsed. - Tag and attribute names are written by renderer authors, so an unlisted one throws (a bug, found in the first test) rather than being silently dropped: no
script,iframe,object,embed,style,link; noon*, nostyle, nosrcdoc. Attributes are an allowlist plusdata-*andaria-*. - The one attribute value that can execute is
href:safeUrlstrips whitespace, control, zero-width and bidi characters before reading the scheme (sojava\tscript:andjavascript:fail) and allows only a relative reference orhttp,https,mailto,tel. Everything else (javascript:,data:,vbscript:,file:, protocol-relative//host, and its backslash spellings/\host,\\host,\/host, which a browser reads as//host) is dropped. The built-in widgets emit no URL at all; the guard is for custom renderers built onh. - Data never authors a class, attribute name or style:
StatusBadge’s class suffix is a validated token (token()),AlertCardseverity is a closed set, layoutcolumns/gap/placement are validated integers/numbers written through CSSOMsetProperty, never concatenated into astyleattribute. - No sandboxed embeds. React and terminal have none, so this renderer adds none; an iframe-based widget is a custom, app-owned decision (and
iframeis not on the tag allowlist). - No ambient
document: elements are created fromhost.ownerDocument(oroptions.document), so the renderer runs under jsdom or in an iframe’s document, and throws a clear error when none exists.
D6 — Accessibility basics. Semantic elements (<table> with <th scope="col">, <ul>/<li>, <button type="button">, native <progress> whose missing value is the standard indeterminate state, so unknown never reads as 0%); landmarks named by aria-label; role="alert" only for error/critical, role="status" (polite) otherwise; text markers (✗ ○ ~ ⋯ …) as well as attributes so state is not colour-only; the title is a real <h1> and section titles <h2>. The renderer ships no CSS: class names (metric-value, badge--ready, alert--error, …) and data-* are the styling contract, as in the React renderer.
D7 — What it does not do. No loom-ui Experience, compile step, locale/i18n layer, sensitivity redaction, or action stamping (viewedVersion): an action is { type, widgetId, payload }, the WidgetAction every loom renderer emits. No Lit, no web components, no framework, no runtime dependency beyond the two loom packages (jsdom, React and the other two renderers are dev-only, for tests and the parity check). No container-role renderers, no chat/media/charts (loom-ui’s catalogue). No server-side rendering string output (renderBlueprintToString is the terminal’s; a DOM needs a DOM). No innerHTML escape hatch.
Alternatives rejected
- Move
renderer-lit/src/domverbatim. Brings@loom-ui/coreaction stamping, the i18n catalogue, the Lit-shaped handle contract and the container-role registry into loom, inverting the dependency this ADR exists to keep one-way. Would also double a 7,000-line surface in the framework for one optional audit row. - Re-render the whole tree on every change (as React’s eager path does). Simplest, but it destroys focus and scroll and costs O(blueprint) per event; the keyed reconcile is ~100 lines.
- A string renderer (
renderBlueprintToHtml). Has to escape correctly in every context, and escaping bugs are the XSS class this design removes by never serializing. - Add it to the
@loom/sdkbarrel. See D1.
Consequences and residuals
- A loom app can serve a live web surface with no framework:
mountBlueprint(document.body, bp, { projectionRuntime, executionId, onAction }). - The DOM renderer is the third implementation of the status matrix (React’s
WidgetFrame, terminal’s glyph rows). The parity test covers the vocabulary and the shown facts, not the markup of each state; extracting a shared matrix function into@loom/blueprint-runtimewould remove the copies and is a follow-up, not a precondition (a second consumer now exists to justify it).- Update 2026-10-11 — resolved. The matrix is now ONE pure decision,
describeWidgetStatus(widget, value, status, check?)in@loom/blueprint-runtime, returning a tagged descriptor (inert | error | empty | stale | pending | loading | ready, plusmessage,finding,showBody,degradedChain). React’sWidgetFrame, the terminal and the DOM renderer draw from it and own only their output; their existing markup/snapshot tests pass unchanged. A malformedsource.degradedFrom(not an array of objects) is{ tag: "invalid" }, drawn as the error placeholder on all three (React/terminal used to throw); each renderer has a test.src/parity.test.tsnow renders the same blueprints through React too (facts, every status’s marker, a failing contract check, the malformed-source placeholder). The terminal’sloadingrow stays⋯ titlewhere DOM and React draw…: a pre-existing difference, now explicit in the parity table. - Update 2026-10-14 — residual closed. The 2026-10-11 extraction shared the DECISION but each renderer still kept its own copy of the vocabulary it draws for it (marker name, ARIA role, glyph, the
glyph title: messageline, the stale tooltip,data-source-*, the degraded tooltip).describeWidgetFrame(widget, descriptor)in@loom/blueprint-runtimenow returns that as data; the three renderers keep only their medium (elements, React nodes, text; the terminal’s ownloadingglyph and stale-without-value form stay local and are pinned). Per ADR 0083 the new export is justified by three consumers (React, terminal, DOM), no new ADR. Behaviour is unchanged, proven by characterisation snapshots written before the refactor (statusGolden.test.*in each renderer). React parity is no longer registry-level only:parity.test.tsasserts the same state, marker, role and degraded-from chain per status on all three surfaces, plus the contract/stale and error/empty precedence; breaking one renderer’s role or degraded chain fails it.
- Update 2026-10-11 — resolved. The matrix is now ONE pure decision,
- A widget that owns input state is re-drawn on a value change. If a second consumer needs
patch(widget)-style in-place updates,DomWidgetRenderercan grow an optional handle form without breaking the function form. - The renderer-lit adapter in loom-ui is not touched. Whether loom-ui adopts this package for its generic path is loom-ui’s decision.
Source: docs/adr/0127-plugin-renderer-dom.md in the loom repository.