Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

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_SCHEMA and every SPI are unchanged; the only dependencies are @loom/blueprint-runtime and @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 of renderBlueprintToString. Not live.
  • mountBlueprint(host, blueprint, { projectionRuntime, executionId, ...options }) -> DomHandle: appends into host, 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 own preservesBlueprintNodes report, 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? }) => HTMLElement returns 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 no innerHTML, outerHTML, insertAdjacentHTML, document.write or srcdoc in 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; no on*, no style, no srcdoc. Attributes are an allowlist plus data-* and aria-*.
  • The one attribute value that can execute is href: safeUrl strips whitespace, control, zero-width and bidi characters before reading the scheme (so java\tscript: and ​javascript: fail) and allows only a relative reference or http, 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 on h.
  • Data never authors a class, attribute name or style: StatusBadge’s class suffix is a validated token (token()), AlertCard severity is a closed set, layout columns/gap/placement are validated integers/numbers written through CSSOM setProperty, never concatenated into a style attribute.
  • No sandboxed embeds. React and terminal have none, so this renderer adds none; an iframe-based widget is a custom, app-owned decision (and iframe is not on the tag allowlist).
  • No ambient document: elements are created from host.ownerDocument (or options.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/dom verbatim. Brings @loom-ui/core action 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/sdk barrel. 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-runtime would 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, plus message, finding, showBody, degradedChain). React’s WidgetFrame, the terminal and the DOM renderer draw from it and own only their output; their existing markup/snapshot tests pass unchanged. A malformed source.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.ts now renders the same blueprints through React too (facts, every status’s marker, a failing contract check, the malformed-source placeholder). The terminal’s loading row stays ⋯ title where 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: message line, the stale tooltip, data-source-*, the degraded tooltip). describeWidgetFrame(widget, descriptor) in @loom/blueprint-runtime now returns that as data; the three renderers keep only their medium (elements, React nodes, text; the terminal’s own loading glyph 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.ts asserts 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.
  • A widget that owns input state is re-drawn on a value change. If a second consumer needs patch(widget)-style in-place updates, DomWidgetRenderer can 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.