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
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/a2ui/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ Catalog components receive resolved props as Angular inputs from the render engi

## Theming

`createSurface.theme` carries agent-supplied presentation hints. `primaryColor` flows to `<a2ui-surface>` as the `--a2ui-primary` CSS custom property, which catalog components consume for accents (buttons, sliders, focus rings). `iconUrl` and `agentDisplayName` identify the agent that owns the surface.
`createSurface.theme` carries agent-supplied presentation hints. `primaryColor` flows to `<a2ui-surface>` as the `--a2ui-primary` CSS custom property, which catalog components consume for accents (buttons, sliders, focus rings). `iconUrl` and `agentDisplayName` identify the agent that owns the surface: when either is set, `<a2ui-surface>` renders a small identity header (a 16px round icon and the display name in muted label text) above the surface. Themeless surfaces render no header.

## Local Handlers

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/a2ui/surface-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ When a component's `children` field is a template (`{ path, componentId }`), the

The final `Spec` is passed to `RenderSpecComponent` along with the `ViewRegistry` (converted to an Angular registry via `toRenderRegistry`). Rendering is fully reactive — when the surface signal updates, the spec recomputes and only affected elements re-render.

**Theming.** The component host applies the agent-set surface theme from `createSurface.theme`: `primaryColor` becomes the `--a2ui-primary` CSS custom property, which catalog components consume for accents. When the agent sets no theme, your `:root`-level `--a2ui-primary` default wins.
**Theming.** The component host applies the agent-set surface theme from `createSurface.theme`: `primaryColor` becomes the `--a2ui-primary` CSS custom property, which catalog components consume for accents. When the agent sets no theme, your `:root`-level `--a2ui-primary` default wins. If the theme carries `agentDisplayName` and/or `iconUrl`, the component also renders a small identity header above the surface — a 16px round icon and the display name in muted label text. Surfaces without those fields render no header.

## Usage Outside ChatComponent

Expand Down
12 changes: 12 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,12 @@
"description": "",
"optional": false
},
{
"name": "agentDisplayName",
"type": "Signal<string | null>",
"description": "Agent identity chrome from `createSurface.theme`. When neither\n`agentDisplayName` nor `iconUrl` is set, no header renders at all\n(zero layout impact for themeless surfaces — the common case).",
"optional": false
},
{
"name": "catalog",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | Readonly<Record<string, Type<unknown> | RenderViewEntry>>>",
Expand All @@ -1106,6 +1112,12 @@
"description": "",
"optional": false
},
{
"name": "iconUrl",
"type": "Signal<string | null>",
"description": "",
"optional": false
},
{
"name": "internalHandlers",
"type": "Signal<object>",
Expand Down
78 changes: 78 additions & 0 deletions libs/chat/src/lib/a2ui/surface.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,84 @@ describe('A2uiSurfaceComponent — validation gate + live context (Phase 3)', ()
});
});

describe('A2uiSurfaceComponent — surface theme chrome', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] }));

function makeThemedState(theme?: Record<string, unknown>) {
const store = createA2uiSurfaceStore();
store.apply({ version: 'v0.9', createSurface: {
surfaceId: 's1', catalogId: 'basic', ...(theme ? { theme } : {}),
} } as never);
store.apply({ version: 'v0.9', updateComponents: {
surfaceId: 's1',
components: [{ id: 'root', component: 'Text', text: 'Hello' }],
} } as never);
return store.surfaceState('s1')()!;
}

it('renders agentDisplayName and iconUrl as a header above the surface', () => {
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeThemedState({
agentDisplayName: 'Flight Bot', iconUrl: 'https://x/icon.png',
}));
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
fx.detectChanges();

const chrome = fx.nativeElement.querySelector('.a2ui-surface-chrome');
expect(chrome).toBeTruthy();
expect(chrome.textContent).toContain('Flight Bot');
const img = chrome.querySelector('img');
expect(img).toBeTruthy();
expect(img.getAttribute('src')).toBe('https://x/icon.png');
expect(img.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(img.getAttribute('alt')).toBe('');
});

it('renders the name alone when only agentDisplayName is set', () => {
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeThemedState({ agentDisplayName: 'Flight Bot' }));
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
fx.detectChanges();

const chrome = fx.nativeElement.querySelector('.a2ui-surface-chrome');
expect(chrome).toBeTruthy();
expect(chrome.textContent).toContain('Flight Bot');
expect(chrome.querySelector('img')).toBeNull();
});

it('renders no chrome element at all for a themeless surface', () => {
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeThemedState());
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
fx.detectChanges();

expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeNull();
// The surface itself still renders.
expect(fx.nativeElement.textContent).toContain('Hello');
});

it('renders no chrome when the theme only carries primaryColor', () => {
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeThemedState({ primaryColor: '#ff0066' }));
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
fx.detectChanges();

expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeNull();
});

it('still applies primaryColor as the --a2ui-primary host style', () => {
const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', makeThemedState({
primaryColor: '#ff0066', agentDisplayName: 'Flight Bot',
}));
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
fx.detectChanges();

expect(fx.nativeElement.style.getPropertyValue('--a2ui-primary')).toBe('#ff0066');
expect(fx.nativeElement.querySelector('.a2ui-surface-chrome')).toBeTruthy();
});
});

describe('A2uiSurfaceComponent — sendDataModel live round-trip (Phase 4)', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] }));

Expand Down
36 changes: 36 additions & 0 deletions libs/chat/src/lib/a2ui/surface.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,33 @@ import type { A2uiViews } from './views';
host: {
'[style.--a2ui-primary]': 'primaryColor()',
},
styles: `
.a2ui-surface-chrome {
display: flex;
align-items: center;
gap: var(--a2ui-spacing-2);
margin-bottom: var(--a2ui-spacing-2);
color: var(--a2ui-label);
font-size: var(--a2ui-typography-label-size);
}
.a2ui-surface-chrome img {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
}
`,
template: `
@if (agentDisplayName() || iconUrl()) {
<div class="a2ui-surface-chrome">
@if (iconUrl(); as icon) {
<img [src]="icon" alt="" referrerpolicy="no-referrer" />
}
@if (agentDisplayName(); as name) {
<span>{{ name }}</span>
}
</div>
}
@if (spec(); as s) {
<render-spec
[spec]="s"
Expand Down Expand Up @@ -115,6 +141,16 @@ export class A2uiSurfaceComponent {
(this.state()?.surface ?? this.surface())?.theme?.primaryColor ?? null
);

/** Agent identity chrome from `createSurface.theme`. When neither
* `agentDisplayName` nor `iconUrl` is set, no header renders at all
* (zero layout impact for themeless surfaces — the common case). */
protected readonly agentDisplayName = computed<string | null>(() =>
(this.state()?.surface ?? this.surface())?.theme?.agentDisplayName ?? null
);
protected readonly iconUrl = computed<string | null>(() =>
(this.state()?.surface ?? this.surface())?.theme?.iconUrl ?? null
);

/** Roots from the surface state. The v0.9 wire contract reserves the
* component id `root` as the single tree root; we keep the renderer
* permissive in case future surfaces emit multiple top-level
Expand Down