From 63f2322197fed4ba0128cc5123d4ee45aaa217ae Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 10 May 2026 07:59:00 -0700 Subject: [PATCH 1/2] feat(a2ui,chat): honor beginRendering.styles per A2UI v1 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical A2UI v1 wire format defines exactly two theming knobs on beginRendering.styles: `font` (primary font family) and `primaryColor` (hex `#RRGGBB`). The lib's type system already declared the field but no consumer read it — agents could emit `styles: { primaryColor: "#FF0000" }` and the renderer silently dropped it. Three coordinated changes for v1 spec compliance: 1. **`libs/a2ui` types** — `A2uiSurface` gains an optional `styles?: { font?, primaryColor? }` field mirroring the wire shape, used as the renderer's single source of truth for v1-spec-defined theming on a given surface. 2. **`libs/chat` surface-store** — beginRendering's `styles` are captured onto the new `A2uiSurface.styles` field at commit time. Re-renders preserve existing styles when the new beginRendering omits them (matches the agent's likely "change the data, keep the look" intent). 3. **`` host** — applies `surface.styles.primaryColor` as `--a2ui-primary` and `surface.styles.font` as `font-family` via host bindings. `null` when unset so consumer-set `:root` defaults are not overridden. Catalog components already consume `var(--a2ui-primary)` for accents (buttons, sliders, focus, etc.); font cascades naturally. This is the spec-compliance half of the theming track (Pass 2a). The internal token system that powers the rest of the catalog's visual quality (spacing scale, typography scale, shape radius, focus ring, motion, elevation) remains the renderer's private vocabulary — explicitly NOT communicated through the wire format. That richer token surface is Pass 2b. Three new unit tests cover: capture from beginRendering, omission when unset, and preservation across re-renders. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/a2ui/src/lib/types.ts | 9 ++++ libs/chat/src/lib/a2ui/surface-store.spec.ts | 43 ++++++++++++++++++++ libs/chat/src/lib/a2ui/surface-store.ts | 7 ++++ libs/chat/src/lib/a2ui/surface.component.ts | 21 ++++++++++ 4 files changed, 80 insertions(+) diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts index fbeab6631..272d167a3 100644 --- a/libs/a2ui/src/lib/types.ts +++ b/libs/a2ui/src/lib/types.ts @@ -231,6 +231,15 @@ export interface A2uiSurface { sendDataModel?: boolean; components: Map; dataModel: Record; + /** Styles set by the agent via `beginRendering.styles`. The + * canonical v1 spec defines exactly two fields: `font` (primary + * font for the UI) and `primaryColor` (hex `#RRGGBB`). The renderer + * applies these as CSS custom properties on the surface root, + * overriding any consumer-set defaults for the duration of the + * surface's life. Anything richer (typography scale, spacing, + * elevation, etc.) is the renderer's private vocabulary and not + * communicated through this field. */ + styles?: { font?: string; primaryColor?: string }; } // --- Outbound shapes --- diff --git a/libs/chat/src/lib/a2ui/surface-store.spec.ts b/libs/chat/src/lib/a2ui/surface-store.spec.ts index e941df8ae..ed1305701 100644 --- a/libs/chat/src/lib/a2ui/surface-store.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-store.spec.ts @@ -123,4 +123,47 @@ describe('A2uiSurfaceStore (v1, deferred-apply)', () => { expect(s()).toBeDefined(); expect(s()!.surfaceId).toBe('s1'); }); + + test('captures styles from beginRendering (v1 spec)', () => { + const store = setup(); + store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ + { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, + ] } }); + store.apply({ beginRendering: { + surfaceId: 's1', + root: 'root', + styles: { font: 'Roboto', primaryColor: '#FF6633' }, + } }); + const s = store.surfaces().get('s1')!; + expect(s.styles).toEqual({ font: 'Roboto', primaryColor: '#FF6633' }); + }); + + test('omits styles field when beginRendering does not include it', () => { + const store = setup(); + store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ + { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, + ] } }); + store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); + const s = store.surfaces().get('s1')!; + expect(s.styles).toBeUndefined(); + }); + + test('preserves existing styles on re-render when new beginRendering omits them', () => { + const store = setup(); + store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ + { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, + ] } }); + store.apply({ beginRendering: { + surfaceId: 's1', + root: 'root', + styles: { primaryColor: '#0A84FF' }, + } }); + // Second beginRendering without styles — keep prior. + store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ + { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, + ] } }); + store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); + const s = store.surfaces().get('s1')!; + expect(s.styles).toEqual({ primaryColor: '#0A84FF' }); + }); }); diff --git a/libs/chat/src/lib/a2ui/surface-store.ts b/libs/chat/src/lib/a2ui/surface-store.ts index 73f0464e5..5f5a70eb6 100644 --- a/libs/chat/src/lib/a2ui/surface-store.ts +++ b/libs/chat/src/lib/a2ui/surface-store.ts @@ -95,11 +95,18 @@ export function createA2uiSurfaceStore(): A2uiSurfaceStore { if (existing) { dataModel = { ...existing.dataModel, ...dataModel }; } + // Capture v1 styles (font, primaryColor) from beginRendering. A + // re-render keeps any prior styles unless the new beginRendering + // explicitly overrides them — this matches the agent's likely + // intent ("change the data, keep the look"). + const nextStyles = begin.styles + ?? existing?.styles; const surface: A2uiSurface = { surfaceId: begin.surfaceId, catalogId: 'basic', components: b.components, dataModel, + ...(nextStyles ? { styles: nextStyles } : {}), }; const next = new Map(surfacesSignal()); next.set(begin.surfaceId, surface); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index cbf877311..dfbdf9b3d 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -13,6 +13,14 @@ import { buildA2uiActionMessage } from './build-action-message'; standalone: true, imports: [RenderSpecComponent], changeDetection: ChangeDetectionStrategy.OnPush, + // The host applies the agent-set v1 styles (`beginRendering.styles`) + // as inline CSS custom properties + font-family. Catalog components + // consume `--a2ui-primary` for accents (buttons, sliders, focus, + // etc.); `font-family` cascades naturally from the host. + host: { + '[style.--a2ui-primary]': 'primaryColor()', + '[style.font-family]': 'fontFamily()', + }, template: ` @if (spec(); as s) { (); readonly action = output(); + /** Agent-set primary color from `beginRendering.styles.primaryColor`. + * Returns null when unset so the host binding doesn't override the + * consumer's `:root`-level `--a2ui-primary` default. */ + readonly primaryColor = computed(() => + this.surface().styles?.primaryColor ?? null + ); + + /** Agent-set font family from `beginRendering.styles.font`. Returns + * null when unset so the host doesn't override consumer fonts. */ + readonly fontFamily = computed(() => + this.surface().styles?.font ?? null + ); + /** Convert the A2UI surface to a json-render Spec for rendering. */ readonly spec = computed(() => surfaceToSpec(this.surface())); From fef4551e47e40ed3a486dffb4100c354382b050b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 10 May 2026 08:04:30 -0700 Subject: [PATCH 2/2] docs(chat): regenerate API docs after styles props additions --- .../content/docs/chat/api/api-docs.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index c643bda05..69626bcf3 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1009,6 +1009,12 @@ "description": "", "optional": false }, + { + "name": "fontFamily", + "type": "Signal", + "description": "Agent-set font family from `beginRendering.styles.font`. Returns\nnull when unset so the host doesn't override consumer fonts.", + "optional": false + }, { "name": "handlers", "type": "InputSignal>", @@ -1021,6 +1027,12 @@ "description": "Merge built-in A2UI handlers with consumer-provided handlers.", "optional": false }, + { + "name": "primaryColor", + "type": "Signal", + "description": "Agent-set primary color from `beginRendering.styles.primaryColor`.\nReturns null when unset so the host binding doesn't override the\nconsumer's `:root`-level `--a2ui-primary` default.", + "optional": false + }, { "name": "registry", "type": "Signal", @@ -3761,6 +3773,12 @@ "description": "", "optional": true }, + { + "name": "styles", + "type": "object", + "description": "Styles set by the agent via `beginRendering.styles`. The\ncanonical v1 spec defines exactly two fields: `font` (primary\nfont for the UI) and `primaryColor` (hex `#RRGGBB`). The renderer\napplies these as CSS custom properties on the surface root,\noverriding any consumer-set defaults for the duration of the\nsurface's life. Anything richer (typography scale, spacing,\nelevation, etc.) is the renderer's private vocabulary and not\ncommunicated through this field.", + "optional": true + }, { "name": "surfaceId", "type": "string",