@cool-ai/beach-a2ui-static
v0.1.2
Published
Synchronous Lit-free static renderer for A2UI v0.9 SurfaceCommand streams. Emits email-client-safe HTML and structured plain text for batched non-browser channels (email, WhatsApp, archive). Zero heavy dependencies; companion to @cool-ai/beach-a2ui's brow
Readme
@cool-ai/beach-a2ui-static
Synchronous, Lit-free renderer for A2UI v0.9 SurfaceCommand streams. Emits email-client-safe HTML and structured plain text for batched non-browser channels — outbound email, WhatsApp summary, archive renders, the plainText alternative on multi-part MIME bodies.
Companion to @cool-ai/beach-a2ui, which keeps the Lit-based renderer for the browser-shaped path under jsdom. Use this package when the consumer never reaches a browser; use @cool-ai/beach-a2ui when the same surface that drives a browser session also needs a server render of the live state.
Why a separate package
The Lit-based @cool-ai/beach-a2ui ships jsdom (~25 MB) and juice as runtime dependencies. Beach's static walker uses neither — it's pure data. Splitting the package keeps the install surface honest: consumers (e.g. @cool-ai/beach-format) that need only the static path don't pull a DOM emulator into their dependency tree.
The same names remain importable from @cool-ai/beach-a2ui/render-server for backwards compatibility; new consumers should import from @cool-ai/beach-a2ui-static directly.
Quick start
import { renderToStaticHTML, renderToStaticText, assertEmailSafe } from '@cool-ai/beach-a2ui-static';
const commands = [
{ version: 'v0.9', createSurface: { surfaceId: 's', catalogId: 'a2ui-basic' } },
{
version: 'v0.9',
updateComponents: {
surfaceId: 's',
components: [
{ id: 'root', component: 'Card', child: 'body' },
{ id: 'body', component: 'Text', text: 'Hello.' },
],
},
},
];
const html = renderToStaticHTML(commands);
const text = renderToStaticText(commands);
assertEmailSafe(html); // throws A2uiEmailSafetyError if anything would break in Gmail / OutlookBoth functions are synchronous. No async wrapping, no jsdom bootstrap, no Lit lifecycle waits.
The channel-safety contract
renderToStaticHTML output passes assertEmailSafe(html) by construction. The helper enforces five rules — the intersection of what Gmail, Yahoo, and Outlook all render:
- No custom-element tags.
<a2ui-…>is stripped by every major client. - No
<style>blocks. Outlook routinely strips them; styles must be inline. - No JavaScript. No
<script>tags; noon…=event-handler attributes. - No flex / grid CSS. Layout in email is table-based.
- No external stylesheet references. No
<link rel="stylesheet">.
Consumers writing their own composers can run their output through the same helper to keep them honest.
Data-binding resolution
DynamicString / DynamicBoolean / DynamicValue props resolve at compose time:
- Literal values pass through unchanged.
{ path }bindings read from the surface's data model via RFC-6901 JSON-Pointer. The data model is accumulated fromupdateDataModelcommands in the stream.{ call, args, returnType }function-call bindings throwA2uiStaticRenderFunctionCallError. The live runtime in the browser owns function-call evaluation; the static walker has no client runtime. Resolve such props server-side before composing, or persist their result into the data model viaupdateDataModel.
Consumer-catalogue components via extensions
Components outside the basic catalogue plug in via options.extensions. Each extension receives resolved props (data-bindings already evaluated) plus the renderer's pre-rendered children string, so the extension wraps the inner content in domain-specific markup without re-implementing basic-catalogue emission.
import { renderToStaticHTML, type HtmlExtension } from '@cool-ai/beach-a2ui-static';
const destinationCard: HtmlExtension = (props, renderedChildren) => `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #e5e5e5;">
<tr><td style="padding:16px;">
<strong>${props.destinationName as string}</strong>
${renderedChildren}
</td></tr>
</table>
`;
const html = renderToStaticHTML(commands, { extensions: { DestinationCard: destinationCard } });Returning the empty string drops the component from the output.
Built-in basic-catalogue coverage
| Component | HTML emission | Text emission |
|---|---|---|
| Card | Table-wrapped cell with inline border / padding / background | Inner text trimmed |
| Column / List | Vertical table, one row per child | Newline-separated children |
| Row | Horizontal table, one cell per child | Space-separated children |
| Text | Variant-aware tag (h1–h5, small, p) with inline font sizing | Headings uppercased (h1/h2) or set off (h3-h5); body passes through |
| Image | <img> with width per variant; alt from description | [image: <description>] |
| Icon | <span aria-label> fallback | Description text |
| Divider | <hr> (or thin vertical pipe) | \n---\n |
| Button | Styled <span> wrapping child label (no action) | [ label ] brackets |
| Tabs | Sequential sections, every tab rendered with title heading | Every tab uppercased + content |
| Modal | Emits content only (drops trigger) | Same |
| Show | Emits child iff when resolves truthy at compose time | Same |
| Video / AudioPlayer | Labelled link fallback (URL anchor) | <label> (<url>) |
| TextField / CheckBox / ChoicePicker / Slider / DateTimeInput | Dropped — no form interactivity in batched channels | Same |
Template-repeat children
Layout components (Row / Column / List) accept a static list of component ids or a template-repeat:
{
id: 'root',
component: 'Column',
children: { componentId: 'item-template', path: '/destinations' },
}The renderer reads the array at path from the data model and renders componentId once per element. Paths inside the template resolve against the element as the local data-model root, not the surface root — so the template can carry { path: '/name' } and pull destinations[i].name for each iteration.
Error types
A2uiStaticRenderError(base) —code: string, every static-render error carries one.A2uiStaticRenderFunctionCallError—componentName,prop,call. Thrown when a binding asks for a function call.A2uiStaticRenderMissingComponentError—surfaceId,parentComponent,parentProp,missingId. Thrown when a child /content/tabs[].childreference points at a component id the surface's components map doesn't have.A2uiEmailSafetyError—violations: ReadonlyArray<EmailSafetyViolation>. Thrown byassertEmailSafewhen HTML violates the channel-safety contract.
Tests
44 tests across the static-HTML and static-text paths cover every basic-catalogue component, data-binding resolution, function-call rejection, template-repeat with local data scope, missing-component throw, every extensions code path, all six assertEmailSafe rules, and the reference fixture Card → Column[Text, Text].
pnpm --filter @cool-ai/beach-a2ui-static testRelated
@cool-ai/beach-a2ui— Lit-based browser-shaped renderer + the bridge / host-fit conventions.@cool-ai/beach-format— Composer primitives; reaches for the static renderer when emitting per-channel output.- CAIB-249 — the CR that introduced this package.
- CAIB-250 — Composer/A2UI consolidation; migrates the format-adapter render paths onto the static renderer.
