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", 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()));