Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,12 @@
"description": "",
"optional": false
},
{
"name": "fontFamily",
"type": "Signal<string | null>",
"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<Record<string, object>>",
Expand All @@ -1021,6 +1027,12 @@
"description": "Merge built-in A2UI handlers with consumer-provided handlers.",
"optional": false
},
{
"name": "primaryColor",
"type": "Signal<string | null>",
"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<AngularRegistry>",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions libs/a2ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,15 @@ export interface A2uiSurface {
sendDataModel?: boolean;
components: Map<string, A2uiComponent>;
dataModel: Record<string, unknown>;
/** 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 ---
Expand Down
43 changes: 43 additions & 0 deletions libs/chat/src/lib/a2ui/surface-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
7 changes: 7 additions & 0 deletions libs/chat/src/lib/a2ui/surface-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions libs/chat/src/lib/a2ui/surface.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
<render-spec
Expand All @@ -31,6 +39,19 @@ export class A2uiSurfaceComponent {
readonly events = output<RenderEvent>();
readonly action = output<A2uiActionMessage>();

/** 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<string | null>(() =>
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<string | null>(() =>
this.surface().styles?.font ?? null
);

/** Convert the A2UI surface to a json-render Spec for rendering. */
readonly spec = computed(() => surfaceToSpec(this.surface()));

Expand Down
Loading