diff --git a/.github/workflows/check_license.yml b/.github/workflows/check_license.yml
index efd701a3c..2ae3dabb4 100644
--- a/.github/workflows/check_license.yml
+++ b/.github/workflows/check_license.yml
@@ -38,7 +38,12 @@ jobs:
- name: Check license headers
run: |
- addlicense -check \
+ if ! addlicense -check \
-l apache \
-c "Google LLC" \
- .
+ .; then
+ echo "License check failed. To fix this, install addlicense and run it:"
+ echo " go install github.com/google/addlicense@latest"
+ echo " addlicense -l apache -c \"Google LLC\" ."
+ exit 1
+ fi
diff --git a/.gitignore b/.gitignore
index 30d7cac9a..928ba0687 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,5 +22,5 @@ coverage/
a2a_agents/python/a2ui_agent/src/a2ui/assets/**/*.json
## new agent SDK path
agent_sdks/python/src/a2ui/assets/**/*.json
-## Generated JS file from the strictly-typed `sandbox.ts`.
+## Generated JS file from the strictly-typed `sandbox.ts`.
samples/client/angular/projects/orchestrator/public/sandbox_iframe/sandbox.js
diff --git a/docs/guides/client-setup.md b/docs/guides/client-setup.md
index 4a006ea52..5f019e9f5 100644
--- a/docs/guides/client-setup.md
+++ b/docs/guides/client-setup.md
@@ -54,18 +54,44 @@ TODO: Add verified setup example.
> coming days.
```bash
-npm install @a2ui/angular @a2ui/web-lib
+npm install @a2ui/angular @a2ui/web_core
```
The Angular renderer provides:
-- **`provideA2UI()` function**: Configures A2UI in your app config
-- **`Surface` component**: Renders A2UI surfaces
-- **`MessageProcessor` service**: Handles incoming A2UI messages
-
-TODO: Add verified setup example.
+- **`A2uiRendererService`**: A service that manages the A2UI message processor and reactive model.
+- **`a2ui-v09-component-host` component**: A dynamic component host that renders A2UI components from a surface.
+- **`A2UI_RENDERER_CONFIG` token**: Used to configure the renderer with catalogs and action handlers.
+
+### Setup Example (v0.9)
+
+A2UI uses versioned imports for its protocol-specific implementations. For v0.9, configure your application providers as follows:
+
+```typescript
+import { ApplicationConfig } from '@angular/core';
+import {
+ A2UI_RENDERER_CONFIG,
+ A2uiRendererService,
+ minimalCatalog
+} from '@a2ui/angular/v0_9';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ {
+ provide: A2UI_RENDERER_CONFIG,
+ useValue: {
+ catalogs: [minimalCatalog],
+ actionHandler: (action) => {
+ console.log('Action dispatched:', action);
+ }
+ }
+ },
+ A2uiRendererService
+ ]
+};
+```
-**See working example:** [Angular restaurant sample](https://github.com/google/a2ui/tree/main/samples/client/angular/projects/restaurant)
+**See working example:** [Angular v0.9 Explorer](https://github.com/google/a2ui/tree/main/renderers/angular/a2ui_explorer)
## React
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 2656b78ae..4edafb517 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -9,7 +9,7 @@ This roadmap outlines the current state and future plans for the A2UI project. T
| Version | Status | Notes |
|---------|--------|-------|
| **v0.8** | âś… Stable | Initial public release |
-| **v0.9** | đźš§ In Progress | Draft specification improvements |
+| **v0.9** | đźš§ Draft | Prompt-First specification improvements |
Key features:
@@ -225,6 +225,6 @@ Want to contribute to the roadmap?
---
-**Last Updated:** December 2025
+**Last Updated:** March 2026
Have questions about the roadmap? [Start a discussion on GitHub](https://github.com/google/A2UI/discussions).
diff --git a/renderers/angular/.npmignore b/renderers/angular/.npmignore
new file mode 100644
index 000000000..9736b564e
--- /dev/null
+++ b/renderers/angular/.npmignore
@@ -0,0 +1 @@
+a2ui_explorer/
diff --git a/renderers/angular/README.md b/renderers/angular/README.md
index 8afc14b19..d9779d15f 100644
--- a/renderers/angular/README.md
+++ b/renderers/angular/README.md
@@ -1,9 +1,84 @@
-Angular implementation of A2UI.
+# A2UI Angular Renderer
-Important: The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
+The Angular implementation of the A2UI framework, providing seamless integration of agent-generated UI into Angular applications.
+
+## Getting Started
+
+### Installation
+
+```bash
+npm install @a2ui/angular @a2ui/web_core
+```
+
+### Protocol Versioning
+
+A2UI supports multiple protocol versions. To use a specific version, use the versioned import path:
+
+```typescript
+// Use the v0.9 implementation
+import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9';
+import { minimalCatalog } from '@a2ui/angular/v0_9';
+```
+
+## Basic Setup
+
+Configure the renderer in your `app.config.ts` using the `A2UI_RENDERER_CONFIG` injection token:
+
+```typescript
+import { ApplicationConfig } from '@angular/core';
+import { A2UI_RENDERER_CONFIG, A2uiRendererService, minimalCatalog } from '@a2ui/angular/v0_9';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ {
+ provide: A2UI_RENDERER_CONFIG,
+ useValue: {
+ catalogs: [minimalCatalog],
+ actionHandler: (action) => {
+ console.log('Action received:', action);
+ }
+ }
+ },
+ A2uiRendererService
+ ]
+};
+```
+
+## Rendering Surfaces
+
+Use the `a2ui-v09-component-host` component to render individual A2UI components within your application:
+
+```typescript
+import { Component } from '@angular/core';
+import { ComponentHostComponent } from '@a2ui/angular/v0_9';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [ComponentHostComponent],
+ template: `
+
+ `
+})
+export class AppComponent {}
+```
+
+## Core Concepts
+
+- **A2uiRendererService**: The central service that manages the connection to the A2UI Message Processor and tracks surface state.
+- **ComponentHostComponent**: A wrapper component that dynamically renders A2UI components based on the current surface model.
+- **Catalogs**: Collections of Angular components mapped to A2UI component types.
+
+## Security
+
+> [!IMPORTANT]
+> The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
All operational data received from an external agent—including its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide crafted data in its fields (e.g., name, skills.description) that, if used without sanitization to construct prompts for a Large Language Model (LLM), could expose your application to prompt injection attacks.
Similarly, any UI definition or data stream received must be treated as untrusted. Malicious agents could attempt to spoof legitimate interfaces to deceive users (phishing), inject malicious scripts via property values (XSS), or generate excessive layout complexity to degrade client performance (DoS). If your application supports optional embedded content (such as iframes or web views), additional care must be taken to prevent exposure to malicious external sites.
-Developer Responsibility: Failure to properly validate data and strictly sandbox rendered content can introduce severe vulnerabilities. Developers are responsible for implementing appropriate security measures—such as input sanitization, Content Security Policies (CSP), strict isolation for optional embedded content, and secure credential handling—to protect their systems and users.
\ No newline at end of file
+**Developer Responsibility**: Failure to properly validate data and strictly sandbox rendered content can introduce severe vulnerabilities. Developers are responsible for implementing appropriate security measures—such as input sanitization, Content Security Policies (CSP), strict isolation for optional embedded content, and secure credential handling—to protect their systems and users.
diff --git a/renderers/angular/a2ui_explorer/public/favicon.ico b/renderers/angular/a2ui_explorer/public/favicon.ico
new file mode 100644
index 000000000..57614f9c9
Binary files /dev/null and b/renderers/angular/a2ui_explorer/public/favicon.ico differ
diff --git a/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts b/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts
new file mode 100644
index 000000000..4a70e99a2
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/action-dispatcher.service.ts
@@ -0,0 +1,29 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { Subject } from 'rxjs';
+import { SurfaceGroupAction } from '@a2ui/web_core/v0_9';
+
+@Injectable({ providedIn: 'root' })
+export class ActionDispatcher {
+ private action$ = new Subject();
+ actions = this.action$.asObservable();
+
+ dispatch(action: SurfaceGroupAction) {
+ this.action$.next(action);
+ }
+}
diff --git a/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts
new file mode 100644
index 000000000..8ce9d764d
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/agent-stub.service.ts
@@ -0,0 +1,126 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { A2uiRendererService } from '@a2ui/angular/v0_9';
+import { SurfaceGroupAction, A2uiMessage } from '@a2ui/web_core/v0_9';
+import { ActionDispatcher } from './action-dispatcher.service';
+
+/**
+ * Context for the 'update_property' event.
+ */
+interface UpdatePropertyContext {
+ path: string;
+ value: any;
+ surfaceId?: string;
+}
+
+/**
+ * Context for the 'submit_form' event.
+ */
+interface SubmitFormContext {
+ [key: string]: any;
+ name?: string;
+}
+
+/**
+ * A stub service that simulates an A2UI agent.
+ * It listens for actions and responds with data model updates or new surfaces.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class AgentStubService {
+ /** Log of actions received from the surface. */
+ actionsLog: Array<{ timestamp: Date; action: SurfaceGroupAction }> = [];
+
+ constructor(
+ private rendererService: A2uiRendererService,
+ private dispatcher: ActionDispatcher,
+ ) {
+ // Subscribe to actions dispatched by the renderer
+ this.dispatcher.actions.subscribe((action) => this.handleAction(action));
+ }
+
+ /**
+ * Pushes actions triggered from the rendered Canvas frame through simulation.
+ * - Logs actions into inspector event frame aggregates.
+ * - Emulates generic server-side evaluation triggers delaying deferred updates.
+ * - Dispatch subsequent node-tree node triggers back over `A2uiRendererService`.
+ */
+ handleAction(action: SurfaceGroupAction) {
+ console.log('[AgentStub] handleAction action:', action);
+ this.actionsLog.push({ timestamp: new Date(), action });
+
+ // Simulate server processing delay
+ setTimeout(() => {
+ if ('event' in action) {
+ const { name, context } = action.event;
+ if (name === 'update_property' && context) {
+ const { path, value, surfaceId } = context as unknown as UpdatePropertyContext;
+ console.log(
+ '[AgentStub] update_property path:',
+ path,
+ 'value:',
+ value,
+ 'surfaceId:',
+ surfaceId,
+ );
+ this.rendererService.processMessages([
+ {
+ version: 'v0.9',
+ updateDataModel: {
+ surfaceId: surfaceId || action.surfaceId,
+ path: path,
+ value: value,
+ },
+ },
+ ]);
+ } else if (name === 'submit_form' && context) {
+ const formData = context as unknown as SubmitFormContext;
+ const nameValue = formData.name || 'Anonymous';
+
+ // Respond with an update to the data model in v0.9 layout
+ this.rendererService.processMessages([
+ {
+ version: 'v0.9',
+ updateDataModel: {
+ surfaceId: action.surfaceId,
+ path: '/form/submitted',
+ value: true,
+ },
+ },
+ {
+ version: 'v0.9',
+ updateDataModel: {
+ surfaceId: action.surfaceId,
+ path: '/form/responseMessage',
+ value: `Hello, ${nameValue}! Your form has been processed.`,
+ },
+ },
+ ]);
+ }
+ }
+ }, 50); // Shorter delay for property updates
+ }
+
+ /**
+ * Initializes a demo session with an initial set of messages.
+ */
+ initializeDemo(initialMessages: A2uiMessage[]) {
+ this.rendererService.processMessages(initialMessages);
+ }
+}
diff --git a/renderers/angular/a2ui_explorer/src/app/app.config.ts b/renderers/angular/a2ui_explorer/src/app/app.config.ts
new file mode 100644
index 000000000..5ed1b0963
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/app.config.ts
@@ -0,0 +1,21 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
+
+export const appConfig: ApplicationConfig = {
+ providers: [provideBrowserGlobalErrorListeners()],
+};
diff --git a/renderers/angular/a2ui_explorer/src/app/app.spec.ts b/renderers/angular/a2ui_explorer/src/app/app.spec.ts
new file mode 100644
index 000000000..e905d1ab9
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/app.spec.ts
@@ -0,0 +1,45 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { App } from './app';
+
+describe('App', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [App],
+ }).compileComponents();
+ });
+
+ it('should create the app', () => {
+ const fixture = TestBed.createComponent(App);
+ const app = fixture.componentInstance;
+ expect(app).toBeTruthy();
+
+ fixture.detectChanges(); // Trigger ngOnInit
+
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('.canvas-frame')).toBeTruthy();
+ });
+
+ it('should render title', () => {
+ const fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('h3')?.textContent).toContain('A2UI Examples');
+ });
+});
diff --git a/renderers/angular/a2ui_explorer/src/app/app.ts b/renderers/angular/a2ui_explorer/src/app/app.ts
new file mode 100644
index 000000000..23665e10d
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/app.ts
@@ -0,0 +1,31 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component } from '@angular/core';
+import { DemoComponent } from './demo.component';
+
+/**
+ * Root Component of the A2UI Angular Demo app.
+ *
+ * This component acts as a direct container that embeds the `` dashboard.
+ * All dynamic canvas layout and agent rendering behavior is handled inside `DemoComponent`.
+ */
+@Component({
+ selector: 'app-root',
+ imports: [DemoComponent],
+ template: '',
+})
+export class App {}
diff --git a/renderers/angular/a2ui_explorer/src/app/card.component.ts b/renderers/angular/a2ui_explorer/src/app/card.component.ts
new file mode 100644
index 000000000..d4089e5ee
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/card.component.ts
@@ -0,0 +1,47 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ComponentHostComponent } from '@a2ui/angular/v0_9';
+import { BoundProperty } from '@a2ui/angular/v0_9';
+
+/**
+ * A simple card component for the demo.
+ */
+@Component({
+ selector: 'demo-card',
+ standalone: true,
+ imports: [CommonModule, ComponentHostComponent],
+ template: `
+
+
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CardComponent {
+ @Input() props: Record = {};
+ @Input() surfaceId!: string;
+}
diff --git a/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts
new file mode 100644
index 000000000..bc8d6915f
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/custom-slider.component.ts
@@ -0,0 +1,62 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { BoundProperty } from '@a2ui/angular/v0_9';
+
+/**
+ * A custom component not part of any catalog, used to verify the renderer's
+ * ability to handle external component types.
+ */
+@Component({
+ selector: 'a2ui-custom-slider',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+
+
+
+ `,
+ styles: [
+ `
+ .custom-slider-container {
+ padding: 10px;
+ border: 1px dashed blue;
+ border-radius: 4px;
+ }
+ input {
+ width: 100%;
+ }
+ `,
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CustomSliderComponent {
+ @Input() props: Record = {};
+
+ handleInput(event: Event) {
+ const val = Number((event.target as HTMLInputElement).value);
+ this.props['value']?.onUpdate(val);
+ }
+}
diff --git a/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts
new file mode 100644
index 000000000..94cc88e55
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/demo-catalog.ts
@@ -0,0 +1,61 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { z } from 'zod';
+import { BaseMinimalCatalog, MINIMAL_COMPONENTS, MINIMAL_FUNCTIONS } from '@a2ui/angular/v0_9';
+import { CustomSliderComponent } from './custom-slider.component';
+import { CardComponent } from './card.component';
+import { AngularComponentImplementation } from '@a2ui/angular/v0_9';
+import { BASIC_FUNCTIONS } from '@a2ui/web_core/v0_9/basic_catalog';
+
+/**
+ * A catalog specific to the demo, extending the minimal catalog with custom components.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class DemoCatalog extends BaseMinimalCatalog {
+ constructor() {
+ const customSliderApi: AngularComponentImplementation = {
+ name: 'CustomSlider',
+ schema: z.object({
+ label: z.string().optional(),
+ value: z.number().optional(),
+ min: z.number().optional(),
+ max: z.number().optional(),
+ }) as any,
+ component: CustomSliderComponent,
+ };
+
+ const cardApi: AngularComponentImplementation = {
+ name: 'Card',
+ schema: z.object({
+ child: z.string().optional(),
+ }) as any,
+ component: CardComponent,
+ };
+
+ const components = [...MINIMAL_COMPONENTS, customSliderApi, cardApi];
+ const functions = [...BASIC_FUNCTIONS, ...MINIMAL_FUNCTIONS];
+
+ super(
+ 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json',
+ components,
+ functions,
+ );
+ }
+}
diff --git a/renderers/angular/a2ui_explorer/src/app/demo.component.ts b/renderers/angular/a2ui_explorer/src/app/demo.component.ts
new file mode 100644
index 000000000..bafa523fd
--- /dev/null
+++ b/renderers/angular/a2ui_explorer/src/app/demo.component.ts
@@ -0,0 +1,441 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ChangeDetectorRef, Component, OnInit, inject, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { A2uiRendererService, A2UI_RENDERER_CONFIG } from '@a2ui/angular/v0_9';
+import { AgentStubService } from './agent-stub.service';
+import { ComponentHostComponent, SurfaceComponent } from '@a2ui/angular/v0_9';
+import { AngularCatalog } from '@a2ui/angular/v0_9';
+import { DemoCatalog } from './demo-catalog';
+import { SurfaceGroupAction, CreateSurfaceMessage } from '@a2ui/web_core/v0_9';
+import { EXAMPLES } from './examples-bundle';
+import { Example } from './types';
+import { ActionDispatcher } from './action-dispatcher.service';
+
+/**
+ * Main dashboard component for A2UI v0.9 Angular Renderer.
+ * It provides a sidebar of examples, a canvas for rendering,
+ * and inspector tools for state auditing.
+ */
+@Component({
+ selector: 'a2ui-v0-9-demo',
+ standalone: true,
+ imports: [CommonModule, ComponentHostComponent, SurfaceComponent],
+ template: `
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RowComponent {
+ /**
+ * Bound properties.
+ */
+ props = input>({});
+ surfaceId = input.required();
+ dataContextPath = input('/');
+
+ protected justify = computed(() => this.props()['justify']?.value());
+ protected align = computed(() => this.props()['align']?.value());
+
+ protected children = computed(() => {
+ const raw = this.props()['children']?.value() || [];
+ return Array.isArray(raw) ? raw : [];
+ });
+
+ protected isRepeating = computed(() => {
+ return !!this.props()['children']?.raw?.componentId;
+ });
+
+ protected templateId = computed(() => {
+ return this.props()['children']?.raw?.componentId;
+ });
+
+ protected getNormalizedPath(index: number) {
+ return getNormalizedPath(this.props()['children']?.raw?.path, this.dataContextPath(), index);
+ }
+}
diff --git a/renderers/angular/src/v0_9/catalog/minimal/text-field.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.spec.ts
new file mode 100644
index 000000000..432fa17a7
--- /dev/null
+++ b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.spec.ts
@@ -0,0 +1,103 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TextFieldComponent } from './text-field.component';
+import { signal } from '@angular/core';
+import { A2uiRendererService } from '../../core/a2ui-renderer.service';
+import { By } from '@angular/platform-browser';
+
+describe('TextFieldComponent', () => {
+ let component: TextFieldComponent;
+ let fixture: ComponentFixture;
+ let mockRendererService: any;
+
+ beforeEach(async () => {
+ mockRendererService = {};
+
+ await TestBed.configureTestingModule({
+ imports: [TextFieldComponent],
+ providers: [{ provide: A2uiRendererService, useValue: mockRendererService }],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TextFieldComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('props', {
+ label: { value: signal('Username'), raw: 'Username', onUpdate: () => {} },
+ value: {
+ value: signal('testuser'),
+ raw: 'testuser',
+ onUpdate: jasmine.createSpy('onUpdate'),
+ },
+ placeholder: { value: signal('Enter username'), raw: 'Enter username', onUpdate: () => {} },
+ variant: { value: signal('text'), raw: 'text', onUpdate: () => {} },
+ });
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should render label if provided', () => {
+ fixture.detectChanges();
+ const label = fixture.debugElement.query(By.css('label'));
+ expect(label.nativeElement.textContent).toBe('Username');
+ });
+
+ it('should not render label if not provided', () => {
+ fixture.componentRef.setInput('props', {
+ ...component.props(),
+ label: { value: signal(null), raw: null, onUpdate: () => {} },
+ });
+ fixture.detectChanges();
+ const label = fixture.debugElement.query(By.css('label'));
+ expect(label).toBeFalsy();
+ });
+
+ it('should render input with correct value and placeholder', () => {
+ fixture.detectChanges();
+ const input = fixture.debugElement.query(By.css('input'));
+ expect(input.nativeElement.value).toBe('testuser');
+ expect(input.nativeElement.placeholder).toBe('Enter username');
+ });
+
+ it('should return correct input type based on variant', () => {
+ // inputType is now a computed signal
+ expect(component.inputType()).toBe('text');
+
+ fixture.componentRef.setInput('props', {
+ ...component.props(),
+ variant: { value: signal('obscured'), raw: 'obscured', onUpdate: () => {} },
+ });
+ expect(component.inputType()).toBe('password');
+
+ fixture.componentRef.setInput('props', {
+ ...component.props(),
+ variant: { value: signal('number'), raw: 'number', onUpdate: () => {} },
+ });
+ expect(component.inputType()).toBe('number');
+ });
+
+ it('should call onUpdate when input changes', () => {
+ fixture.detectChanges();
+ const input = fixture.debugElement.query(By.css('input'));
+ input.nativeElement.value = 'newuser';
+ input.triggerEventHandler('input', { target: input.nativeElement });
+
+ expect(component.props()['value'].onUpdate).toHaveBeenCalledWith('newuser');
+ });
+});
diff --git a/renderers/angular/src/v0_9/catalog/minimal/text-field.component.ts b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.ts
new file mode 100644
index 000000000..c4af8a867
--- /dev/null
+++ b/renderers/angular/src/v0_9/catalog/minimal/text-field.component.ts
@@ -0,0 +1,99 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
+import { BoundProperty } from '../../core/types';
+
+
+/**
+ * An interactive text input component that supports labels, placeholders, and variants.
+ *
+ * It maps its value to a bound data path and supports 'obscured' (password) and
+ * 'number' variants.
+ */
+@Component({
+ selector: 'a2ui-v09-text-field',
+ imports: [],
+ template: `
+
+ @if (label()) {
+
+ }
+
+
+
+ `,
+ styles: [
+ `
+ :host {
+ display: block;
+ flex: 1;
+ width: 100%;
+ }
+ .a2ui-text-field-container {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: 4px;
+ }
+ input {
+ padding: 8px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ }
+ `,
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TextFieldComponent {
+ /**
+ * Bound properties.
+ */
+ props = input>({});
+ surfaceId = input();
+ componentId = input();
+ dataContextPath = input();
+
+
+
+ variant = computed(() => this.props()['variant']?.value());
+ label = computed(() => this.props()['label']?.value());
+ value = computed(() => this.props()['value']?.value() ?? '');
+ placeholder = computed(() => this.props()['placeholder']?.value() ?? '');
+
+ inputType = computed(() => {
+ switch (this.variant()) {
+ case 'obscured':
+ return 'password';
+ case 'number':
+ return 'number';
+ default:
+ return 'text';
+ }
+ });
+
+ handleInput(event: Event) {
+ const value = (event.target as HTMLInputElement).value;
+ // Update the data path. If anything is listening to this path, it will be
+ // notified.
+ this.props()['value']?.onUpdate(value);
+ }
+}
diff --git a/renderers/angular/src/v0_9/catalog/minimal/text.component.spec.ts b/renderers/angular/src/v0_9/catalog/minimal/text.component.spec.ts
new file mode 100644
index 000000000..fc639d2f5
--- /dev/null
+++ b/renderers/angular/src/v0_9/catalog/minimal/text.component.spec.ts
@@ -0,0 +1,57 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TextComponent } from './text.component';
+import { By } from '@angular/platform-browser';
+import { signal } from '@angular/core';
+
+describe('TextComponent', () => {
+ let component: TextComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TextComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TextComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('props', {
+ text: { value: signal('Hello World'), raw: 'Hello World', onUpdate: () => {} },
+ weight: { value: signal('bold'), raw: 'bold', onUpdate: () => {} },
+ style: { value: signal('italic'), raw: 'italic', onUpdate: () => {} },
+ });
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should render the text', () => {
+ fixture.detectChanges();
+ const span = fixture.debugElement.query(By.css('span'));
+ expect(span.nativeElement.textContent.trim()).toBe('Hello World');
+ });
+
+ it('should apply font-weight and font-style', () => {
+ fixture.detectChanges();
+ const span = fixture.debugElement.query(By.css('span'));
+ expect(span.styles['font-weight']).toBe('bold');
+ expect(span.styles['font-style']).toBe('italic');
+ });
+});
diff --git a/renderers/angular/src/v0_9/catalog/minimal/text.component.ts b/renderers/angular/src/v0_9/catalog/minimal/text.component.ts
new file mode 100644
index 000000000..0121751a7
--- /dev/null
+++ b/renderers/angular/src/v0_9/catalog/minimal/text.component.ts
@@ -0,0 +1,48 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
+import { BoundProperty } from '../../core/types';
+
+/**
+ * A basic text component that supports font weights and styles.
+ */
+@Component({
+ selector: 'a2ui-v09-text',
+ imports: [],
+ template: `
+
+ {{ text() }}
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TextComponent {
+ /**
+ * Bound properties.
+ */
+ props = input>({});
+ surfaceId = input();
+ componentId = input();
+ dataContextPath = input();
+
+ text = computed(() => this.props()['text']?.value() ?? '');
+ fontWeight = computed(() => this.props()['weight']?.value());
+ fontStyle = computed(() => this.props()['style']?.value());
+}
diff --git a/renderers/angular/src/v0_9/catalog/types.ts b/renderers/angular/src/v0_9/catalog/types.ts
new file mode 100644
index 000000000..9e9afd9a0
--- /dev/null
+++ b/renderers/angular/src/v0_9/catalog/types.ts
@@ -0,0 +1,31 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Type } from '@angular/core';
+import { Catalog, ComponentApi } from '@a2ui/web_core/v0_9';
+
+/**
+ * Extends the generic ComponentApi to include Angular-specific component types.
+ */
+export interface AngularComponentImplementation extends ComponentApi {
+ /** The Angular component class used to render this component. */
+ readonly component: Type;
+}
+
+/**
+ * Base class for Angular-specific component catalogs.
+ */
+export class AngularCatalog extends Catalog {}
diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.spec.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.spec.ts
new file mode 100644
index 000000000..dd5caa803
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { A2uiRendererService, A2UI_RENDERER_CONFIG } from './a2ui-renderer.service';
+import { AngularCatalog } from '../catalog/types';
+
+describe('A2uiRendererService', () => {
+ let service: A2uiRendererService;
+ let mockCatalog: any;
+
+ beforeEach(() => {
+ mockCatalog = {
+ components: new Map(),
+ functions: new Map(),
+ get invoker() {
+ return (name: string, args: any, ctx: any, ab?: any) => {
+ const fn = mockCatalog.functions.get(name);
+ if (fn) return fn(args, ctx, ab);
+ console.warn(`Function "${name}" not found in catalog`);
+ return undefined;
+ };
+ },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ A2uiRendererService,
+ {
+ provide: A2UI_RENDERER_CONFIG,
+ useValue: { catalogs: [mockCatalog] },
+ },
+ ],
+ });
+
+ service = TestBed.inject(A2uiRendererService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('initialization', () => {
+ it('should create surfaceGroup', () => {
+ expect(service.surfaceGroup).toBeDefined();
+ });
+ });
+
+ describe('processMessages', () => {
+ it('should delegate to MessageProcessor', () => {
+ // Access private _messageProcessor via bracket notation for testing if needed,
+ // or verify indirectly by inspecting surfaceGroup after messages.
+ // Since MessageProcessor is complex, we can just verify it doesn't crash
+ // and updates model if we pass valid messages.
+ // For a pure unit test, we might consider mocking MessageProcessor if it was injected,
+ // but it's instantiated via 'new'.
+ // Let's pass an empty array to verify delegate runs without error.
+ expect(() => service.processMessages([])).not.toThrow();
+ });
+ });
+
+ describe('ngOnDestroy', () => {
+ it('should dispose surfaceGroup', () => {
+ const surfaceGroup = service.surfaceGroup;
+ expect(surfaceGroup).toBeDefined();
+
+ const disposeSpy = spyOn(surfaceGroup as any, 'dispose');
+
+ service.ngOnDestroy();
+
+ expect(disposeSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts
new file mode 100644
index 000000000..0367b7ac4
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/a2ui-renderer.service.ts
@@ -0,0 +1,83 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable, OnDestroy, InjectionToken, Inject } from '@angular/core';
+import {
+ MessageProcessor,
+ SurfaceGroupModel,
+ ActionListener as ActionHandler,
+ A2uiMessage,
+ SurfaceGroupAction,
+} from '@a2ui/web_core/v0_9';
+import { AngularComponentImplementation, AngularCatalog } from '../catalog/types';
+
+/**
+ * Configuration for the A2UI renderer.
+ */
+export interface RendererConfiguration {
+ /** The catalogs containing the available components and functions. */
+ catalogs: AngularCatalog[];
+ /** Optional handler for actions dispatched from any surface. */
+ actionHandler?: (action: SurfaceGroupAction) => void;
+}
+
+/**
+ * Injection token for the A2UI renderer configuration.
+ */
+export const A2UI_RENDERER_CONFIG = new InjectionToken(
+ 'A2UI_RENDERER_CONFIG',
+);
+
+/**
+ * Manages A2UI v0.9 rendering sessions by bridging the MessageProcessor to Angular.
+ *
+ * This service is responsible for processing incoming A2UI messages and making the
+ * resulting surface models available to Angular components.
+ */
+@Injectable()
+export class A2uiRendererService implements OnDestroy {
+ private _messageProcessor: MessageProcessor;
+ private _catalogs: AngularCatalog[] = [];
+
+ constructor(@Inject(A2UI_RENDERER_CONFIG) private config: RendererConfiguration) {
+ this._catalogs = this.config.catalogs;
+
+ this._messageProcessor = new MessageProcessor(
+ this._catalogs,
+ this.config.actionHandler as ActionHandler,
+ );
+ }
+
+ /**
+ * Processes a list of A2UI messages and updates the internal model.
+ *
+ * @param messages The list of messages to process.
+ */
+ processMessages(messages: A2uiMessage[]): void {
+ this._messageProcessor.processMessages(messages);
+ }
+
+ /**
+ * The current surface group model containing all active surfaces.
+ */
+ get surfaceGroup(): SurfaceGroupModel {
+ return this._messageProcessor.model;
+ }
+
+ ngOnDestroy(): void {
+ this._messageProcessor.model.dispose();
+ }
+}
diff --git a/renderers/angular/src/v0_9/core/component-binder.service.spec.ts b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts
new file mode 100644
index 000000000..483e4ea67
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/component-binder.service.spec.ts
@@ -0,0 +1,143 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { DestroyRef } from '@angular/core';
+import { signal as preactSignal } from '@preact/signals-core';
+import { ComponentContext } from '@a2ui/web_core/v0_9';
+import { ComponentBinder } from './component-binder.service';
+
+describe('ComponentBinder', () => {
+ let binder: ComponentBinder;
+ let mockDestroyRef: jasmine.SpyObj;
+ let onDestroyCallback: () => void;
+
+ beforeEach(() => {
+ onDestroyCallback = () => {};
+ mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']);
+ mockDestroyRef.onDestroy.and.callFake((callback: () => void) => {
+ onDestroyCallback = callback;
+ return () => {}; // Return unregister function
+ });
+
+ TestBed.configureTestingModule({
+ providers: [ComponentBinder, { provide: DestroyRef, useValue: mockDestroyRef }],
+ });
+
+ binder = TestBed.inject(ComponentBinder);
+ });
+
+ it('should be created', () => {
+ expect(binder).toBeTruthy();
+ });
+
+ it('should bind properties to Angular signals', () => {
+ const mockComponentModel = {
+ properties: {
+ text: 'Hello',
+ visible: true,
+ },
+ };
+
+ const mockpSigText = preactSignal('Hello');
+ const mockpSigVisible = preactSignal(true);
+
+ const mockDataContext = {
+ resolveSignal: jasmine.createSpy('resolveSignal').and.callFake((val: any) => {
+ if (val === 'Hello') return mockpSigText;
+ if (val === true) return mockpSigVisible;
+ return preactSignal(val);
+ }),
+ set: jasmine.createSpy('set'),
+ };
+
+ const mockContext = {
+ componentModel: mockComponentModel,
+ dataContext: mockDataContext,
+ } as unknown as ComponentContext;
+
+ const bound = binder.bind(mockContext);
+
+ expect(bound['text']).toBeDefined();
+ expect(bound['visible']).toBeDefined();
+ expect(bound['text'].value()).toBe('Hello');
+ expect(bound['visible'].value()).toBe(true);
+
+ // Verify resolveSignal was called
+ expect(mockDataContext.resolveSignal).toHaveBeenCalledWith('Hello');
+ expect(mockDataContext.resolveSignal).toHaveBeenCalledWith(true);
+ });
+
+ it('should add update() method for data bindings (two-way binding)', () => {
+ const mockComponentModel = {
+ properties: {
+ value: { path: '/data/text' },
+ },
+ };
+
+ const mockpSig = preactSignal('initial');
+ const mockDataContext = {
+ resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig),
+ set: jasmine.createSpy('set'),
+ };
+
+ const mockContext = {
+ componentModel: mockComponentModel,
+ dataContext: mockDataContext,
+ } as unknown as ComponentContext;
+
+ const bound = binder.bind(mockContext);
+
+ expect(bound['value']).toBeDefined();
+ expect(bound['value'].value()).toBe('initial');
+ expect(bound['value'].onUpdate).toBeDefined();
+
+ // Call update
+ bound['value'].onUpdate('new-value');
+
+ // Verify set was called on DataContext
+ expect(mockDataContext.set).toHaveBeenCalledWith('/data/text', 'new-value');
+ });
+
+ it('should NOT add update() method for literals', () => {
+ const mockComponentModel = {
+ properties: {
+ text: 'Literal String',
+ },
+ };
+
+ const mockpSig = preactSignal('Literal String');
+ const mockDataContext = {
+ resolveSignal: jasmine.createSpy('resolveSignal').and.returnValue(mockpSig),
+ set: jasmine.createSpy('set'),
+ };
+
+ const mockContext = {
+ componentModel: mockComponentModel,
+ dataContext: mockDataContext,
+ } as unknown as ComponentContext;
+
+ const bound = binder.bind(mockContext);
+
+ expect(bound['text']).toBeDefined();
+ expect(bound['text'].value()).toBe('Literal String');
+ expect(bound['text'].onUpdate).toBeDefined(); // No-op for literals
+
+ // Call onUpdate on literal, should not crash or call set
+ bound['text'].onUpdate('new');
+ expect(mockDataContext.set).not.toHaveBeenCalled();
+ });
+});
diff --git a/renderers/angular/src/v0_9/core/component-binder.service.ts b/renderers/angular/src/v0_9/core/component-binder.service.ts
new file mode 100644
index 000000000..54e8f295b
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/component-binder.service.ts
@@ -0,0 +1,63 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DestroyRef, Injectable, inject, NgZone } from '@angular/core';
+import { ComponentContext } from '@a2ui/web_core/v0_9';
+import { toAngularSignal } from './utils';
+import { BoundProperty } from './types';
+
+/**
+ * Binds A2UI ComponentModel properties to reactive Angular Signals.
+ *
+ * This service resolves data bindings from the A2UI DataContext and exposes them
+ * as Angular Signals for use in renderer components.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class ComponentBinder {
+ private destroyRef = inject(DestroyRef);
+ private ngZone = inject(NgZone);
+
+ /**
+ * Binds all properties of a component to an object of Angular Signals.
+ *
+ * @param context The ComponentContext containing the model and data context.
+ * @returns An object where each key corresponds to a component prop and its value is an Angular Signal.
+ */
+ bind(context: ComponentContext): Record {
+ const props = context.componentModel.properties;
+ const bound: Record = {};
+
+ for (const key of Object.keys(props)) {
+ const value = props[key];
+ const preactSig = context.dataContext.resolveSignal(value);
+ const angSig = toAngularSignal(preactSig as any, this.destroyRef, this.ngZone);
+
+ const isBoundPath = value && typeof value === 'object' && 'path' in value;
+
+ bound[key] = {
+ value: angSig,
+ raw: value,
+ onUpdate: isBoundPath
+ ? (newValue: any) => context.dataContext.set(value.path, newValue)
+ : () => {}, // No-op for non-bound values
+ };
+ }
+
+ return bound;
+ }
+}
diff --git a/renderers/angular/src/v0_9/core/component-host.component.spec.ts b/renderers/angular/src/v0_9/core/component-host.component.spec.ts
new file mode 100644
index 000000000..6dcc4e062
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/component-host.component.spec.ts
@@ -0,0 +1,182 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed, ComponentFixture } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { ComponentHostComponent } from './component-host.component';
+import { A2uiRendererService } from './a2ui-renderer.service';
+import { AngularCatalog } from '../catalog/types';
+import { ComponentBinder } from './component-binder.service';
+import { ComponentContext } from '@a2ui/web_core/v0_9';
+import { Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'test-child',
+ template: '
Child Component
',
+})
+class TestChildComponent {
+ @Input() props: any;
+ @Input() surfaceId?: string;
+ @Input() componentId?: string;
+ @Input() dataContextPath?: string;
+}
+
+describe('ComponentHostComponent', () => {
+ let component: ComponentHostComponent;
+ let fixture: ComponentFixture;
+ let mockRendererService: any;
+ let mockCatalog: any;
+ let mockBinder: jasmine.SpyObj;
+ let mockSurface: any;
+ let mockSurfaceGroup: any;
+
+ beforeEach(async () => {
+ mockCatalog = {
+ id: 'test-catalog',
+ components: new Map([['TestType', { component: TestChildComponent }]]),
+ };
+
+ mockSurface = {
+ componentsModel: new Map([
+ ['comp1', { id: 'comp1', type: 'TestType', properties: { text: 'Hello' } }],
+ ]),
+ catalog: mockCatalog,
+ };
+
+ mockSurfaceGroup = {
+ getSurface: jasmine.createSpy('getSurface').and.returnValue(mockSurface),
+ };
+
+ mockRendererService = {
+ surfaceGroup: mockSurfaceGroup,
+ };
+
+ mockBinder = jasmine.createSpyObj('ComponentBinder', ['bind']);
+ mockBinder.bind.and.returnValue({
+ text: { value: () => 'bound-hello', onUpdate: () => {} } as any,
+ });
+
+ await TestBed.configureTestingModule({
+ imports: [ComponentHostComponent],
+ providers: [
+ { provide: A2uiRendererService, useValue: mockRendererService },
+ { provide: ComponentBinder, useValue: mockBinder },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ComponentHostComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('componentId', 'comp1');
+ fixture.componentRef.setInput('surfaceId', 'surf1');
+ });
+
+ it('should be created', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('ngOnInit', () => {
+ it('should resolve component type and bind props', () => {
+ fixture.detectChanges(); // Triggers ngOnInit
+
+ // @ts-ignore - Accessing protected property
+ expect(component.componentType).toBe(TestChildComponent);
+ // @ts-ignore - Accessing protected property
+ expect(component.props).toEqual({
+ text: jasmine.objectContaining({ value: jasmine.any(Function) }) as any,
+ });
+
+ expect(mockSurfaceGroup.getSurface).toHaveBeenCalledWith('surf1');
+ expect(mockBinder.bind).toHaveBeenCalled();
+
+ // Verify context creation implicitly by checking if bind was called with a ComponentContext
+ const bindArg = mockBinder.bind.calls.mostRecent().args[0];
+ expect(bindArg).toBeInstanceOf(ComponentContext);
+ expect(bindArg.componentModel.id).toBe('comp1');
+ expect(bindArg.dataContext.path).toBe('/');
+ });
+
+ it('should use provided dataContextPath for ComponentContext', () => {
+ fixture.componentRef.setInput('dataContextPath', '/nested/path');
+ fixture.detectChanges();
+
+ const bindArg = mockBinder.bind.calls.mostRecent().args[0];
+ expect(bindArg.dataContext.path).toBe('/nested/path');
+ });
+
+ it('should warn and return if surface not found', () => {
+ const consoleWarnSpy = spyOn(console, 'warn');
+ mockSurfaceGroup.getSurface.and.returnValue(null);
+
+ fixture.detectChanges();
+
+ // @ts-ignore
+ expect(component.componentType).toBeNull();
+ expect(consoleWarnSpy).toHaveBeenCalledWith('Surface surf1 not found');
+ });
+
+ it('should warn and return if component model not found', () => {
+ const consoleWarnSpy = spyOn(console, 'warn');
+ mockSurface.componentsModel.clear();
+
+ fixture.detectChanges();
+
+ // @ts-ignore
+ expect(component.componentType).toBeNull();
+ expect(consoleWarnSpy).toHaveBeenCalledWith('Component comp1 not found in surface surf1');
+ });
+
+ it('should error and return if component type not in catalog', () => {
+ const consoleErrorSpy = spyOn(console, 'error');
+ mockCatalog.components.clear();
+
+ fixture.detectChanges();
+
+ // @ts-ignore
+ expect(component.componentType).toBeNull();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Component type "TestType" not found in catalog "test-catalog"',
+ );
+ });
+
+ it('should trigger destroyRef on destroy', () => {
+ fixture.detectChanges(); // Trigger ngOnInit
+
+ // Destroy fixture
+ fixture.destroy();
+
+ // Implicitly verifies no crash on destroy
+ expect(component).toBeTruthy();
+ });
+ });
+
+ describe('Template rendering', () => {
+ it('should render the resolved component', () => {
+ fixture.detectChanges(); // Triggers ngOnInit and render
+
+ const compiled = fixture.nativeElement;
+ expect(compiled.innerHTML).toContain('Child Component');
+ });
+ it('should pass dataContextPath to the rendered component', () => {
+ fixture.componentRef.setInput('dataContextPath', '/some/path');
+ fixture.detectChanges();
+
+ const childDebugElement = fixture.debugElement.query(By.directive(TestChildComponent));
+ expect(childDebugElement).toBeTruthy();
+ const childInstance = childDebugElement.componentInstance as TestChildComponent;
+ expect(childInstance.dataContextPath).toBe('/some/path');
+ });
+ });
+});
diff --git a/renderers/angular/src/v0_9/core/component-host.component.ts b/renderers/angular/src/v0_9/core/component-host.component.ts
new file mode 100644
index 000000000..6ab897d35
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/component-host.component.ts
@@ -0,0 +1,100 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ ChangeDetectionStrategy,
+ Component,
+ DestroyRef,
+ OnInit,
+ Type,
+ inject,
+ input,
+} from '@angular/core';
+import { NgComponentOutlet } from '@angular/common';
+import { ComponentContext } from '@a2ui/web_core/v0_9';
+import { A2uiRendererService } from './a2ui-renderer.service';
+import { AngularCatalog } from '../catalog/types';
+import { ComponentBinder } from './component-binder.service';
+
+/**
+ * Dynamically renders an A2UI component as defined in the current surface model.
+ *
+ * This component acts as a bridge between the A2UI surface model and Angular components,
+ * resolving the appropriate component from the catalog and binding its properties.
+ */
+@Component({
+ selector: 'a2ui-v09-component-host',
+ imports: [NgComponentOutlet],
+ template: `
+ @if (componentType) {
+
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ComponentHostComponent implements OnInit {
+ componentId = input('root');
+ surfaceId = input.required();
+ dataContextPath = input('/');
+
+ private rendererService = inject(A2uiRendererService);
+ private binder = inject(ComponentBinder);
+ private destroyRef = inject(DestroyRef);
+
+ protected componentType: Type | null = null;
+ protected props: any = {};
+ private context?: ComponentContext;
+
+ ngOnInit(): void {
+ const surface = this.rendererService.surfaceGroup?.getSurface(this.surfaceId());
+
+ if (!surface) {
+ console.warn(`Surface ${this.surfaceId()} not found`);
+ return;
+ }
+
+ const componentModel = surface.componentsModel.get(this.componentId());
+
+ if (!componentModel) {
+ console.warn(`Component ${this.componentId()} not found in surface ${this.surfaceId()}`);
+ return;
+ }
+
+ // Resolve component from the surface's catalog
+ const catalog = surface.catalog as AngularCatalog;
+ const api = catalog.components.get(componentModel.type);
+
+ if (!api) {
+ console.error(`Component type "${componentModel.type}" not found in catalog "${catalog.id}"`);
+ return;
+ }
+ this.componentType = api.component;
+
+ // Create context
+ this.context = new ComponentContext(surface, this.componentId(), this.dataContextPath());
+ this.props = this.binder.bind(this.context);
+
+ this.destroyRef.onDestroy(() => {
+ // ComponentContext itself doesn't have a dispose, but its inner components might.
+ // However, SurfaceModel takes care of component disposal.
+ });
+ }
+}
diff --git a/renderers/angular/src/v0_9/core/function_binding.spec.ts b/renderers/angular/src/v0_9/core/function_binding.spec.ts
new file mode 100644
index 000000000..ce7641e8a
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/function_binding.spec.ts
@@ -0,0 +1,130 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DataContext, SurfaceModel } from '@a2ui/web_core/v0_9';
+import { DestroyRef } from '@angular/core';
+import { MinimalCatalog } from '../catalog/minimal/minimal-catalog';
+import { toAngularSignal } from './utils';
+
+describe('Function Bindings', () => {
+ let mockDestroyRef: jasmine.SpyObj;
+
+ beforeEach(() => {
+ mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']);
+ mockDestroyRef.onDestroy.and.returnValue(() => {});
+ });
+
+ describe('capitalize', () => {
+ it('should update output correctly when bound input updates using function call binding', () => {
+ const catalog = new MinimalCatalog();
+
+ // Create Surface Model and DataContext
+ const surface = new SurfaceModel('surface_1', catalog);
+ const dataModel = surface.dataModel;
+ const context = new DataContext(surface, '/');
+
+ const callValue = {
+ call: 'capitalize',
+ args: {
+ value: {
+ path: '/inputValue',
+ },
+ },
+ returnType: 'string',
+ };
+
+ // 1. Resolve Signal
+ const resSig = context.resolveSignal(callValue as any);
+
+ // 2. Convert to Angular Signal
+ const angSig = toAngularSignal(resSig, mockDestroyRef);
+
+ // 3. Initial state
+ expect(angSig()).toBe('');
+
+ // 4. Update data model Simulation typing
+ dataModel.set('/inputValue', 'regression test');
+
+ // 5. Verify reactive updates
+ expect(angSig()).toBe('Regression test');
+
+ // 6. Update again to confirm reactive stream remains healthy
+ dataModel.set('/inputValue', 'another test');
+ expect(angSig()).toBe('Another test');
+ });
+ });
+
+ describe('formatString', () => {
+ it('should correctly format string with dynamic path and dollar sign', () => {
+ const catalog = new MinimalCatalog();
+
+ // Create Surface Model and DataContext
+ const surface = new SurfaceModel('surface_1', catalog);
+ const dataModel = surface.dataModel;
+ const context = new DataContext(surface, '/');
+
+ // formatString with path binding: '$${/price}'
+ const callValue = {
+ call: 'formatString',
+ args: {
+ value: '$${/price}',
+ },
+ returnType: 'string',
+ };
+
+ // 1. Resolve Signal (Preact)
+ const resSig = context.resolveSignal(callValue as any);
+
+ // 2. Convert to Angular Signal
+ const angSig = toAngularSignal(resSig, mockDestroyRef);
+
+ // 3. Initial state (price is undefined, so should be '$')
+ expect(angSig()).toBe('$');
+
+ // 4. Update data model
+ dataModel.set('/price', 42);
+
+ // 5. Verify reactive updates - should be '$42'
+ // Regression check: This previously would have returned the Signal object
+ // stringified as '[object Object]' due to instanceof failures across packages.
+ expect(angSig()).toBe('$42');
+ expect(typeof angSig()).toBe('string');
+ });
+
+ it('should handle multiple path interpolations correctly', () => {
+ const catalog = new MinimalCatalog();
+ const surface = new SurfaceModel('surface_1', catalog);
+ const dataModel = surface.dataModel;
+ const context = new DataContext(surface, '/');
+
+ const callValue = {
+ call: 'formatString',
+ args: {
+ value: '${/firstName} ${/lastName}',
+ },
+ returnType: 'string',
+ };
+
+ const resSig = context.resolveSignal(callValue as any);
+ const angSig = toAngularSignal(resSig, mockDestroyRef);
+
+ dataModel.set('/firstName', 'A2UI');
+ dataModel.set('/lastName', 'Renderer');
+
+ expect(angSig()).toBe('A2UI Renderer');
+ });
+ });
+});
diff --git a/renderers/angular/src/v0_9/core/surface.component.ts b/renderers/angular/src/v0_9/core/surface.component.ts
new file mode 100644
index 000000000..82be811d2
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/surface.component.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { ComponentHostComponent } from './component-host.component';
+
+/**
+ * High-level component for rendering an entire A2UI surface.
+ *
+ * It automatically renders the 'root' component of the specified surface.
+ */
+@Component({
+ selector: 'a2ui-v09-surface',
+ standalone: true,
+ imports: [ComponentHostComponent],
+ template: `
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SurfaceComponent {
+ /** The unique identifier of the surface to render. */
+ surfaceId = input.required();
+
+ /**
+ * The path within the surface's data model that represents the current state.
+ * Defaults to the root ('/').
+ */
+ dataContextPath = input('/');
+}
diff --git a/renderers/angular/src/v0_9/core/types.ts b/renderers/angular/src/v0_9/core/types.ts
new file mode 100644
index 000000000..b72b4018a
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/types.ts
@@ -0,0 +1,29 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Signal } from '@angular/core';
+
+/**
+ * Represents a component property bound to an Angular Signal and update logic.
+ */
+export interface BoundProperty {
+ /** The reactive Angular Signal */
+ readonly value: Signal;
+ /** The raw value from the ComponentModel */
+ readonly raw: any;
+ /** Callback to update the value in the DataContext (if bound) */
+ readonly onUpdate: (newValue: T) => void;
+}
diff --git a/renderers/angular/src/v0_9/core/utils.spec.ts b/renderers/angular/src/v0_9/core/utils.spec.ts
new file mode 100644
index 000000000..82c3232e1
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/utils.spec.ts
@@ -0,0 +1,96 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DestroyRef } from '@angular/core';
+import { signal as preactSignal } from '@preact/signals-core';
+import { toAngularSignal, getNormalizedPath } from './utils';
+
+describe('toAngularSignal', () => {
+ let mockDestroyRef: jasmine.SpyObj;
+ let onDestroyCallback: () => void;
+
+ beforeEach(() => {
+ onDestroyCallback = () => {};
+ mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']);
+ mockDestroyRef.onDestroy.and.callFake((callback: () => void) => {
+ onDestroyCallback = callback;
+ return () => {}; // Return unregister function
+ });
+ });
+
+ it('should initialize with the current value of Preact signal', () => {
+ const pSig = preactSignal('initial');
+ const angSig = toAngularSignal(pSig, mockDestroyRef);
+
+ expect(angSig()).toBe('initial');
+ });
+
+ it('should update Angular signal when Preact signal changes', () => {
+ const pSig = preactSignal('initial');
+ const angSig = toAngularSignal(pSig, mockDestroyRef);
+
+ expect(angSig()).toBe('initial');
+
+ pSig.value = 'updated';
+ expect(angSig()).toBe('updated');
+ });
+
+ it('should dispose Preact effect when DestroyRef triggers', () => {
+ const pSig = preactSignal('initial');
+ const angSig = toAngularSignal(pSig, mockDestroyRef);
+
+ expect(angSig()).toBe('initial');
+
+ // Trigger cleanup
+ onDestroyCallback();
+
+ pSig.value = 'updated';
+ // Angular signal should NOT update after disposal
+ expect(angSig()).toBe('initial');
+ });
+
+ it('should call unsubscribe on Preact signal if available on destroy', () => {
+ const pSig = preactSignal('initial') as any;
+ const unsubscribeSpy = jasmine.createSpy('unsubscribe');
+ pSig.unsubscribe = unsubscribeSpy;
+
+ toAngularSignal(pSig, mockDestroyRef);
+
+ expect(unsubscribeSpy).not.toHaveBeenCalled();
+
+ // Trigger cleanup
+ onDestroyCallback();
+
+ expect(unsubscribeSpy).toHaveBeenCalled();
+ });
+});
+
+describe('getNormalizedPath', () => {
+ it('should handle absolute paths', () => {
+ expect(getNormalizedPath('/absolute', '/', 0)).toBe('/absolute/0');
+ expect(getNormalizedPath('/absolute/', '/base', 5)).toBe('/absolute/5');
+ });
+
+ it('should resolve relative paths against dataContextPath', () => {
+ expect(getNormalizedPath('relative', '/', 2)).toBe('/relative/2');
+ expect(getNormalizedPath('relative', '/base', 3)).toBe('/base/relative/3');
+ });
+
+ it('should handle empty paths', () => {
+ expect(getNormalizedPath('', '/', 1)).toBe('/1');
+ expect(getNormalizedPath('', '/base', 4)).toBe('/base/4');
+ });
+});
diff --git a/renderers/angular/src/v0_9/core/utils.ts b/renderers/angular/src/v0_9/core/utils.ts
new file mode 100644
index 000000000..060203571
--- /dev/null
+++ b/renderers/angular/src/v0_9/core/utils.ts
@@ -0,0 +1,80 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { DestroyRef, Signal, signal as angularSignal } from '@angular/core';
+import { Signal as PreactSignal, effect } from '@a2ui/web_core/v0_9';
+
+/**
+ * Bridges a Preact Signal to a reactive Angular Signal.
+ *
+ * This utility handles the lifecycle mapping between Preact and Angular,
+ * ensuring that updates are propagated correctly and resources are cleaned up.
+ *
+ * @param preactSignal The source Preact Signal.
+ * @param destroyRef Angular DestroyRef for lifecycle management.
+ * @param ngZone Optional NgZone to ensure updates run within the Angular zone.
+ * @returns A read-only Angular Signal.
+ */
+import { NgZone } from '@angular/core';
+
+export function toAngularSignal(
+ preactSignal: PreactSignal,
+ destroyRef: DestroyRef,
+ ngZone?: NgZone,
+): Signal {
+ const s = angularSignal(preactSignal.peek());
+
+ const dispose = effect(() => {
+ if (ngZone) {
+ ngZone.run(() => s.set(preactSignal.value));
+ } else {
+ s.set(preactSignal.value);
+ }
+ });
+
+ destroyRef.onDestroy(() => {
+ dispose();
+ // Some signals returned by DataContext.resolveSignal have a custom unsubscribe for AbortControllers
+ if ((preactSignal as any).unsubscribe) {
+ (preactSignal as any).unsubscribe();
+ }
+ });
+
+ return s.asReadonly();
+}
+
+/**
+ * Normalizes a data model path by combining a relative path with a base context.
+ *
+ * This is used to create unique absolute paths for components within a repeating
+ * collection or nested structure.
+ *
+ * @param path The relative or absolute path from the component properties.
+ * @param dataContextPath The current base data context path.
+ * @param index The index of the child component.
+ * @returns A fully normalized absolute path for the indexed child.
+ */
+export function getNormalizedPath(path: string, dataContextPath: string, index: number): string {
+ let normalized = path || '';
+ if (!normalized.startsWith('/')) {
+ const base = dataContextPath === '/' ? '' : dataContextPath;
+ normalized = `${base}/${normalized}`;
+ }
+ if (normalized.endsWith('/')) {
+ normalized = normalized.slice(0, -1);
+ }
+ return `${normalized}/${index}`;
+}
diff --git a/renderers/angular/src/v0_9/ng-package.json b/renderers/angular/src/v0_9/ng-package.json
new file mode 100644
index 000000000..ed278942e
--- /dev/null
+++ b/renderers/angular/src/v0_9/ng-package.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
+ "lib": {
+ "entryFile": "public-api.ts"
+ }
+}
diff --git a/renderers/angular/src/v0_9/public-api.ts b/renderers/angular/src/v0_9/public-api.ts
new file mode 100644
index 000000000..d580eec08
--- /dev/null
+++ b/renderers/angular/src/v0_9/public-api.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Core Services and Components
+export * from './core/a2ui-renderer.service';
+export * from './core/component-host.component';
+export * from './core/surface.component';
+export * from './core/component-binder.service';
+export * from './core/types';
+export * from './core/utils';
+
+// Catalog Types and Implementations
+export * from './catalog/types';
+export * from './catalog/minimal/minimal-catalog';
+
+// Minimal Catalog Components
+export * from './catalog/minimal/text.component';
+export * from './catalog/minimal/row.component';
+export * from './catalog/minimal/column.component';
+export * from './catalog/minimal/button.component';
+export * from './catalog/minimal/text-field.component';
diff --git a/renderers/angular/tsconfig.json b/renderers/angular/tsconfig.json
index 9f6412a72..a28822add 100644
--- a/renderers/angular/tsconfig.json
+++ b/renderers/angular/tsconfig.json
@@ -11,7 +11,13 @@
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
- "module": "preserve"
+ "baseUrl": ".",
+ "moduleResolution": "bundler",
+ "paths": {
+ "@a2ui/angular/v0_8": ["src/v0_8/public-api.ts"],
+ "@a2ui/angular/v0_9": ["src/v0_9/public-api.ts"],
+ "@preact/signals-core": ["../web_core/node_modules/@preact/signals-core"]
+ }
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
diff --git a/renderers/angular/tsconfig.lib.json b/renderers/angular/tsconfig.lib.json
index 6984a0e01..a7bc21849 100644
--- a/renderers/angular/tsconfig.lib.json
+++ b/renderers/angular/tsconfig.lib.json
@@ -7,10 +7,6 @@
"inlineSources": true,
"types": []
},
- "include": [
- "src/**/*.ts"
- ],
- "exclude": [
- "**/*.spec.ts"
- ]
+ "include": ["src/**/*.ts"],
+ "exclude": ["**/*.spec.ts"]
}
diff --git a/renderers/angular/tsconfig.spec.json b/renderers/angular/tsconfig.spec.json
index 79ee881a8..d427f4c3a 100644
--- a/renderers/angular/tsconfig.spec.json
+++ b/renderers/angular/tsconfig.spec.json
@@ -2,11 +2,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
- "types": [
- "jasmine"
- ]
+ "types": ["jasmine"]
},
- "include": [
- "src/**/*.ts"
- ]
+ "include": ["src/**/*.ts"]
}
diff --git a/renderers/angular/v0_9 b/renderers/angular/v0_9
new file mode 120000
index 000000000..2b358cef5
--- /dev/null
+++ b/renderers/angular/v0_9
@@ -0,0 +1 @@
+src/v0_9
\ No newline at end of file
diff --git a/renderers/markdown/markdown-it/package-lock.json b/renderers/markdown/markdown-it/package-lock.json
index 0b5559b64..5f9b24ebf 100644
--- a/renderers/markdown/markdown-it/package-lock.json
+++ b/renderers/markdown/markdown-it/package-lock.json
@@ -39,6 +39,7 @@
},
"devDependencies": {
"@types/node": "^24.11.0",
+ "c8": "^11.0.0",
"typescript": "^5.8.3",
"wireit": "^0.15.0-pre.2",
"zod-to-json-schema": "^3.25.1"
diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json
index 3e983139c..0f26b0420 100644
--- a/renderers/web_core/package.json
+++ b/renderers/web_core/package.json
@@ -42,6 +42,7 @@
"build:tsc": "wireit",
"test": "wireit",
"test:coverage": "c8 node --test \"dist/**/*.test.js\""
+
},
"wireit": {
"copy-spec": {
diff --git a/samples/client/angular/package-lock.json b/samples/client/angular/package-lock.json
index 3bcccd7e7..164c28232 100644
--- a/samples/client/angular/package-lock.json
+++ b/samples/client/angular/package-lock.json
@@ -36,6 +36,7 @@
"ng2-charts": "^8.0.0",
"tslib": "^2.3.0",
"uuid": "^13.0.0",
+ "zod": "^3.25.76",
"zone.js": "~0.15.0"
},
"devDependencies": {
@@ -1163,6 +1164,7 @@
},
"devDependencies": {
"@types/node": "^24.11.0",
+ "c8": "^11.0.0",
"typescript": "^5.8.3",
"wireit": "^0.15.0-pre.2",
"zod-to-json-schema": "^3.25.1"
@@ -2130,6 +2132,15 @@
"yarn": ">= 1.13.0"
}
},
+ "node_modules/@angular/cli/node_modules/zod": {
+ "version": "4.3.6",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@angular/common": {
"version": "21.2.0",
"license": "MIT",
@@ -13989,7 +14000,8 @@
}
},
"node_modules/zod": {
- "version": "4.3.6",
+ "version": "3.25.76",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/samples/client/angular/package.json b/samples/client/angular/package.json
index 8e967a56b..114ae3741 100644
--- a/samples/client/angular/package.json
+++ b/samples/client/angular/package.json
@@ -31,9 +31,8 @@
"private": true,
"dependencies": {
"@a2a-js/sdk": "^0.3.4",
- "@modelcontextprotocol/ext-apps": "^1.2.0",
- "@a2ui/web_core": "file:../../../renderers/web_core",
"@a2ui/markdown-it": "file:../../../renderers/markdown/markdown-it",
+ "@a2ui/web_core": "file:../../../renderers/web_core",
"@angular/cdk": "^20.2.10",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
@@ -45,6 +44,7 @@
"@angular/platform-server": "^21.2.0",
"@angular/ssr": "^21.2.0",
"@google/genai": "^1.22.0",
+ "@modelcontextprotocol/ext-apps": "^1.2.0",
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"excalibur": "0.31.0",
@@ -53,6 +53,7 @@
"ng2-charts": "^8.0.0",
"tslib": "^2.3.0",
"uuid": "^13.0.0",
+ "zod": "^3.25.76",
"zone.js": "~0.15.0"
},
"devDependencies": {