Add ez-assistant and kerberos service folders

This commit is contained in:
kelin
2026-02-11 14:56:03 -05:00
parent e4e8ae1b87
commit 9ccfb36923
4471 changed files with 746463 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
@a2ui:registry=https://us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/
//us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/:always-auth=true

View File

@@ -0,0 +1,9 @@
Angular implementation of A2UI.
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.

View File

@@ -0,0 +1,35 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"projects": {
"lib": {
"projectType": "library",
"root": ".",
"sourceRoot": "./src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"configurations": {
"production": {
"tsConfig": "./tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "./tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"tsConfig": "./tsconfig.spec.json"
}
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "./dist",
"lib": {
"entryFile": "src/public-api.ts"
},
"allowedNonPeerDependencies": ["markdown-it", "@a2ui/lit"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
{
"name": "@a2ui/angular",
"version": "0.8.1",
"scripts": {
"build": "ng build"
},
"dependencies": {
"@a2ui/lit": "file:../lit",
"markdown-it": "^14.1.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0"
},
"devDependencies": {
"@angular/build": "^21.0.2",
"@angular/cli": "^21.0.2",
"@angular/compiler": "^21.0.0",
"@angular/compiler-cli": "^21.0.3",
"@angular/core": "^21.0.0",
"@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.17.19",
"@types/uuid": "^10.0.0",
"@vitest/browser": "^4.0.15",
"cypress": "^15.6.0",
"google-artifactregistry-auth": "^3.5.0",
"jasmine-core": "~5.9.0",
"jsdom": "^27.2.0",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.1.0",
"ng-packagr": "^21.0.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"sass": "^1.93.2",
"tslib": "^2.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.15"
},
"sideEffects": false,
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
}

View File

@@ -0,0 +1,50 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-audio',
template: `
@let resolvedUrl = this.resolvedUrl();
@if (resolvedUrl) {
<section [class]="theme.components.AudioPlayer" [style]="theme.additionalStyles?.AudioPlayer">
<audio controls [src]="resolvedUrl"></audio>
</section>
}
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
audio {
display: block;
width: 100%;
box-sizing: border-box;
}
`
})
export class Audio extends DynamicComponent {
readonly url = input.required<Primitives.StringValue | null>();
protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url()));
}

View File

@@ -0,0 +1,56 @@
/*
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 { Component, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-button',
imports: [Renderer],
template: `
<button
[class]="theme.components.Button"
[style]="theme.additionalStyles?.Button"
(click)="handleClick()"
>
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="component().properties.child"
/>
</button>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
}
`,
})
export class Button extends DynamicComponent<Types.ButtonNode> {
readonly action = input.required<Types.Action | null>();
protected handleClick() {
const action = this.action();
if (action) {
super.sendAction(action);
}
}
}

View File

@@ -0,0 +1,57 @@
/*
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 { Component, ViewEncapsulation } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
import { Types } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-card',
imports: [Renderer],
encapsulation: ViewEncapsulation.None,
styles: `
a2ui-card {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
a2ui-card > section {
height: 100%;
width: 100%;
min-height: 0;
overflow: auto;
}
a2ui-card > section > * {
height: 100%;
width: 100%;
}
`,
template: `
@let properties = component().properties;
@let children = properties.children || [properties.child];
<section [class]="theme.components.Card" [style]="theme.additionalStyles?.Card">
@for (child of children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class Card extends DynamicComponent<Types.CardNode> { }

View File

@@ -0,0 +1,73 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-checkbox',
template: `
<section
[class]="theme.components.CheckBox.container"
[style]="theme.additionalStyles?.CheckBox"
>
<input
autocomplete="off"
type="checkbox"
[id]="inputId"
[checked]="inputChecked()"
[class]="theme.components.CheckBox.element"
(change)="handleChange($event)"
/>
<label [htmlFor]="inputId" [class]="theme.components.CheckBox.label">{{
resolvedLabel()
}}</label>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
input {
display: block;
width: 100%;
}
`,
})
export class Checkbox extends DynamicComponent {
readonly value = input.required<Primitives.BooleanValue | null>();
readonly label = input.required<Primitives.StringValue | null>();
protected inputChecked = computed(() => super.resolvePrimitive(this.value()) ?? false);
protected resolvedLabel = computed(() => super.resolvePrimitive(this.label()));
protected inputId = super.getUniqueId('a2ui-checkbox');
protected handleChange(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.checked, this.surfaceId());
}
}

View File

@@ -0,0 +1,96 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-column',
imports: [Renderer],
styles: `
:host {
display: flex;
flex: var(--weight);
}
section {
display: flex;
flex-direction: column;
min-width: 100%;
height: 100%;
box-sizing: border-box;
}
.align-start {
align-items: start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: end;
}
.align-stretch {
align-items: stretch;
}
.distribute-start {
justify-content: start;
}
.distribute-center {
justify-content: center;
}
.distribute-end {
justify-content: end;
}
.distribute-spaceBetween {
justify-content: space-between;
}
.distribute-spaceAround {
justify-content: space-around;
}
.distribute-spaceEvenly {
justify-content: space-evenly;
}
`,
template: `
<section [class]="classes()" [style]="theme.additionalStyles?.Column">
@for (child of component().properties.children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class Column extends DynamicComponent<Types.ColumnNode> {
readonly alignment = input<Types.ResolvedColumn['alignment']>('stretch');
readonly distribution = input<Types.ResolvedColumn['distribution']>('start');
protected readonly classes = computed(() => ({
...this.theme.components.Column,
[`align-${this.alignment()}`]: true,
[`distribute-${this.distribution()}`]: true,
}));
}

View File

@@ -0,0 +1,127 @@
/*
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 { computed, Component, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-datetime-input',
template: `
<section [class]="theme.components.DateTimeInput.container">
<label [for]="inputId" [class]="theme.components.DateTimeInput.label">{{ label() }}</label>
<input
autocomplete="off"
[attr.type]="inputType()"
[id]="inputId"
[class]="theme.components.DateTimeInput.element"
[style]="theme.additionalStyles?.DateTimeInput"
[value]="inputValue()"
(input)="handleInput($event)"
/>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
input {
display: block;
width: 100%;
box-sizing: border-box;
}
`,
})
export class DatetimeInput extends DynamicComponent {
readonly value = input.required<Primitives.StringValue | null>();
readonly enableDate = input.required<boolean>();
readonly enableTime = input.required<boolean>();
protected readonly inputId = super.getUniqueId('a2ui-datetime-input');
protected inputType = computed(() => {
const enableDate = this.enableDate();
const enableTime = this.enableTime();
if (enableDate && enableTime) {
return 'datetime-local';
} else if (enableDate) {
return 'date';
} else if (enableTime) {
return 'time';
}
return 'datetime-local';
});
protected label = computed(() => {
// TODO: this should likely be passed from the model.
const inputType = this.inputType();
if (inputType === 'date') {
return 'Date';
} else if (inputType === 'time') {
return 'Time';
}
return 'Date & Time';
});
protected inputValue = computed(() => {
const inputType = this.inputType();
const parsed = super.resolvePrimitive(this.value()) || '';
const date = parsed ? new Date(parsed) : null;
if (!date || isNaN(date.getTime())) {
return '';
}
const year = this.padNumber(date.getFullYear());
const month = this.padNumber(date.getMonth());
const day = this.padNumber(date.getDate());
const hours = this.padNumber(date.getHours());
const minutes = this.padNumber(date.getMinutes());
// Browsers are picky with what format they allow for the `value` attribute of date/time inputs.
// We need to parse it out of the provided value. Note that we don't use `toISOString`,
// because the resulting value is relative to UTC.
if (inputType === 'date') {
return `${year}-${month}-${day}`;
} else if (inputType === 'time') {
return `${hours}:${minutes}`;
}
return `${year}-${month}-${day}T${hours}:${minutes}`;
});
protected handleInput(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.value, this.surfaceId());
}
private padNumber(value: number) {
return value.toString().padStart(2, '0');
}
}

View File

@@ -0,0 +1,185 @@
/*
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 { inputBinding } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { Catalog } from '../rendering/catalog';
import { Row } from './row';
import { Column } from './column';
import { Text } from './text';
export const DEFAULT_CATALOG: Catalog = {
Row: {
type: () => Row,
bindings: (node) => {
const properties = (node as Types.RowNode).properties;
return [
inputBinding('alignment', () => properties.alignment ?? 'stretch'),
inputBinding('distribution', () => properties.distribution ?? 'start'),
];
},
},
Column: {
type: () => Column,
bindings: (node) => {
const properties = (node as Types.ColumnNode).properties;
return [
inputBinding('alignment', () => properties.alignment ?? 'stretch'),
inputBinding('distribution', () => properties.distribution ?? 'start'),
];
},
},
List: {
type: () => import('./list').then((r) => r.List),
bindings: (node) => {
const properties = (node as Types.ListNode).properties;
return [inputBinding('direction', () => properties.direction ?? 'vertical')];
},
},
Card: () => import('./card').then((r) => r.Card),
Image: {
type: () => import('./image').then((r) => r.Image),
bindings: (node) => {
const properties = (node as Types.ImageNode).properties;
return [
inputBinding('url', () => properties.url),
inputBinding('usageHint', () => properties.usageHint),
];
},
},
Icon: {
type: () => import('./icon').then((r) => r.Icon),
bindings: (node) => {
const properties = (node as Types.IconNode).properties;
return [inputBinding('name', () => properties.name)];
},
},
Video: {
type: () => import('./video').then((r) => r.Video),
bindings: (node) => {
const properties = (node as Types.VideoNode).properties;
return [inputBinding('url', () => properties.url)];
},
},
AudioPlayer: {
type: () => import('./audio').then((r) => r.Audio),
bindings: (node) => {
const properties = (node as Types.AudioPlayerNode).properties;
return [inputBinding('url', () => properties.url)];
},
},
Text: {
type: () => Text,
bindings: (node) => {
const properties = (node as Types.TextNode).properties;
return [
inputBinding('text', () => properties.text),
inputBinding('usageHint', () => properties.usageHint || null),
];
},
},
Button: {
type: () => import('./button').then((r) => r.Button),
bindings: (node) => {
const properties = (node as Types.ButtonNode).properties;
return [inputBinding('action', () => properties.action)];
},
},
Divider: () => import('./divider').then((r) => r.Divider),
MultipleChoice: {
type: () => import('./multiple-choice').then((r) => r.MultipleChoice),
bindings: (node) => {
const properties = (node as Types.MultipleChoiceNode).properties;
return [
inputBinding('options', () => properties.options || []),
inputBinding('value', () => properties.selections),
inputBinding('description', () => 'Select an item'), // TODO: this should be defined in the properties
];
},
},
TextField: {
type: () => import('./text-field').then((r) => r.TextField),
bindings: (node) => {
const properties = (node as Types.TextFieldNode).properties;
return [
inputBinding('text', () => properties.text ?? null),
inputBinding('label', () => properties.label),
inputBinding('inputType', () => properties.type),
];
},
},
DateTimeInput: {
type: () => import('./datetime-input').then((r) => r.DatetimeInput),
bindings: (node) => {
const properties = (node as Types.DateTimeInputNode).properties;
return [
inputBinding('enableDate', () => properties.enableDate),
inputBinding('enableTime', () => properties.enableTime),
inputBinding('value', () => properties.value),
];
},
},
CheckBox: {
type: () => import('./checkbox').then((r) => r.Checkbox),
bindings: (node) => {
const properties = (node as Types.CheckboxNode).properties;
return [
inputBinding('label', () => properties.label),
inputBinding('value', () => properties.value),
];
},
},
Slider: {
type: () => import('./slider').then((r) => r.Slider),
bindings: (node) => {
const properties = (node as Types.SliderNode).properties;
return [
inputBinding('value', () => properties.value),
inputBinding('minValue', () => properties.minValue),
inputBinding('maxValue', () => properties.maxValue),
inputBinding('label', () => ''), // TODO: this should be defined in the properties
];
},
},
Tabs: {
type: () => import('./tabs').then((r) => r.Tabs),
bindings: (node) => {
const properties = (node as Types.TabsNode).properties;
return [inputBinding('tabs', () => properties.tabItems)];
},
},
Modal: {
type: () => import('./modal').then((r) => r.Modal),
bindings: () => [],
},
};

View File

@@ -0,0 +1,37 @@
/*
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 { Component } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: 'a2ui-divider',
template: '<hr [class]="theme.components.Divider" [style]="theme.additionalStyles?.Divider"/>',
styles: `
:host {
display: block;
min-height: 0;
overflow: auto;
}
hr {
height: 1px;
background: #ccc;
border: none;
}
`,
})
export class Divider extends DynamicComponent {}

View File

@@ -0,0 +1,44 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-icon',
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
`,
template: `
@let resolvedName = this.resolvedName();
@if (resolvedName) {
<section [class]="theme.components.Icon" [style]="theme.additionalStyles?.Icon">
<span class="g-icon">{{ resolvedName }}</span>
</section>
}
`,
})
export class Icon extends DynamicComponent {
readonly name = input.required<Primitives.StringValue | null>();
protected readonly resolvedName = computed(() => this.resolvePrimitive(this.name()));
}

View File

@@ -0,0 +1,62 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Primitives, Styles, Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: 'a2ui-image',
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
img {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
}
`,
template: `
@let resolvedUrl = this.resolvedUrl();
@if (resolvedUrl) {
<section [class]="classes()" [style]="theme.additionalStyles?.Image">
<img [src]="resolvedUrl" />
</section>
}
`,
})
export class Image extends DynamicComponent {
readonly url = input.required<Primitives.StringValue | null>();
readonly usageHint = input.required<Types.ResolvedImage['usageHint'] | null>();
protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url()));
protected classes = computed(() => {
const usageHint = this.usageHint();
return Styles.merge(
this.theme.components.Image.all,
usageHint ? this.theme.components.Image[usageHint] : {},
);
});
}

View File

@@ -0,0 +1,63 @@
/*
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 { Component, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-list',
imports: [Renderer],
host: {
'[attr.direction]': 'direction()',
},
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
:host([direction='vertical']) section {
display: grid;
}
:host([direction='horizontal']) section {
display: flex;
max-width: 100%;
overflow-x: scroll;
overflow-y: hidden;
scrollbar-width: none;
> ::slotted(*) {
flex: 1 0 fit-content;
max-width: min(80%, 400px);
}
}
`,
template: `
<section [class]="theme.components.List" [style]="theme.additionalStyles?.List">
@for (child of component().properties.children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class List extends DynamicComponent<Types.ListNode> {
readonly direction = input<'vertical' | 'horizontal'>('vertical');
}

View File

@@ -0,0 +1,113 @@
/*
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 { Component, signal, viewChild, ElementRef, effect } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Types } from '@a2ui/lit/0.8';
import { Renderer } from '../rendering';
@Component({
selector: 'a2ui-modal',
imports: [Renderer],
template: `
@if (showDialog()) {
<dialog #dialog [class]="theme.components.Modal.backdrop" (click)="handleDialogClick($event)">
<section [class]="theme.components.Modal.element" [style]="theme.additionalStyles?.Modal">
<div class="controls">
<button (click)="closeDialog()">
<span class="g-icon">close</span>
</button>
</div>
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="component().properties.contentChild"
/>
</section>
</dialog>
} @else {
<section (click)="showDialog.set(true)">
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="component().properties.entryPointChild"
/>
</section>
}
`,
styles: `
dialog {
padding: 0;
border: none;
background: none;
& section {
& .controls {
display: flex;
justify-content: end;
margin-bottom: 4px;
& button {
padding: 0;
background: none;
width: 20px;
height: 20px;
pointer: cursor;
border: none;
cursor: pointer;
}
}
}
}
`,
})
export class Modal extends DynamicComponent<Types.ModalNode> {
protected readonly showDialog = signal(false);
protected readonly dialog = viewChild<ElementRef<HTMLDialogElement>>('dialog');
constructor() {
super();
effect(() => {
const dialog = this.dialog();
if (dialog && !dialog.nativeElement.open) {
dialog.nativeElement.showModal();
}
});
}
protected handleDialogClick(event: MouseEvent) {
if (event.target instanceof HTMLDialogElement) {
this.closeDialog();
}
}
protected closeDialog() {
const dialog = this.dialog();
if (!dialog) {
return;
}
if (!dialog.nativeElement.open) {
dialog.nativeElement.close();
}
this.showDialog.set(false);
}
}

View File

@@ -0,0 +1,77 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-multiple-choice',
template: `
<section [class]="theme.components.MultipleChoice.container">
<label [class]="theme.components.MultipleChoice.label" [for]="selectId">{{
description()
}}</label>
<select
(change)="handleChange($event)"
[id]="selectId"
[value]="selectValue()"
[class]="theme.components.MultipleChoice.element"
[style]="theme.additionalStyles?.MultipleChoice"
>
@for (option of options(); track option.value) {
<option [value]="option.value">{{ resolvePrimitive(option.label) }}</option>
}
</select>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
select {
width: 100%;
box-sizing: border-box;
}
`,
})
export class MultipleChoice extends DynamicComponent {
readonly options = input.required<{ label: Primitives.StringValue; value: string }[]>();
readonly value = input.required<Primitives.StringValue | null>();
readonly description = input.required<string>();
protected readonly selectId = super.getUniqueId('a2ui-multiple-choice');
protected selectValue = computed(() => super.resolvePrimitive(this.value()));
protected handleChange(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLSelectElement) || !event.target.value || !path) {
return;
}
this.processor.setData(
this.component(),
this.processor.resolvePath(path, this.component().dataContextPath),
event.target.value,
);
}
}

View File

@@ -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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
import { Types } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-row',
imports: [Renderer],
host: {
'[attr.alignment]': 'alignment()',
'[attr.distribution]': 'distribution()',
},
styles: `
:host {
display: flex;
flex: var(--weight);
}
section {
display: flex;
flex-direction: row;
width: 100%;
min-height: 100%;
box-sizing: border-box;
}
.align-start {
align-items: start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: end;
}
.align-stretch {
align-items: stretch;
}
.distribute-start {
justify-content: start;
}
.distribute-center {
justify-content: center;
}
.distribute-end {
justify-content: end;
}
.distribute-spaceBetween {
justify-content: space-between;
}
.distribute-spaceAround {
justify-content: space-around;
}
.distribute-spaceEvenly {
justify-content: space-evenly;
}
`,
template: `
<section [class]="classes()" [style]="theme.additionalStyles?.Row">
@for (child of component().properties.children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class Row extends DynamicComponent<Types.RowNode> {
readonly alignment = input<Types.ResolvedRow['alignment']>('stretch');
readonly distribution = input<Types.ResolvedRow['distribution']>('start');
protected readonly classes = computed(() => ({
...this.theme.components.Row,
[`align-${this.alignment()}`]: true,
[`distribute-${this.distribution()}`]: true,
}));
}

View File

@@ -0,0 +1,73 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Primitives } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: '[a2ui-slider]',
template: `
<section [class]="theme.components.Slider.container">
<label [class]="theme.components.Slider.label" [for]="inputId">
{{ label() }}
</label>
<input
autocomplete="off"
type="range"
[value]="resolvedValue()"
[min]="minValue()"
[max]="maxValue()"
[id]="inputId"
(input)="handleInput($event)"
[class]="theme.components.Slider.element"
[style]="theme.additionalStyles?.Slider"
/>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
}
input {
display: block;
width: 100%;
box-sizing: border-box;
}
`,
})
export class Slider extends DynamicComponent {
readonly value = input.required<Primitives.NumberValue | null>();
readonly label = input('');
readonly minValue = input.required<number | undefined>();
readonly maxValue = input.required<number | undefined>();
protected readonly inputId = super.getUniqueId('a2ui-slider');
protected resolvedValue = computed(() => super.resolvePrimitive(this.value()) ?? 0);
protected handleInput(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.valueAsNumber, this.surfaceId());
}
}

View File

@@ -0,0 +1,99 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-surface',
imports: [Renderer],
template: `
@let surfaceId = this.surfaceId();
@let surface = this.surface();
@if (surfaceId && surface) {
<ng-container a2ui-renderer [surfaceId]="surfaceId" [component]="surface.componentTree!" />
}
`,
styles: `
:host {
display: flex;
min-height: 0;
max-height: 100%;
flex-direction: column;
gap: 16px;
}
`,
host: {
'[style]': 'styles()',
},
})
export class Surface {
readonly surfaceId = input.required<Types.SurfaceID | null>();
readonly surface = input.required<Types.Surface | null>();
protected readonly styles = computed(() => {
const surface = this.surface();
const styles: Record<string, string> = {};
if (surface?.styles) {
for (const [key, value] of Object.entries(surface.styles)) {
switch (key) {
// Here we generate a palette from the singular primary color received
// from the surface data. We will want the values to range from
// 0 <= x <= 100, where 0 = back, 100 = white, and 50 = the primary
// color itself. As such we use a color-mix to create the intermediate
// values.
//
// Note: since we use half the range for black to the primary color,
// and half the range for primary color to white the mixed values have
// to go up double the amount, i.e., a range from black to primary
// color needs to fit in 0 -> 50 rather than 0 -> 100.
case 'primaryColor': {
styles['--p-100'] = '#ffffff';
styles['--p-99'] = `color-mix(in srgb, ${value} 2%, white 98%)`;
styles['--p-98'] = `color-mix(in srgb, ${value} 4%, white 96%)`;
styles['--p-95'] = `color-mix(in srgb, ${value} 10%, white 90%)`;
styles['--p-90'] = `color-mix(in srgb, ${value} 20%, white 80%)`;
styles['--p-80'] = `color-mix(in srgb, ${value} 40%, white 60%)`;
styles['--p-70'] = `color-mix(in srgb, ${value} 60%, white 40%)`;
styles['--p-60'] = `color-mix(in srgb, ${value} 80%, white 20%)`;
styles['--p-50'] = value;
styles['--p-40'] = `color-mix(in srgb, ${value} 80%, black 20%)`;
styles['--p-35'] = `color-mix(in srgb, ${value} 70%, black 30%)`;
styles['--p-30'] = `color-mix(in srgb, ${value} 60%, black 40%)`;
styles['--p-25'] = `color-mix(in srgb, ${value} 50%, black 50%)`;
styles['--p-20'] = `color-mix(in srgb, ${value} 40%, black 60%)`;
styles['--p-15'] = `color-mix(in srgb, ${value} 30%, black 70%)`;
styles['--p-10'] = `color-mix(in srgb, ${value} 20%, black 80%)`;
styles['--p-5'] = `color-mix(in srgb, ${value} 10%, black 90%)`;
styles['--0'] = '#00000';
break;
}
case 'font': {
styles['--font-family'] = value;
styles['--font-family-flex'] = value;
break;
}
}
}
}
return styles;
});
}

View File

@@ -0,0 +1,72 @@
/*
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 { Component, computed, input, signal } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
import { Styles, Types } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-tabs',
imports: [Renderer],
template: `
@let tabs = this.tabs();
@let selectedIndex = this.selectedIndex();
<section [class]="theme.components.Tabs.container" [style]="theme.additionalStyles?.Tabs">
<div [class]="theme.components.Tabs.element">
@for (tab of tabs; track tab) {
<button
(click)="this.selectedIndex.set($index)"
[disabled]="selectedIndex === $index"
[class]="buttonClasses()[selectedIndex]"
>
{{ resolvePrimitive(tab.title) }}
</button>
}
</div>
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="tabs[selectedIndex].child"
/>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
}
`,
})
export class Tabs extends DynamicComponent {
protected selectedIndex = signal(0);
readonly tabs = input.required<Types.ResolvedTabItem[]>();
protected readonly buttonClasses = computed(() => {
const selectedIndex = this.selectedIndex();
return this.tabs().map((_, index) => {
return index === selectedIndex
? Styles.merge(
this.theme.components.Tabs.controls.all,
this.theme.components.Tabs.controls.selected,
)
: this.theme.components.Tabs.controls.all;
});
});
}

View File

@@ -0,0 +1,86 @@
/*
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 { computed, Component, input } from '@angular/core';
import { Primitives, Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: 'a2ui-text-field',
styles: `
:host {
display: flex;
flex: var(--weight);
}
section,
input,
label {
box-sizing: border-box;
}
input {
display: block;
width: 100%;
}
label {
display: block;
margin-bottom: 4px;
}
`,
template: `
@let resolvedLabel = this.resolvedLabel();
<section [class]="theme.components.TextField.container">
@if (resolvedLabel) {
<label [for]="inputId" [class]="theme.components.TextField.label">{{
resolvedLabel
}}</label>
}
<input
autocomplete="off"
[class]="theme.components.TextField.element"
[style]="theme.additionalStyles?.TextField"
(input)="handleInput($event)"
[id]="inputId"
[value]="inputValue()"
placeholder="Please enter a value"
[type]="inputType() === 'number' ? 'number' : 'text'"
/>
</section>
`,
})
export class TextField extends DynamicComponent {
readonly text = input.required<Primitives.StringValue | null>();
readonly label = input.required<Primitives.StringValue | null>();
readonly inputType = input.required<Types.ResolvedTextField['type'] | null>();
protected inputValue = computed(() => super.resolvePrimitive(this.text()) || '');
protected resolvedLabel = computed(() => super.resolvePrimitive(this.label()));
protected inputId = super.getUniqueId('a2ui-input');
protected handleInput(event: Event) {
const path = this.text()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.value, this.surfaceId());
}
}

View File

@@ -0,0 +1,137 @@
/*
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 { Component, computed, inject, input, ViewEncapsulation } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives, Styles, Types } from '@a2ui/lit/0.8';
import { MarkdownRenderer } from '../data/markdown';
interface HintedStyles {
h1: Record<string, string>;
h2: Record<string, string>;
h3: Record<string, string>;
h4: Record<string, string>;
h5: Record<string, string>;
body: Record<string, string>;
caption: Record<string, string>;
}
@Component({
selector: 'a2ui-text',
template: `
<section
[class]="classes()"
[style]="additionalStyles()"
[innerHTML]="resolvedText()"
></section>
`,
encapsulation: ViewEncapsulation.None,
styles: `
a2ui-text {
display: block;
flex: var(--weight);
}
a2ui-text h1,
a2ui-text h2,
a2ui-text h3,
a2ui-text h4,
a2ui-text h5 {
line-height: inherit;
font: inherit;
}
`,
})
export class Text extends DynamicComponent {
private markdownRenderer = inject(MarkdownRenderer);
readonly text = input.required<Primitives.StringValue | null>();
readonly usageHint = input.required<Types.ResolvedText['usageHint'] | null>();
protected resolvedText = computed(() => {
const usageHint = this.usageHint();
let value = super.resolvePrimitive(this.text());
if (value == null) {
return '(empty)';
}
switch (usageHint) {
case 'h1':
value = `# ${value}`;
break;
case 'h2':
value = `## ${value}`;
break;
case 'h3':
value = `### ${value}`;
break;
case 'h4':
value = `#### ${value}`;
break;
case 'h5':
value = `##### ${value}`;
break;
case 'caption':
value = `*${value}*`;
break;
default:
value = String(value);
break;
}
return this.markdownRenderer.render(
value,
Styles.appendToAll(this.theme.markdown, ['ol', 'ul', 'li'], {}),
);
});
protected classes = computed(() => {
const usageHint = this.usageHint();
return Styles.merge(
this.theme.components.Text.all,
usageHint ? this.theme.components.Text[usageHint] : {},
);
});
protected additionalStyles = computed(() => {
const usageHint = this.usageHint();
const styles = this.theme.additionalStyles?.Text;
if (!styles) {
return null;
}
let additionalStyles: Record<string, string> = {};
if (this.areHintedStyles(styles)) {
additionalStyles = styles[usageHint ?? 'body'];
} else {
additionalStyles = styles;
}
return additionalStyles;
});
private areHintedStyles(styles: unknown): styles is HintedStyles {
if (typeof styles !== 'object' || !styles || Array.isArray(styles)) {
return false;
}
const expected = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'caption', 'body'];
return expected.every((v) => v in styles);
}
}

View File

@@ -0,0 +1,50 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-video',
template: `
@let resolvedUrl = this.resolvedUrl();
@if (resolvedUrl) {
<section [class]="theme.components.Video" [style]="theme.additionalStyles?.Video">
<video controls [src]="resolvedUrl"></video>
</section>
}
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
video {
display: block;
width: 100%;
box-sizing: border-box;
}
`,
})
export class Video extends DynamicComponent {
readonly url = input.required<Primitives.StringValue | null>();
protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url()));
}

View File

@@ -0,0 +1,25 @@
/*
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 { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { Catalog, Theme } from './rendering';
export function provideA2UI(config: { catalog: Catalog; theme: Theme }): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: Catalog, useValue: config.catalog },
{ provide: Theme, useValue: config.theme },
]);
}

View File

@@ -0,0 +1,36 @@
/*
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 { Binding, InjectionToken, Type } from '@angular/core';
import { DynamicComponent } from './dynamic-component';
import { Types } from '@a2ui/lit/0.8';
export type CatalogLoader = () =>
| Promise<Type<DynamicComponent<any>>>
| Type<DynamicComponent<any>>;
export type CatalogEntry<T extends Types.AnyComponentNode> =
| CatalogLoader
| {
type: CatalogLoader;
bindings: (data: T) => Binding[];
};
export interface Catalog {
[key: string]: CatalogEntry<Types.AnyComponentNode>;
}
export const Catalog = new InjectionToken<Catalog>('Catalog');

View File

@@ -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 { Types, Primitives } from '@a2ui/lit/0.8';
import { Directive, inject, input } from '@angular/core';
import { MessageProcessor } from '../data';
import { Theme } from './theming';
let idCounter = 0;
@Directive({
host: {
'[style.--weight]': 'weight()',
},
})
export abstract class DynamicComponent<T extends Types.AnyComponentNode = Types.AnyComponentNode> {
protected readonly processor = inject(MessageProcessor);
protected readonly theme = inject(Theme);
readonly surfaceId = input.required<Types.SurfaceID | null>();
readonly component = input.required<T>();
readonly weight = input.required<string | number>();
protected sendAction(action: Types.Action): Promise<Types.ServerToClientMessage[]> {
const component = this.component();
const surfaceId = this.surfaceId() ?? undefined;
const context: Record<string, unknown> = {};
if (action.context) {
for (const item of action.context) {
if (item.value.literalBoolean) {
context[item.key] = item.value.literalBoolean;
} else if (item.value.literalNumber) {
context[item.key] = item.value.literalNumber;
} else if (item.value.literalString) {
context[item.key] = item.value.literalString;
} else if (item.value.path) {
const path = this.processor.resolvePath(item.value.path, component.dataContextPath);
const value = this.processor.getData(component, path, surfaceId);
context[item.key] = value;
}
}
}
const message: Types.A2UIClientEventMessage = {
userAction: {
name: action.name,
sourceComponentId: component.id,
surfaceId: surfaceId!,
timestamp: new Date().toISOString(),
context,
},
};
return this.processor.dispatch(message);
}
protected resolvePrimitive(value: Primitives.StringValue | null): string | null;
protected resolvePrimitive(value: Primitives.BooleanValue | null): boolean | null;
protected resolvePrimitive(value: Primitives.NumberValue | null): number | null;
protected resolvePrimitive(
value: Primitives.StringValue | Primitives.BooleanValue | Primitives.NumberValue | null,
) {
const component = this.component();
const surfaceId = this.surfaceId();
if (!value || typeof value !== 'object') {
return null;
} else if (value.literal != null) {
return value.literal;
} else if (value.path) {
return this.processor.getData(component, value.path, surfaceId ?? undefined);
} else if ('literalString' in value) {
return value.literalString;
} else if ('literalNumber' in value) {
return value.literalNumber;
} else if ('literalBoolean' in value) {
return value.literalBoolean;
}
return null;
}
protected getUniqueId(prefix: string) {
return `${prefix}-${idCounter++}`;
}
}

View File

@@ -0,0 +1,20 @@
/*
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 * from './catalog';
export * from './dynamic-component';
export * from './renderer';
export * from './theming';

View File

@@ -0,0 +1,109 @@
/*
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 {
Binding,
ComponentRef,
Directive,
DOCUMENT,
effect,
inject,
input,
inputBinding,
OnDestroy,
PLATFORM_ID,
Type,
untracked,
ViewContainerRef,
} from '@angular/core';
import { Types, Styles } from '@a2ui/lit/0.8';
import { Catalog } from './catalog';
import { isPlatformBrowser } from '@angular/common';
@Directive({
selector: 'ng-container[a2ui-renderer]',
})
export class Renderer implements OnDestroy {
private viewContainerRef = inject(ViewContainerRef);
private catalog = inject(Catalog);
private static hasInsertedStyles = false;
private currentRef: ComponentRef<unknown> | null = null;
private isDestroyed = false;
readonly surfaceId = input.required<Types.SurfaceID>();
readonly component = input.required<Types.AnyComponentNode>();
constructor() {
effect(() => {
const surfaceId = this.surfaceId();
const component = this.component();
untracked(() => this.render(surfaceId, component));
});
const platformId = inject(PLATFORM_ID);
const document = inject(DOCUMENT);
if (!Renderer.hasInsertedStyles && isPlatformBrowser(platformId)) {
const styles = document.createElement('style');
styles.textContent = Styles.structuralStyles;
document.head.appendChild(styles);
Renderer.hasInsertedStyles = true;
}
}
ngOnDestroy(): void {
this.isDestroyed = true;
this.clear();
}
private async render(surfaceId: Types.SurfaceID, component: Types.AnyComponentNode) {
const config = this.catalog[component.type];
let newComponent: Type<unknown> | null = null;
let componentBindings: Binding[] | null = null;
if (typeof config === 'function') {
newComponent = await config();
} else if (typeof config === 'object') {
newComponent = await config.type();
componentBindings = config.bindings(component as any);
}
this.clear();
if (newComponent && !this.isDestroyed) {
const bindings = [
inputBinding('surfaceId', () => surfaceId),
inputBinding('component', () => component),
inputBinding('weight', () => component.weight ?? 'initial'),
];
if (componentBindings) {
bindings.push(...componentBindings);
}
this.currentRef = this.viewContainerRef.createComponent(newComponent, {
bindings,
injector: this.viewContainerRef.injector,
});
}
}
private clear() {
this.currentRef?.destroy();
this.currentRef = null;
}
}

View File

@@ -0,0 +1,22 @@
/*
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 { Types } from '@a2ui/lit/0.8';
import { InjectionToken } from '@angular/core';
export const Theme = new InjectionToken<Theme>('Theme');
export type Theme = Types.Theme;

View File

@@ -0,0 +1,21 @@
/*
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 * from './lib/rendering/index';
export * from './lib/data/index';
export * from './lib/config';
export * from './lib/catalog/default';
export { Surface } from './lib/catalog/surface';

View File

@@ -0,0 +1,23 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -0,0 +1,2 @@
@a2ui:registry=https://us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/
//us-npm.pkg.dev/oss-exit-gate-prod/a2ui--npm/:always-auth=true

View File

@@ -0,0 +1,9 @@
Lit implementation of A2UI.
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.

View File

@@ -0,0 +1,9 @@
Lit implementation of A2UI.
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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
{
"name": "@a2ui/lit",
"version": "0.8.1",
"description": "A2UI Lit Library",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
},
"./0.8": {
"types": "./dist/src/0.8/core.d.ts",
"default": "./dist/src/0.8/core.js"
},
"./ui": {
"types": "./dist/src/0.8/ui/ui.d.ts",
"default": "./dist/src/0.8/ui/ui.js"
}
},
"type": "module",
"scripts": {
"prepack": "npm run build",
"build": "wireit",
"build:tsc": "wireit",
"dev": "npm run serve --watch",
"test": "wireit",
"serve": "wireit",
"copy-spec": "wireit"
},
"wireit": {
"copy-spec": {
"command": "mkdir -p src/0.8/schemas && cp ../../specification/0.8/json/*.json src/0.8/schemas",
"files": [
"../../specification/0.8/json/*.json"
],
"output": [
"src/0.8/schemas/*.json"
]
},
"serve": {
"command": "vite dev",
"dependencies": [
"build"
],
"service": true
},
"test": {
"command": "node --test --enable-source-maps --test-reporter spec dist/src/0.8/*.test.js",
"dependencies": [
"build"
]
},
"build": {
"dependencies": [
"build:tsc"
]
},
"build:tsc": {
"command": "tsc -b --pretty",
"env": {
"FORCE_COLOR": "1"
},
"dependencies": [
"copy-spec"
],
"files": [
"src/**/*.ts",
"src/**/*.json",
"tsconfig.json"
],
"output": [
"dist/",
"!dist/**/*.min.js{,.map}"
],
"clean": "if-file-deleted"
}
},
"repository": {
"directory": "renderers/lit",
"type": "git",
"url": "git+https://github.com/google/A2UI.git"
},
"files": [
"dist/src"
],
"keywords": [],
"author": "Google",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/google/A2UI/issues"
},
"homepage": "https://github.com/google/A2UI/tree/main/web#readme",
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.10.1",
"google-artifactregistry-auth": "^3.5.0",
"typescript": "^5.8.3",
"wireit": "^0.15.0-pre.2"
},
"dependencies": {
"@lit-labs/signals": "^0.1.3",
"@lit/context": "^1.1.4",
"lit": "^3.3.1",
"markdown-it": "^14.1.0",
"signal-utils": "^0.21.1"
}
}

View File

@@ -0,0 +1,35 @@
/*
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 * as Events from "./events/events.js";
export * as Types from "./types/types.js";
export * as Primitives from "./types/primitives.js";
export * as Styles from "./styles/index.js";
import * as Guards from "./data/guards.js";
import { create as createSignalA2uiMessageProcessor } from "./data/signal-model-processor.js";
import { A2uiMessageProcessor } from "./data/model-processor.js";
import A2UIClientEventMessage from "./schemas/server_to_client_with_standard_catalog.json" with { type: "json" };
export const Data = {
createSignalA2uiMessageProcessor,
A2uiMessageProcessor,
Guards,
};
export const Schemas = {
A2UIClientEventMessage,
};

View File

@@ -0,0 +1,28 @@
/*
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 { Action } from "../types/components.js";
import { AnyComponentNode } from "../types/types.js";
import { BaseEventDetail } from "./base.js";
type Namespace = "a2ui";
export interface A2UIAction extends BaseEventDetail<`${Namespace}.action`> {
readonly action: Action;
readonly dataContextPath: string;
readonly sourceComponentId: string;
readonly sourceComponent: AnyComponentNode | null;
}

View File

@@ -0,0 +1,19 @@
/*
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 interface BaseEventDetail<EventType extends string> {
readonly eventType: EventType;
}

View File

@@ -0,0 +1,53 @@
/*
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 type * as A2UI from "./a2ui.js";
import { BaseEventDetail } from "./base.js";
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
};
type EnforceEventTypeMatch<T extends Record<string, BaseEventDetail<string>>> =
{
[K in keyof T]: T[K] extends BaseEventDetail<infer EventType>
? EventType extends K
? T[K]
: never
: never;
};
export type StateEventDetailMap = EnforceEventTypeMatch<{
"a2ui.action": A2UI.A2UIAction;
}>;
export class StateEvent<
T extends keyof StateEventDetailMap
> extends CustomEvent<StateEventDetailMap[T]> {
static eventName = "a2uiaction";
constructor(readonly payload: StateEventDetailMap[T]) {
super(StateEvent.eventName, { detail: payload, ...eventInit });
}
}
declare global {
interface HTMLElementEventMap {
a2uiaction: StateEvent<"a2ui.action">;
}
}

View File

@@ -0,0 +1,18 @@
/*
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 * from "./core.js";
export * as UI from "./ui/ui.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
# Copied schema files
# (needed for the build but otherwise redundant)
*.json
!server_to_client_with_standard_catalog.json

View File

@@ -0,0 +1,827 @@
{
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
"type": "object",
"additionalProperties": false,
"properties": {
"beginRendering": {
"type": "object",
"description": "Signals the client to begin rendering a surface with a root component and specific styles.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"root": {
"type": "string",
"description": "The ID of the root component to render."
},
"styles": {
"type": "object",
"description": "Styling information for the UI.",
"additionalProperties": false,
"properties": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}
},
"required": ["root", "surfaceId"]
},
"surfaceUpdate": {
"type": "object",
"description": "Updates a surface with a new set of components.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"type": "object",
"description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column."
},
"component": {
"type": "object",
"description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.",
"additionalProperties": false,
"properties": {
"Text": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "object",
"description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.",
"enum": [
"h1",
"h2",
"h3",
"h4",
"h5",
"caption",
"body"
]
}
},
"required": ["text"]
},
"Image": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
"enum": [
"contain",
"cover",
"fill",
"none",
"scale-down"
]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.",
"enum": [
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header"
]
}
},
"required": ["url"]
},
"Icon": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "object",
"description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string",
"enum": [
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"payment",
"person",
"phone",
"photo",
"print",
"refresh",
"search",
"send",
"settings",
"share",
"shoppingCart",
"star",
"starHalf",
"starOff",
"upload",
"visibility",
"visibilityOff",
"warning"
]
},
"path": {
"type": "string"
}
}
}
},
"required": ["name"]
},
"Video": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"AudioPlayer": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"description": {
"type": "object",
"description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"Row": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Column": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["children"]
},
"List": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"direction": {
"type": "string",
"description": "The direction in which the list items are laid out.",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Card": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to be rendered inside the card."
}
},
"required": ["child"]
},
"Tabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabItems": {
"type": "array",
"description": "An array of objects, where each object defines a tab with a title and a child component.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "object",
"description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"child": {
"type": "string"
}
},
"required": ["title", "child"]
}
}
},
"required": ["tabItems"]
},
"Divider": {
"type": "object",
"additionalProperties": false,
"properties": {
"axis": {
"type": "string",
"description": "The orientation of the divider.",
"enum": ["horizontal", "vertical"]
}
}
},
"Modal": {
"type": "object",
"additionalProperties": false,
"properties": {
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with (e.g., a button)."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal."
}
},
"required": ["entryPointChild", "contentChild"]
},
"Button": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to display in the button, typically a Text component."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"context": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "object",
"description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"literalString": {
"type": "string"
},
"literalNumber": {
"type": "number"
},
"literalBoolean": {
"type": "boolean"
}
}
}
},
"required": ["key", "value"]
}
}
},
"required": ["name"]
}
},
"required": ["child", "action"]
},
"CheckBox": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "object",
"description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').",
"additionalProperties": false,
"properties": {
"literalBoolean": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
}
},
"required": ["label", "value"]
},
"TextField": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"text": {
"type": "object",
"description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"textFieldType": {
"type": "string",
"description": "The type of input field to display.",
"enum": [
"date",
"longText",
"number",
"shortText",
"obscured"
]
},
"validationRegexp": {
"type": "string",
"description": "A regular expression used for client-side validation of the input."
}
},
"required": ["label"]
},
"DateTimeInput": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"enableDate": {
"type": "boolean",
"description": "If true, allows the user to select a date."
},
"enableTime": {
"type": "boolean",
"description": "If true, allows the user to select a time."
},
"outputFormat": {
"type": "string",
"description": "The desired format for the output string after a date or time is selected."
}
},
"required": ["value"]
},
"MultipleChoice": {
"type": "object",
"additionalProperties": false,
"properties": {
"selections": {
"type": "object",
"description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').",
"additionalProperties": false,
"properties": {
"literalArray": {
"type": "array",
"items": {
"type": "string"
}
},
"path": {
"type": "string"
}
}
},
"options": {
"type": "array",
"description": "An array of available options for the user to choose from.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "string",
"description": "The value to be associated with this option when selected."
}
},
"required": ["label", "value"]
}
},
"maxAllowedSelections": {
"type": "integer",
"description": "The maximum number of options that the user is allowed to select."
}
},
"required": ["selections", "options"]
},
"Slider": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
"additionalProperties": false,
"properties": {
"literalNumber": {
"type": "number"
},
"path": {
"type": "string"
}
}
},
"minValue": {
"type": "number",
"description": "The minimum value of the slider."
},
"maxValue": {
"type": "number",
"description": "The maximum value of the slider."
}
},
"required": ["value"]
}
}
}
},
"required": ["id", "component"]
}
}
},
"required": ["surfaceId", "components"]
},
"dataModelUpdate": {
"type": "object",
"description": "Updates the data model for a surface.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced."
},
"contents": {
"type": "array",
"description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.",
"items": {
"type": "object",
"description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "The key for this data entry."
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
},
"valueMap": {
"description": "Represents a map as an adjacency list.",
"type": "array",
"items": {
"type": "object",
"description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
}
},
"required": ["key"]
}
}
},
"required": ["key"]
}
}
},
"required": ["contents", "surfaceId"]
},
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"]
}
}
}

View File

@@ -0,0 +1,55 @@
/*
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.
*/
const opacityBehavior = `
&:not([disabled]) {
cursor: pointer;
opacity: var(--opacity, 0);
transition: opacity var(--speed, 0.2s) cubic-bezier(0, 0, 0.3, 1);
&:hover,
&:focus {
opacity: 1;
}
}`;
export const behavior = `
${new Array(21)
.fill(0)
.map((_, idx) => {
return `.behavior-ho-${idx * 5} {
--opacity: ${idx / 20};
${opacityBehavior}
}`;
})
.join("\n")}
.behavior-o-s {
overflow: scroll;
}
.behavior-o-a {
overflow: auto;
}
.behavior-o-h {
overflow: hidden;
}
.behavior-sw-n {
scrollbar-width: none;
}
`;

View File

@@ -0,0 +1,42 @@
/*
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 { grid } from "./shared.js";
export const border = `
${new Array(25)
.fill(0)
.map((_, idx) => {
return `
.border-bw-${idx} { border-width: ${idx}px; }
.border-btw-${idx} { border-top-width: ${idx}px; }
.border-bbw-${idx} { border-bottom-width: ${idx}px; }
.border-blw-${idx} { border-left-width: ${idx}px; }
.border-brw-${idx} { border-right-width: ${idx}px; }
.border-ow-${idx} { outline-width: ${idx}px; }
.border-br-${idx} { border-radius: ${idx * grid}px; overflow: hidden;}`;
})
.join("\n")}
.border-br-50pc {
border-radius: 50%;
}
.border-bs-s {
border-style: solid;
}
`;

View File

@@ -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 { PaletteKey, PaletteKeyVals, shades } from "../types/colors.js";
import { toProp } from "./utils.js";
const color = <C extends PaletteKeyVals>(src: PaletteKey<C>) =>
`
${src
.map((key: string) => {
const inverseKey = getInverseKey(key);
return `.color-bc-${key} { border-color: light-dark(var(${toProp(
key
)}), var(${toProp(inverseKey)})); }`;
})
.join("\n")}
${src
.map((key: string) => {
const inverseKey = getInverseKey(key);
const vals = [
`.color-bgc-${key} { background-color: light-dark(var(${toProp(
key
)}), var(${toProp(inverseKey)})); }`,
`.color-bbgc-${key}::backdrop { background-color: light-dark(var(${toProp(
key
)}), var(${toProp(inverseKey)})); }`,
];
for (let o = 0.1; o < 1; o += 0.1) {
vals.push(`.color-bbgc-${key}_${(o * 100).toFixed(0)}::backdrop {
background-color: light-dark(oklch(from var(${toProp(
key
)}) l c h / calc(alpha * ${o.toFixed(1)})), oklch(from var(${toProp(
inverseKey
)}) l c h / calc(alpha * ${o.toFixed(1)})) );
}
`);
}
return vals.join("\n");
})
.join("\n")}
${src
.map((key: string) => {
const inverseKey = getInverseKey(key);
return `.color-c-${key} { color: light-dark(var(${toProp(
key
)}), var(${toProp(inverseKey)})); }`;
})
.join("\n")}
`;
const getInverseKey = (key: string): string => {
const match = key.match(/^([a-z]+)(\d+)$/);
if (!match) return key;
const [, prefix, shadeStr] = match;
const shade = parseInt(shadeStr, 10);
const target = 100 - shade;
const inverseShade = shades.reduce((prev, curr) =>
Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev
);
return `${prefix}${inverseShade}`;
};
const keyFactory = <K extends PaletteKeyVals>(prefix: K) => {
return shades.map((v) => `${prefix}${v}`) as PaletteKey<K>;
};
export const colors = [
color(keyFactory("p")),
color(keyFactory("s")),
color(keyFactory("t")),
color(keyFactory("n")),
color(keyFactory("nv")),
color(keyFactory("e")),
`
.color-bgc-transparent {
background-color: transparent;
}
:host {
color-scheme: var(--color-scheme);
}
`,
];

View File

@@ -0,0 +1,60 @@
/*
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.
*/
/**
* CSS classes for Google Symbols.
*
* Usage:
*
* ```html
* <span class="g-icon">pen_spark</span>
* ```
*/
export const icons = `
.g-icon {
font-family: "Material Symbols Outlined", "Google Symbols";
font-weight: normal;
font-style: normal;
font-display: optional;
font-size: 20px;
width: 1em;
height: 1em;
user-select: none;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
overflow: hidden;
font-variation-settings: "FILL" 0, "wght" 300, "GRAD" 0, "opsz" 48,
"ROND" 100;
&.filled {
font-variation-settings: "FILL" 1, "wght" 300, "GRAD" 0, "opsz" 48,
"ROND" 100;
}
&.filled-heavy {
font-variation-settings: "FILL" 1, "wght" 700, "GRAD" 0, "opsz" 48,
"ROND" 100;
}
}
`;

View File

@@ -0,0 +1,37 @@
/*
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 { behavior } from "./behavior.js";
import { border } from "./border.js";
import { colors } from "./colors.js";
import { icons } from "./icons.js";
import { layout } from "./layout.js";
import { opacity } from "./opacity.js";
import { type } from "./type.js";
export * from "./utils.js";
export const structuralStyles: string = [
behavior,
border,
colors,
icons,
layout,
opacity,
type,
]
.flat(Infinity)
.join("\n");

View File

@@ -0,0 +1,235 @@
/*
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 { grid } from "./shared.js";
export const layout = `
:host {
${new Array(16)
.fill(0)
.map((_, idx) => {
return `--g-${idx + 1}: ${(idx + 1) * grid}px;`;
})
.join("\n")}
}
${new Array(49)
.fill(0)
.map((_, index) => {
const idx = index - 24;
const lbl = idx < 0 ? `n${Math.abs(idx)}` : idx.toString();
return `
.layout-p-${lbl} { --padding: ${
idx * grid
}px; padding: var(--padding); }
.layout-pt-${lbl} { padding-top: ${idx * grid}px; }
.layout-pr-${lbl} { padding-right: ${idx * grid}px; }
.layout-pb-${lbl} { padding-bottom: ${idx * grid}px; }
.layout-pl-${lbl} { padding-left: ${idx * grid}px; }
.layout-m-${lbl} { --margin: ${idx * grid}px; margin: var(--margin); }
.layout-mt-${lbl} { margin-top: ${idx * grid}px; }
.layout-mr-${lbl} { margin-right: ${idx * grid}px; }
.layout-mb-${lbl} { margin-bottom: ${idx * grid}px; }
.layout-ml-${lbl} { margin-left: ${idx * grid}px; }
.layout-t-${lbl} { top: ${idx * grid}px; }
.layout-r-${lbl} { right: ${idx * grid}px; }
.layout-b-${lbl} { bottom: ${idx * grid}px; }
.layout-l-${lbl} { left: ${idx * grid}px; }`;
})
.join("\n")}
${new Array(25)
.fill(0)
.map((_, idx) => {
return `
.layout-g-${idx} { gap: ${idx * grid}px; }`;
})
.join("\n")}
${new Array(8)
.fill(0)
.map((_, idx) => {
return `
.layout-grd-col${idx + 1} { grid-template-columns: ${"1fr "
.repeat(idx + 1)
.trim()}; }`;
})
.join("\n")}
.layout-pos-a {
position: absolute;
}
.layout-pos-rel {
position: relative;
}
.layout-dsp-none {
display: none;
}
.layout-dsp-block {
display: block;
}
.layout-dsp-grid {
display: grid;
}
.layout-dsp-iflex {
display: inline-flex;
}
.layout-dsp-flexvert {
display: flex;
flex-direction: column;
}
.layout-dsp-flexhor {
display: flex;
flex-direction: row;
}
.layout-fw-w {
flex-wrap: wrap;
}
.layout-al-fs {
align-items: start;
}
.layout-al-fe {
align-items: end;
}
.layout-al-c {
align-items: center;
}
.layout-as-n {
align-self: normal;
}
.layout-js-c {
justify-self: center;
}
.layout-sp-c {
justify-content: center;
}
.layout-sp-ev {
justify-content: space-evenly;
}
.layout-sp-bt {
justify-content: space-between;
}
.layout-sp-s {
justify-content: start;
}
.layout-sp-e {
justify-content: end;
}
.layout-ji-e {
justify-items: end;
}
.layout-r-none {
resize: none;
}
.layout-fs-c {
field-sizing: content;
}
.layout-fs-n {
field-sizing: none;
}
.layout-flx-0 {
flex: 0 0 auto;
}
.layout-flx-1 {
flex: 1 0 auto;
}
.layout-c-s {
contain: strict;
}
/** Widths **/
${new Array(10)
.fill(0)
.map((_, idx) => {
const weight = (idx + 1) * 10;
return `.layout-w-${weight} { width: ${weight}%; max-width: ${weight}%; }`;
})
.join("\n")}
${new Array(16)
.fill(0)
.map((_, idx) => {
const weight = idx * grid;
return `.layout-wp-${idx} { width: ${weight}px; }`;
})
.join("\n")}
/** Heights **/
${new Array(10)
.fill(0)
.map((_, idx) => {
const height = (idx + 1) * 10;
return `.layout-h-${height} { height: ${height}%; }`;
})
.join("\n")}
${new Array(16)
.fill(0)
.map((_, idx) => {
const height = idx * grid;
return `.layout-hp-${idx} { height: ${height}px; }`;
})
.join("\n")}
.layout-el-cv {
& img,
& video {
width: 100%;
height: 100%;
object-fit: cover;
margin: 0;
}
}
.layout-ar-sq {
aspect-ratio: 1 / 1;
}
.layout-ex-fb {
margin: calc(var(--padding) * -1) 0 0 calc(var(--padding) * -1);
width: calc(100% + var(--padding) * 2);
height: calc(100% + var(--padding) * 2);
}
`;

View File

@@ -0,0 +1,24 @@
/*
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 const opacity = `
${new Array(21)
.fill(0)
.map((_, idx) => {
return `.opacity-el-${idx * 5} { opacity: ${idx / 20}; }`;
})
.join("\n")}
`;

View File

@@ -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 const grid = 4;

View File

@@ -0,0 +1,156 @@
/*
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 const type = `
:host {
--default-font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
--default-font-family-mono: "Courier New", Courier, monospace;
}
.typography-f-s {
font-family: var(--font-family, var(--default-font-family));
font-optical-sizing: auto;
font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0;
}
.typography-f-sf {
font-family: var(--font-family-flex, var(--default-font-family));
font-optical-sizing: auto;
}
.typography-f-c {
font-family: var(--font-family-mono, var(--default-font-family));
font-optical-sizing: auto;
font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0;
}
.typography-v-r {
font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0, "ROND" 100;
}
.typography-ta-s {
text-align: start;
}
.typography-ta-c {
text-align: center;
}
.typography-fs-n {
font-style: normal;
}
.typography-fs-i {
font-style: italic;
}
.typography-sz-ls {
font-size: 11px;
line-height: 16px;
}
.typography-sz-lm {
font-size: 12px;
line-height: 16px;
}
.typography-sz-ll {
font-size: 14px;
line-height: 20px;
}
.typography-sz-bs {
font-size: 12px;
line-height: 16px;
}
.typography-sz-bm {
font-size: 14px;
line-height: 20px;
}
.typography-sz-bl {
font-size: 16px;
line-height: 24px;
}
.typography-sz-ts {
font-size: 14px;
line-height: 20px;
}
.typography-sz-tm {
font-size: 16px;
line-height: 24px;
}
.typography-sz-tl {
font-size: 22px;
line-height: 28px;
}
.typography-sz-hs {
font-size: 24px;
line-height: 32px;
}
.typography-sz-hm {
font-size: 28px;
line-height: 36px;
}
.typography-sz-hl {
font-size: 32px;
line-height: 40px;
}
.typography-sz-ds {
font-size: 36px;
line-height: 44px;
}
.typography-sz-dm {
font-size: 45px;
line-height: 52px;
}
.typography-sz-dl {
font-size: 57px;
line-height: 64px;
}
.typography-ws-p {
white-space: pre-line;
}
.typography-ws-nw {
white-space: nowrap;
}
.typography-td-none {
text-decoration: none;
}
/** Weights **/
${new Array(9)
.fill(0)
.map((_, idx) => {
const weight = (idx + 1) * 100;
return `.typography-w-${weight} { font-weight: ${weight}; }`;
})
.join("\n")}
`;

View File

@@ -0,0 +1,104 @@
/*
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 { ColorPalettes } from "../types/colors.js";
export function merge(...classes: Array<Record<string, boolean>>) {
const styles: Record<string, boolean> = {};
for (const clazz of classes) {
for (const [key, val] of Object.entries(clazz)) {
const prefix = key.split("-").with(-1, "").join("-");
const existingKeys = Object.keys(styles).filter((key) =>
key.startsWith(prefix)
);
for (const existingKey of existingKeys) {
delete styles[existingKey];
}
styles[key] = val;
}
}
return styles;
}
export function appendToAll(
target: Record<string, string[]>,
exclusions: string[],
...classes: Array<Record<string, boolean>>
) {
const updatedTarget: Record<string, string[]> = structuredClone(target);
// Step through each of the new blocks we've been handed.
for (const clazz of classes) {
// For each of the items in the list, create the prefix value, e.g., for
// typography-f-s reduce to typography-f-. This will allow us to find any
// and all matches across the target that have the same prefix and swap them
// out for the updated item.
for (const key of Object.keys(clazz)) {
const prefix = key.split("-").with(-1, "").join("-");
// Now we have the prefix step through all iteme in the target, and
// replace the value in the array when we find it.
for (const [tagName, classesToAdd] of Object.entries(updatedTarget)) {
if (exclusions.includes(tagName)) {
continue;
}
let found = false;
for (let t = 0; t < classesToAdd.length; t++) {
if (classesToAdd[t].startsWith(prefix)) {
found = true;
// In theory we should be able to break after finding a single
// entry here because we shouldn't have items with the same prefix
// in the array, but for safety we'll run to the end of the array
// and ensure we've captured all possible items with the prefix.
classesToAdd[t] = key;
}
}
if (!found) {
classesToAdd.push(key);
}
}
}
}
return updatedTarget;
}
export function createThemeStyles(
palettes: ColorPalettes
): Record<string, string> {
const styles: Record<string, string> = {};
for (const palette of Object.values(palettes)) {
for (const [key, val] of Object.entries(palette)) {
const prop = toProp(key);
styles[prop] = val;
}
}
return styles;
}
export function toProp(key: string) {
if (key.startsWith("nv")) {
return `--nv-${key.slice(2)}`;
}
return `--${key[0]}-${key.slice(1)}`;
}

View File

@@ -0,0 +1,80 @@
/*
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.
*/
/**
* A message from the client describing its capabilities, such as the component
* catalog it supports. Exactly ONE of the properties in this object must be
* set.
*/
export type ClientCapabilitiesUri = string;
export type ClientCapabilitiesDynamic = {
components: { [key: string]: unknown };
styles: { [key: string]: unknown };
};
export type ClientCapabilities =
| { catalogUri: ClientCapabilitiesUri }
| { dynamicCatalog: ClientCapabilitiesDynamic };
/**
* A message sent from the client to the server. Exactly ONE of the properties
* in this object must be set.
*/
export interface ClientToServerMessage {
userAction?: UserAction;
clientUiCapabilities?: ClientCapabilities;
error?: ClientError;
/** Demo content */
request?: unknown;
}
/**
* Represents a user-initiated action, sent from the client to the server.
*/
export interface UserAction {
/**
* The name of the action.
*/
name: string;
/**
* The ID of the surface.
*/
surfaceId: string;
/**
* The ID of the component that triggered the event.
*/
sourceComponentId: string;
/**
* An ISO timestamp of when the event occurred.
*/
timestamp: string;
/**
* A JSON object containing the key-value pairs from the component's
* `action.context`, after resolving all data bindings.
*/
context?: {
[k: string]: unknown;
};
}
/**
* A message from the client indicating an error occurred, for example,
* during UI rendering.
*/
export interface ClientError {
[k: string]: unknown;
}

View File

@@ -0,0 +1,66 @@
/*
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.
*/
type ColorShade =
| 0
| 5
| 10
| 15
| 20
| 25
| 30
| 35
| 40
| 50
| 60
| 70
| 80
| 90
| 95
| 98
| 99
| 100;
export type PaletteKeyVals = "n" | "nv" | "p" | "s" | "t" | "e";
export const shades: ColorShade[] = [
0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100,
];
type CreatePalette<Prefix extends PaletteKeyVals> = {
[Key in `${Prefix}${ColorShade}`]: string;
};
export type PaletteKey<Prefix extends PaletteKeyVals> = Array<
keyof CreatePalette<Prefix>
>;
export type PaletteKeys = {
neutral: PaletteKey<"n">;
neutralVariant: PaletteKey<"nv">;
primary: PaletteKey<"p">;
secondary: PaletteKey<"s">;
tertiary: PaletteKey<"t">;
error: PaletteKey<"e">;
};
export type ColorPalettes = {
neutral: CreatePalette<"n">;
neutralVariant: CreatePalette<"nv">;
primary: CreatePalette<"p">;
secondary: CreatePalette<"s">;
tertiary: CreatePalette<"t">;
error: CreatePalette<"e">;
};

View File

@@ -0,0 +1,211 @@
/*
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 { StringValue } from "./primitives";
export interface Action {
/**
* A unique name identifying the action (e.g., 'submitForm').
*/
name: string;
/**
* A key-value map of data bindings to be resolved when the action is triggered.
*/
context?: {
key: string;
/**
* The dynamic value. Define EXACTLY ONE of the nested properties.
*/
value: {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
/**
* A fixed, hardcoded string value.
*/
literalString?: string;
literalNumber?: number;
literalBoolean?: boolean;
};
}[];
}
export interface Text {
text: StringValue;
usageHint: "h1" | "h2" | "h3" | "h4" | "h5" | "caption" | "body";
}
export interface Image {
url: StringValue;
usageHint:
| "icon"
| "avatar"
| "smallFeature"
| "mediumFeature"
| "largeFeature"
| "header";
fit?: "contain" | "cover" | "fill" | "none" | "scale-down";
}
export interface Icon {
name: StringValue;
}
export interface Video {
url: StringValue;
}
export interface AudioPlayer {
url: StringValue;
/**
* A label, title, or placeholder text.
*/
description?: StringValue;
}
export interface Tabs {
/**
* A list of tabs, each with a title and a child component ID.
*/
tabItems: {
/**
* The title of the tab.
*/
title: {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
/**
* A fixed, hardcoded string value.
*/
literalString?: string;
};
/**
* A reference to a component instance by its unique ID.
*/
child: string;
}[];
}
export interface Divider {
/**
* The orientation.
*/
axis?: "horizontal" | "vertical";
/**
* The color of the divider (e.g., hex code or semantic name).
*/
color?: string;
/**
* The thickness of the divider.
*/
thickness?: number;
}
export interface Modal {
/**
* The ID of the component (e.g., a button) that triggers the modal.
*/
entryPointChild: string;
/**
* The ID of the component to display as the modal's content.
*/
contentChild: string;
}
export interface Button {
/**
* The ID of the component to display as the button's content.
*/
child: string;
/**
* Represents a user-initiated action.
*/
action: Action;
}
export interface Checkbox {
label: StringValue;
value: {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
literalBoolean?: boolean;
};
}
export interface TextField {
text?: StringValue;
/**
* A label, title, or placeholder text.
*/
label: StringValue;
type?: "shortText" | "number" | "date" | "longText";
/**
* A regex string to validate the input.
*/
validationRegexp?: string;
}
export interface DateTimeInput {
value: StringValue;
enableDate?: boolean;
enableTime?: boolean;
/**
* The string format for the output (e.g., 'YYYY-MM-DD').
*/
outputFormat?: string;
}
export interface MultipleChoice {
selections: {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
literalArray?: string[];
};
options?: {
label: {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
/**
* A fixed, hardcoded string value.
*/
literalString?: string;
};
value: string;
}[];
maxAllowedSelections?: number;
}
export interface Slider {
value: {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
literalNumber?: number;
};
minValue?: number;
maxValue?: number;
}

View File

@@ -0,0 +1,60 @@
/*
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 interface StringValue {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
/**
* A fixed, hardcoded string value.
*/
literalString?: string;
/**
* A fixed, hardcoded string value.
*/
literal?: string;
}
export interface NumberValue {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
/**
* A fixed, hardcoded number value.
*/
literalNumber?: number;
/**
* A fixed, hardcoded number value.
*/
literal?: number;
}
export interface BooleanValue {
/**
* A data binding reference to a location in the data model (e.g., '/user/name').
*/
path?: string;
/**
* A fixed, hardcoded boolean value.
*/
literalBoolean?: boolean;
/**
* A fixed, hardcoded boolean value.
*/
literal?: boolean;
}

View File

@@ -0,0 +1,474 @@
/*
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 {
type ClientToServerMessage as A2UIClientEventMessage,
type ClientCapabilitiesDynamic,
} from "./client-event.js";
export { type Action } from "./components.js";
import {
AudioPlayer,
Button,
Checkbox,
DateTimeInput,
Divider,
Icon,
Image,
MultipleChoice,
Slider,
Text,
TextField,
Video,
} from "./components";
import { StringValue } from "./primitives";
export type MessageProcessor = {
getSurfaces(): ReadonlyMap<string, Surface>;
clearSurfaces(): void;
processMessages(messages: ServerToClientMessage[]): void;
/**
* Retrieves the data for a given component node and a relative path string.
* This correctly handles the special `.` path, which refers to the node's
* own data context.
*/
getData(
node: AnyComponentNode,
relativePath: string,
surfaceId: string
): DataValue | null;
setData(
node: AnyComponentNode | null,
relativePath: string,
value: DataValue,
surfaceId: string
): void;
resolvePath(path: string, dataContextPath?: string): string;
};
export type Theme = {
components: {
AudioPlayer: Record<string, boolean>;
Button: Record<string, boolean>;
Card: Record<string, boolean>;
Column: Record<string, boolean>;
CheckBox: {
container: Record<string, boolean>;
element: Record<string, boolean>;
label: Record<string, boolean>;
};
DateTimeInput: {
container: Record<string, boolean>;
element: Record<string, boolean>;
label: Record<string, boolean>;
};
Divider: Record<string, boolean>;
Image: {
all: Record<string, boolean>;
icon: Record<string, boolean>;
avatar: Record<string, boolean>;
smallFeature: Record<string, boolean>;
mediumFeature: Record<string, boolean>;
largeFeature: Record<string, boolean>;
header: Record<string, boolean>;
};
Icon: Record<string, boolean>;
List: Record<string, boolean>;
Modal: {
backdrop: Record<string, boolean>;
element: Record<string, boolean>;
};
MultipleChoice: {
container: Record<string, boolean>;
element: Record<string, boolean>;
label: Record<string, boolean>;
};
Row: Record<string, boolean>;
Slider: {
container: Record<string, boolean>;
element: Record<string, boolean>;
label: Record<string, boolean>;
};
Tabs: {
container: Record<string, boolean>;
element: Record<string, boolean>;
controls: {
all: Record<string, boolean>;
selected: Record<string, boolean>;
};
};
Text: {
all: Record<string, boolean>;
h1: Record<string, boolean>;
h2: Record<string, boolean>;
h3: Record<string, boolean>;
h4: Record<string, boolean>;
h5: Record<string, boolean>;
caption: Record<string, boolean>;
body: Record<string, boolean>;
};
TextField: {
container: Record<string, boolean>;
element: Record<string, boolean>;
label: Record<string, boolean>;
};
Video: Record<string, boolean>;
};
elements: {
a: Record<string, boolean>;
audio: Record<string, boolean>;
body: Record<string, boolean>;
button: Record<string, boolean>;
h1: Record<string, boolean>;
h2: Record<string, boolean>;
h3: Record<string, boolean>;
h4: Record<string, boolean>;
h5: Record<string, boolean>;
iframe: Record<string, boolean>;
input: Record<string, boolean>;
p: Record<string, boolean>;
pre: Record<string, boolean>;
textarea: Record<string, boolean>;
video: Record<string, boolean>;
};
markdown: {
p: string[];
h1: string[];
h2: string[];
h3: string[];
h4: string[];
h5: string[];
ul: string[];
ol: string[];
li: string[];
a: string[];
strong: string[];
em: string[];
};
additionalStyles?: {
AudioPlayer?: Record<string, string>;
Button?: Record<string, string>;
Card?: Record<string, string>;
Column?: Record<string, string>;
CheckBox?: Record<string, string>;
DateTimeInput?: Record<string, string>;
Divider?: Record<string, string>;
Heading?: Record<string, string>;
Icon?: Record<string, string>;
Image?: Record<string, string>;
List?: Record<string, string>;
Modal?: Record<string, string>;
MultipleChoice?: Record<string, string>;
Row?: Record<string, string>;
Slider?: Record<string, string>;
Tabs?: Record<string, string>;
Text?:
| Record<string, string>
| {
h1: Record<string, string>;
h2: Record<string, string>;
h3: Record<string, string>;
h4: Record<string, string>;
h5: Record<string, string>;
body: Record<string, string>;
caption: Record<string, string>;
};
TextField?: Record<string, string>;
Video?: Record<string, string>;
};
};
/**
* Represents a user-initiated action, sent from the client to the server.
*/
export interface UserAction {
/**
* The name of the action, taken from the component's `action.action`
* property.
*/
actionName: string;
/**
* The `id` of the component that triggered the event.
*/
sourceComponentId: string;
/**
* An ISO 8601 timestamp of when the event occurred.
*/
timestamp: string;
/**
* A JSON object containing the key-value pairs from the component's
* `action.context`, after resolving all data bindings.
*/
context?: {
[k: string]: unknown;
};
}
/** A recursive type for any valid JSON-like value in the data model. */
export type DataValue =
| string
| number
| boolean
| null
| DataMap
| DataObject
| DataArray;
export type DataObject = { [key: string]: DataValue };
export type DataMap = Map<string, DataValue>;
export type DataArray = DataValue[];
/** A template for creating components from a list in the data model. */
export interface ComponentArrayTemplate {
componentId: string;
dataBinding: string;
}
/** Defines a list of child components, either explicitly or via a template. */
export interface ComponentArrayReference {
explicitList?: string[];
template?: ComponentArrayTemplate;
}
/** Represents the general shape of a component's properties. */
export type ComponentProperties = {
// Allow any property, but define known structural ones for type safety.
children?: ComponentArrayReference;
child?: string;
[k: string]: unknown;
};
/** A raw component instance from a SurfaceUpdate message. */
export interface ComponentInstance {
id: string;
weight?: number;
component?: ComponentProperties;
}
export interface BeginRenderingMessage {
surfaceId: string;
root: string;
styles?: Record<string, string>;
}
export interface SurfaceUpdateMessage {
surfaceId: string;
components: ComponentInstance[];
}
export interface DataModelUpdate {
surfaceId: string;
path?: string;
contents: ValueMap[];
}
// ValueMap is a type of DataObject for passing to the data model.
export type ValueMap = DataObject & {
key: string;
/** May be JSON */
valueString?: string;
valueNumber?: number;
valueBoolean?: boolean;
valueMap?: ValueMap[];
};
export interface DeleteSurfaceMessage {
surfaceId: string;
}
export interface ServerToClientMessage {
beginRendering?: BeginRenderingMessage;
surfaceUpdate?: SurfaceUpdateMessage;
dataModelUpdate?: DataModelUpdate;
deleteSurface?: DeleteSurfaceMessage;
}
/**
* A recursive type for any value that can appear within a resolved component
* tree. This is the main type that makes the recursive resolution possible.
*/
export type ResolvedValue =
| string
| number
| boolean
| null
| AnyComponentNode
| ResolvedMap
| ResolvedArray;
/** A generic map where each value has been recursively resolved. */
export type ResolvedMap = { [key: string]: ResolvedValue };
/** A generic array where each item has been recursively resolved. */
export type ResolvedArray = ResolvedValue[];
/**
* A base interface that all component nodes share.
*/
interface BaseComponentNode {
id: string;
weight?: number;
dataContextPath?: string;
slotName?: string;
}
export interface TextNode extends BaseComponentNode {
type: "Text";
properties: ResolvedText;
}
export interface ImageNode extends BaseComponentNode {
type: "Image";
properties: ResolvedImage;
}
export interface IconNode extends BaseComponentNode {
type: "Icon";
properties: ResolvedIcon;
}
export interface VideoNode extends BaseComponentNode {
type: "Video";
properties: ResolvedVideo;
}
export interface AudioPlayerNode extends BaseComponentNode {
type: "AudioPlayer";
properties: ResolvedAudioPlayer;
}
export interface RowNode extends BaseComponentNode {
type: "Row";
properties: ResolvedRow;
}
export interface ColumnNode extends BaseComponentNode {
type: "Column";
properties: ResolvedColumn;
}
export interface ListNode extends BaseComponentNode {
type: "List";
properties: ResolvedList;
}
export interface CardNode extends BaseComponentNode {
type: "Card";
properties: ResolvedCard;
}
export interface TabsNode extends BaseComponentNode {
type: "Tabs";
properties: ResolvedTabs;
}
export interface DividerNode extends BaseComponentNode {
type: "Divider";
properties: ResolvedDivider;
}
export interface ModalNode extends BaseComponentNode {
type: "Modal";
properties: ResolvedModal;
}
export interface ButtonNode extends BaseComponentNode {
type: "Button";
properties: ResolvedButton;
}
export interface CheckboxNode extends BaseComponentNode {
type: "CheckBox";
properties: ResolvedCheckbox;
}
export interface TextFieldNode extends BaseComponentNode {
type: "TextField";
properties: ResolvedTextField;
}
export interface DateTimeInputNode extends BaseComponentNode {
type: "DateTimeInput";
properties: ResolvedDateTimeInput;
}
export interface MultipleChoiceNode extends BaseComponentNode {
type: "MultipleChoice";
properties: ResolvedMultipleChoice;
}
export interface SliderNode extends BaseComponentNode {
type: "Slider";
properties: ResolvedSlider;
}
export interface CustomNode extends BaseComponentNode {
type: string;
// For custom nodes, properties are just a map of string keys to any resolved value.
properties: CustomNodeProperties;
}
/**
* The complete discriminated union of all possible resolved component nodes.
* A renderer would use this type for any given node in the component tree.
*/
export type AnyComponentNode =
| TextNode
| IconNode
| ImageNode
| VideoNode
| AudioPlayerNode
| RowNode
| ColumnNode
| ListNode
| CardNode
| TabsNode
| DividerNode
| ModalNode
| ButtonNode
| CheckboxNode
| TextFieldNode
| DateTimeInputNode
| MultipleChoiceNode
| SliderNode
| CustomNode;
// These components do not contain other components can reuse their
// original interfaces.
export type ResolvedText = Text;
export type ResolvedIcon = Icon;
export type ResolvedImage = Image;
export type ResolvedVideo = Video;
export type ResolvedAudioPlayer = AudioPlayer;
export type ResolvedDivider = Divider;
export type ResolvedCheckbox = Checkbox;
export type ResolvedTextField = TextField;
export type ResolvedDateTimeInput = DateTimeInput;
export type ResolvedMultipleChoice = MultipleChoice;
export type ResolvedSlider = Slider;
export interface ResolvedRow {
children: AnyComponentNode[];
distribution?:
| "start"
| "center"
| "end"
| "spaceBetween"
| "spaceAround"
| "spaceEvenly";
alignment?: "start" | "center" | "end" | "stretch";
}
export interface ResolvedColumn {
children: AnyComponentNode[];
distribution?:
| "start"
| "center"
| "end"
| "spaceBetween"
| "spaceAround"
| "spaceEvenly";
alignment?: "start" | "center" | "end" | "stretch";
}
export interface ResolvedButton {
child: AnyComponentNode;
action: Button["action"];
}
export interface ResolvedList {
children: AnyComponentNode[];
direction?: "vertical" | "horizontal";
alignment?: "start" | "center" | "end" | "stretch";
}
export interface ResolvedCard {
child: AnyComponentNode;
children: AnyComponentNode[];
}
export interface ResolvedTabItem {
title: StringValue;
child: AnyComponentNode;
}
export interface ResolvedTabs {
tabItems: ResolvedTabItem[];
}
export interface ResolvedModal {
entryPointChild: AnyComponentNode;
contentChild: AnyComponentNode;
}
export interface CustomNodeProperties {
[k: string]: ResolvedValue;
}
export type SurfaceID = string;
/** The complete state of a single UI surface. */
export interface Surface {
rootComponentId: string | null;
componentTree: AnyComponentNode | null;
dataModel: DataMap;
components: Map<string, ComponentInstance>;
styles: Record<string, string>;
}

View File

@@ -0,0 +1,96 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { classMap } from "lit/directives/class-map.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-audioplayer")
export class Audio extends Root {
@property()
accessor url: StringValue | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
audio {
display: block;
width: 100%;
}
`,
];
#renderAudio() {
if (!this.url) {
return nothing;
}
if (this.url && typeof this.url === "object") {
if ("literalString" in this.url) {
return html`<audio controls src=${this.url.literalString} />`;
} else if ("literal" in this.url) {
return html`<audio controls src=${this.url.literal} />`;
} else if (this.url && "path" in this.url && this.url.path) {
if (!this.processor || !this.component) {
return html`(no processor)`;
}
const audioUrl = this.processor.getData(
this.component,
this.url.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (!audioUrl) {
return html`Invalid audio URL`;
}
if (typeof audioUrl !== "string") {
return html`Invalid audio URL`;
}
return html`<audio controls src=${audioUrl} />`;
}
}
return html`(empty)`;
}
render() {
return html`<section
class=${classMap(this.theme.components.AudioPlayer)}
style=${this.theme.additionalStyles?.AudioPlayer
? styleMap(this.theme.additionalStyles?.AudioPlayer)
: nothing}
>
${this.#renderAudio()}
</section>`;
}
}

View File

@@ -0,0 +1,65 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StateEvent } from "../events/events.js";
import { classMap } from "lit/directives/class-map.js";
import { Action } from "../types/components.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-button")
export class Button extends Root {
@property()
accessor action: Action | null = null;
static styles = [
structuralStyles,
css`
:host {
display: block;
flex: var(--weight);
min-height: 0;
}
`,
];
render() {
return html`<button
class=${classMap(this.theme.components.Button)}
style=${this.theme.additionalStyles?.Button
? styleMap(this.theme.additionalStyles?.Button)
: nothing}
@click=${() => {
if (!this.action) {
return;
}
const evt = new StateEvent<"a2ui.action">({
eventType: "a2ui.action",
action: this.action,
dataContextPath: this.dataContextPath,
sourceComponentId: this.id,
sourceComponent: this.component,
});
this.dispatchEvent(evt);
}}
>
<slot></slot>
</button>`;
}
}

View File

@@ -0,0 +1,64 @@
/*
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 { html, css, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { Root } from "./root.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-card")
export class Card extends Root {
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
section {
height: 100%;
width: 100%;
min-height: 0;
overflow: auto;
::slotted(*) {
height: 100%;
width: 100%;
}
}
`,
];
render() {
return html` <section
class=${classMap(this.theme.components.Card)}
style=${this.theme.additionalStyles?.Card
? styleMap(this.theme.additionalStyles?.Card)
: nothing}
>
<slot></slot>
</section>`;
}
}

View File

@@ -0,0 +1,139 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue, BooleanValue } from "../types/primitives";
import { classMap } from "lit/directives/class-map.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-checkbox")
export class Checkbox extends Root {
@property()
accessor value: BooleanValue | null = null;
@property()
accessor label: StringValue | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
input {
display: block;
width: 100%;
}
.description {
font-size: 14px;
margin-bottom: 4px;
}
`,
];
#setBoundValue(value: string) {
if (!this.value || !this.processor) {
return;
}
if (!("path" in this.value)) {
return;
}
if (!this.value.path) {
return;
}
this.processor.setData(
this.component,
this.value.path,
value,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
}
#renderField(value: boolean | number) {
return html` <section
class=${classMap(this.theme.components.CheckBox.container)}
style=${this.theme.additionalStyles?.CheckBox
? styleMap(this.theme.additionalStyles?.CheckBox)
: nothing}
>
<input
class=${classMap(this.theme.components.CheckBox.element)}
autocomplete="off"
@input=${(evt: Event) => {
if (!(evt.target instanceof HTMLInputElement)) {
return;
}
this.#setBoundValue(evt.target.value);
}}
id="data"
type="checkbox"
.value=${value}
/>
<label class=${classMap(this.theme.components.CheckBox.label)} for="data"
>${this.label?.literalString}</label
>
</section>`;
}
render() {
if (this.value && typeof this.value === "object") {
if ("literalBoolean" in this.value && this.value.literalBoolean) {
return this.#renderField(this.value.literalBoolean);
} else if ("literal" in this.value && this.value.literal !== undefined) {
return this.#renderField(this.value.literal);
} else if (this.value && "path" in this.value && this.value.path) {
if (!this.processor || !this.component) {
return html`(no model)`;
}
const textValue = this.processor.getData(
this.component,
this.value.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (textValue === null) {
return html`Invalid label`;
}
if (typeof textValue !== "boolean") {
return html`Invalid label`;
}
return this.#renderField(textValue);
}
}
return nothing;
}
}

View File

@@ -0,0 +1,104 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { classMap } from "lit/directives/class-map.js";
import { ResolvedColumn } from "../types/types";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-column")
export class Column extends Root {
@property({ reflect: true, type: String })
accessor alignment: ResolvedColumn["alignment"] = "stretch";
@property({ reflect: true, type: String })
accessor distribution: ResolvedColumn["distribution"] = "start";
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: flex;
flex: var(--weight);
}
section {
display: flex;
flex-direction: column;
min-width: 100%;
height: 100%;
}
:host([alignment="start"]) section {
align-items: start;
}
:host([alignment="center"]) section {
align-items: center;
}
:host([alignment="end"]) section {
align-items: end;
}
:host([alignment="stretch"]) section {
align-items: stretch;
}
:host([distribution="start"]) section {
justify-content: start;
}
:host([distribution="center"]) section {
justify-content: center;
}
:host([distribution="end"]) section {
justify-content: end;
}
:host([distribution="spaceBetween"]) section {
justify-content: space-between;
}
:host([distribution="spaceAround"]) section {
justify-content: space-around;
}
:host([distribution="spaceEvenly"]) section {
justify-content: space-evenly;
}
`,
];
render() {
return html`<section
class=${classMap(this.theme.components.Column)}
style=${this.theme.additionalStyles?.Column
? styleMap(this.theme.additionalStyles?.Column)
: nothing}
>
<slot></slot>
</section>`;
}
}

View File

@@ -0,0 +1,58 @@
/*
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 { CustomElementConstructorOf } from "./ui.js";
export class ComponentRegistry {
private registry: Map<string, CustomElementConstructorOf<HTMLElement>> =
new Map();
register(
typeName: string,
constructor: CustomElementConstructorOf<HTMLElement>,
tagName?: string
) {
if (!/^[a-zA-Z0-9]+$/.test(typeName)) {
throw new Error(
`[Registry] Invalid typeName '${typeName}'. Must be alphanumeric.`
);
}
this.registry.set(typeName, constructor);
const actualTagName = tagName || `a2ui-custom-${typeName.toLowerCase()}`;
const existingName = customElements.getName(constructor);
if (existingName) {
// Constructor is already registered.
if (existingName !== actualTagName) {
throw new Error(
`Component ${typeName} is already registered as ${existingName}, but requested as ${actualTagName}.`
);
}
return;
}
if (!customElements.get(actualTagName)) {
customElements.define(actualTagName, constructor);
}
}
get(typeName: string): CustomElementConstructorOf<HTMLElement> | undefined {
return this.registry.get(typeName);
}
}
export const componentRegistry = new ComponentRegistry();

View File

@@ -0,0 +1,20 @@
/*
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 { createContext } from "@lit/context";
import { type Theme } from "../../types/types.js";
export const themeContext = createContext<Theme | undefined>("A2UITheme");

View File

@@ -0,0 +1,22 @@
/*
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 '../component-registry.js';
export function registerCustomComponents() {
// No default custom components in the core library.
// Applications should register their own components.
}

View File

@@ -0,0 +1,197 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { classMap } from "lit/directives/class-map.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-datetimeinput")
export class DateTimeInput extends Root {
@property()
accessor value: StringValue | null = null;
@property()
accessor label: StringValue | null = null;
@property({ reflect: false, type: Boolean })
accessor enableDate = true;
@property({ reflect: false, type: Boolean })
accessor enableTime = true;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
input {
display: block;
border-radius: 8px;
padding: 8px;
border: 1px solid #ccc;
width: 100%;
}
`,
];
#setBoundValue(value: string) {
if (!this.value || !this.processor) {
return;
}
if (!("path" in this.value)) {
return;
}
if (!this.value.path) {
return;
}
this.processor.setData(
this.component,
this.value.path,
value,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
}
#renderField(value: string) {
return html`<section
class=${classMap(this.theme.components.DateTimeInput.container)}
>
<label
for="data"
class=${classMap(this.theme.components.DateTimeInput.label)}
>${this.#getPlaceholderText()}</label
>
<input
autocomplete="off"
class=${classMap(this.theme.components.DateTimeInput.element)}
style=${this.theme.additionalStyles?.DateTimeInput
? styleMap(this.theme.additionalStyles?.DateTimeInput)
: nothing}
@input=${(evt: Event) => {
if (!(evt.target instanceof HTMLInputElement)) {
return;
}
this.#setBoundValue(evt.target.value);
}}
id="data"
name="data"
.value=${this.#formatInputValue(value)}
.placeholder=${this.#getPlaceholderText()}
.type=${this.#getInputType()}
/>
</section>`;
}
#getInputType() {
if (this.enableDate && this.enableTime) {
return "datetime-local";
} else if (this.enableDate) {
return "date";
} else if (this.enableTime) {
return "time";
}
return "datetime-local";
}
#formatInputValue(value: string) {
const inputType = this.#getInputType();
const date = value ? new Date(value) : null;
if (!date || isNaN(date.getTime())) {
return "";
}
const year = this.#padNumber(date.getFullYear());
const month = this.#padNumber(date.getMonth());
const day = this.#padNumber(date.getDate());
const hours = this.#padNumber(date.getHours());
const minutes = this.#padNumber(date.getMinutes());
// Browsers are picky with what format they allow for the `value` attribute of date/time inputs.
// We need to parse it out of the provided value. Note that we don't use `toISOString`,
// because the resulting value is relative to UTC.
if (inputType === "date") {
return `${year}-${month}-${day}`;
} else if (inputType === "time") {
return `${hours}:${minutes}`;
}
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
#padNumber(value: number) {
return value.toString().padStart(2, "0");
}
#getPlaceholderText() {
// TODO: this should likely be passed from the model.
const inputType = this.#getInputType();
if (inputType === "date") {
return "Date";
} else if (inputType === "time") {
return "Time";
}
return "Date & Time";
}
render() {
if (this.value && typeof this.value === "object") {
if ("literalString" in this.value && this.value.literalString) {
return this.#renderField(this.value.literalString);
} else if ("literal" in this.value && this.value.literal !== undefined) {
return this.#renderField(this.value.literal);
} else if (this.value && "path" in this.value && this.value.path) {
if (!this.processor || !this.component) {
return html`(no model)`;
}
const textValue = this.processor.getData(
this.component,
this.value.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (typeof textValue !== "string") {
return html`(invalid)`;
}
return this.#renderField(textValue);
}
}
return nothing;
}
}

View File

@@ -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 { markdown } from "./markdown.js";

View File

@@ -0,0 +1,152 @@
/*
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 { noChange } from "lit";
import {
Directive,
DirectiveParameters,
Part,
directive,
} from "lit/directive.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import MarkdownIt from "markdown-it";
import { RenderRule } from "markdown-it/lib/renderer.mjs";
import * as Sanitizer from "./sanitizer.js";
class MarkdownDirective extends Directive {
#markdownIt = MarkdownIt({
highlight: (str, lang) => {
switch (lang) {
case "html": {
const iframe = document.createElement("iframe");
iframe.classList.add("html-view");
iframe.srcdoc = str;
iframe.sandbox = "";
return iframe.innerHTML;
}
default:
return Sanitizer.escapeNodeText(str);
}
},
});
#lastValue: string | null = null;
#lastTagClassMap: string | null = null;
update(_part: Part, [value, tagClassMap]: DirectiveParameters<this>) {
if (
this.#lastValue === value &&
JSON.stringify(tagClassMap) === this.#lastTagClassMap
) {
return noChange;
}
this.#lastValue = value;
this.#lastTagClassMap = JSON.stringify(tagClassMap);
return this.render(value, tagClassMap);
}
#originalClassMap = new Map<string, RenderRule | undefined>();
#applyTagClassMap(tagClassMap: Record<string, string[]>) {
Object.entries(tagClassMap).forEach(([tag]) => {
let tokenName;
switch (tag) {
case "p":
tokenName = "paragraph";
break;
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
tokenName = "heading";
break;
case "ul":
tokenName = "bullet_list";
break;
case "ol":
tokenName = "ordered_list";
break;
case "li":
tokenName = "list_item";
break;
case "a":
tokenName = "link";
break;
case "strong":
tokenName = "strong";
break;
case "em":
tokenName = "em";
break;
}
if (!tokenName) {
return;
}
const key = `${tokenName}_open`;
this.#markdownIt.renderer.rules[key] = (
tokens,
idx,
options,
_env,
self
) => {
const token = tokens[idx];
const tokenClasses = tagClassMap[token.tag] ?? [];
for (const clazz of tokenClasses) {
token.attrJoin("class", clazz);
}
return self.renderToken(tokens, idx, options);
};
});
}
#unapplyTagClassMap() {
for (const [key] of this.#originalClassMap) {
delete this.#markdownIt.renderer.rules[key];
}
this.#originalClassMap.clear();
}
/**
* Renders the markdown string to HTML using MarkdownIt.
*
* Note: MarkdownIt doesn't enable HTML in its output, so we render the
* value directly without further sanitization.
* @see https://github.com/markdown-it/markdown-it/blob/master/docs/security.md
*/
render(value: string, tagClassMap?: Record<string, string[]>) {
if (tagClassMap) {
this.#applyTagClassMap(tagClassMap);
}
const htmlString = this.#markdownIt.render(value);
this.#unapplyTagClassMap();
return unsafeHTML(htmlString);
}
}
export const markdown = directive(MarkdownDirective);
const markdownItStandalone = MarkdownIt();
export function renderMarkdownToHtmlString(value: string): string {
return markdownItStandalone.render(value);
}

View File

@@ -0,0 +1,40 @@
/*
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 { html, render } from "lit";
/**
* This is only safe for (and intended to be used for) text node positions. If
* you are using attribute position, then this is only safe if the attribute
* value is surrounded by double-quotes, and is unsafe otherwise (because the
* value could break out of the attribute value and e.g. add another attribute).
*/
export function escapeNodeText(str: string | null | undefined) {
const frag = document.createElement("div");
render(html`${str}`, frag);
return frag.innerHTML.replaceAll(/<!--([^-]*)-->/gim, "");
}
export function unescapeNodeText(str: string | null | undefined) {
if (!str) {
return "";
}
const frag = document.createElement("textarea");
frag.innerHTML = str;
return frag.value;
}

View File

@@ -0,0 +1,51 @@
/*
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 { html, css, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { Root } from "./root.js";
import { styleMap } from "lit/directives/style-map.js";
import { classMap } from "lit/directives/class-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-divider")
export class Divider extends Root {
static styles = [
structuralStyles,
css`
:host {
display: block;
min-height: 0;
overflow: auto;
}
hr {
height: 1px;
background: #ccc;
border: none;
}
`,
];
render() {
return html`<hr
class=${classMap(this.theme.components.Divider)}
style=${this.theme.additionalStyles?.Divider
? styleMap(this.theme.additionalStyles?.Divider)
: nothing}
/>`;
}
}

View File

@@ -0,0 +1,98 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { classMap } from "lit/directives/class-map.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-icon")
export class Icon extends Root {
@property()
accessor name: StringValue | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
`,
];
#renderIcon() {
if (!this.name) {
return nothing;
}
const render = (url: string) => {
url = url.replace(/([A-Z])/gm, "_$1").toLocaleLowerCase();
return html`<span class="g-icon">${url}</span>`;
};
if (this.name && typeof this.name === "object") {
if ("literalString" in this.name) {
const iconName = this.name.literalString ?? "";
return render(iconName);
} else if ("literal" in this.name) {
const iconName = this.name.literal ?? "";
return render(iconName);
} else if (this.name && "path" in this.name && this.name.path) {
if (!this.processor || !this.component) {
return html`(no model)`;
}
const iconName = this.processor.getData(
this.component,
this.name.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (!iconName) {
return html`Invalid icon name`;
}
if (typeof iconName !== "string") {
return html`Invalid icon name`;
}
return render(iconName);
}
}
return html`(empty)`;
}
render() {
return html`<section
class=${classMap(this.theme.components.Icon)}
style=${this.theme.additionalStyles?.Icon
? styleMap(this.theme.additionalStyles?.Icon)
: nothing}
>
${this.#renderIcon()}
</section>`;
}
}

View File

@@ -0,0 +1,118 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { classMap } from "lit/directives/class-map.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
import { ResolvedImage } from "../types/types.js";
import { Styles } from "../index.js";
@customElement("a2ui-image")
export class Image extends Root {
@property()
accessor url: StringValue | null = null;
@property()
accessor usageHint: ResolvedImage["usageHint"] | null = null;
@property()
accessor fit: "contain" | "cover" | "fill" | "none" | "scale-down" | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
img {
display: block;
width: 100%;
height: 100%;
object-fit: var(--object-fit, fill);
}
`,
];
#renderImage() {
if (!this.url) {
return nothing;
}
const render = (url: string) => {
return html`<img src=${url} />`;
};
if (this.url && typeof this.url === "object") {
if ("literalString" in this.url) {
const imageUrl = this.url.literalString ?? "";
return render(imageUrl);
} else if ("literal" in this.url) {
const imageUrl = this.url.literal ?? "";
return render(imageUrl);
} else if (this.url && "path" in this.url && this.url.path) {
if (!this.processor || !this.component) {
return html`(no model)`;
}
const imageUrl = this.processor.getData(
this.component,
this.url.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (!imageUrl) {
return html`Invalid image URL`;
}
if (typeof imageUrl !== "string") {
return html`Invalid image URL`;
}
return render(imageUrl);
}
}
return html`(empty)`;
}
render() {
const classes = Styles.merge(
this.theme.components.Image.all,
this.usageHint ? this.theme.components.Image[this.usageHint] : {}
);
return html`<section
class=${classMap(classes)}
style=${styleMap({
...(this.theme.additionalStyles?.Image ?? {}),
"--object-fit": this.fit ?? "fill",
})}
>
${this.#renderImage()}
</section>`;
}
}

View File

@@ -0,0 +1,72 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-list")
export class List extends Root {
@property({ reflect: true, type: String })
accessor direction: "vertical" | "horizontal" = "vertical";
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
:host([direction="vertical"]) section {
display: grid;
}
:host([direction="horizontal"]) section {
display: flex;
max-width: 100%;
overflow-x: scroll;
overflow-y: hidden;
scrollbar-width: none;
> ::slotted(*) {
flex: 1 0 fit-content;
max-width: min(80%, 400px);
}
}
`,
];
render() {
return html`<section
class=${classMap(this.theme.components.List)}
style=${this.theme.additionalStyles?.List
? styleMap(this.theme.additionalStyles?.List)
: nothing}
>
<slot></slot>
</section>`;
}
}

View File

@@ -0,0 +1,131 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { Root } from "./root.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
import { ref } from "lit/directives/ref.js";
@customElement("a2ui-modal")
export class Modal extends Root {
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
dialog {
padding: 0 0 0 0;
border: none;
background: none;
& section {
& #controls {
display: flex;
justify-content: end;
margin-bottom: 4px;
& button {
padding: 0;
background: none;
width: 20px;
height: 20px;
pointer: cursor;
border: none;
cursor: pointer;
}
}
}
}
`,
];
@state()
accessor #showModal = false;
@query("dialog")
accessor #modalRef: HTMLDialogElement | null = null;
#closeModal() {
if (!this.#modalRef) {
return;
}
if (this.#modalRef.open) {
this.#modalRef.close();
}
this.#showModal = false;
}
render() {
if (!this.#showModal) {
return html`<section
@click=${() => {
this.#showModal = true;
}}
>
<slot name="entry"></slot>
</section>`;
}
return html`<dialog
class=${classMap(this.theme.components.Modal.backdrop)}
@click=${(evt: Event) => {
// Only clicks on the background close the dialog.
const [top] = evt.composedPath();
if (!(top instanceof HTMLDialogElement)) {
return;
}
this.#closeModal();
}}
${ref((el?: Element) => {
const showModalIfNeeded = () => {
const validElement = el && el instanceof HTMLDialogElement;
if (!validElement || el.open) {
return;
}
el.showModal();
};
requestAnimationFrame(showModalIfNeeded);
})}
>
<section
class=${classMap(this.theme.components.Modal.element)}
style=${this.theme.additionalStyles?.Modal
? styleMap(this.theme.additionalStyles?.Modal)
: nothing}
>
<div id="controls">
<button
@click=${() => {
this.#closeModal();
}}
>
<span class="g-icon">close</span>
</button>
</div>
<slot></slot>
</section>
</dialog>`;
}
}

View File

@@ -0,0 +1,142 @@
/*
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 { html, css, PropertyValues, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
import { extractStringValue } from "./utils/utils.js";
@customElement("a2ui-multiplechoice")
export class MultipleChoice extends Root {
@property()
accessor description: string | null = null;
@property()
accessor options: { label: StringValue; value: string }[] = [];
@property()
accessor selections: StringValue | string[] = [];
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
select {
width: 100%;
}
.description {
}
`,
];
#setBoundValue(value: string[]) {
console.log(value);
if (!this.selections || !this.processor) {
return;
}
if (!("path" in this.selections)) {
return;
}
if (!this.selections.path) {
return;
}
this.processor.setData(
this.component,
this.selections.path,
value,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
const shouldUpdate = changedProperties.has("options");
if (!shouldUpdate) {
return;
}
if (!this.processor || !this.component || Array.isArray(this.selections)) {
return;
}
this.selections;
const selectionValue = this.processor.getData(
this.component,
this.selections.path!,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (!Array.isArray(selectionValue)) {
return;
}
this.#setBoundValue(selectionValue as string[]);
}
render() {
return html`<section class=${classMap(
this.theme.components.MultipleChoice.container
)}>
<label class=${classMap(
this.theme.components.MultipleChoice.label
)} for="data">${this.description ?? "Select an item"}</div>
<select
name="data"
id="data"
class=${classMap(this.theme.components.MultipleChoice.element)}
style=${
this.theme.additionalStyles?.MultipleChoice
? styleMap(this.theme.additionalStyles?.MultipleChoice)
: nothing
}
@change=${(evt: Event) => {
if (!(evt.target instanceof HTMLSelectElement)) {
return;
}
this.#setBoundValue([evt.target.value]);
}}
>
${this.options.map((option) => {
const label = extractStringValue(
option.label,
this.component,
this.processor,
this.surfaceId
);
return html`<option ${option.value}>${label}</option>`;
})}
</select>
</section>`;
}
}

View File

@@ -0,0 +1,475 @@
/*
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 { SignalWatcher } from "@lit-labs/signals";
import { consume } from "@lit/context";
import {
css,
html,
LitElement,
nothing,
PropertyValues,
render,
TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import { effect } from "signal-utils/subtle/microtask-effect";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { StringValue } from "../types/primitives.js";
import { Theme, AnyComponentNode, SurfaceID } from "../types/types.js";
import { themeContext } from "./context/theme.js";
import { structuralStyles } from "./styles.js";
import { componentRegistry } from "./component-registry.js";
type NodeOfType<T extends AnyComponentNode["type"]> = Extract<
AnyComponentNode,
{ type: T }
>;
// This is the base class all the components will inherit
@customElement("a2ui-root")
export class Root extends (SignalWatcher(LitElement) as typeof LitElement) {
@property()
accessor surfaceId: SurfaceID | null = null;
@property()
accessor component: AnyComponentNode | null = null;
@consume({ context: themeContext })
accessor theme!: Theme;
@property({ attribute: false })
accessor childComponents: AnyComponentNode[] | null = null;
@property({ attribute: false })
accessor processor: A2uiMessageProcessor | null = null;
@property()
accessor dataContextPath: string = "";
@property()
accessor enableCustomElements = false;
@property()
set weight(weight: string | number) {
this.#weight = weight;
this.style.setProperty("--weight", `${weight}`);
}
get weight() {
return this.#weight;
}
#weight: string | number = 1;
static styles = [
structuralStyles,
css`
:host {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 80%;
}
`,
];
/**
* Holds the cleanup function for our effect.
* We need this to stop the effect when the component is disconnected.
*/
#lightDomEffectDisposer: null | (() => void) = null;
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("childComponents")) {
if (this.#lightDomEffectDisposer) {
this.#lightDomEffectDisposer();
}
// This effect watches the A2UI Children signal and updates the Light DOM.
this.#lightDomEffectDisposer = effect(() => {
// 1. Read the signal to create the subscription.
const allChildren = this.childComponents ?? null;
// 2. Generate the template for the children.
const lightDomTemplate = this.renderComponentTree(allChildren);
// 3. Imperatively render that template into the component itself.
render(lightDomTemplate, this, { host: this });
});
}
}
/**
* Clean up the effect when the component is removed from the DOM.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.#lightDomEffectDisposer) {
this.#lightDomEffectDisposer();
}
}
/**
* Turns the SignalMap into a renderable TemplateResult for Lit.
*/
private renderComponentTree(
components: AnyComponentNode[] | null
): TemplateResult | typeof nothing {
if (!components) {
return nothing;
}
if (!Array.isArray(components)) {
return nothing;
}
return html` ${map(components, (component) => {
// 1. Check if there is a registered custom component or override.
if (this.enableCustomElements) {
const registeredCtor = componentRegistry.get(component.type);
// We also check customElements.get for non-registered but defined elements
const elCtor = registeredCtor || customElements.get(component.type);
if (elCtor) {
const node = component as AnyComponentNode;
const el = new elCtor() as Root;
el.id = node.id;
if (node.slotName) {
el.slot = node.slotName;
}
el.component = node;
el.weight = node.weight ?? "initial";
el.processor = this.processor;
el.surfaceId = this.surfaceId;
el.dataContextPath = node.dataContextPath ?? "/";
for (const [prop, val] of Object.entries(component.properties)) {
// @ts-expect-error We're off the books.
el[prop] = val;
}
return html`${el}`;
}
}
// 2. Fallback to standard components.
switch (component.type) {
case "List": {
const node = component as NodeOfType<"List">;
const childComponents = node.properties.children;
return html`<a2ui-list
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.direction=${node.properties.direction ?? "vertical"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.childComponents=${childComponents}
.enableCustomElements=${this.enableCustomElements}
></a2ui-list>`;
}
case "Card": {
const node = component as NodeOfType<"Card">;
let childComponents: AnyComponentNode[] | null =
node.properties.children;
if (!childComponents && node.properties.child) {
childComponents = [node.properties.child];
}
return html`<a2ui-card
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.childComponents=${childComponents}
.dataContextPath=${node.dataContextPath ?? ""}
.enableCustomElements=${this.enableCustomElements}
></a2ui-card>`;
}
case "Column": {
const node = component as NodeOfType<"Column">;
return html`<a2ui-column
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.childComponents=${node.properties.children ?? null}
.dataContextPath=${node.dataContextPath ?? ""}
.alignment=${node.properties.alignment ?? "stretch"}
.distribution=${node.properties.distribution ?? "start"}
.enableCustomElements=${this.enableCustomElements}
></a2ui-column>`;
}
case "Row": {
const node = component as NodeOfType<"Row">;
return html`<a2ui-row
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.childComponents=${node.properties.children ?? null}
.dataContextPath=${node.dataContextPath ?? ""}
.alignment=${node.properties.alignment ?? "stretch"}
.distribution=${node.properties.distribution ?? "start"}
.enableCustomElements=${this.enableCustomElements}
></a2ui-row>`;
}
case "Image": {
const node = component as NodeOfType<"Image">;
return html`<a2ui-image
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.url=${node.properties.url ?? null}
.dataContextPath=${node.dataContextPath ?? ""}
.usageHint=${node.properties.usageHint}
.fit=${node.properties.fit}
.enableCustomElements=${this.enableCustomElements}
></a2ui-image>`;
}
case "Icon": {
const node = component as NodeOfType<"Icon">;
return html`<a2ui-icon
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.name=${node.properties.name ?? null}
.dataContextPath=${node.dataContextPath ?? ""}
.enableCustomElements=${this.enableCustomElements}
></a2ui-icon>`;
}
case "AudioPlayer": {
const node = component as NodeOfType<"AudioPlayer">;
return html`<a2ui-audioplayer
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.url=${node.properties.url ?? null}
.dataContextPath=${node.dataContextPath ?? ""}
.enableCustomElements=${this.enableCustomElements}
></a2ui-audioplayer>`;
}
case "Button": {
const node = component as NodeOfType<"Button">;
return html`<a2ui-button
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath ?? ""}
.action=${node.properties.action}
.childComponents=${[node.properties.child]}
.enableCustomElements=${this.enableCustomElements}
></a2ui-button>`;
}
case "Text": {
const node = component as NodeOfType<"Text">;
return html`<a2ui-text
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.model=${this.processor}
.surfaceId=${this.surfaceId}
.processor=${this.processor}
.dataContextPath=${node.dataContextPath}
.text=${node.properties.text}
.usageHint=${node.properties.usageHint}
.enableCustomElements=${this.enableCustomElements}
></a2ui-text>`;
}
case "CheckBox": {
const node = component as NodeOfType<"CheckBox">;
return html`<a2ui-checkbox
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath ?? ""}
.label=${node.properties.label}
.value=${node.properties.value}
.enableCustomElements=${this.enableCustomElements}
></a2ui-checkbox>`;
}
case "DateTimeInput": {
const node = component as NodeOfType<"DateTimeInput">;
return html`<a2ui-datetimeinput
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath ?? ""}
.enableDate=${node.properties.enableDate ?? true}
.enableTime=${node.properties.enableTime ?? true}
.outputFormat=${node.properties.outputFormat}
.value=${node.properties.value}
.enableCustomElements=${this.enableCustomElements}
></a2ui-datetimeinput>`;
}
case "Divider": {
// TODO: thickness, axis and color.
const node = component as NodeOfType<"Divider">;
return html`<a2ui-divider
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.thickness=${node.properties.thickness}
.axis=${node.properties.axis}
.color=${node.properties.color}
.enableCustomElements=${this.enableCustomElements}
></a2ui-divider>`;
}
case "MultipleChoice": {
// TODO: maxAllowedSelections and selections.
const node = component as NodeOfType<"MultipleChoice">;
return html`<a2ui-multiplechoice
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.options=${node.properties.options}
.maxAllowedSelections=${node.properties.maxAllowedSelections}
.selections=${node.properties.selections}
.enableCustomElements=${this.enableCustomElements}
></a2ui-multiplechoice>`;
}
case "Slider": {
const node = component as NodeOfType<"Slider">;
return html`<a2ui-slider
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.value=${node.properties.value}
.minValue=${node.properties.minValue}
.maxValue=${node.properties.maxValue}
.enableCustomElements=${this.enableCustomElements}
></a2ui-slider>`;
}
case "TextField": {
// TODO: type and validationRegexp.
const node = component as NodeOfType<"TextField">;
return html`<a2ui-textfield
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.label=${node.properties.label}
.text=${node.properties.text}
.type=${node.properties.type}
.validationRegexp=${node.properties.validationRegexp}
.enableCustomElements=${this.enableCustomElements}
></a2ui-textfield>`;
}
case "Video": {
const node = component as NodeOfType<"Video">;
return html`<a2ui-video
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.url=${node.properties.url}
.enableCustomElements=${this.enableCustomElements}
></a2ui-video>`;
}
case "Tabs": {
const node = component as NodeOfType<"Tabs">;
const titles: StringValue[] = [];
const childComponents: AnyComponentNode[] = [];
if (node.properties.tabItems) {
for (const item of node.properties.tabItems) {
titles.push(item.title);
childComponents.push(item.child);
}
}
return html`<a2ui-tabs
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.titles=${titles}
.childComponents=${childComponents}
.enableCustomElements=${this.enableCustomElements}
></a2ui-tabs>`;
}
case "Modal": {
const node = component as NodeOfType<"Modal">;
const childComponents: AnyComponentNode[] = [
node.properties.entryPointChild,
node.properties.contentChild,
];
node.properties.entryPointChild.slotName = "entry";
return html`<a2ui-modal
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial"}
.processor=${this.processor}
.surfaceId=${this.surfaceId}
.dataContextPath=${node.dataContextPath}
.childComponents=${childComponents}
.enableCustomElements=${this.enableCustomElements}
></a2ui-modal>`;
}
default: {
return this.renderCustomComponent(component);
}
}
})}`;
}
private renderCustomComponent(component: AnyComponentNode) {
if (!this.enableCustomElements) {
return;
}
const node = component as AnyComponentNode;
const registeredCtor = componentRegistry.get(component.type);
const elCtor = registeredCtor || customElements.get(component.type);
if (!elCtor) {
return html`Unknown element ${component.type}`;
}
const el = new elCtor() as Root;
el.id = node.id;
if (node.slotName) {
el.slot = node.slotName;
}
el.component = node;
el.weight = node.weight ?? "initial";
el.processor = this.processor;
el.surfaceId = this.surfaceId;
el.dataContextPath = node.dataContextPath ?? "/";
for (const [prop, val] of Object.entries(component.properties)) {
// @ts-expect-error We're off the books.
el[prop] = val;
}
return html`${el}`;
}
render(): TemplateResult | typeof nothing {
return html`<slot></slot>`;
}
}

View File

@@ -0,0 +1,104 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { classMap } from "lit/directives/class-map.js";
import { ResolvedRow } from "../types/types";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-row")
export class Row extends Root {
@property({ reflect: true, type: String })
accessor alignment: ResolvedRow["alignment"] = "stretch";
@property({ reflect: true, type: String })
accessor distribution: ResolvedRow["distribution"] = "start";
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: flex;
flex: var(--weight);
}
section {
display: flex;
flex-direction: row;
width: 100%;
min-height: 100%;
}
:host([alignment="start"]) section {
align-items: start;
}
:host([alignment="center"]) section {
align-items: center;
}
:host([alignment="end"]) section {
align-items: end;
}
:host([alignment="stretch"]) section {
align-items: stretch;
}
:host([distribution="start"]) section {
justify-content: start;
}
:host([distribution="center"]) section {
justify-content: center;
}
:host([distribution="end"]) section {
justify-content: end;
}
:host([distribution="spaceBetween"]) section {
justify-content: space-between;
}
:host([distribution="spaceAround"]) section {
justify-content: space-around;
}
:host([distribution="spaceEvenly"]) section {
justify-content: space-evenly;
}
`,
];
render() {
return html`<section
class=${classMap(this.theme.components.Row)}
style=${this.theme.additionalStyles?.Row
? styleMap(this.theme.additionalStyles?.Row)
: nothing}
>
<slot></slot>
</section>`;
}
}

View File

@@ -0,0 +1,159 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { NumberValue, StringValue } from "../types/primitives";
import { ResolvedTextField } from "../types/types.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
import { extractNumberValue, extractStringValue } from "./utils/utils.js";
@customElement("a2ui-slider")
export class Slider extends Root {
@property()
accessor value: NumberValue | null = null;
@property()
accessor minValue = 0;
@property()
accessor maxValue = 0;
@property()
accessor label: StringValue | null = null;
@property()
accessor inputType: ResolvedTextField["type"] | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
}
input {
display: block;
width: 100%;
}
.description {
}
`,
];
#setBoundValue(value: string) {
if (!this.value || !this.processor) {
return;
}
if (!("path" in this.value)) {
return;
}
if (!this.value.path) {
return;
}
this.processor.setData(
this.component,
this.value.path,
value,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
}
#renderField(value: string | number) {
return html`<section
class=${classMap(this.theme.components.Slider.container)}
>
<label class=${classMap(this.theme.components.Slider.label)} for="data">
${this.label?.literalString ?? ""}
</label>
<input
autocomplete="off"
class=${classMap(this.theme.components.Slider.element)}
style=${this.theme.additionalStyles?.Slider
? styleMap(this.theme.additionalStyles?.Slider)
: nothing}
@input=${(evt: Event) => {
if (!(evt.target instanceof HTMLInputElement)) {
return;
}
this.#setBoundValue(evt.target.value);
}}
id="data"
name="data"
.value=${value}
type="range"
min=${this.minValue ?? "0"}
max=${this.maxValue ?? "0"}
/>
<span class=${classMap(this.theme.components.Slider.label)}
>${this.value
? extractNumberValue(
this.value,
this.component,
this.processor,
this.surfaceId
)
: "0"}</span
>
</section>`;
}
render() {
if (this.value && typeof this.value === "object") {
if ("literalNumber" in this.value && this.value.literalNumber) {
return this.#renderField(this.value.literalNumber);
} else if ("literal" in this.value && this.value.literal !== undefined) {
return this.#renderField(this.value.literal);
} else if (this.value && "path" in this.value && this.value.path) {
if (!this.processor || !this.component) {
return html`(no processor)`;
}
const textValue = this.processor.getData(
this.component,
this.value.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (textValue === null) {
return html`Invalid value`;
}
if (typeof textValue !== "string" && typeof textValue !== "number") {
return html`Invalid value`;
}
return this.#renderField(textValue);
}
}
return nothing;
}
}

View File

@@ -0,0 +1,20 @@
/*
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 { unsafeCSS } from "lit";
import { structuralStyles as unsafeStructuralStyles } from "../styles/index.js";
export const structuralStyles = unsafeCSS(unsafeStructuralStyles);

View File

@@ -0,0 +1,134 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { SurfaceID, Surface as SurfaceState } from "../types/types";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { Root } from "./root.js";
import { styleMap } from "lit/directives/style-map.js";
@customElement("a2ui-surface")
export class Surface extends Root {
@property()
accessor surfaceId: SurfaceID | null = null;
@property()
accessor surface: SurfaceState | null = null;
@property()
accessor processor: A2uiMessageProcessor | null = null;
static styles = [
css`
:host {
display: flex;
min-height: 0;
max-height: 100%;
flex-direction: column;
gap: 16px;
}
#surface-logo {
display: flex;
justify-content: center;
& img {
width: 50%;
max-width: 220px;
}
}
a2ui-root {
flex: 1;
}
`,
];
#renderLogo() {
if (!this.surface?.styles.logoUrl) {
return nothing;
}
return html`<div id="surface-logo">
<img src=${this.surface.styles.logoUrl} />
</div>`;
}
#renderSurface() {
const styles: Record<string, string> = {};
if (this.surface?.styles) {
for (const [key, value] of Object.entries(this.surface.styles)) {
switch (key) {
// Here we generate a palette from the singular primary color received
// from the surface data. We will want the values to range from
// 0 <= x <= 100, where 0 = back, 100 = white, and 50 = the primary
// color itself. As such we use a color-mix to create the intermediate
// values.
//
// Note: since we use half the range for black to the primary color,
// and half the range for primary color to white the mixed values have
// to go up double the amount, i.e., a range from black to primary
// color needs to fit in 0 -> 50 rather than 0 -> 100.
case "primaryColor": {
styles["--p-100"] = "#ffffff";
styles["--p-99"] = `color-mix(in srgb, ${value} 2%, white 98%)`;
styles["--p-98"] = `color-mix(in srgb, ${value} 4%, white 96%)`;
styles["--p-95"] = `color-mix(in srgb, ${value} 10%, white 90%)`;
styles["--p-90"] = `color-mix(in srgb, ${value} 20%, white 80%)`;
styles["--p-80"] = `color-mix(in srgb, ${value} 40%, white 60%)`;
styles["--p-70"] = `color-mix(in srgb, ${value} 60%, white 40%)`;
styles["--p-60"] = `color-mix(in srgb, ${value} 80%, white 20%)`;
styles["--p-50"] = value;
styles["--p-40"] = `color-mix(in srgb, ${value} 80%, black 20%)`;
styles["--p-35"] = `color-mix(in srgb, ${value} 70%, black 30%)`;
styles["--p-30"] = `color-mix(in srgb, ${value} 60%, black 40%)`;
styles["--p-25"] = `color-mix(in srgb, ${value} 50%, black 50%)`;
styles["--p-20"] = `color-mix(in srgb, ${value} 40%, black 60%)`;
styles["--p-15"] = `color-mix(in srgb, ${value} 30%, black 70%)`;
styles["--p-10"] = `color-mix(in srgb, ${value} 20%, black 80%)`;
styles["--p-5"] = `color-mix(in srgb, ${value} 10%, black 90%)`;
styles["--0"] = "#00000";
break;
}
case "font": {
styles["--font-family"] = value;
styles["--font-family-flex"] = value;
break;
}
}
}
}
return html`<a2ui-root
style=${styleMap(styles)}
.surfaceId=${this.surfaceId}
.processor=${this.processor}
.childComponents=${this.surface?.componentTree
? [this.surface.componentTree]
: null}
></a2ui-root>`;
}
render() {
if (!this.surface) {
return nothing;
}
return html`${[this.#renderLogo(), this.#renderSurface()]}`;
}
}

View File

@@ -0,0 +1,132 @@
/*
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 { html, css, PropertyValues, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { repeat } from "lit/directives/repeat.js";
import { StringValue } from "../types/primitives.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
import { Styles } from "../index.js";
@customElement("a2ui-tabs")
export class Tabs extends Root {
@property()
accessor titles: StringValue[] | null = null;
@property()
accessor selected = 0;
static styles = [
structuralStyles,
css`
:host {
display: block;
flex: var(--weight);
}
`,
];
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("selected")) {
for (const child of this.children) {
child.removeAttribute("slot");
}
const selectedChild = this.children[this.selected];
if (!selectedChild) {
return;
}
selectedChild.slot = "current";
}
}
#renderTabs() {
if (!this.titles) {
return nothing;
}
return html`<div
id="buttons"
class=${classMap(this.theme.components.Tabs.element)}
>
${repeat(this.titles, (title, idx) => {
let titleString = "";
if ("literalString" in title && title.literalString) {
titleString = title.literalString;
} else if ("literal" in title && title.literal !== undefined) {
titleString = title.literal;
} else if (title && "path" in title && title.path) {
if (!this.processor || !this.component) {
return html`(no model)`;
}
const textValue = this.processor.getData(
this.component,
title.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (typeof textValue !== "string") {
return html`(invalid)`;
}
titleString = textValue;
}
let classes;
if (this.selected === idx) {
classes = Styles.merge(
this.theme.components.Tabs.controls.all,
this.theme.components.Tabs.controls.selected
);
} else {
classes = { ...this.theme.components.Tabs.controls.all };
}
return html`<button
?disabled=${this.selected === idx}
class=${classMap(classes)}
@click=${() => {
this.selected = idx;
}}
>
${titleString}
</button>`;
})}
</div>`;
}
#renderSlot() {
return html`<slot name="current"></slot>`;
}
render() {
return html`<section
class=${classMap(this.theme.components.Tabs.container)}
style=${this.theme.additionalStyles?.Tabs
? styleMap(this.theme.additionalStyles?.Tabs)
: nothing}
>
${[this.#renderTabs(), this.#renderSlot()]}
</section>`;
}
}

View File

@@ -0,0 +1,131 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { classMap } from "lit/directives/class-map.js";
import { ResolvedTextField } from "../types/types";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { extractStringValue } from "./utils/utils.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-textfield")
export class TextField extends Root {
@property()
accessor text: StringValue | null = null;
@property()
accessor label: StringValue | null = null;
@property()
accessor inputType: ResolvedTextField["type"] | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: flex;
flex: var(--weight);
}
input {
display: block;
width: 100%;
}
label {
display: block;
margin-bottom: 4px;
}
`,
];
#setBoundValue(value: string) {
if (!this.text || !this.processor) {
return;
}
if (!("path" in this.text)) {
return;
}
if (!this.text.path) {
return;
}
this.processor.setData(
this.component,
this.text.path,
value,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
}
#renderField(value: string | number, label: string) {
return html` <section
class=${classMap(this.theme.components.TextField.container)}
>
${label && label !== ""
? html`<label
class=${classMap(this.theme.components.TextField.label)}
for="data"
>${label}</label
>`
: nothing}
<input
autocomplete="off"
class=${classMap(this.theme.components.TextField.element)}
style=${this.theme.additionalStyles?.TextField
? styleMap(this.theme.additionalStyles?.TextField)
: nothing}
@input=${(evt: Event) => {
if (!(evt.target instanceof HTMLInputElement)) {
return;
}
this.#setBoundValue(evt.target.value);
}}
name="data"
id="data"
.value=${value}
.placeholder=${"Please enter a value"}
type=${this.inputType === "number" ? "number" : "text"}
/>
</section>`;
}
render() {
const label = extractStringValue(
this.label,
this.component,
this.processor,
this.surfaceId
);
const value = extractStringValue(
this.text,
this.component,
this.processor,
this.surfaceId
);
return this.#renderField(value, label);
}
}

View File

@@ -0,0 +1,164 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { markdown } from "./directives/directives.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { classMap } from "lit/directives/class-map.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
import { Styles } from "../index.js";
import { ResolvedText, Theme } from "../types/types.js";
interface HintedStyles {
h1: Record<string, string>;
h2: Record<string, string>;
h3: Record<string, string>;
h4: Record<string, string>;
h5: Record<string, string>;
body: Record<string, string>;
caption: Record<string, string>;
}
@customElement("a2ui-text")
export class Text extends Root {
@property()
accessor text: StringValue | null = null;
@property({ reflect: true, attribute: "usage-hint" })
accessor usageHint: ResolvedText["usageHint"] | null = null;
static styles = [
structuralStyles,
css`
:host {
display: block;
flex: var(--weight);
}
h1,
h2,
h3,
h4,
h5 {
line-height: inherit;
font: inherit;
}
`,
];
#renderText() {
let textValue: string | null | undefined = null;
if (this.text && typeof this.text === "object") {
if ("literalString" in this.text && this.text.literalString) {
textValue = this.text.literalString;
} else if ("literal" in this.text && this.text.literal !== undefined) {
textValue = this.text.literal;
} else if (this.text && "path" in this.text && this.text.path) {
if (!this.processor || !this.component) {
return html`(no model)`;
}
const value = this.processor.getData(
this.component,
this.text.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (value !== null && value !== undefined) {
textValue = value.toString();
}
}
}
if (textValue === null || textValue === undefined) {
return html`(empty)`;
}
let markdownText = textValue;
switch (this.usageHint) {
case "h1":
markdownText = `# ${markdownText}`;
break;
case "h2":
markdownText = `## ${markdownText}`;
break;
case "h3":
markdownText = `### ${markdownText}`;
break;
case "h4":
markdownText = `#### ${markdownText}`;
break;
case "h5":
markdownText = `##### ${markdownText}`;
break;
case "caption":
markdownText = `*${markdownText}*`;
break;
default:
break; // Body.
}
return html`${markdown(
markdownText,
Styles.appendToAll(this.theme.markdown, ["ol", "ul", "li"], {})
)}`;
}
#areHintedStyles(styles: unknown): styles is HintedStyles {
if (typeof styles !== "object") return false;
if (Array.isArray(styles)) return false;
if (!styles) return false;
const expected = ["h1", "h2", "h3", "h4", "h5", "h6", "caption", "body"];
return expected.every((v) => v in styles);
}
#getAdditionalStyles() {
let additionalStyles: Record<string, string> = {};
const styles = this.theme.additionalStyles?.Text;
if (!styles) return additionalStyles;
if (this.#areHintedStyles(styles)) {
const hint = this.usageHint ?? "body";
additionalStyles = styles[hint] as Record<string, string>;
} else {
additionalStyles = styles;
}
return additionalStyles;
}
render() {
const classes = Styles.merge(
this.theme.components.Text.all,
this.usageHint ? this.theme.components.Text[this.usageHint] : {}
);
return html`<section
class=${classMap(classes)}
style=${this.theme.additionalStyles?.Text
? styleMap(this.#getAdditionalStyles())
: nothing}
>
${this.#renderText()}
</section>`;
}
}

View File

@@ -0,0 +1,119 @@
/*
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 type TagName = keyof A2UITagNameMap;
// A type that describes a constructor function which returns an instance of T
export type CustomElementConstructorOf<T extends HTMLElement> = {
// The 'new' signature ensures it can be instantiated
new (): T;
} & typeof HTMLElement;
import { Audio } from "./audio.js";
import { Button } from "./button.js";
import { Card } from "./card.js";
import { Checkbox } from "./checkbox.js";
import { Column } from "./column.js";
import { DateTimeInput } from "./datetime-input.js";
import { Divider } from "./divider.js";
import { Icon } from "./icon.js";
import { Image } from "./image.js";
import { List } from "./list.js";
import { MultipleChoice } from "./multiple-choice.js";
import { Modal } from "./modal.js";
import { Root } from "./root.js";
import { Row } from "./row.js";
import { Slider } from "./slider.js";
import { Surface } from "./surface.js";
import { Tabs } from "./tabs.js";
import { TextField } from "./text-field.js";
import { Text } from "./text.js";
import { Video } from "./video.js";
export * as Context from "./context/theme.js";
export * as Utils from "./utils/utils.js";
export { ComponentRegistry, componentRegistry } from "./component-registry.js";
export { registerCustomComponents } from "./custom-components/index.js";
export {
Audio,
Button,
Card,
Column,
Checkbox,
DateTimeInput,
Divider,
Icon,
Image,
List,
MultipleChoice,
Modal,
Row,
Slider,
Root,
Surface,
Tabs,
Text,
TextField,
Video,
};
interface A2UITagNameMap {
"a2ui-audioplayer": Audio;
"a2ui-button": Button;
"a2ui-card": Card;
"a2ui-checkbox": Checkbox;
"a2ui-column": Column;
"a2ui-datetimeinput": DateTimeInput;
"a2ui-divider": Divider;
"a2ui-icon": Icon;
"a2ui-image": Image;
"a2ui-list": List;
"a2ui-modal": Modal;
"a2ui-multiplechoice": MultipleChoice;
"a2ui-root": Root;
"a2ui-row": Row;
"a2ui-slider": Slider;
"a2ui-surface": Surface;
"a2ui-tabs": Tabs;
"a2ui-text": Text;
"a2ui-textfield": TextField;
"a2ui-video": Video;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface HTMLElementTagNameMap extends A2UITagNameMap {}
}
/**
* Type-safely retrieves a custom element constructor using the tagName map.
* @param tagName The tag name to look up (must exist in HTMLElementTagNameMap).
* @returns The specific constructor type or undefined.
*/
export function instanceOf<T extends keyof A2UITagNameMap>(tagName: T) {
// Use a type assertion: we tell TypeScript to trust that the value returned
// by customElements.get(tagName) matches the type defined in our map.
const ctor = customElements.get(tagName) as
| CustomElementConstructorOf<A2UITagNameMap[T]>
| undefined;
if (!ctor) {
console.warn("No element definition for", tagName);
return;
}
return new ctor();
}

View File

@@ -0,0 +1,92 @@
/*
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 { A2uiMessageProcessor } from "../../data/model-processor.js";
import { NumberValue, type StringValue } from "../../types/primitives.js";
import { type AnyComponentNode } from "../../types/types.js";
export function extractStringValue(
val: StringValue | null,
component: AnyComponentNode | null,
processor: A2uiMessageProcessor | null,
surfaceId: string | null
): string {
if (val !== null && typeof val === "object") {
if ("literalString" in val) {
return val.literalString ?? "";
} else if ("literal" in val && val.literal !== undefined) {
return val.literal ?? "";
} else if (val && "path" in val && val.path) {
if (!processor || !component) {
return "(no model)";
}
const textValue = processor.getData(
component,
val.path,
surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (textValue === null || typeof textValue !== "string") {
return "";
}
return textValue;
}
}
return "";
}
export function extractNumberValue(
val: NumberValue | null,
component: AnyComponentNode | null,
processor: A2uiMessageProcessor | null,
surfaceId: string | null
): number {
if (val !== null && typeof val === "object") {
if ("literalNumber" in val) {
return val.literalNumber ?? 0;
} else if ("literal" in val && val.literal !== undefined) {
return val.literal ?? 0;
} else if (val && "path" in val && val.path) {
if (!processor || !component) {
return -1;
}
let numberValue = processor.getData(
component,
val.path,
surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (typeof numberValue === "string") {
numberValue = Number.parseInt(numberValue, 10);
if (Number.isNaN(numberValue)) {
numberValue = null;
}
}
if (numberValue === null || typeof numberValue !== "number") {
return -1;
}
return numberValue;
}
}
return 0;
}

View File

@@ -0,0 +1,77 @@
/*
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 function isEmbedUri(uri: string | null): boolean {
if (!uri) {
return false;
}
return /^https:\/\/www\.youtube\.com\/embed\//.test(uri);
}
export function isShareUri(uri: string | null): boolean {
if (!uri) {
return false;
}
return /^https:\/\/youtu\.be\//.test(uri);
}
export function isWatchUri(uri: string): boolean {
return /^https:\/\/www\.youtube\.com\/watch\?v=/.test(uri);
}
export function isShortsUri(uri: string): boolean {
return /^https:\/\/www\.youtube\.com\/shorts\//.test(uri);
}
export function convertShareUriToEmbedUri(uri: string) {
const regex = /^https:\/\/youtu\.be\/(.*?)(?:[&\\?]|$)/;
const matches = regex.exec(uri);
if (!matches) {
return null;
}
const embedId = matches[1];
return `https://www.youtube.com/embed/${embedId}`;
}
export function convertWatchOrShortsUriToEmbedUri(uri: string) {
const regex =
/^https:\/\/www\.youtube\.com\/(?:shorts\/|embed\/|watch\?v=)(.*?)(?:[&\\?]|$)/;
const matches = regex.exec(uri);
if (!matches) {
return null;
}
const embedId = matches[1];
return `https://www.youtube.com/embed/${embedId}`;
}
export function videoIdFromWatchOrShortsOrEmbedUri(uri: string) {
const regex =
/^https:\/\/www\.youtube\.com\/(?:shorts\/|embed\/|watch\?v=)(.*?)(?:[&\\?]|$)/;
const matches = regex.exec(uri);
if (!matches) {
return null;
}
return matches[1];
}
export function createWatchUriFromVideoId(id: string) {
return `https://www.youtube.com/watch?v=${id}`;
}

View File

@@ -0,0 +1,96 @@
/*
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 { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Root } from "./root.js";
import { StringValue } from "../types/primitives.js";
import { A2uiMessageProcessor } from "../data/model-processor.js";
import { classMap } from "lit/directives/class-map.js";
import { styleMap } from "lit/directives/style-map.js";
import { structuralStyles } from "./styles.js";
@customElement("a2ui-video")
export class Video extends Root {
@property()
accessor url: StringValue | null = null;
static styles = [
structuralStyles,
css`
* {
box-sizing: border-box;
}
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
video {
display: block;
width: 100%;
}
`,
];
#renderVideo() {
if (!this.url) {
return nothing;
}
if (this.url && typeof this.url === "object") {
if ("literalString" in this.url) {
return html`<video controls src=${this.url.literalString} />`;
} else if ("literal" in this.url) {
return html`<video controls src=${this.url.literal} />`;
} else if (this.url && "path" in this.url && this.url.path) {
if (!this.processor || !this.component) {
return html`(no processor)`;
}
const videoUrl = this.processor.getData(
this.component,
this.url.path,
this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
);
if (!videoUrl) {
return html`Invalid video URL`;
}
if (typeof videoUrl !== "string") {
return html`Invalid video URL`;
}
return html`<video controls src=${videoUrl} />`;
}
}
return html`(empty)`;
}
render() {
return html`<section
class=${classMap(this.theme.components.Video)}
style=${this.theme.additionalStyles?.Video
? styleMap(this.theme.additionalStyles?.Video)
: nothing}
>
${this.#renderVideo()}
</section>`;
}
}

View File

@@ -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 * as v0_8 from "./0.8/index.js";

View File

@@ -0,0 +1,35 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
// "allowJs": true,
"preserveWatchOutput": true,
"sourceMap": true,
"target": "es2022",
"module": "esnext",
"lib": ["es2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"useDefineForClassFields": false,
"rootDir": ".",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo",
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.json"]
}