+ `;
+ }
+}
+```
+
+## Register the component
+
+Update `lib/src/0.8/ui/custom-components/index.ts` to register your new component.
+You must pass the desired tag name as the third argument.
+
+```typescript
+import { componentRegistry } from "../component-registry.js";
+import { MyComponent } from "./my-component.js"; // Import your component
+
+export function registerCustomComponents() {
+ // Register with explicit tag name
+ componentRegistry.register("MyComponent", MyComponent, "my-component");
+}
+
+export { MyComponent }; // Export for type usage if needed
+```
+
+## Define the schema (server-side)
+
+Create a JSON schema for your component properties. This will be used by the server to validate messages.
+Example: `lib/my_component_schema.json`
+
+```json
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": { "const": "object" },
+ "properties": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "myProp": {
+ "type": "string",
+ "description": "A sample property."
+ }
+ },
+ "required": ["myProp"]
+ }
+ },
+ "required": ["type", "properties"]
+}
+```
+
+## Use in client application
+
+In your client application (e.g., `contact` sample), ensure you import and call the registration function.
+
+```typescript
+import { registerCustomComponents } from "@a2ui/lit/ui";
+
+// Call this once at startup
+registerCustomComponents();
+```
+
+## Overriding standard components
+
+You can replace standard A2UI components (like `TextField`, `Video`, `Button`) with your own custom implementations.
+
+### Steps to override
+
+1. **Create your component** extending `Root` (just like a custom component).
+
+2. **Ensure it accepts the standard properties** for that component type (e.g., `label` and `text` for `TextField`).
+
+3. **Register it** using the **standard type name** (e.g., `"TextField"`).
+
+ ```typescript
+ // 1. Define your override
+ class MyPremiumTextField extends Root {
+ @property() accessor label = "";
+ @property() accessor text = "";
+
+ static styles = [
+ ...Root.styles,
+ css`
+ /* your premium styles */
+ `,
+ ];
+
+ render() {
+ return html`
+
+
+
+
+ `;
+ }
+ }
+
+ // 2. Register with the STANDARD type name
+ import { componentRegistry } from "@a2ui/lit/ui";
+ componentRegistry.register(
+ "TextField",
+ MyPremiumTextField,
+ "my-premium-textfield"
+ );
+ ```
+
+**Result:**
+When the server sends a `TextField` component, the client will now render `` instead of the default ``.
+
+## Verify
+
+You can verify the component by creating a simple HTML test file or by sending a server message with the new component type.
+
+**Server message example:**
+
+```json
+{
+ "surfaceId": "main",
+ "component": {
+ "type": "MyComponent",
+ "id": "comp-1",
+ "properties": {
+ "myProp": "Hello World"
+ }
+ }
+}
+```
+
+## Troubleshooting
+
+- **`NotSupportedError`**: If you see "constructor has already been used", ensure you **removed** the `@customElement` decorator from your component class.
+- **Component not rendering**: Check if `registerCustomComponents()` is actually called. Verify the tag name in the DOM matches what you registered (e.g., `` vs ``).
+- **Styles missing**: Ensure `static styles` includes `...Root.styles`.
diff --git a/samples/client/lit/component_gallery/ui/custom-components/org-chart.ts b/samples/client/lit/component_gallery/ui/custom-components/org-chart.ts
new file mode 100644
index 000000000..c2feab1ea
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/org-chart.ts
@@ -0,0 +1,200 @@
+/*
+ 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
+
+ https://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 { Root } from '@a2ui/lit/ui';
+import { v0_8 } from '@a2ui/lit';
+import { html, css, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { map } from 'lit/directives/map.js';
+
+// Use aliases for convenience
+const StateEvent = v0_8.Events.StateEvent;
+type Action = v0_8.Types.Action;
+
+export interface OrgChartNode {
+ title: string;
+ name: string;
+}
+
+@customElement('org-chart')
+export class OrgChart extends Root {
+ @property({ type: Array }) accessor chain: OrgChartNode[] = [];
+ @property({ type: Object }) accessor action: Action | null = null;
+
+ static styles = [
+ ...Root.styles,
+ css`
+ :host {
+ display: block;
+ padding: 16px;
+ font-family: 'Roboto', sans-serif;
+ }
+
+ .container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ }
+
+ .node {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 12px 24px;
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+ min-width: 200px;
+ position: relative;
+ transition: transform 0.2s, box-shadow 0.2s;
+ cursor: pointer;
+ }
+
+ .node:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ }
+
+ .node:focus {
+ outline: 2px solid #1a73e8;
+ outline-offset: 2px;
+ }
+
+ .node.current {
+ background: #e8f0fe;
+ border-color: #1a73e8;
+ border-width: 2px;
+ }
+
+ .title {
+ font-size: 0.85rem;
+ color: #5f6368;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+ }
+
+ .name {
+ font-size: 1.1rem;
+ font-weight: 500;
+ color: #202124;
+ }
+
+ .arrow {
+ color: #9aa0a6;
+ font-size: 24px;
+ line-height: 1;
+ }
+ `];
+
+ render() {
+ let chainData: OrgChartNode[] | null = null;
+ let unresolvedChain: any = this.chain;
+
+ // Resolve "chain" if it is a path object
+ const chainAsAny = this.chain as any;
+ if (chainAsAny && typeof chainAsAny === 'object' && 'path' in chainAsAny && chainAsAny.path) {
+ if (this.processor) {
+ const resolved = this.processor.getData(this.component, chainAsAny.path, this.surfaceId ?? 'default');
+ if (resolved) {
+ unresolvedChain = resolved;
+ }
+ }
+ }
+
+ if (Array.isArray(unresolvedChain)) {
+ chainData = unresolvedChain as OrgChartNode[];
+ } else if (unresolvedChain instanceof Map) {
+ // Handle Map (values are the nodes)
+ const entries = Array.from((unresolvedChain as Map).entries());
+ entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
+ chainData = entries.map(entry => entry[1]);
+ } else if (typeof unresolvedChain === 'object' && unresolvedChain !== null) {
+ chainData = Object.values(unresolvedChain);
+ }
+
+ // Normalize items: model processor converts nested objects to Maps, so we must convert them back
+ chainData = (chainData || []).map(node => {
+ // Helper to safely get property regardless of type
+ const getVal = (k: string) => {
+ if (node instanceof Map) return node.get(k);
+ return (node as any)?.[k];
+ };
+
+ return {
+ title: getVal('title') ?? '',
+ name: getVal('name') ?? '',
+ };
+ });
+
+ if (!chainData || chainData.length === 0) {
+ return html`
+ `;
+ }
+
+ private handleNodeClick(node: OrgChartNode) {
+ if (!this.action) return;
+
+ // Create a new action with the node's context merged in
+ const newContext = [
+ ...(this.action.context || []),
+ {
+ key: 'clickedNodeTitle',
+ value: { literalString: node.title }
+ },
+ {
+ key: 'clickedNodeName',
+ value: { literalString: node.name }
+ }
+ ];
+
+ const actionWithContext: Action = {
+ ...this.action,
+ context: newContext as Action['context']
+ };
+
+ const evt = new StateEvent<"a2ui.action">({
+ eventType: "a2ui.action",
+ action: actionWithContext,
+ dataContextPath: this.dataContextPath,
+ sourceComponentId: this.id,
+ sourceComponent: this.component,
+ });
+ this.dispatchEvent(evt);
+ }
+}
diff --git a/samples/client/lit/component_gallery/ui/custom-components/premium-text-field.ts b/samples/client/lit/component_gallery/ui/custom-components/premium-text-field.ts
new file mode 100644
index 000000000..968b0fbfd
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/premium-text-field.ts
@@ -0,0 +1,100 @@
+/*
+ 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
+
+ https://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 { Root } from '@a2ui/lit/ui';
+import { html, css } from 'lit';
+import { property } from 'lit/decorators.js';
+
+export class PremiumTextField extends Root {
+ @property() accessor label = '';
+ @property() accessor text = '';
+
+ static styles = [
+ ...Root.styles,
+ css`
+ :host {
+ display: block;
+ padding: 16px;
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+ border: 1px solid #e0e0e0;
+ transition: all 0.2s ease;
+ font-family: 'Inter', sans-serif;
+ }
+ :host(:hover) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(0,0,0,0.12);
+ }
+ .input-container {
+ position: relative;
+ margin-top: 8px;
+ }
+ input {
+ width: 100%;
+ padding: 12px 16px;
+ font-size: 16px;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ outline: none;
+ transition: border-color 0.2s;
+ box-sizing: border-box;
+ background: #fafafa;
+ }
+ input:focus {
+ border-color: #6200ee;
+ background: #fff;
+ }
+ label {
+ display: block;
+ font-size: 14px;
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 4px;
+ }
+ .hint {
+ margin-top: 8px;
+ font-size: 12px;
+ color: #666;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+ .badge {
+ background: #6200ee;
+ color: white;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: bold;
+ text-transform: uppercase;
+ }
+ `
+ ];
+
+ render() {
+ return html`
+
+
+
+
+
+ Custom
+ This is a premium override of the standard TextField.
+
+ `;
+ }
+}
diff --git a/samples/client/lit/component_gallery/ui/custom-components/register-components.ts b/samples/client/lit/component_gallery/ui/custom-components/register-components.ts
new file mode 100644
index 000000000..047a0438c
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/register-components.ts
@@ -0,0 +1,69 @@
+/*
+ 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
+
+ https://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 { componentRegistry } from "@a2ui/lit/ui";
+import { OrgChart } from "./org-chart.js";
+import { WebFrame } from "./web-frame.js";
+import { PremiumTextField } from "./premium-text-field.js";
+
+export function registerContactComponents() {
+ // Register OrgChart
+ componentRegistry.register("OrgChart", OrgChart, "org-chart", {
+ type: "object",
+ properties: {
+ chain: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ title: { type: "string" },
+ name: { type: "string" },
+ },
+ required: ["title", "name"],
+ },
+ },
+ action: { $ref: "#/definitions/Action" },
+ },
+ required: ["chain"],
+ });
+
+ // Register PremiumTextField as an override for TextField
+ componentRegistry.register(
+ "TextField",
+ PremiumTextField,
+ "premium-text-field"
+ );
+
+ // Register WebFrame
+ componentRegistry.register("WebFrame", WebFrame, "a2ui-web-frame", {
+ type: "object",
+ properties: {
+ url: { type: "string" },
+ html: { type: "string" },
+ height: { type: "number" },
+ interactionMode: {
+ type: "string",
+ enum: ["readOnly", "interactive"]
+ },
+ allowedEvents: {
+ type: "array",
+ items: { type: "string" }
+ }
+ },
+ });
+
+ console.log("Registered Contact App Custom Components");
+}
diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/README.md b/samples/client/lit/component_gallery/ui/custom-components/test/README.md
new file mode 100644
index 000000000..f4277bfeb
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/test/README.md
@@ -0,0 +1,30 @@
+# Contact sample verification tests
+
+This directory contains tests to verify custom component integration specifically within the `contact` sample application environment.
+
+## How to run
+
+These tests run via the Vite development server used by the contact sample.
+
+### 1. Start the dev server
+From the `web/lit/samples/contact` directory, run:
+
+```bash
+npm run dev
+```
+
+### 2. Access the tests
+Open your browser and navigate to the local server (usually port 5173):
+
+- **Component override test**:
+ [http://localhost:5173/ui/custom-components/test/override-test.html](http://localhost:5173/ui/custom-components/test/override-test.html)
+ *Verifies that a standard component (TextField) can be overridden by a custom implementation.*
+
+- **Hierarchy graph integration test**:
+ [http://localhost:5173/ui/custom-components/test/hierarchy-test.html](http://localhost:5173/ui/custom-components/test/hierarchy-test.html)
+ *Verifies that the HierarchyGraph component renders correctly within the contact app's build setup.*
+
+## Files
+
+- `override-test.html` & `override-test.ts`: Implements and tests a custom `TextField` override.
+- `hierarchy-test.html`: Tests the `HierarchyGraph` component.
diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/org-chart-test.html b/samples/client/lit/component_gallery/ui/custom-components/test/org-chart-test.html
new file mode 100644
index 000000000..b54b5710c
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/test/org-chart-test.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+ A2UI Org Chart Test (Contact Sample)
+
+
+
+
+
+
A2UI Org Chart Test (Contact Sample)
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/override-test.html b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.html
new file mode 100644
index 000000000..7edf0291c
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+ A2UI Component Override Test
+
+
+
+
+
+
Component Override Test
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/lit/component_gallery/ui/custom-components/test/override-test.ts b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.ts
new file mode 100644
index 000000000..7ded768bd
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/test/override-test.ts
@@ -0,0 +1,46 @@
+/*
+ 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
+
+ https://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 { componentRegistry, Root } from "@a2ui/lit/ui";
+import { html, css } from "lit";
+import { property } from "lit/decorators.js";
+// 1. Define the override
+import { PremiumTextField } from "../premium-text-field.js";
+
+// 2. Register it as "TextField"
+componentRegistry.register("TextField", PremiumTextField, "premium-text-field");
+console.log("Registered PremiumTextField override");
+
+// 3. Render a standard TextField component node
+const container = document.getElementById("app");
+if (container) {
+ const root = document.createElement("a2ui-root") as Root;
+
+ const textFieldComponent = {
+ type: "TextField",
+ id: "tf-1",
+ properties: {
+ label: "Enter your name",
+ text: "John Doe",
+ },
+ };
+
+ // Root renders its *children*, so we must pass the component as a child.
+ root.childComponents = [textFieldComponent];
+
+ root.enableCustomElements = true; // Enable the feature
+ container.appendChild(root);
+}
diff --git a/samples/client/lit/component_gallery/ui/custom-components/web-frame.ts b/samples/client/lit/component_gallery/ui/custom-components/web-frame.ts
new file mode 100644
index 000000000..6eee51cf7
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/custom-components/web-frame.ts
@@ -0,0 +1,234 @@
+/**
+ * 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 { html, css, PropertyValues, nothing } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { Root } from "@a2ui/lit/ui";
+import { v0_8 } from "@a2ui/lit";
+
+
+interface WebFrameConfig {
+ [key: string]: unknown;
+}
+
+@customElement("a2ui-web-frame")
+export class WebFrame extends Root {
+ static override styles = [
+ ...Root.styles,
+ css`
+ :host {
+ display: block;
+ width: 100%;
+ border: 1px solid #eee;
+ position: relative;
+ overflow: hidden; /* For Aspect Ratio / Container */
+ }
+ iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: #f5f5f5;
+ }
+ .controls {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ display: flex;
+ gap: 10px;
+ z-index: 10;
+ }
+ .controls button {
+ width: 32px;
+ height: 32px;
+ font-size: 20px;
+ cursor: pointer;
+ background: white;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+ .controls button:hover {
+ background: #f0f0f0;
+ }
+ `,
+ ];
+
+ /* --- Properties (Server Contract) --- */
+
+ @property({ type: String })
+ accessor url: string = "";
+
+ @property({ type: String })
+ accessor html: string = "";
+
+ @property({ type: Number })
+ accessor height: number | undefined = undefined;
+
+ @property({ type: String })
+ accessor interactionMode: "readOnly" | "interactive" = "readOnly";
+
+ @property({ type: Array })
+ accessor allowedEvents: string[] = [];
+
+ // --- Internal State ---
+
+ @query("iframe")
+ accessor iframe!: HTMLIFrameElement;
+
+ // --- Security Constants ---
+ static readonly TRUSTED_DOMAINS = [
+ "localhost",
+ "127.0.0.1",
+ "openstreetmap.org",
+ "youtube.com",
+ "maps.google.com"
+ ];
+
+ override render() {
+ const sandboxAttr = this.#calculateSandbox();
+ // Default to aspect ratio if no height. Use 16:9 or 4:3.
+ const style = this.height ? `height: ${this.height}px;` : 'aspect-ratio: 4/3;';
+
+ // Determine content: srcdoc (html) vs src (url)
+ const srcRaw = this.url;
+ // VERY IMPORTANT: If html is empty, do NOT pass it to srcdoc, otherwise it overrides src with blank page.
+ const srcDocRaw = this.html || undefined;
+
+ return html`
+
+ `;
+ }
+}
diff --git a/samples/client/lit/component_gallery/ui/ui.ts b/samples/client/lit/component_gallery/ui/ui.ts
new file mode 100644
index 000000000..7726f29d7
--- /dev/null
+++ b/samples/client/lit/component_gallery/ui/ui.ts
@@ -0,0 +1,17 @@
+/*
+ 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
+
+ https://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.
+ */
+
+export { Snackbar } from "./snackbar";
diff --git a/samples/client/lit/component_gallery/vite.config.ts b/samples/client/lit/component_gallery/vite.config.ts
new file mode 100644
index 000000000..16c0e4785
--- /dev/null
+++ b/samples/client/lit/component_gallery/vite.config.ts
@@ -0,0 +1,46 @@
+
+/*
+ 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
+
+ https://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 { config } from "dotenv";
+import { UserConfig } from "vite";
+import * as Middleware from "./middleware";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default async () => {
+ config();
+
+ const entry: Record = {
+ component_gallery: resolve(__dirname, "index.html"),
+ };
+
+ return {
+ plugins: [Middleware.A2AMiddleware.plugin()],
+ build: {
+ rollupOptions: {
+ input: entry,
+ },
+ target: "esnext",
+ },
+ define: {},
+ resolve: {
+ dedupe: ["lit"],
+ },
+ } satisfies UserConfig;
+};