<!-- GENERATED by scripts/build-llms.mjs from llms/conversation.md — do not edit this file. -->

# `lr-widget-renderer`

- **Import** `import '@aceshooting/lyra-ui/components/lr-widget-renderer.js';` (stable tag alias; registers the tag)
- **Class** `LyraWidgetRenderer`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/widget-renderer/widget-renderer.class.js`
- **Family** `components/conversation/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** `dompurify`, `katex`, `marked`, `shiki` — see `llms/peers.md`
- **Themeable via** 4 parts, 0 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-widget-renderer`

Renders an agent-streamed version-two declarative JSON widget document through an immutable,
allowlisted `type -> lyra tag` registry. Mapped tags and children render declaratively, so populated
documents work during SSR without a global `document`; primitive mapped props remain property-only
and are assigned during hydration. Keyed reconciliation preserves the mapped element's focus,
scroll position, and internal state across streamed document updates. An unchanged resolved mapped
node also retains its memoized template and ref callback, so unrelated renderer updates neither
reassign its props nor detach and reattach its action/binding listeners. Built-in `row`/`col`/`text`
structural nodes render through ordinary nested templates. Not a
form runtime (no input/select/form types in the default registry), no expression language or
implicit state mutation, no remote widget/schema fetching, and it never renders arbitrary HTML or
navigates (no `href` props are allowlisted anywhere). Controlled state binding is deliberately
narrow: a versioned document may bind an allowlisted primitive prop to a JSON Pointer, and the host
must apply every requested change itself.

Default authoring diagnostics are silent outside Lit's development mode. An explicit expert
`ResolveContext.warn` callback remains active in every runtime; warning keys and the suppression cap
remain scoped to the current context generation. Runtime `lr-render-error` reporting is independent
of this development diagnostic policy.

`createWidgetDocument` validates authored trees immediately. To handle a received, untrusted
document, pass its envelope to the renderer's `document` boundary; malformed envelopes clear prior
output and emit `lr-render-error`. The malformed-stream example exercises this boundary.

**Exported types:**

- `LyraWidgetNode { readonly type: string; readonly id?: string; readonly props?:
Readonly<Record<string, unknown>>; readonly children?: readonly (LyraWidgetNode | string)[];
readonly slot?: string; readonly actionId?: string; readonly payload?: unknown }` — `id` is a stable public
  identity and reconciliation key. Bound/actionable nodes require a unique, nonempty id; other
  nodes fall back to a deterministic structural `nodePath`. `slot` is honored only when the parent
  type allowlists it, `actionId` arms the type's declared action trigger, and `payload` is echoed
  back in `lr-widget-action`.
- `LyraWidgetBinding { $bind: string; fallback?: string | number | boolean | null }` — an explicit JSON
  Pointer lookup used as an allowlisted prop value. `fallback` is used only when the pointer cannot
  resolve.
- `LyraWidgetDocument { readonly version: '2'; readonly root: LyraWidgetNode }` — the sole versioned tree source.
- `createWidgetDocument(root: LyraWidgetNode): LyraWidgetDocument` — creates an immediate frozen
  version-two snapshot for former unversioned tree assignments. Traversal uses the renderer's depth,
  node, per-node prop, and per-string length ceilings; malformed, cyclic, duplicate-id, or hostile
  structure throws `TypeError`. Node records, child arrays, and prop records are copied and frozen,
  while opaque prop values and action payloads intentionally retain caller identity.

The package root and the normal `widget-renderer.js` registration entry expose the renderer
`LyraWidgetRenderer`/`LyraWidgetRendererEventMap` together with the complete stable authoring
surface: `LyraWidgetNode`, `LyraWidgetBinding`, `LyraWidgetDocument`, `createWidgetDocument`,
`LyraWidgetPropType`, `LyraWidgetInteraction`, `LyraWidgetTypeDefinition`,
`LyraWidgetTypeRegistry`, `createWidgetTypeRegistry`, `isWidgetTypeRegistry`, and
`DEFAULT_WIDGET_TYPE_REGISTRY`. Advanced consumers that need `resolveTree`, `ResolveContext`,
`ResolvedNode`, `ResolvedText`, or `ResolvedElement` import the explicit expert route
`@aceshooting/lyra-ui/components/conversation/widget-renderer/resolve.js`. Pointer-reading helpers
and the resolver's hard safety ceilings remain internal implementation details.

The expert route's exact resolver contracts are `ResolvedText { nodeKey: string; nodePath: string;
kind: 'text'; text: string; slot?: string }`, `ResolvedElement { nodeId?: string; nodeKey: string;
nodePath: string; kind: 'builtin-row' | 'builtin-col' | 'builtin-text' | 'mapped'; tag?: string;
interactive: boolean; props: Record<string, unknown>; actionEvent?: string; actionId?: string;
payload?: unknown; bindings: Array<{ prop: string; path: string; event?: string }>; children:
ResolvedNode[]; slot?: string }`, and `ResolveContext { registry: LyraWidgetTypeRegistry;
bindingState: unknown; warned: Set<string>; warn?: (message: string) => void }`.
`resolveTree(root: LyraWidgetNode | null | undefined, ctx: ResolveContext): ResolvedNode | null`
resolves one bounded snapshot; invalid structure returns `null`.

**Properties:**

- `document: LyraWidgetDocument | null = null` (property only) — the sole tree source. `null`
  renders an empty base; a present document with an invalid/missing root fails closed, clears prior
  output, and emits exactly one `lr-render-error`.
- `bindingState?: unknown` (property only) — explicit controlled binding state. `null` is a real
  state value, not an absence sentinel.
- `registry: LyraWidgetTypeRegistry = DEFAULT_WIDGET_TYPE_REGISTRY` (property only) — immutable
  per-instance registry; mutable structural `Map` values are rejected.

**Registry module (`widget-renderer/registry.js`):**
`createWidgetTypeRegistry(entries?: Iterable<readonly [string, LyraWidgetTypeDefinition]>):
LyraWidgetTypeRegistry` validates, snapshots, and freezes a unique-key registry;
`isWidgetTypeRegistry(value: unknown): value is LyraWidgetTypeRegistry` is its untyped-boundary
guard. `LyraWidgetTypeRegistry extends ReadonlyMap<string,
Readonly<LyraWidgetTypeDefinition>> {}` is an opaque branded snapshot, so a mutable structural
`Map` is not assignable. `LyraWidgetTypeDefinition { tag: string; interaction:
'none' | 'control'; props?:
Record<string, 'string' | 'number' | 'boolean'>; forcedProps?: Record<string, unknown>; slots?:
string[]; action?: { event: string }; bindings?: Record<string, { event: string }> }` — `tag` is
resolved prefix-aware, `props` is a prop allowlist (a prop absent here, or whose runtime type doesn't
match, is silently skipped — never assigned),
`forcedProps` always apply and are never overridable by `LyraWidgetNode.props`, `slots` allowlists child
`slot` names (a disallowed one renders unslotted rather than being dropped), and `action.event` is
the native/custom DOM event that arms `lr-widget-action` when a node also sets `actionId`.
`bindings?: Record<string, { event: string }>` maps an allowlisted prop to the control event that
requests its controlled update. `interaction` is explicit: actions/bindings require `control`, and
a control descendant under another control fails closed. `DEFAULT_WIDGET_TYPE_REGISTRY` is the
frozen built-in snapshot; there are no module-global register/clear/get mutation APIs.

**Built-in schema:** `text` (plain text node) and `row`/`col` (internal flex wrappers; props `gap:
's'|'m'|'l'`, `align: 'start'|'center'|'end'|'stretch'`, `justify:
'start'|'center'|'end'|'between'`) are structural and cannot be registered. The immutable
`DEFAULT_WIDGET_TYPE_REGISTRY` maps `card` →
`lr-card` (`appearance`), `badge` → `lr-badge` (`variant`), `button` → `lr-button` (`variant`,
`appearance`, `size`, `disabled`, `loading`; action: `click`), `stat` → `lr-stat` (`label`,
`value`, `unit`, `variant`, `caption`, `sub`), `result-card` → `lr-result-card` (`title`),
`result-field` → `lr-result-field` (`label`, `value`), `markdown` → `lr-markdown` (`content`),
`image` → `lr-media-card` (`src`, `alt`, `filename`; forced `{ kind: 'image' }`). The registration
entry defines those eight mapped custom elements and `lr-widget-renderer`; it installs no mutable
module-global registry state.

**Events:** `lr-widget-action` — `detail: { actionId, payload, nodeId, nodeKey, nodePath }`, the single bubbling action
channel. `lr-render-error` — `detail: { error }`, the root or a reachable nested node was
structurally unusable (including a non-object node, invalid `props`/`children` shape, or a tree the
depth/size caps made empty). The rejected update clears prior rendered content and emits this event
once rather than throwing. `lr-widget-state-change` —
`detail: { path, value, nodeId, nodeKey, nodePath, prop }`, emitted when a bound mapped control
requests a controlled update. The renderer never mutates caller data; assign a new `bindingState`
value to complete the update.

**CSS parts:** `base` (the root wrapper, `display: contents`), `row`, `col`, `text` (built-in
structural nodes only — a mapped lyra component exposes its own parts instead).

For predictable rendering, documents are limited to depth 32, 5,000 nodes, and 100 properties per
node. Content beyond those size limits is omitted or truncated; malformed documents fail closed,
clearing prior output and emitting `lr-render-error`. `document` is captured when assigned, so
mutate a copy and assign it to update; `bindingState` updates bindings without changing that
captured document or registry. Exported deterministic `nodeKey`/`nodePath` identity drives
reconciliation.

```ts
import { html } from "lit";
import {
  createWidgetDocument,
  createWidgetTypeRegistry,
  DEFAULT_WIDGET_TYPE_REGISTRY,
} from "@aceshooting/lyra-ui/components/conversation/widget-renderer/widget-renderer.js";
import "@aceshooting/lyra-ui/components/data/sparkline/sparkline.js";
import { tag } from "@aceshooting/lyra-ui/utilities/prefix.js";

const registry = createWidgetTypeRegistry([
  ...DEFAULT_WIDGET_TYPE_REGISTRY,
  [
    "sparkline",
    { tag: tag("sparkline"), interaction: "none", props: { data: "string" } },
  ],
]);
const widgetDocument = createWidgetDocument(msg.widget);
const view = html`<lr-widget-renderer
  .document=${widgetDocument}
  .registry=${registry}
  @lr-widget-action=${(event: CustomEvent) =>
    sendToAgent(event.detail.actionId, event.detail.payload)}
></lr-widget-renderer>`;
```

For a controlled binding, use a per-instance registry and apply the event's requested value back to
`bindingState` (this one-field example binds `/name`). The direct class route supplies the
default type mappings but defines only `lr-widget-renderer`; register every mapped custom element
you use yourself. The normal `widget-renderer.js` entry also registers the eight default mapped
elements, so do not import it on this route.

```html
<lr-widget-renderer id="bound-widget"></lr-widget-renderer>
```

```js
import { LyraWidgetRenderer } from "@aceshooting/lyra-ui/components/conversation/widget-renderer/widget-renderer.class.js";
import { createWidgetDocument } from "@aceshooting/lyra-ui/components/conversation/widget-renderer/resolve.js";
import { createWidgetTypeRegistry } from "@aceshooting/lyra-ui/components/conversation/widget-renderer/registry.js";
import { defineElement, tag } from "@aceshooting/lyra-ui/utilities/prefix.js";
import "@aceshooting/lyra-ui/components/forms/input/input.js";

defineElement("widget-renderer", LyraWidgetRenderer);

const renderer = document.querySelector("#bound-widget");
renderer.registry = createWidgetTypeRegistry([
  [
    "bound-input",
    {
      tag: tag("input"),
      interaction: "control",
      props: { label: "string", value: "string" },
      bindings: { value: { event: "lr-input" } },
    },
  ],
]);
renderer.document = createWidgetDocument({
  type: "bound-input",
  id: "name",
  props: { label: "Name", value: { $bind: "/name", fallback: "" } },
});
renderer.bindingState = { name: "Ada" };
renderer.addEventListener("lr-widget-state-change", (event) => {
  renderer.bindingState = { name: event.detail.value };
});
```

**Optional peer deps:** none new — the normal registration entry directly imports the eight mapped
components (`markdown` keeps its own `marked`/`dompurify` optional-peer fallback). The manual class
route supplies the same default type mappings while leaving component registration under the
consumer's control.
