A2UI host-fit conventions
A2UI v0.9 specifies nothing about how a catalogue surface should adapt when its container width is unknown at build time — a mobile shell, a partner embed, a sidebar that's narrower than the desktop reference layout. The protocol's only capability negotiation is supportedCatalogIds. Google's framing — render-target agnostic — leaves all host-fit decisions to the renderer with no convention to follow.
Beach is positioned as the open-source A2UI implementation, and catalogue authors using Beach hit this gap first and hardest. They are product engineers shipping surfaces into varying hosts, not protocol authors. This guide is Beach's community convention for host-fit. If A2UI v1.0 later adopts a similar shape, Beach migrates; if it adopts something different, Beach ships a compat shim.
The principle
There are two host-fit problems and they have different solutions:
Self-driving fit. A surface lays out one way at 320px and a different way at 800px. The host doesn't need to coordinate; the surface just needs to know its own size. Solution: container queries — a web-platform standard. The renderer sets
container-type: inline-sizeon every surface so@containerqueries inside catalogue components target the surface's allocated width, not the viewport.Host-driven fit. The host wants to suppress decorative chrome (because it owns the chrome itself), pick a density, set a row cap, or steer picker invocation. The surface can't infer these from its own size. Solution:
--beach-host-config— an optional CSS custom property carrying a JSON-encodedHostConfig. Hosts that opt in write it; surfaces and components read it; surfaces that don't read it still work standalone.
Sophisticated hosts use both. Catalogue authors who don't care about either still ship working surfaces.
Container queries inside catalogue components
Use @container against inline-size, never viewport media queries.
:host {
/* container-type: inline-size is set by the renderer on the surface root,
so component-level container queries inherit a sized container without
the component declaring its own. */
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
@container (min-width: 480px) {
:host {
grid-template-columns: repeat(2, 1fr);
}
}
@container (min-width: 768px) {
:host {
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
}
A surface embedded in a 360px-wide WhatsApp shell renders one column even on a desktop. A surface embedded in a 1200px-wide partner panel renders three columns even on a phone. Viewport queries get both wrong; container queries get both right.
Recommended breakpoints
Beach exports two values to keep catalogue components consistent across packages:
import { BREAKPOINT_NARROW, BREAKPOINT_WIDE } from '@cool-ai/beach-a2ui/host-fit';
// 480 and 768 in CSS pixels.
| Band | Range | Typical hosts |
|---|---|---|
| narrow | ≤ 480px | phone embeds, narrow drawers, message-shell modals |
| medium | 481–768px | tablet portrait, half-width split panes |
| wide | ≥ 769px | desktop full-width, partner embeds with generous space |
The values are recommendations, not rules. A specific catalogue may have layouts that warrant additional breakpoints; the convention is "use container queries against inline-size, default to these bands when no domain-specific reason exists to differ."
--beach-host-config — optional host overrides
Hosts that want to override per-surface behaviour write a JSON-encoded HostConfig to --beach-host-config on the surface root (or any ancestor). Surfaces and components read it via parseHostConfig(element) from @cool-ai/beach-a2ui/host-fit.
Schema
interface HostConfig {
mode?: 'standalone' | 'embedded';
width?: number; // host-allocated width in CSS pixels
pickerHost?: 'self' | 'parent';
density?: 'compact' | 'comfortable';
maxRows?: number;
}
All fields are optional. Surfaces honour the fields they care about and ignore the rest, which keeps forward compatibility — the host adding a new field tomorrow does not break consumers of today's surfaces.
Setting it (host side)
.partner-embed {
--beach-host-config: '{"mode":"embedded","density":"compact","maxRows":5}';
}
Or per-surface, for finer-grained control:
<a2ui-surface
data-surface-id="recommendations"
style="--beach-host-config: '{"mode":"embedded","maxRows":3}';"
></a2ui-surface>
Reading it (surface / component side)
import { parseHostConfig } from '@cool-ai/beach-a2ui/host-fit';
class RecommendationsSurface extends HTMLElement {
connectedCallback() {
const config = parseHostConfig(this);
if (config.mode === 'embedded') {
// suppress own chrome — host owns it
}
if (config.maxRows !== undefined) {
this.maxVisibleRows = config.maxRows;
}
if (config.density === 'compact') {
this.classList.add('density-compact');
}
}
}
parseHostConfig is read-only and side-effect-free. It returns {} for unset, blank, or unparseable values — never throws. Misconfigured hosts emit a single console.warn so the gap surfaces during development.
Why a CSS custom property, not a constructor argument
Adaptive Cards uses constructor-injected HostConfig. A2UI surfaces are protocol-mounted via the renderer — the consumer doesn't construct surfaces directly, so the constructor injection mechanism doesn't fit. CSS custom properties are the standard cross-component override channel on the web platform; the cascade gives you root-level defaults, surface-level overrides, and per-instance controls for free.
Why --beach-*, not --a2ui-*
Beach is an implementation of A2UI, not the protocol's owner. Reserving an --a2ui-host-config name ahead of A2UI v1.0 would create upstream-vs-implementation friction even with Beach positioned as the reference impl. The --beach-* namespace keeps Beach's convention isolated; if A2UI v1.0 adopts a similar shape, the migration is a rename; if it adopts a different shape, Beach ships a compat shim.
What is not in this convention (yet)
- A2UI protocol extensions. This convention adds nothing to the A2UI protocol surface — no new schema fields, no new capability negotiation. It's pure renderer-side guidance.
- Layout primitives (Column, Row, List). When
@cool-ai/beach-a2ui-basicsships these, they will default tocontainer-type: inline-sizeso@containerqueries against the surface's children work out of the box. Today the surface root is the only opted-in container.
Cross-references
- Sub-surface composition — the cross-surface picker / drill-down event family. The
pickerHost: 'self' | 'parent'config slot reserved here is the host-side opt-in for that pattern. - Adaptive Cards HostConfig — analogous shape; different binding mechanism.
- W3C CSS Containment Module Level 3 — container queries — the underlying web standard.
@cool-ai/beach-a2uiREADME — renderer overview and exports.