@cool-ai/beach-a2ui-renderer-lit
v0.5.5
Published
A2UI v0.9 wire-type re-exports plus Beach-specific glue (id-order tracker, host-fit conventions). Authoring and rendering live in @a2ui/web_core / @a2ui/lit.
Readme
@cool-ai/beach-a2ui-renderer-lit
A2UI v0.9 wire-type re-exports plus Beach-specific glue (id-order tracker, host-fit conventions). Authoring and rendering live in the A2UI ecosystem itself; this package is a thin bridge.
Home: cool-ai.org · Documentation: cool-ai.org/docs
Install
npm install @cool-ai/beach-a2ui-renderer-lit @a2ui/web_core@a2ui/web_core is a peer dependency. Consumers using only the bridge or host-fit subpaths (no wire-shape types) need not install it.
What this package contains
The package now ships only Beach-specific glue and re-exports from @a2ui/web_core/v0_9. Earlier versions shipped a parallel typed-component layer + DOM/HTML renderers; that layer has been retired. This is the consequence of the "no parallel walker" architectural commitment — A2UI's catalogue model is the canonical authoring surface, @a2ui/lit is the canonical browser renderer, and Beach contributes only what is genuinely Beach-specific.
| Subpath | What it ships |
|---|---|
| @cool-ai/beach-a2ui-renderer-lit | Wire-shape type re-exports (SurfaceCommand, CreateSurfaceCommand, …), SurfaceInteractionEvent, and the Beach SurfaceComponent type schema |
| @cool-ai/beach-a2ui-renderer-lit/bridge | createIdOrderTracker — Beach-specific glue for paged-update patterns (updateDataModel against indexed paths) |
| @cool-ai/beach-a2ui-renderer-lit/host-fit | parseHostConfig + breakpoint constants — Beach's --beach-host-config viewport conventions for catalogue components |
| @cool-ai/beach-a2ui-renderer-lit/render-server | renderToHtml and renderToText — server-side rendering of A2UI surfaces. Runs @a2ui/lit under jsdom and adapts the rendered DOM tree per channel. |
Authoring A2UI surfaces
Author components against A2UI v0.9's open catalogue shape. Either use object literals directly:
import type { SurfaceCommand } from '@cool-ai/beach-a2ui-renderer-lit';
const surface: SurfaceCommand[] = [
{ version: 'v0.9', createSurface: { surfaceId: 'flight-results', catalogId: 'basic' } },
{ version: 'v0.9', updateComponents: { surfaceId: 'flight-results', components: [
{ component: 'Heading', text: 'Flights to Paris', level: 2 },
{ component: 'List', items: { path: '/flights' }, template: {
component: 'Card',
children: [
{ component: 'Text', text: { path: 'flightNumber' }, variant: 'bold' },
{ component: 'Text', text: { path: 'pricePerPerson' } },
],
} },
] } },
];Or compose against catalogue-specific helpers from @cool-ai/beach-a2ui-basics, which extends @a2ui/web_core's basic catalogue with Beach-shaped primitives (Pill, etc.) registered via the catalogue mechanism.
Rendering
Browser
Browser DOM rendering is delegated entirely to @a2ui/lit:
import { A2UIClient } from '@a2ui/lit';
const client = new A2UIClient(document.getElementById('workspace')!);
client.handle(surface);Server (email, WhatsApp, SMS, voice)
Server-side rendering ships in @cool-ai/beach-a2ui-renderer-lit/render-server. Two pairs of renderers cover Beach's outbound delivery surfaces — a Lit-based pair for the live-runtime path and a static pair for batched non-browser channels:
Lit-based (browser-shaped, async, jsdom + Lit):
renderToHtml(commands, options?)— email-client-compatible HTML. Runs@a2ui/litunder jsdom; serialises the rendered DOM tree (including shadow roots); applies juice to inline styles.options.inlineCss = falseskips the juicer pass.renderToText(commands, options?)— plain text via DOM-tree walker with semantic HTML rules (block elements newline-separate,<a>becomestext (url),<button>becomes[ text ], headings prefix line breaks, list items bullet). For WhatsApp / SMS / voice composers.
Static (Lit-free, synchronous, no jsdom):
renderToStaticHTML(commands, options?)— same input, different output strategy: synchronous walk of theSurfaceCommandstream, table-based markup, every style inline, no custom-element tags. The channel-safety contract is codified inassertEmailSafe(html)(also exported); every Beach test runs its output through it.renderToStaticText(commands, options?)— sibling to the above for plain text.
Use the static pair when the consumer never reaches a browser: outbound email, WhatsApp summary, archived render, MIME plainText alternative. Use the Lit pair when the same surface that will reach a browser also needs a server render and you want byte-equal output across both paths.
import { renderToHtml, renderToText } from '@cool-ai/beach-a2ui-renderer-lit/render-server';
const html = await renderToHtml(commands); // email body
const text = await renderToText(commands); // WhatsApp message bodyThe architectural commitment for the Lit-based pair: Beach does not walk the SurfaceCommand stream itself. @a2ui/lit walks it (Google's reference renderer); Beach adapts the resulting DOM tree. New A2UI components — Card v2, novel layouts, future primitives — get text + HTML output for free because they compose existing HTML semantics.
The static pair walks the stream directly because the channels it serves (email, WhatsApp, archive) cannot accept custom-element tags. Per-component emission rules live in render-server/static/emit-html.ts and emit-text.ts; data-bindings ({ path }) resolve at compose time against the accumulated updateDataModel state via RFC-6901 JSON-Pointer; function-call bindings throw A2uiStaticRenderFunctionCallError (the live runtime owns function-call evaluation, not the static walker). Consumer-catalogue components plug in via the extensions option: each extension receives resolved props (data-bindings already evaluated) plus the pre-rendered children string, so it wraps the inner content in domain-specific markup without re-implementing basic-catalogue emission.
import { renderToStaticHTML, assertEmailSafe } from '@cool-ai/beach-a2ui-renderer-lit/render-server';
const html = renderToStaticHTML(commands, {
extensions: {
DestinationCard: (props, renderedChildren) =>
`<table><tr><td><strong>${props.destinationName}</strong>${renderedChildren}</td></tr></table>`,
},
});
assertEmailSafe(html); // throws A2uiEmailSafetyError if anything would break in Gmail / OutlookCustom catalogues
Both renderers accept an additional catalogs option for surfaces that use consumer catalogues alongside the default basic catalogue:
import { renderToHtml, renderToText } from '@cool-ai/beach-a2ui-renderer-lit/render-server';
import type { Catalog } from '@cool-ai/beach-a2ui-renderer-lit/render-server';
import { taCatalogue } from '@my-org/ta-catalogue';
const html = await renderToHtml(commands, { catalogs: [taCatalogue] });
const text = await renderToText(commands, { catalogs: [taCatalogue] });The same Catalog type also re-exports from @cool-ai/beach-a2ui-renderer-lit for catalogue authoring. Two constraints:
@a2ui/litflavour, not@a2ui/web_coreflavour. Catalogues passed here must register their custom elements via@customElementat import time — the same shapeA2uiBridge({ catalogues })accepts on the browser. A data-only@a2ui/web_corecatalogue without Lit elements will not render.- SSR-compatible component implementations. Component classes get loaded inside the jsdom render bootstrap, so they cannot rely on browser-only APIs (
window.fetch, click handlers firing during render, network calls in connected lifecycle hooks). The render pass is one-shot serialisation; any work that assumes a live browser does not apply.
basicCatalog is always registered regardless of the catalogs value; the list is additive. Passing catalogs: [], undefined, or omitting the field are equivalent.
The renderer awaits every Lit element in the surface subtree, not just the outer <a2ui-surface> host. Consumer-catalogue elements with deferred internal renders are settled before the rendered tree is serialised. If a custom element fails to settle within a bounded retry budget the renderer throws rather than emitting an empty wrapper; that loud failure is intentional — the alternative (silent empty output) hides real rendering bugs.
Merging catalogues
A2UI v0.9 binds each surface to exactly one catalogue. A surface that uses Card (basic) wrapping Show (basics) wrapping a custom domain component needs all three resolvable from one catalogue. mergeCatalogues() from @cool-ai/beach-a2ui-renderer-lit (also re-exported from @cool-ai/beach-a2ui-renderer-lit/render-server and @cool-ai/beach-a2ui-renderer-lit/catalogues) builds that single catalogue:
import { basicCatalog } from '@a2ui/lit/v0_9';
import { beachBasicsCatalog } from '@cool-ai/beach-a2ui-basics';
import { mergeCatalogues } from '@cool-ai/beach-a2ui-renderer-lit';
const taCatalogue = mergeCatalogues(
'https://travel-assistant.cool-digital.co.uk/a2ui/v0.9/catalogue.json',
[basicCatalog, beachBasicsCatalog, taOwnComponents],
);
// Surfaces created with `catalogId: taCatalogue.id` resolve every
// component from any source catalogue under one Map lookup.
const html = await renderToHtml(commands, { catalogs: [taCatalogue] });Collision rules:
- Component-name collisions: default
onCollision: 'error'throwsA2uiCatalogueMergeErrornaming both source catalogue ids. Use'first-wins'to make basic-catalogue entries authoritative; use'last-wins'for consumer-side overrides of basic entries. functionsmap: only one source catalogue may carry a non-emptyfunctionsmap; the helper throws if two do. Function merging is intentionally not automatic — consumers should build a single source-of-truth catalogue and pass that explicitly.themeSchema: only one theme schema may be present; identity-equal references are accepted (same theme passed through multiple catalogues). Different schemas throw.
Each merged entry preserves its tagName and other extra fields verbatim from the source — the helper is a structural combine, not a re-shape. Merged catalogues can themselves be merged again (the operation is associative for non-conflicting inputs).
Wire into @cool-ai/beach-format's A2uiContentRenderer:
import { A2uiContentRenderer } from '@cool-ai/beach-format';
import { renderToHtml, renderToText } from '@cool-ai/beach-a2ui-renderer-lit/render-server';
const a2uiRenderer = new A2uiContentRenderer({
html: (commands) => renderToHtml(commands as SurfaceCommand[]),
text: (commands) => renderToText(commands as SurfaceCommand[]),
});The render-server subpath has runtime deps on jsdom and juice and a peer-dep on @a2ui/lit. Consumers using only bridge / host-fit (no server rendering) do not pull these in.
Defensive instrumentation — validateCatalogues and renderer error types
renderToHtml and renderToText invoke validateCatalogues inline before processing commands. The validator catches the two catalogue-shape failures that previously surfaced as silent <a2ui-surface></a2ui-surface> output:
- A
@a2ui/web_coredata-only catalogue (notagNameon entries) was passed where a@a2ui/lit-flavoured catalogue is expected — throwsA2uiRendererCatalogueShapeErrorwithreason: 'missing-tagName'. - A Lit catalogue module was imported before
bootstrapServerRender()provisioned jsdom'scustomElementsregistry, so the@customElementdecorator'scustomElements.define()call no-op'd — throwsA2uiRendererCatalogueShapeErrorwithreason: 'unregistered-tagName'.
After the render pass, an empty-render guard catches the remaining three failure modes (unknown component name, consumer-catalogue element's render() returned nothing, top-level component id ≠ "root") by throwing A2uiRendererEmptyRenderError with the affected surfaceIds.
You can call validateCatalogues directly from your own boot path — after a dynamic catalogue import — to surface failures at module load rather than at first render:
import { bootstrapServerRender, validateCatalogues } from '@cool-ai/beach-a2ui-renderer-lit/render-server';
await bootstrapServerRender();
const { taCatalogue } = await import('@your-org/ta-a2ui-catalogue');
validateCatalogues([taCatalogue]); // throws now, not at first renderExported error types (A2uiRendererError base, A2uiRendererCatalogueShapeError, A2uiRendererEmptyRenderError) carry catalogueId / componentName / reason / surfaceIds / commandCount fields so consumers can route on specific failure modes if needed.
bridge — id-order tracker
Some progressive-update patterns receive a list of IDs early (a discover-grid populated with destinations) and per-ID detail later (hotel results for one destination). To target the later updates with a JSON-Pointer updateDataModel path, the integration layer needs the index of each ID within the original ordered list. createIdOrderTracker is the small piece of Beach-specific glue that records and looks up that mapping.
import { createIdOrderTracker } from '@cool-ai/beach-a2ui-renderer-lit/bridge';
const tracker = createIdOrderTracker();
tracker.set('discover-grid', ['Paris', 'Rome', 'Tokyo', 'Lima']);
const idx = tracker.indexOf('discover-grid', 'Rome'); // 1
// updateDataModel path: `/destinations/${idx}/hotels`Tiny, no external dependencies, scope-it-however-the-consumer-wants.
host-fit — viewport conventions
Catalogue components that adapt to their host (chat panel vs. full-page surface vs. modal) need a uniform way to discover the host's intended sizing. parseHostConfig reads the --beach-host-config CSS custom property — a JSON blob describing breakpoints, container types, and other host hints — and returns a typed config consumers can read inside their components.
import { parseHostConfig, BREAKPOINT_NARROW } from '@cool-ai/beach-a2ui-renderer-lit/host-fit';
const config = parseHostConfig(myComponent);
if (config.maxWidth !== undefined && config.maxWidth < BREAKPOINT_NARROW) {
// narrow-host layout
}This convention exists so catalogue components can use @container queries against the surface's allocated inline size, not the viewport. The renderer no longer applies container-type: inline-size automatically — that lived in the retired Beach renderer; consumers who use @a2ui/lit apply the same CSS in their own stylesheet (.a2ui-surface { container-type: inline-size; }).
Coexisting with bespoke part renderers
A consumer with stateful or proprietary UI (an itinerary builder with slot-bound state, a multi-centre timeline) runs their own renderer alongside @a2ui/lit. The pattern is to dispatch envelope parts by partType:
envelopeStream.on('part', (part) => {
switch (part.partType) {
case 'a2ui-surface':
a2uiClient.handle(part.data); // @a2ui/lit
break;
case 'ta.itinerary-slot-state':
itineraryRenderer.apply(part.data); // consumer-owned
break;
}
});a2ui-surface and consumer-defined types are siblings in the envelope. Both reach the browser via the same SSE stream, dispatched by partType. Beach's scope here is the dispatch + the bridge helpers above; the actual rendering is Google's reference renderer plus consumer-owned bespoke renderers.
SurfaceComponent type schema
SurfaceComponent is Beach's typed authoring vocabulary for surface components. It is a discriminated union where the type field identifies the component kind. Handlers use it to produce typed a2ui-surface parts; a builder layer (forthcoming) translates SurfaceComponent[] to the A2UI v0.9 wire shape (SurfaceCommand[]) consumed by @a2ui/lit.
Note the naming convention: SurfaceComponent uses type: as the discriminant ({ type: 'Text', text: 'hello' }). The A2UI v0.9 wire shape uses component: ({ component: 'Text', text: 'hello' }). These two layers are distinct — SurfaceComponent is the Beach authoring surface; SurfaceCommand is the wire format sent to the browser.
import type { SurfaceComponent } from '@cool-ai/beach-a2ui-renderer-lit';
const components: SurfaceComponent[] = [
{ type: 'Heading', text: 'Flights to Paris', level: 2 },
{ type: 'List', items: { path: 'flights' }, template: {
type: 'Card',
children: [
{ type: 'Text', text: { path: 'flightNumber' }, variant: 'bold' },
{ type: 'Text', text: { $: 'formatNumber', value: { path: 'price' }, style: 'currency', currency: 'GBP' } },
{ type: 'Button', label: 'Book', action: { type: 'submit', payload: { flightId: 'id' } } },
],
} },
];Bindable<T> — literal or data-model binding
Any field typed as Bindable<T> accepts either a literal value ("hello", 99) or a PathRef ({ path: 'user.name' }) that is resolved at render time from the surface's data model.
Format expressions
FormatExpression produces a display string at render time:
{ $: 'formatNumber', value: { path: 'price' }, style: 'currency', currency: 'GBP' }
{ $: 'formatDate', value: { path: 'departureDate' }, format: 'short' }
{ $: 'formatString', template: 'Hello, {name}!', values: { name: { path: 'user.name' } } }Full export list
SurfaceComponent and all constituent interfaces (TextComponent, HeadingComponent, BadgeComponent, ImageComponent, IconComponent, RowComponent, ColumnComponent, CardComponent, ListComponent, DividerComponent, ButtonComponent, TextInputComponent, CheckBoxComponent, ChoicePickerComponent, TabsComponent, ComparisonTableComponent, TimelineComponent, PriceBreakdownComponent, RatingComponent, GalleryComponent) plus supporting types (PathRef, Bindable, FormatExpression, ButtonAction, TabItem, ComparisonTableColumn, PriceLine, PriceLineVariant, GalleryItem) are all exported from the package root.
What this package no longer contains
Beach's parallel A2UI renderer layer has been retired. Specifically removed:
- Typed component constructors (
text,heading,card,button, …). Author component object literals directly or use@cool-ai/beach-a2ui-basics's catalogue helpers. - The vanilla-DOM
A2UIRendererand the originalrenderHtmlserver-side renderer. Browser rendering uses@a2ui/lit; server-side rendering ships in the@cool-ai/beach-a2ui-renderer-lit/render-serversubpath. - The
./builder,./renderer, and./htmlsubpath exports.
Not in this package
- The envelope builder that includes the surface part (
@cool-ai/beach-protocol). - Channel delivery of surface messages (
@cool-ai/beach-transport). - Browser rendering (
@a2ui/lit, Google's reference). - Catalogue extensions (
@cool-ai/beach-a2ui-basics, plus consumer-defined catalogues).
Consumers
Any agent with a UI. Pure API-serving agents skip this package.
Related
- A2UI spec — the protocol this package re-exports types from.
@a2ui/web_core— the canonical type and catalogue source.@a2ui/lit— Google's reference browser renderer.@cool-ai/beach-a2ui-basics— Beach-shaped catalogue extensions.- https://cool-ai.org/docs/envelope — how surface messages travel in the envelope.
