diff --git a/renderers/web_core/package-lock.json b/renderers/web_core/package-lock.json index 8bf2ca709..0d8881461 100644 --- a/renderers/web_core/package-lock.json +++ b/renderers/web_core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a2ui/web_core", - "version": "0.8.2", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a2ui/web_core", - "version": "0.8.2", + "version": "0.8.5", "license": "Apache-2.0", "dependencies": { "@preact/signals-core": "^1.13.0", diff --git a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts new file mode 100644 index 000000000..5b3472ff6 --- /dev/null +++ b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 { describe, it } from "node:test"; +import * as assert from "node:assert"; +import { readFileSync } from "fs"; +import { resolve, join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { BASIC_COMPONENTS } from "./basic_components.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// `__dirname` will be `dist/src/v0_9/basic_catalog/components` when run via `node --test dist/**/*.test.js` +const SPEC_DIR_V0_9 = resolve( + __dirname, + "../../../../../../../specification/v0_9/json", +); + +function getZodShape(zodObj: any): any { + let current = zodObj; + while (current?._def) { + if (current._def.typeName === "ZodObject") return current.shape || current._def.shape(); + current = current._def.innerType ?? current._def.schema; + } + return undefined; +} + +function getZodArrayElement(zodObj: any): any { + let current = zodObj; + while (current?._def) { + if (current._def.typeName === "ZodArray") return current._def.type; + current = current._def.innerType ?? current._def.schema; + } + return undefined; +} + +describe("Basic Components Schema Verification", () => { + it("verifies all basic components exist in the catalog and their required properties and descriptions align", () => { + const jsonSpecPath = join(SPEC_DIR_V0_9, "basic_catalog.json"); + const officialSchema = JSON.parse(readFileSync(jsonSpecPath, "utf-8")); + + const componentsMap = officialSchema.components; + + for (const api of BASIC_COMPONENTS) { + const componentName = api.name; + const jsonComponentDef = componentsMap[componentName]; + assert.ok( + jsonComponentDef, + `Component ${componentName} not found in basic_catalog.json` + ); + + const specificPropsDef = jsonComponentDef.allOf.find( + (item: any) => item.properties && item.properties.component + ); + + assert.ok( + specificPropsDef, + `Could not find specific properties definition for ${componentName} in basic_catalog.json` + ); + + if (specificPropsDef.description) { + assert.strictEqual( + api.schema.description, + specificPropsDef.description, + `Component description mismatch for ${componentName}` + ); + } + + const jsonProperties = specificPropsDef.properties; + const jsonRequired = specificPropsDef.required || []; + + const zodShape = getZodShape(api.schema); + + // Check CatalogComponentCommon properties which are not in specificPropsDef but in allOf + const catalogCommonDef = officialSchema.$defs.CatalogComponentCommon; + if (catalogCommonDef?.properties) { + for (const propName in catalogCommonDef.properties) { + const jsonProp = catalogCommonDef.properties[propName]; + if (zodShape[propName] && jsonProp.description) { + assert.strictEqual( + zodShape[propName].description, + jsonProp.description, + `Description mismatch for common property '${propName}' of component '${componentName}'` + ); + } + } + } + + for (const propName of Object.keys(jsonProperties)) { + if (propName === "component") continue; // Handled by envelope + const jsonProp = jsonProperties[propName]; + const zodPropSchema = zodShape[propName]; + + assert.ok( + zodPropSchema, + `Property '${propName}' is missing in Zod schema for component '${componentName}'` + ); + + if (jsonProp.description) { + assert.strictEqual( + zodPropSchema.description, + jsonProp.description, + `Description mismatch for property '${propName}' of component '${componentName}'` + ); + } + + // Check array items + if (jsonProp.type === "array" && jsonProp.items && jsonProp.items.properties) { + const itemProps = jsonProp.items.properties; + const zodItemShape = getZodShape(getZodArrayElement(zodPropSchema)); + for (const itemProp of Object.keys(itemProps)) { + if (itemProps[itemProp].description) { + assert.strictEqual( + zodItemShape[itemProp].description, + itemProps[itemProp].description, + `Description mismatch for array item property '${propName}.${itemProp}' of component '${componentName}'` + ); + } + } + } + } + + for (const reqProp of jsonRequired) { + if (reqProp === "component") continue; + assert.ok( + zodShape[reqProp], + `Required property '${reqProp}' from JSON schema is missing in Zod schema for component '${componentName}'` + ); + + const propSchema = zodShape[reqProp]; + let isOptional = false; + let current = propSchema; + while (current && current._def) { + if (current._def.typeName === "ZodOptional" || current._def.typeName === "ZodDefault") { + isOptional = true; + break; + } + current = current._def.innerType; + } + + if (isOptional) { + assert.fail(`Property '${reqProp}' is required in JSON but optional/default in Zod for '${componentName}'`); + } + } + } + }); +}); \ No newline at end of file diff --git a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts new file mode 100644 index 000000000..c846556f6 --- /dev/null +++ b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts @@ -0,0 +1,297 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 { z } from "zod"; +import { + DynamicStringSchema, + DynamicNumberSchema, + DynamicBooleanSchema, + DynamicStringListSchema, + ChildListSchema, + ComponentIdSchema, + ActionSchema, + AccessibilityAttributesSchema, + CheckableSchema, +} from "../../schema/common-types.js"; +import { ComponentApi } from "../../catalog/types.js"; + +const CommonProps = { + accessibility: AccessibilityAttributesSchema.optional(), + weight: z.number().describe("The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column.").optional(), +}; + +export const TextApi: ComponentApi = { + name: "Text", + schema: z.object({ + ...CommonProps, + text: DynamicStringSchema.describe("The text content to display. 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."), + variant: z.enum(["h1", "h2", "h3", "h4", "h5", "caption", "body"]).default("body").describe("A hint for the base text style.").optional(), + }).strict(), +}; + +export const ImageApi: ComponentApi = { + name: "Image", + schema: z.object({ + ...CommonProps, + url: DynamicStringSchema.describe("The URL of the image to display."), + fit: z.enum(["contain", "cover", "fill", "none", "scaleDown"]).default("fill").describe("Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.").optional(), + variant: z.enum([ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header", + ]).default("mediumFeature").describe("A hint for the image size and style.").optional(), + }).strict(), +}; + +const ICON_NAMES = [ + "accountCircle", "add", "arrowBack", "arrowForward", "attachFile", + "calendarToday", "call", "camera", "check", "close", "delete", + "download", "edit", "event", "error", "fastForward", "favorite", + "favoriteOff", "folder", "help", "home", "info", "locationOn", + "lock", "lockOpen", "mail", "menu", "moreVert", "moreHoriz", + "notificationsOff", "notifications", "pause", "payment", "person", + "phone", "photo", "play", "print", "refresh", "rewind", "search", + "send", "settings", "share", "shoppingCart", "skipNext", + "skipPrevious", "star", "starHalf", "starOff", "stop", "upload", + "visibility", "visibilityOff", "volumeDown", "volumeMute", + "volumeOff", "volumeUp", "warning" +] as const; + +export const IconApi: ComponentApi = { + name: "Icon", + schema: z.object({ + ...CommonProps, + name: z.union([ + z.enum(ICON_NAMES), + z.object({ + path: z.string(), + }).strict(), + ]).describe("The name of the icon to display."), + }).strict(), +}; + +export const VideoApi: ComponentApi = { + name: "Video", + schema: z.object({ + ...CommonProps, + url: DynamicStringSchema.describe("The URL of the video to display."), + }).strict(), +}; + +export const AudioPlayerApi: ComponentApi = { + name: "AudioPlayer", + schema: z.object({ + ...CommonProps, + url: DynamicStringSchema.describe("The URL of the audio to be played."), + description: DynamicStringSchema.describe("A description of the audio, such as a title or summary.").optional(), + }).strict(), +}; + +export const RowApi: ComponentApi = { + name: "Row", + schema: z.object({ + ...CommonProps, + children: ChildListSchema.describe("Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID."), + justify: z.enum([ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start", + "stretch", + ]).default("start").describe("Defines the arrangement of children along the main axis (horizontally). Use 'spaceBetween' to push items to the edges, or 'start'/'end'/'center' to pack them together.").optional(), + align: z.enum(["start", "center", "end", "stretch"]).default("stretch").describe("Defines the alignment of children along the cross axis (vertically). This is similar to the CSS 'align-items' property, but uses camelCase values (e.g., 'start').").optional(), + }).strict().describe("A layout component that arranges its children horizontally. To create a grid layout, nest Columns within this Row."), +}; + +export const ColumnApi: ComponentApi = { + name: "Column", + schema: z.object({ + ...CommonProps, + children: ChildListSchema.describe("Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID."), + justify: z.enum([ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly", + "stretch", + ]).default("start").describe("Defines the arrangement of children along the main axis (vertically). Use 'spaceBetween' to push items to the edges (e.g. header at top, footer at bottom), or 'start'/'end'/'center' to pack them together.").optional(), + align: z.enum(["center", "end", "start", "stretch"]).default("stretch").describe("Defines the alignment of children along the cross axis (horizontally). This is similar to the CSS 'align-items' property.").optional(), + }).strict().describe("A layout component that arranges its children vertically. To create a grid layout, nest Rows within this Column."), +}; + +export const ListApi: ComponentApi = { + name: "List", + schema: z.object({ + ...CommonProps, + children: ChildListSchema.describe("Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list."), + direction: z.enum(["vertical", "horizontal"]).default("vertical").describe("The direction in which the list items are laid out.").optional(), + align: z.enum(["start", "center", "end", "stretch"]).default("stretch").describe("Defines the alignment of children along the cross axis.").optional(), + }).strict(), +}; + +export const CardApi: ComponentApi = { + name: "Card", + schema: z.object({ + ...CommonProps, + child: ComponentIdSchema.describe("The ID of the single child component to be rendered inside the card. To display multiple elements, you MUST wrap them in a layout component (like Column or Row) and pass that container's ID here. Do NOT pass multiple IDs or a non-existent ID. Do NOT define the child component inline."), + }).strict(), +}; + +export const TabsApi: ComponentApi = { + name: "Tabs", + schema: z.object({ + ...CommonProps, + tabs: z.array( + z.object({ + title: DynamicStringSchema.describe("The tab title."), + child: ComponentIdSchema.describe("The ID of the child component. Do NOT define the component inline."), + }).strict() + ).min(1).describe("An array of objects, where each object defines a tab with a title and a child component."), + }).strict(), +}; + +export const ModalApi: ComponentApi = { + name: "Modal", + schema: z.object({ + ...CommonProps, + trigger: ComponentIdSchema.describe("The ID of the component that opens the modal when interacted with (e.g., a button). Do NOT define the component inline."), + content: ComponentIdSchema.describe("The ID of the component to be displayed inside the modal. Do NOT define the component inline."), + }).strict(), +}; + +export const DividerApi: ComponentApi = { + name: "Divider", + schema: z.object({ + ...CommonProps, + axis: z.enum(["horizontal", "vertical"]).default("horizontal").describe("The orientation of the divider.").optional(), + }).strict(), +}; + +export const ButtonApi: ComponentApi = { + name: "Button", + schema: z.object({ + ...CommonProps, + child: ComponentIdSchema.describe("The ID of the child component. Use a 'Text' component for a labeled button. Only use an 'Icon' if the requirements explicitly ask for an icon-only button. Do NOT define the child component inline."), + variant: z.enum(["default", "primary", "borderless"]).default("default").describe("A hint for the button style. If omitted, a default button style is used. 'primary' indicates this is the main call-to-action button. 'borderless' means the button has no visual border or background, making its child content appear like a clickable link.").optional(), + action: ActionSchema, + checks: CheckableSchema.shape.checks, + }).strict(), +}; + +export const TextFieldApi: ComponentApi = { + name: "TextField", + schema: z.object({ + ...CommonProps, + label: DynamicStringSchema.describe("The text label for the input field."), + value: DynamicStringSchema.describe("The value of the text field.").optional(), + variant: z.enum(["longText", "number", "shortText", "obscured"]).default("shortText").describe("The type of input field to display.").optional(), + validationRegexp: z.string().describe("A regular expression used for client-side validation of the input.").optional(), + checks: CheckableSchema.shape.checks, + }).strict(), +}; + +export const CheckBoxApi: ComponentApi = { + name: "CheckBox", + schema: z.object({ + ...CommonProps, + label: DynamicStringSchema.describe("The text to display next to the checkbox."), + value: DynamicBooleanSchema.describe("The current state of the checkbox (true for checked, false for unchecked)."), + checks: CheckableSchema.shape.checks, + }).strict(), +}; + +export const ChoicePickerApi: ComponentApi = { + name: "ChoicePicker", + schema: z.object({ + ...CommonProps, + label: DynamicStringSchema.describe("The label for the group of options.").optional(), + variant: z.enum(["multipleSelection", "mutuallyExclusive"]).default("mutuallyExclusive").describe("A hint for how the choice picker should be displayed and behave.").optional(), + options: z.array( + z.object({ + label: DynamicStringSchema.describe("The text to display for this option."), + value: z.string().describe("The stable value associated with this option."), + }).strict() + ).describe("The list of available options to choose from."), + value: DynamicStringListSchema.describe("The list of currently selected values. This should be bound to a string array in the data model."), + displayStyle: z.enum(["checkbox", "chips"]).default("checkbox").describe("The display style of the component.").optional(), + filterable: z.boolean().default(false).describe("If true, displays a search input to filter the options.").optional(), + checks: CheckableSchema.shape.checks, + }).strict().describe("A component that allows selecting one or more options from a list."), +}; + +export const SliderApi: ComponentApi = { + name: "Slider", + schema: z.object({ + ...CommonProps, + label: DynamicStringSchema.describe("The label for the slider.").optional(), + min: z.number().default(0).describe("The minimum value of the slider.").optional(), + max: z.number().describe("The maximum value of the slider."), + value: DynamicNumberSchema.describe("The current value of the slider."), + checks: CheckableSchema.shape.checks, + }).strict(), +}; + +export const DateTimeInputApi: ComponentApi = { + name: "DateTimeInput", + schema: z.object({ + ...CommonProps, + value: DynamicStringSchema.describe("The selected date and/or time value in ISO 8601 format. If not yet set, initialize with an empty string."), + enableDate: z.boolean().default(false).describe("If true, allows the user to select a date.").optional(), + enableTime: z.boolean().default(false).describe("If true, allows the user to select a time.").optional(), + min: z.union([ + DynamicStringSchema, + z.string().date(), + z.string().time(), + z.string().datetime(), + ]).describe("The minimum allowed date/time in ISO 8601 format.").optional(), + max: z.union([ + DynamicStringSchema, + z.string().date(), + z.string().time(), + z.string().datetime(), + ]).describe("The maximum allowed date/time in ISO 8601 format.").optional(), + label: DynamicStringSchema.describe("The text label for the input field.").optional(), + checks: CheckableSchema.shape.checks, + }).strict(), +}; + +export const BASIC_COMPONENTS: ComponentApi[] = [ + TextApi, + ImageApi, + IconApi, + VideoApi, + AudioPlayerApi, + RowApi, + ColumnApi, + ListApi, + CardApi, + TabsApi, + ModalApi, + DividerApi, + ButtonApi, + TextFieldApi, + CheckBoxApi, + ChoicePickerApi, + SliderApi, + DateTimeInputApi, +]; diff --git a/renderers/web_core/src/v0_9/basic_catalog/index.ts b/renderers/web_core/src/v0_9/basic_catalog/index.ts index 52a00de85..ffb6b767c 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/index.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/index.ts @@ -16,3 +16,4 @@ export * from "./expressions/expression_parser.js"; export * from "./functions/basic_functions.js"; +export * from "./components/basic_components.js";