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:

  1. 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-size on every surface so @container queries inside catalogue components target the surface's allocated width, not the viewport.

  2. 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-encoded HostConfig. 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: '{&quot;mode&quot;:&quot;embedded&quot;,&quot;maxRows&quot;: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-basics ships these, they will default to container-type: inline-size so @container queries against the surface's children work out of the box. Today the surface root is the only opted-in container.

Cross-references