diff --git a/.gitignore b/.gitignore
index 21a866348..85c8a0502 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Worktrees
.worktrees/
.superpowers/
+.claude/worktrees/
# Node
node_modules/
diff --git a/apps/website/content/docs/chat/a2ui/catalog.mdx b/apps/website/content/docs/chat/a2ui/catalog.mdx
index b05cfb9d9..297e4eddf 100644
--- a/apps/website/content/docs/chat/a2ui/catalog.mdx
+++ b/apps/website/content/docs/chat/a2ui/catalog.mdx
@@ -153,7 +153,7 @@ Renders a button that dispatches an action when clicked.
| `variant` | `'primary' \| 'borderless'` | Visual style. Defaults to `'primary'` |
| `disabled` | `boolean` | Disables the button when `true` |
| `action` | `A2uiAction` | Action to execute on click (event or function call) |
-| `checks` | `A2uiCheck[]` | Validation checks — button is disabled if any fail |
+| `validationResult` | `A2uiValidationResult` | Pre-computed validation result — button is disabled if `valid` is `false` |
| `emit` | injected | Event emitter provided by the render engine |
**Action types:**
diff --git a/apps/website/content/docs/chat/a2ui/overview.mdx b/apps/website/content/docs/chat/a2ui/overview.mdx
index 47a2b171b..3228922f9 100644
--- a/apps/website/content/docs/chat/a2ui/overview.mdx
+++ b/apps/website/content/docs/chat/a2ui/overview.mdx
@@ -187,6 +187,108 @@ If no consumer handler matches the `call` name, built-in handlers are used as fa
Handler return values are emitted on the `RenderHandlerEvent` — observe them via the `renderEvent` output on `ChatComponent`.
+## Validation
+
+A2UI v0.9 uses `CheckRule` objects for client-side validation. Input components and buttons can define a `checks` array — each check has a `condition` (a `DynamicBoolean`) and an error `message`.
+
+### CheckRule Shape
+
+```json
+{
+ "checks": [
+ {
+ "condition": { "call": "required", "args": { "value": { "path": "/name" } } },
+ "message": "Name is required"
+ }
+ ]
+}
+```
+
+The `condition` can be:
+- A **boolean literal**: `true` or `false`
+- A **path reference**: `{ "path": "/agreed" }` — resolves to a data model value
+- A **FunctionCall**: `{ "call": "required", "args": { ... } }` — invokes a named function
+- A **composite**: `{ "call": "and", "args": { "values": [...] } }` — combines multiple conditions
+
+### Built-in Functions
+
+| Category | Functions |
+|----------|-----------|
+| Validation | `required`, `regex`, `length`, `numeric`, `email` |
+| Logic | `and`, `or`, `not` |
+| Formatting | `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize` |
+| Navigation | `openUrl` |
+
+### Input Component Behavior
+
+Input components (`TextField`, `CheckBox`, `ChoicePicker`, `Slider`, `DateTimeInput`) validate continuously — errors display inline as the user interacts. The input border changes color to indicate validation state.
+
+### Button Behavior
+
+Per the v0.9 spec: if any check fails, the button is automatically disabled. Error messages display below the button.
+
+### Composite Conditions
+
+Use `and`, `or`, and `not` to compose complex validation rules:
+
+```json
+{
+ "condition": {
+ "call": "and",
+ "args": {
+ "values": [
+ { "call": "required", "args": { "value": { "path": "/name" } } },
+ { "call": "or", "args": { "values": [
+ { "call": "required", "args": { "value": { "path": "/email" } } },
+ { "call": "required", "args": { "value": { "path": "/phone" } } }
+ ]}}
+ ]
+ }
+ },
+ "message": "Name required, plus email or phone"
+}
+```
+
+### Custom Catalog Components
+
+Custom catalog components receive a pre-computed `validationResult` prop:
+
+```typescript
+interface A2uiValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+```
+
+Use the shared `A2uiValidationErrorsComponent` for consistent error display:
+
+```typescript
+import { A2uiValidationErrorsComponent } from '@cacheplane/chat';
+
+@Component({
+ imports: [A2uiValidationErrorsComponent],
+ template: `
+
+
+ `,
+})
+export class MyCustomInputComponent {
+ readonly value = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
+}
+```
+
+### Theming
+
+Validation styling uses CSS custom properties:
+
+| Property | Default | Usage |
+|----------|---------|-------|
+| `--a2ui-error` | `#ef4444` | Error text and invalid border color |
+| `--a2ui-border` | `rgba(255,255,255,0.1)` | Default input border |
+| `--a2ui-input-bg` | `rgba(255,255,255,0.05)` | Input background |
+| `--a2ui-label` | `rgba(255,255,255,0.6)` | Label text color |
+
## What's Next
diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py
index 48aa1c2f4..f66894a8e 100644
--- a/cockpit/chat/a2ui/python/src/graph.py
+++ b/cockpit/chat/a2ui/python/src/graph.py
@@ -23,10 +23,20 @@
]},
{"id": "name_field", "component": "TextField",
"label": "Name", "value": {"path": "/name"}, "placeholder": "Your full name",
- "_bindings": {"value": "/name"}},
+ "_bindings": {"value": "/name"},
+ "checks": [
+ {"condition": {"call": "required", "args": {"value": {"path": "/name"}}},
+ "message": "Name is required"},
+ ]},
{"id": "email_field", "component": "TextField",
"label": "Email", "value": {"path": "/email"}, "placeholder": "you@company.com",
- "_bindings": {"value": "/email"}},
+ "_bindings": {"value": "/email"},
+ "checks": [
+ {"condition": {"call": "required", "args": {"value": {"path": "/email"}}},
+ "message": "Email is required"},
+ {"condition": {"call": "email", "args": {"value": {"path": "/email"}}},
+ "message": "Must be a valid email address"},
+ ]},
{"id": "dept_picker", "component": "ChoicePicker",
"label": "Department",
"options": ["Engineering", "Sales", "Support", "Marketing"],
@@ -38,6 +48,14 @@
{"id": "divider", "component": "Divider"},
{"id": "submit_btn", "component": "Button",
"label": "Submit",
+ "checks": [
+ {"condition": {"call": "and", "args": {"values": [
+ {"call": "required", "args": {"value": {"path": "/name"}}},
+ {"call": "email", "args": {"value": {"path": "/email"}}},
+ {"path": "/consent"},
+ ]}},
+ "message": "Complete all required fields and agree to be contacted"},
+ ],
"action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}},
]}),
])
diff --git a/docs/superpowers/plans/2026-04-10-a2ui-validation.md b/docs/superpowers/plans/2026-04-10-a2ui-validation.md
new file mode 100644
index 000000000..5451b20b4
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-10-a2ui-validation.md
@@ -0,0 +1,1329 @@
+# A2UI Validation — v0.9 Spec Alignment Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Align the A2UI validation system with the v0.9 spec's `CheckRule` shape, build a complete function evaluation engine with all 14 basic catalog functions, and wire pre-computed validation results into catalog components with inline error display.
+
+**Architecture:** Replace `A2uiCheck` with v0.9 `A2uiCheckRule` (`{ condition: DynamicBoolean, message }`). Merge the 5 validation functions from `validate.ts` into the unified `FUNCTIONS` registry in `functions.ts`, add `formatString`, and add array resolution to `resolveDynamic()`. Replace `validateChecks()` with `evaluateCheckRules()` that uses `resolveDynamic()` for recursive condition evaluation. In `surfaceToSpec()`, evaluate checks and attach pre-computed `validationResult` props. Create a shared `A2uiValidationErrorsComponent` and update all `Checkable` catalog components.
+
+**Tech Stack:** Angular 19 (signals, inputs), Vitest, `@cacheplane/a2ui`, `@cacheplane/chat`, `@cacheplane/render`
+
+---
+
+### Task 1: Align types — `A2uiCheck` to `A2uiCheckRule`
+
+**Files:**
+- Modify: `libs/a2ui/src/lib/types.ts:51-58,66`
+- Modify: `libs/a2ui/src/index.ts:6,11`
+
+- [ ] **Step 1: Update the type definition**
+
+In `libs/a2ui/src/lib/types.ts`, replace the `A2uiCheck` interface (lines 51-58) with:
+
+```typescript
+// --- Validation (v0.9 CheckRule) ---
+
+export interface A2uiCheckRule {
+ condition: DynamicBoolean;
+ message: string;
+}
+```
+
+And update `A2uiComponent.checks` on line 66 from `A2uiCheck[]` to `A2uiCheckRule[]`:
+
+```typescript
+export interface A2uiComponent {
+ id: string;
+ component: string;
+ children?: A2uiChildList;
+ action?: A2uiAction;
+ checks?: A2uiCheckRule[];
+ [key: string]: unknown;
+}
+```
+
+- [ ] **Step 2: Update the public API export**
+
+In `libs/a2ui/src/index.ts`, replace `A2uiCheck` with `A2uiCheckRule` in the type export on line 6:
+
+```typescript
+export type {
+ A2uiTheme, A2uiPathRef, A2uiFunctionCall,
+ DynamicValue, DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList,
+ A2uiChildTemplate, A2uiChildList,
+ A2uiEventAction, A2uiLocalAction, A2uiAction, A2uiCheckRule,
+ A2uiComponent,
+ A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface,
+ A2uiMessage, A2uiSurface,
+} from './lib/types';
+```
+
+- [ ] **Step 3: Run type check to see what breaks**
+
+Run: `npx nx run a2ui:build 2>&1 | tail -20`
+
+Expected: Compilation errors in `validate.ts`, `validate.spec.ts`, `button.component.ts`, and `surface.component.spec.ts` referencing the old `A2uiCheck` type. These will be fixed in subsequent tasks.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add libs/a2ui/src/lib/types.ts libs/a2ui/src/index.ts
+git commit -m "feat(a2ui): align A2uiCheck to v0.9 CheckRule shape as A2uiCheckRule"
+```
+
+---
+
+### Task 2: Merge validation functions into unified `FUNCTIONS` registry and add `formatString`
+
+**Files:**
+- Modify: `libs/a2ui/src/lib/functions.ts:1-47`
+- Modify: `libs/a2ui/src/lib/functions.spec.ts:1-43`
+
+- [ ] **Step 1: Write failing tests for the new functions**
+
+Replace the full file `libs/a2ui/src/lib/functions.spec.ts` with:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { describe, it, expect } from 'vitest';
+import { executeFunction } from './functions';
+
+const model = { name: 'Alice', price: 1234.5, date: '2026-04-09', count: 1 };
+
+describe('executeFunction', () => {
+ // --- Validation functions ---
+ it('required passes for non-empty string', () => {
+ expect(executeFunction('required', { value: 'hello' }, model)).toBe(true);
+ });
+
+ it('required fails for empty string', () => {
+ expect(executeFunction('required', { value: '' }, model)).toBe(false);
+ });
+
+ it('required fails for null', () => {
+ expect(executeFunction('required', { value: null }, model)).toBe(false);
+ });
+
+ it('required fails for undefined', () => {
+ expect(executeFunction('required', { value: undefined }, model)).toBe(false);
+ });
+
+ it('regex passes for matching pattern', () => {
+ expect(executeFunction('regex', { value: 'abc123', pattern: '^[a-z]+\\d+$' }, model)).toBe(true);
+ });
+
+ it('regex fails for non-matching pattern', () => {
+ expect(executeFunction('regex', { value: '!!!', pattern: '^\\w+$' }, model)).toBe(false);
+ });
+
+ it('length passes within range', () => {
+ expect(executeFunction('length', { value: 'hello', min: 3, max: 10 }, model)).toBe(true);
+ });
+
+ it('length fails below min', () => {
+ expect(executeFunction('length', { value: 'hi', min: 3 }, model)).toBe(false);
+ });
+
+ it('length fails above max', () => {
+ expect(executeFunction('length', { value: 'toolong', max: 3 }, model)).toBe(false);
+ });
+
+ it('numeric passes in range', () => {
+ expect(executeFunction('numeric', { value: 5, min: 0, max: 10 }, model)).toBe(true);
+ });
+
+ it('numeric fails out of range', () => {
+ expect(executeFunction('numeric', { value: 20, min: 0, max: 10 }, model)).toBe(false);
+ });
+
+ it('numeric fails for NaN', () => {
+ expect(executeFunction('numeric', { value: 'abc' }, model)).toBe(false);
+ });
+
+ it('email passes for valid email', () => {
+ expect(executeFunction('email', { value: 'a@b.com' }, model)).toBe(true);
+ });
+
+ it('email fails for invalid email', () => {
+ expect(executeFunction('email', { value: 'not-email' }, model)).toBe(false);
+ });
+
+ // --- Formatting functions ---
+ it('formatString interpolates args', () => {
+ expect(executeFunction('formatString', { template: 'Hello ${name}!', name: 'Alice' }, model)).toBe('Hello Alice!');
+ });
+
+ it('formatString handles missing args', () => {
+ expect(executeFunction('formatString', { template: 'Hello ${name}!' }, model)).toBe('Hello !');
+ });
+
+ it('formatNumber with grouping', () => {
+ expect(executeFunction('formatNumber', { value: 1234, grouping: true }, model)).toMatch(/1.234/);
+ });
+
+ it('formatNumber with precision', () => {
+ expect(executeFunction('formatNumber', { value: 1234.5, precision: 2 }, model)).toBe('1234.50');
+ });
+
+ it('formatCurrency formats as USD by default', () => {
+ const result = executeFunction('formatCurrency', { value: 9.99 }, model) as string;
+ expect(result).toContain('9.99');
+ });
+
+ it('formatDate returns a date string', () => {
+ const result = executeFunction('formatDate', { value: '2026-04-09' }, model);
+ expect(typeof result).toBe('string');
+ expect(result).toBeTruthy();
+ });
+
+ it('pluralize singular', () => {
+ expect(executeFunction('pluralize', { count: 1, singular: 'item', plural: 'items' }, model)).toBe('item');
+ });
+
+ it('pluralize plural', () => {
+ expect(executeFunction('pluralize', { count: 3, singular: 'item', plural: 'items' }, model)).toBe('items');
+ });
+
+ // --- Logic functions ---
+ it('and returns true when all truthy (object args)', () => {
+ expect(executeFunction('and', { a: true, b: true }, model)).toBe(true);
+ });
+
+ it('and returns false when any falsy (object args)', () => {
+ expect(executeFunction('and', { a: true, b: false }, model)).toBe(false);
+ });
+
+ it('and returns true for values array all truthy', () => {
+ expect(executeFunction('and', { values: [true, true, true] }, model)).toBe(true);
+ });
+
+ it('and returns false for values array with falsy', () => {
+ expect(executeFunction('and', { values: [true, false, true] }, model)).toBe(false);
+ });
+
+ it('or returns true when any truthy', () => {
+ expect(executeFunction('or', { a: false, b: true }, model)).toBe(true);
+ });
+
+ it('or returns true for values array with any truthy', () => {
+ expect(executeFunction('or', { values: [false, true, false] }, model)).toBe(true);
+ });
+
+ it('or returns false for values array all falsy', () => {
+ expect(executeFunction('or', { values: [false, false] }, model)).toBe(false);
+ });
+
+ it('not inverts boolean', () => {
+ expect(executeFunction('not', { value: true }, model)).toBe(false);
+ });
+
+ // --- Navigation ---
+ it('openUrl returns null (no window in test)', () => {
+ expect(executeFunction('openUrl', { url: 'https://example.com' }, model)).toBeNull();
+ });
+
+ // --- Unknown ---
+ it('returns null for unknown function', () => {
+ expect(executeFunction('unknownFn', {}, model)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify the new validation function tests fail**
+
+Run: `npx nx test a2ui -- --reporter=verbose 2>&1 | tail -30`
+
+Expected: New tests for `required`, `regex`, `length`, `numeric`, `email`, `formatString` FAIL. Existing tests should still pass.
+
+- [ ] **Step 3: Add validation functions and `formatString` to the unified registry**
+
+Replace the full file `libs/a2ui/src/lib/functions.ts` with:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+
+type FnExecutor = (args: Record, model: Record) => unknown;
+
+const FUNCTIONS: Record = {
+ // Validation functions
+ required: (args) => args['value'] != null && String(args['value']).trim() !== '',
+ regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'] ?? '')),
+ length: (args) => {
+ const len = String(args['value'] ?? '').length;
+ return len >= Number(args['min'] ?? 0) && len <= Number(args['max'] ?? Infinity);
+ },
+ numeric: (args) => {
+ const n = Number(args['value']);
+ return !isNaN(n) && n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity);
+ },
+ email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'] ?? '')),
+
+ // Formatting functions
+ formatString: (args) => {
+ const template = String(args['template'] ?? '');
+ return template.replace(/\$\{(\w+)\}/g, (_, key) => String(args[key] ?? ''));
+ },
+ formatNumber: (args) => {
+ const n = Number(args['value']);
+ const precision = Number(args['precision'] ?? 0);
+ if (args['grouping']) {
+ return n.toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision });
+ }
+ return n.toFixed(precision);
+ },
+ formatCurrency: (args) => {
+ return Number(args['value']).toLocaleString(
+ String(args['locale'] ?? 'en-US'),
+ { style: 'currency', currency: String(args['currency'] ?? 'USD') },
+ );
+ },
+ formatDate: (args) => {
+ return new Date(String(args['value'])).toLocaleDateString(
+ String(args['locale'] ?? undefined),
+ );
+ },
+ pluralize: (args) => {
+ return Number(args['count']) === 1 ? String(args['singular']) : String(args['plural']);
+ },
+
+ // Logic functions
+ and: (args) => {
+ const values = args['values'];
+ return Array.isArray(values) ? values.every(Boolean) : Object.values(args).every(Boolean);
+ },
+ or: (args) => {
+ const values = args['values'];
+ return Array.isArray(values) ? values.some(Boolean) : Object.values(args).some(Boolean);
+ },
+ not: (args) => !args['value'],
+
+ // Navigation
+ openUrl: (args) => {
+ if (typeof globalThis.window !== 'undefined') {
+ globalThis.window.open(String(args['url']), '_blank');
+ }
+ return null;
+ },
+};
+
+export function executeFunction(
+ name: string,
+ args: Record,
+ model: Record,
+): unknown {
+ const fn = FUNCTIONS[name];
+ if (!fn) return null;
+ return fn(args, model);
+}
+```
+
+- [ ] **Step 4: Run tests to verify all pass**
+
+Run: `npx nx test a2ui -- --reporter=verbose --testPathPattern=functions 2>&1 | tail -30`
+
+Expected: All tests PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add libs/a2ui/src/lib/functions.ts libs/a2ui/src/lib/functions.spec.ts
+git commit -m "feat(a2ui): add validation functions and formatString to unified registry"
+```
+
+---
+
+### Task 3: Add array resolution to `resolveDynamic()`
+
+**Files:**
+- Modify: `libs/a2ui/src/lib/resolve.ts:42-73`
+- Modify: `libs/a2ui/src/lib/resolve.spec.ts`
+
+- [ ] **Step 1: Write failing tests for array resolution**
+
+Add the following tests at the end of the `describe('resolveDynamic', ...)` block in `libs/a2ui/src/lib/resolve.spec.ts`:
+
+```typescript
+ it('resolves arrays by recursing into each element', () => {
+ const arr = [
+ { path: '/user/name' },
+ 'literal',
+ 42,
+ ];
+ expect(resolveDynamic(arr, model)).toEqual(['Alice', 'literal', 42]);
+ });
+
+ it('resolves nested function calls in arrays', () => {
+ const arr = [
+ { call: 'pluralize', args: { count: 1, singular: 'cat', plural: 'cats' } },
+ { call: 'pluralize', args: { count: 2, singular: 'dog', plural: 'dogs' } },
+ ];
+ expect(resolveDynamic(arr, model)).toEqual(['cat', 'dogs']);
+ });
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npx nx test a2ui -- --reporter=verbose --testPathPattern=resolve 2>&1 | tail -20`
+
+Expected: The two new tests FAIL — arrays currently pass through as literals without resolving elements.
+
+- [ ] **Step 3: Add array handling to `resolveDynamic()`**
+
+In `libs/a2ui/src/lib/resolve.ts`, add the following block inside `resolveDynamic()` after the `if (value == null)` check (after line 47) and before the `isPathRef` check:
+
+```typescript
+ // Array — recurse into each element
+ if (Array.isArray(value)) {
+ return value.map(item => resolveDynamic(item, model, scope));
+ }
+```
+
+The full function should now read:
+
+```typescript
+export function resolveDynamic(
+ value: unknown,
+ model: Record,
+ scope?: A2uiScope,
+): unknown {
+ if (value == null) return value;
+
+ // Array — recurse into each element
+ if (Array.isArray(value)) {
+ return value.map(item => resolveDynamic(item, model, scope));
+ }
+
+ // Path reference
+ if (isPathRef(value)) {
+ return resolvePathRef(value, model, scope);
+ }
+
+ // Function call — execute registered function
+ if (isFunctionCall(value)) {
+ const fc = value as A2uiFunctionCall;
+ // Resolve args that may themselves be dynamic
+ const resolvedArgs: Record = {};
+ for (const [k, v] of Object.entries(fc.args)) {
+ resolvedArgs[k] = resolveDynamic(v, model, scope);
+ }
+ const result = executeFunction(fc.call, resolvedArgs, model);
+ return result ?? `[${fc.call}]`;
+ }
+
+ // Template string interpolation
+ if (typeof value === 'string' && value.includes('${')) {
+ return interpolateTemplate(value, model, scope);
+ }
+
+ // Literal passthrough
+ return value;
+}
+```
+
+- [ ] **Step 4: Run tests to verify all pass**
+
+Run: `npx nx test a2ui -- --reporter=verbose --testPathPattern=resolve 2>&1 | tail -20`
+
+Expected: All tests PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add libs/a2ui/src/lib/resolve.ts libs/a2ui/src/lib/resolve.spec.ts
+git commit -m "feat(a2ui): add array resolution to resolveDynamic for nested DynamicValue arrays"
+```
+
+---
+
+### Task 4: Replace `validateChecks()` with `evaluateCheckRules()`
+
+**Files:**
+- Modify: `libs/a2ui/src/lib/validate.ts:1-37`
+- Modify: `libs/a2ui/src/lib/validate.spec.ts:1-71`
+- Modify: `libs/a2ui/src/index.ts:17-18`
+
+- [ ] **Step 1: Write the new tests**
+
+Replace the full file `libs/a2ui/src/lib/validate.spec.ts` with:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { describe, it, expect } from 'vitest';
+import { evaluateCheckRules } from './validate';
+import type { A2uiCheckRule } from './types';
+
+describe('evaluateCheckRules', () => {
+ const model = { name: 'Alice', email: 'alice@example.com', zip: '', agreed: true };
+
+ it('passes when condition evaluates to true', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Name required' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('fails when condition evaluates to false', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'required', args: { value: { path: '/zip' } } }, message: 'Zip required' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: false, errors: ['Zip required'] });
+ });
+
+ it('resolves path references in condition args', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'email', args: { value: { path: '/email' } } }, message: 'Invalid email' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('supports boolean literal conditions', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: true, message: 'Always passes' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('supports path ref conditions (boolean in data model)', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { path: '/agreed' }, message: 'Must agree' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('handles falsy path ref conditions', () => {
+ const modelWithFalse = { ...model, agreed: false };
+ const checks: A2uiCheckRule[] = [
+ { condition: { path: '/agreed' }, message: 'Must agree' },
+ ];
+ expect(evaluateCheckRules(checks, modelWithFalse)).toEqual({ valid: false, errors: ['Must agree'] });
+ });
+
+ it('supports nested and/or composition', () => {
+ const checks: A2uiCheckRule[] = [
+ {
+ condition: {
+ call: 'and',
+ args: {
+ values: [
+ { call: 'required', args: { value: { path: '/name' } } },
+ { call: 'email', args: { value: { path: '/email' } } },
+ ],
+ },
+ },
+ message: 'Name and valid email required',
+ },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('nested and fails when inner condition fails', () => {
+ const checks: A2uiCheckRule[] = [
+ {
+ condition: {
+ call: 'and',
+ args: {
+ values: [
+ { call: 'required', args: { value: { path: '/name' } } },
+ { call: 'required', args: { value: { path: '/zip' } } },
+ ],
+ },
+ },
+ message: 'All fields required',
+ },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: false, errors: ['All fields required'] });
+ });
+
+ it('collects multiple errors from multiple checks', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'required', args: { value: { path: '/zip' } } }, message: 'Zip required' },
+ { condition: false, message: 'Always fails' },
+ ];
+ const result = evaluateCheckRules(checks, model);
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual(['Zip required', 'Always fails']);
+ });
+
+ it('returns valid for empty checks array', () => {
+ expect(evaluateCheckRules([], model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('supports regex with path-resolved value', () => {
+ const checks: A2uiCheckRule[] = [
+ {
+ condition: { call: 'regex', args: { value: { path: '/name' }, pattern: '^[A-Z]' } },
+ message: 'Must start with uppercase',
+ },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npx nx test a2ui -- --reporter=verbose --testPathPattern=validate 2>&1 | tail -20`
+
+Expected: FAIL — `evaluateCheckRules` does not exist yet, `A2uiCheck` import gone.
+
+- [ ] **Step 3: Implement `evaluateCheckRules()`**
+
+Replace the full file `libs/a2ui/src/lib/validate.ts` with:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import type { A2uiCheckRule } from './types';
+import { resolveDynamic } from './resolve';
+
+export interface A2uiValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+
+export function evaluateCheckRules(
+ checks: A2uiCheckRule[],
+ model: Record,
+): A2uiValidationResult {
+ const errors: string[] = [];
+ for (const check of checks) {
+ const result = resolveDynamic(check.condition, model);
+ if (!result) errors.push(check.message);
+ }
+ return { valid: errors.length === 0, errors };
+}
+```
+
+- [ ] **Step 4: Update the public API export**
+
+In `libs/a2ui/src/index.ts`, replace lines 17-18:
+
+```typescript
+export { evaluateCheckRules } from './lib/validate';
+export type { A2uiValidationResult } from './lib/validate';
+```
+
+- [ ] **Step 5: Run tests to verify all pass**
+
+Run: `npx nx test a2ui -- --reporter=verbose 2>&1 | tail -30`
+
+Expected: All tests PASS (functions, resolve, validate, parser, pointer)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add libs/a2ui/src/lib/validate.ts libs/a2ui/src/lib/validate.spec.ts libs/a2ui/src/index.ts
+git commit -m "feat(a2ui): replace validateChecks with evaluateCheckRules using resolveDynamic"
+```
+
+---
+
+### Task 5: Wire `evaluateCheckRules()` into `surfaceToSpec()`
+
+**Files:**
+- Modify: `libs/chat/src/lib/a2ui/surface.component.ts:1-10,27,60-63`
+- Modify: `libs/chat/src/lib/a2ui/surface.component.spec.ts`
+
+- [ ] **Step 1: Write failing tests for `validationResult` in spec output**
+
+Add the following describe block at the end of `libs/chat/src/lib/a2ui/surface.component.spec.ts`:
+
+```typescript
+describe('surfaceToSpec — validation', () => {
+ function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface {
+ const map = new Map();
+ for (const c of components) map.set(c.id, c);
+ return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel };
+ }
+
+ it('evaluates checks and attaches validationResult prop', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'TextField', label: 'Name',
+ value: { path: '/name' },
+ checks: [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Name required' },
+ ],
+ },
+ ],
+ { name: 'Alice' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toEqual({ valid: true, errors: [] });
+ });
+
+ it('attaches failing validationResult when check fails', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'TextField', label: 'Name',
+ value: { path: '/name' },
+ checks: [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Name required' },
+ ],
+ },
+ ],
+ { name: '' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toEqual({ valid: false, errors: ['Name required'] });
+ });
+
+ it('evaluates composite and condition', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'Button', label: 'Submit',
+ checks: [
+ {
+ condition: {
+ call: 'and',
+ args: {
+ values: [
+ { call: 'required', args: { value: { path: '/name' } } },
+ { call: 'email', args: { value: { path: '/email' } } },
+ ],
+ },
+ },
+ message: 'All fields required',
+ },
+ ],
+ },
+ ],
+ { name: 'Alice', email: 'alice@example.com' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toEqual({ valid: true, errors: [] });
+ });
+
+ it('does not attach validationResult when no checks defined', () => {
+ const surface = makeSurface([
+ { id: 'root', component: 'Text', text: 'Hello' },
+ ]);
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toBeUndefined();
+ });
+
+ it('does not pass raw checks as props', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'TextField', label: 'Name',
+ checks: [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Required' },
+ ],
+ },
+ ],
+ { name: 'Alice' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['checks']).toBeUndefined();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify the new tests fail**
+
+Run: `npx nx test chat -- --reporter=verbose --testPathPattern=surface 2>&1 | tail -20`
+
+Expected: FAIL — `validationResult` prop is not attached, raw `checks` prop is still being passed.
+
+- [ ] **Step 3: Update `surfaceToSpec()` to evaluate checks**
+
+In `libs/chat/src/lib/a2ui/surface.component.ts`, add the import for `evaluateCheckRules` at the top (line 7):
+
+```typescript
+import { resolveDynamic, getByPointer, evaluateCheckRules } from '@cacheplane/a2ui';
+```
+
+Then replace the checks passthrough block (lines 60-63):
+
+```typescript
+ // Pass checks through
+ if (comp.checks) {
+ props['checks'] = comp.checks;
+ }
+```
+
+With:
+
+```typescript
+ // Evaluate checks and attach pre-computed validation result
+ if (comp.checks) {
+ props['validationResult'] = evaluateCheckRules(comp.checks, surface.dataModel);
+ }
+```
+
+- [ ] **Step 4: Run tests to verify all pass**
+
+Run: `npx nx test chat -- --reporter=verbose --testPathPattern=surface 2>&1 | tail -20`
+
+Expected: All tests PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add libs/chat/src/lib/a2ui/surface.component.ts libs/chat/src/lib/a2ui/surface.component.spec.ts
+git commit -m "feat(chat): evaluate checks in surfaceToSpec, attach pre-computed validationResult"
+```
+
+---
+
+### Task 6: Create shared `A2uiValidationErrorsComponent`
+
+**Files:**
+- Create: `libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts`
+
+- [ ] **Step 1: Create the shared component**
+
+Create `libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+
+@Component({
+ selector: 'a2ui-validation-errors',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+ @if (!result().valid) {
+ @for (error of result().errors; track error) {
+ {{ error }}
+ }
+ }
+ `,
+})
+export class A2uiValidationErrorsComponent {
+ readonly result = input({ valid: true, errors: [] });
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `npx nx build chat 2>&1 | tail -10`
+
+Expected: Build succeeds (component is not yet imported anywhere, but should compile cleanly).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts
+git commit -m "feat(chat): add shared A2uiValidationErrorsComponent for inline error display"
+```
+
+---
+
+### Task 7: Update catalog components to use `validationResult`
+
+**Files:**
+- Modify: `libs/chat/src/lib/a2ui/catalog/button.component.ts:1-34`
+- Modify: `libs/chat/src/lib/a2ui/catalog/text-field.component.ts:1-35`
+- Modify: `libs/chat/src/lib/a2ui/catalog/check-box.component.ts:1-27`
+- Modify: `libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts:1-32`
+- Modify: `libs/chat/src/lib/a2ui/catalog/slider.component.ts:1-39`
+- Modify: `libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts:1-39`
+
+- [ ] **Step 1: Update `A2uiButtonComponent`**
+
+Replace the full file `libs/chat/src/lib/a2ui/catalog/button.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
+
+@Component({
+ selector: 'a2ui-button',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ `,
+})
+export class A2uiButtonComponent {
+ readonly label = input('');
+ readonly variant = input('primary');
+ readonly disabled = input(false);
+ readonly validationResult = input({ valid: true, errors: [] });
+ readonly emit = input<(event: string) => void>(() => { /* noop */ });
+
+ handleClick(): void {
+ this.emit()('click');
+ }
+}
+```
+
+- [ ] **Step 2: Update `A2uiTextFieldComponent`**
+
+Replace the full file `libs/chat/src/lib/a2ui/catalog/text-field.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
+
+@Component({
+ selector: 'a2ui-text-field',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (label()) {
}
+
+
+
+ `,
+})
+export class A2uiTextFieldComponent {
+ readonly label = input('');
+ readonly value = input('');
+ readonly placeholder = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
+ readonly _bindings = input>({});
+ readonly emit = input<(event: string) => void>(() => { /* noop */ });
+
+ onInput(event: Event): void {
+ const val = (event.target as HTMLInputElement).value;
+ const path = this._bindings()?.['value'];
+ if (path) {
+ this.emit()(`a2ui:datamodel:${path}:${val}`);
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Update `A2uiCheckBoxComponent`**
+
+Replace the full file `libs/chat/src/lib/a2ui/catalog/check-box.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
+
+@Component({
+ selector: 'a2ui-check-box',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+
+ `,
+})
+export class A2uiCheckBoxComponent {
+ readonly label = input('');
+ readonly checked = input(false);
+ readonly validationResult = input({ valid: true, errors: [] });
+ readonly _bindings = input>({});
+ readonly emit = input<(event: string) => void>(() => { /* noop */ });
+
+ onChange(event: Event): void {
+ const val = (event.target as HTMLInputElement).checked;
+ const path = this._bindings()?.['checked'];
+ if (path) {
+ this.emit()(`a2ui:datamodel:${path}:${val}`);
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Update `A2uiChoicePickerComponent`**
+
+Replace the full file `libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
+
+@Component({
+ selector: 'a2ui-choice-picker',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (label()) {
}
+
+
+
+ `,
+})
+export class A2uiChoicePickerComponent {
+ readonly label = input('');
+ readonly options = input([]);
+ readonly selected = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
+ readonly _bindings = input>({});
+ readonly emit = input<(event: string) => void>(() => { /* noop */ });
+
+ onChange(event: Event): void {
+ const val = (event.target as HTMLSelectElement).value;
+ const path = this._bindings()?.['selected'];
+ if (path) {
+ this.emit()(`a2ui:datamodel:${path}:${val}`);
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Update `A2uiSliderComponent`**
+
+Replace the full file `libs/chat/src/lib/a2ui/catalog/slider.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
+
+@Component({
+ selector: 'a2ui-slider',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (label()) {
+
+ }
+
+
+
+ `,
+})
+export class A2uiSliderComponent {
+ readonly label = input('');
+ readonly value = input(0);
+ readonly min = input(0);
+ readonly max = input(100);
+ readonly step = input(1);
+ readonly validationResult = input({ valid: true, errors: [] });
+ readonly _bindings = input>({});
+ readonly emit = input<(event: string) => void>(() => { /* noop */ });
+
+ onInput(event: Event): void {
+ const val = Number((event.target as HTMLInputElement).value);
+ const path = this._bindings()?.['value'];
+ if (path) {
+ this.emit()(`a2ui:datamodel:${path}:${val}`);
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Update `A2uiDateTimeInputComponent`**
+
+Replace the full file `libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts`:
+
+```typescript
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
+
+@Component({
+ selector: 'a2ui-date-time-input',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (label()) {
+
+ }
+
+
+
+ `,
+})
+export class A2uiDateTimeInputComponent {
+ readonly label = input('');
+ readonly value = input('');
+ readonly inputType = input<'date' | 'time' | 'datetime-local'>('date');
+ readonly min = input('');
+ readonly max = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
+ readonly _bindings = input>({});
+ readonly emit = input<(event: string) => void>(() => { /* noop */ });
+
+ onChange(event: Event): void {
+ const val = (event.target as HTMLInputElement).value;
+ const path = this._bindings()?.['value'];
+ if (path) {
+ this.emit()(`a2ui:datamodel:${path}:${val}`);
+ }
+ }
+}
+```
+
+- [ ] **Step 7: Run all chat tests to verify**
+
+Run: `npx nx test chat -- --reporter=verbose 2>&1 | tail -30`
+
+Expected: All tests PASS
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add libs/chat/src/lib/a2ui/catalog/button.component.ts libs/chat/src/lib/a2ui/catalog/text-field.component.ts libs/chat/src/lib/a2ui/catalog/check-box.component.ts libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts libs/chat/src/lib/a2ui/catalog/slider.component.ts libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts
+git commit -m "feat(chat): update catalog components to use pre-computed validationResult"
+```
+
+---
+
+### Task 8: Update cockpit example with v0.9 CheckRule validation
+
+**Files:**
+- Modify: `cockpit/chat/a2ui/python/src/graph.py:14-43`
+
+- [ ] **Step 1: Update the contact form JSONL to include checks**
+
+Replace the `CONTACT_FORM_JSONL` variable in `cockpit/chat/a2ui/python/src/graph.py` (lines 14-43):
+
+```python
+CONTACT_FORM_JSONL = A2UI_PREFIX + "\n" + "\n".join([
+ json.dumps({"type": "createSurface", "surfaceId": "contact", "catalogId": "basic"}),
+ json.dumps({"type": "updateDataModel", "surfaceId": "contact", "value": {
+ "name": "", "email": "", "department": "Engineering", "consent": False,
+ }}),
+ json.dumps({"type": "updateComponents", "surfaceId": "contact", "components": [
+ {"id": "root", "component": "Column", "children": ["card"]},
+ {"id": "card", "component": "Card", "title": "Contact Us", "children": [
+ "name_field", "email_field", "dept_picker", "consent_check", "divider", "submit_btn",
+ ]},
+ {"id": "name_field", "component": "TextField",
+ "label": "Name", "value": {"path": "/name"}, "placeholder": "Your full name",
+ "_bindings": {"value": "/name"},
+ "checks": [
+ {"condition": {"call": "required", "args": {"value": {"path": "/name"}}},
+ "message": "Name is required"},
+ ]},
+ {"id": "email_field", "component": "TextField",
+ "label": "Email", "value": {"path": "/email"}, "placeholder": "you@company.com",
+ "_bindings": {"value": "/email"},
+ "checks": [
+ {"condition": {"call": "required", "args": {"value": {"path": "/email"}}},
+ "message": "Email is required"},
+ {"condition": {"call": "email", "args": {"value": {"path": "/email"}}},
+ "message": "Must be a valid email address"},
+ ]},
+ {"id": "dept_picker", "component": "ChoicePicker",
+ "label": "Department",
+ "options": ["Engineering", "Sales", "Support", "Marketing"],
+ "selected": {"path": "/department"},
+ "_bindings": {"selected": "/department"}},
+ {"id": "consent_check", "component": "CheckBox",
+ "label": "I agree to be contacted", "checked": {"path": "/consent"},
+ "_bindings": {"checked": "/consent"}},
+ {"id": "divider", "component": "Divider"},
+ {"id": "submit_btn", "component": "Button",
+ "label": "Submit",
+ "checks": [
+ {"condition": {"call": "and", "args": {"values": [
+ {"call": "required", "args": {"value": {"path": "/name"}}},
+ {"call": "email", "args": {"value": {"path": "/email"}}},
+ {"path": "/consent"},
+ ]}},
+ "message": "Complete all required fields and agree to be contacted"},
+ ],
+ "action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}},
+ ]}),
+])
+```
+
+- [ ] **Step 2: Verify the Python graph still works**
+
+Run: `cd cockpit/chat/a2ui/python && python -c "from src.graph import graph; print('Graph compiled:', graph is not None)" 2>&1`
+
+Expected: `Graph compiled: True`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cockpit/chat/a2ui/python/src/graph.py
+git commit -m "feat(cockpit): add v0.9 CheckRule validation to A2UI contact form example"
+```
+
+---
+
+### Task 9: Update documentation
+
+**Files:**
+- Modify: `apps/website/content/docs/chat/a2ui/overview.mdx`
+
+- [ ] **Step 1: Add Validation section to A2UI overview**
+
+In `apps/website/content/docs/chat/a2ui/overview.mdx`, add the following section before the `## What's Next` heading:
+
+```markdown
+## Validation
+
+A2UI v0.9 uses `CheckRule` objects for client-side validation. Input components and buttons can define a `checks` array — each check has a `condition` (a `DynamicBoolean`) and an error `message`.
+
+### CheckRule Shape
+
+```json
+{
+ "checks": [
+ {
+ "condition": { "call": "required", "args": { "value": { "path": "/name" } } },
+ "message": "Name is required"
+ }
+ ]
+}
+```
+
+The `condition` can be:
+- A **boolean literal**: `true` or `false`
+- A **path reference**: `{ "path": "/agreed" }` — resolves to a data model value
+- A **FunctionCall**: `{ "call": "required", "args": { ... } }` — invokes a named function
+- A **composite**: `{ "call": "and", "args": { "values": [...] } }` — combines multiple conditions
+
+### Built-in Functions
+
+| Category | Functions |
+|----------|-----------|
+| Validation | `required`, `regex`, `length`, `numeric`, `email` |
+| Logic | `and`, `or`, `not` |
+| Formatting | `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize` |
+| Navigation | `openUrl` |
+
+### Input Component Behavior
+
+Input components (`TextField`, `CheckBox`, `ChoicePicker`, `Slider`, `DateTimeInput`) validate continuously — errors display inline as the user interacts. The input border changes color to indicate validation state.
+
+### Button Behavior
+
+Per the v0.9 spec: if any check fails, the button is automatically disabled. Error messages display below the button.
+
+### Composite Conditions
+
+Use `and`, `or`, and `not` to compose complex validation rules:
+
+```json
+{
+ "condition": {
+ "call": "and",
+ "args": {
+ "values": [
+ { "call": "required", "args": { "value": { "path": "/name" } } },
+ { "call": "or", "args": { "values": [
+ { "call": "required", "args": { "value": { "path": "/email" } } },
+ { "call": "required", "args": { "value": { "path": "/phone" } } }
+ ]}}
+ ]
+ }
+ },
+ "message": "Name required, plus email or phone"
+}
+```
+
+### Custom Catalog Components
+
+Custom catalog components receive a pre-computed `validationResult` prop:
+
+```typescript
+interface A2uiValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+```
+
+Use the shared `A2uiValidationErrorsComponent` for consistent error display:
+
+```typescript
+import { A2uiValidationErrorsComponent } from '@cacheplane/chat';
+
+@Component({
+ imports: [A2uiValidationErrorsComponent],
+ template: `
+
+
+ `,
+})
+export class MyCustomInputComponent {
+ readonly value = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
+}
+```
+
+### Theming
+
+Validation styling uses CSS custom properties:
+
+| Property | Default | Usage |
+|----------|---------|-------|
+| `--a2ui-error` | `#ef4444` | Error text and invalid border color |
+| `--a2ui-border` | `rgba(255,255,255,0.1)` | Default input border |
+| `--a2ui-input-bg` | `rgba(255,255,255,0.05)` | Input background |
+| `--a2ui-label` | `rgba(255,255,255,0.6)` | Label text color |
+```
+
+- [ ] **Step 2: Export `A2uiValidationErrorsComponent` from chat library**
+
+Check whether `A2uiValidationErrorsComponent` needs to be exported for custom catalog components. Read the chat library's public API barrel file and add an export if needed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/website/content/docs/chat/a2ui/overview.mdx
+git commit -m "docs: add A2UI validation section with v0.9 CheckRule, functions, and theming"
+```
diff --git a/docs/superpowers/specs/2026-04-10-a2ui-validation-design.md b/docs/superpowers/specs/2026-04-10-a2ui-validation-design.md
new file mode 100644
index 000000000..6e65da75e
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-10-a2ui-validation-design.md
@@ -0,0 +1,411 @@
+# A2UI Validation — v0.9 Spec Alignment Design
+
+**Date:** 2026-04-10
+**Status:** Approved
+
+## Overview
+
+Align the A2UI validation system with the v0.9 spec's `CheckRule` shape, build a complete function evaluation engine covering all 14 basic catalog functions, and wire pre-computed validation results into catalog components with inline error display. This makes input components validate continuously and buttons auto-disable when checks fail, matching the v0.9 renderer contract.
+
+## 1. Type Alignment — `A2uiCheck` to `A2uiCheckRule`
+
+### Problem
+
+Our `A2uiCheck` type uses a flattened `{ call, args, message }` shape. The v0.9 spec defines `CheckRule` as `{ condition: DynamicBoolean, message: string }` where `condition` is a `DynamicBoolean` — a boolean literal, a `{ path }` ref, or a `FunctionCall`. Our shape doesn't support composite conditions (`and`/`or`/`not`) or path-ref conditions.
+
+### Solution
+
+Replace `A2uiCheck` with `A2uiCheckRule`:
+
+```typescript
+// libs/a2ui/src/lib/types.ts
+
+// Remove:
+export interface A2uiCheck {
+ call: string;
+ args: Record;
+ message: string;
+}
+
+// Add:
+export interface A2uiCheckRule {
+ condition: DynamicBoolean;
+ message: string;
+}
+```
+
+`A2uiComponent.checks` changes from `A2uiCheck[]` to `A2uiCheckRule[]`. All references update across the codebase: public API exports in `libs/a2ui/src/index.ts`, `surfaceToSpec()`, `A2uiButtonComponent`, and `validateChecks`.
+
+**v0.9 spec reference:** `common_types.json#/$defs/CheckRule` — `{ condition: DynamicBoolean, message: string }`. The `condition` field accepts:
+- Boolean literal: `true` / `false`
+- Path reference: `{ "path": "/formData/agreed" }`
+- FunctionCall: `{ "call": "required", "args": { "value": { "path": "/name" } } }`
+- Nested composition: `{ "call": "and", "args": { "values": [...] } }`
+
+**Files:**
+- `libs/a2ui/src/lib/types.ts` — rename type, update `A2uiComponent.checks`
+- `libs/a2ui/src/index.ts` — update export
+- All consumers of `A2uiCheck`
+
+## 2. Function Evaluation Engine — All 14 Basic Catalog Functions
+
+### Problem
+
+Functions are split across two files with two registries:
+- `functions.ts` has 8 functions: `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `openUrl`, `and`, `or`, `not`
+- `validate.ts` has 5 validators: `required`, `regex`, `length`, `numeric`, `email`
+
+Missing: `formatString` (the 14th function). The split means validation can't use the recursive `resolveDynamic()` path, and `and`/`or`/`not` in `functions.ts` operate on pre-resolved args but aren't wired to validation.
+
+### Solution
+
+Merge all 14 functions into the single `FUNCTIONS` registry in `functions.ts`:
+
+```typescript
+// libs/a2ui/src/lib/functions.ts — unified registry
+
+const FUNCTIONS: Record = {
+ // Validation functions (moved from validate.ts)
+ required: (args) => args['value'] != null && String(args['value']).trim() !== '',
+ regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'] ?? '')),
+ length: (args) => {
+ const len = String(args['value'] ?? '').length;
+ return len >= Number(args['min'] ?? 0) && len <= Number(args['max'] ?? Infinity);
+ },
+ numeric: (args) => {
+ const n = Number(args['value']);
+ return !isNaN(n) && n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity);
+ },
+ email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'] ?? '')),
+
+ // Formatting functions
+ formatString: (args) => {
+ const template = String(args['template'] ?? '');
+ return template.replace(/\$\{(\w+)\}/g, (_, key) => String(args[key] ?? ''));
+ },
+ formatNumber: (args) => {
+ const n = Number(args['value']);
+ const precision = Number(args['precision'] ?? 0);
+ if (args['grouping']) {
+ return n.toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision });
+ }
+ return n.toFixed(precision);
+ },
+ formatCurrency: (args) => {
+ return Number(args['value']).toLocaleString(
+ String(args['locale'] ?? 'en-US'),
+ { style: 'currency', currency: String(args['currency'] ?? 'USD') },
+ );
+ },
+ formatDate: (args) => {
+ return new Date(String(args['value'])).toLocaleDateString(
+ String(args['locale'] ?? undefined),
+ );
+ },
+ pluralize: (args) => {
+ return Number(args['count']) === 1 ? String(args['singular']) : String(args['plural']);
+ },
+
+ // Logic functions
+ and: (args) => {
+ const values = args['values'];
+ return Array.isArray(values) ? values.every(Boolean) : Object.values(args).every(Boolean);
+ },
+ or: (args) => {
+ const values = args['values'];
+ return Array.isArray(values) ? values.some(Boolean) : Object.values(args).some(Boolean);
+ },
+ not: (args) => !args['value'],
+
+ // Navigation
+ openUrl: (args) => {
+ if (typeof globalThis.window !== 'undefined') {
+ globalThis.window.open(String(args['url']), '_blank');
+ }
+ return null;
+ },
+};
+```
+
+**Key insight:** `resolveDynamic()` handles the recursion needed for nested `FunctionCall` conditions. A `condition` of `{ call: 'and', args: { values: [{ call: 'required', args: { value: { path: '/name' } } }] } }` resolves naturally: `resolveDynamic` resolves the nested args (path refs, nested function calls), then calls `executeFunction('and', resolvedArgs, model)`.
+
+**Required fix:** `resolveDynamic()` currently doesn't recurse into arrays — an arg like `values: [{ call: 'required', ... }]` hits the literal passthrough. Add array handling:
+
+```typescript
+// libs/a2ui/src/lib/resolve.ts — inside resolveDynamic(), before literal passthrough
+if (Array.isArray(value)) {
+ return value.map(item => resolveDynamic(item, model, scope));
+}
+```
+
+This enables `and`/`or` to receive arrays of resolved boolean values from nested function calls.
+
+**`and`/`or` update:** The current `and`/`or` use `Object.values(args).every(Boolean)`. For v0.9, the `values` arg is an array of DynamicBooleans. After `resolveDynamic` resolves the array elements, `and` checks `args['values'].every(Boolean)`. We support both shapes for backwards compatibility.
+
+**v0.9 spec reference:** `basic_catalog.json#/functions` — 14 named functions: `required`, `regex`, `length`, `numeric`, `email`, `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`, `openUrl`.
+
+**Files:**
+- `libs/a2ui/src/lib/functions.ts` — add 6 functions (`required`, `regex`, `length`, `numeric`, `email`, `formatString`), update `and`/`or`
+- `libs/a2ui/src/lib/resolve.ts` — add array handling in `resolveDynamic()` for nested DynamicValue arrays
+
+## 3. `validate.ts` — Thin Wrapper Using `resolveDynamic`
+
+### Problem
+
+`validateChecks()` operates on the old `A2uiCheck` shape with flat args, no path resolution, no recursion.
+
+### Solution
+
+Replace with `evaluateCheckRules()` that uses `resolveDynamic` for condition evaluation:
+
+```typescript
+// libs/a2ui/src/lib/validate.ts
+import type { A2uiCheckRule } from './types';
+import { resolveDynamic } from './resolve';
+
+export interface A2uiValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+
+export function evaluateCheckRules(
+ checks: A2uiCheckRule[],
+ model: Record,
+): A2uiValidationResult {
+ const errors: string[] = [];
+ for (const check of checks) {
+ const result = resolveDynamic(check.condition, model);
+ if (!result) errors.push(check.message);
+ }
+ return { valid: errors.length === 0, errors };
+}
+```
+
+`resolveDynamic(check.condition, model)` handles all condition shapes:
+- Boolean literal: returns the boolean
+- Path ref `{ path: '/agreed' }`: resolves to data model value (truthy/falsy)
+- FunctionCall `{ call: 'required', ... }`: resolves args recursively, executes function, returns boolean
+- Nested `{ call: 'and', args: { values: [...] } }`: recursive resolution, then logical evaluation
+
+**Public API change:** Export `evaluateCheckRules` and `A2uiCheckRule` instead of `validateChecks` and `A2uiCheck`.
+
+**Files:**
+- `libs/a2ui/src/lib/validate.ts` — replace `validateChecks` with `evaluateCheckRules`
+- `libs/a2ui/src/index.ts` — update exports
+
+## 4. `surfaceToSpec()` — Pre-computed Validation Results
+
+### Problem
+
+`surfaceToSpec()` currently passes raw `checks` through as props. Catalog components don't have access to the data model to resolve path refs in check args.
+
+### Solution
+
+Evaluate checks during spec conversion, attach pre-computed `validationResult` as a prop:
+
+```typescript
+// libs/chat/src/lib/a2ui/surface.component.ts — inside surfaceToSpec()
+
+// Replace:
+if (comp.checks) {
+ props['checks'] = comp.checks;
+}
+
+// With:
+if (comp.checks) {
+ props['validationResult'] = evaluateCheckRules(comp.checks, surface.dataModel);
+}
+```
+
+**Reactivity:** `surfaceToSpec()` runs inside a `computed` signal on `A2uiSurfaceComponent`. Any data model change (user typing, checkbox toggle, slider drag) re-triggers spec conversion, which re-evaluates all checks, producing fresh `validationResult` props. Continuous validation comes for free through Angular's signal reactivity.
+
+**Custom catalog components** receive the same `validationResult: { valid: boolean, errors: string[] }` prop. They never need to import the evaluation engine or access the data model.
+
+**Files:**
+- `libs/chat/src/lib/a2ui/surface.component.ts` — replace raw checks passthrough with `evaluateCheckRules()` call
+
+## 5. Shared Validation Errors Component
+
+### Problem
+
+Each catalog component would need to duplicate error rendering logic.
+
+### Solution
+
+A shared `A2uiValidationErrorsComponent` that any `Checkable` component includes:
+
+```typescript
+// libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts
+@Component({
+ selector: 'a2ui-validation-errors',
+ standalone: true,
+ template: `
+ @if (!result().valid) {
+ @for (error of result().errors; track error) {
+ {{ error }}
+ }
+ }
+ `,
+})
+export class A2uiValidationErrorsComponent {
+ readonly result = input({ valid: true, errors: [] });
+}
+```
+
+**Theming:** Uses `--a2ui-error` CSS custom property, falling back to red. Inherits from the surface theme, consistent with the existing `--chat-*` pattern. No hardcoded colors in the final implementation — the fallback is for development only.
+
+**Files:**
+- `libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts` — new file
+
+## 6. Catalog Component Updates
+
+### Input Components (Checkable)
+
+All input components that inherit from the v0.9 `Checkable` mixin gain:
+1. `validationResult` input with default `{ valid: true, errors: [] }`
+2. `` in template
+
+**Components updated:** `TextField`, `CheckBox`, `ChoicePicker`, `Slider`, `DateTimeInput`
+
+Example for TextField:
+
+```typescript
+@Component({
+ selector: 'a2ui-text-field',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ template: `
+
+ @if (label()) {
}
+
+
+
+ `,
+})
+export class A2uiTextFieldComponent {
+ readonly validationResult = input({ valid: true, errors: [] });
+ // ... existing inputs and methods
+}
+```
+
+### Button
+
+Button changes from calling `validateChecks()` locally to reading the pre-computed `validationResult`:
+
+```typescript
+@Component({
+ selector: 'a2ui-button',
+ standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ template: `
+
+
+ `,
+})
+export class A2uiButtonComponent {
+ readonly validationResult = input({ valid: true, errors: [] });
+ // Remove: readonly checks = input([]);
+ // Remove: isValid() method
+}
+```
+
+**v0.9 spec behavior:** "If any check fails, the button is automatically disabled on the client." Error messages display below the button.
+
+**Files:**
+- `libs/chat/src/lib/a2ui/catalog/text-field.component.ts`
+- `libs/chat/src/lib/a2ui/catalog/check-box.component.ts`
+- `libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts`
+- `libs/chat/src/lib/a2ui/catalog/slider.component.ts`
+- `libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts`
+- `libs/chat/src/lib/a2ui/catalog/button.component.ts`
+
+## 7. Cockpit Example Update
+
+Update the contact form to demonstrate v0.9 `CheckRule` validation:
+
+```python
+# cockpit/chat/a2ui/python/src/graph.py
+{"id": "name_field", "component": "TextField",
+ "label": "Name", "value": {"path": "/name"},
+ "_bindings": {"value": "/name"},
+ "checks": [
+ {"condition": {"call": "required", "args": {"value": {"path": "/name"}}},
+ "message": "Name is required"}
+ ]},
+{"id": "email_field", "component": "TextField",
+ "label": "Email", "value": {"path": "/email"},
+ "_bindings": {"value": "/email"},
+ "checks": [
+ {"condition": {"call": "required", "args": {"value": {"path": "/email"}}},
+ "message": "Email is required"},
+ {"condition": {"call": "email", "args": {"value": {"path": "/email"}}},
+ "message": "Must be a valid email address"}
+ ]},
+{"id": "submit_btn", "component": "Button",
+ "label": "Submit",
+ "checks": [
+ {"condition": {"call": "and", "args": {"values": [
+ {"call": "required", "args": {"value": {"path": "/name"}}},
+ {"call": "email", "args": {"value": {"path": "/email"}}}
+ ]}},
+ "message": "Complete all required fields"}
+ ],
+ "action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}}
+```
+
+**Files:**
+- `cockpit/chat/a2ui/python/src/graph.py`
+
+## 8. Documentation
+
+### Updated Pages
+
+- **`apps/website/content/docs/chat/a2ui/overview.mdx`** — Add "Validation" section:
+ - v0.9 `CheckRule` shape with `condition: DynamicBoolean`
+ - Built-in validation functions (required, regex, length, numeric, email)
+ - Composite conditions with and/or/not
+ - Inline error display on input components
+ - Button auto-disable behavior
+ - `validationResult` prop contract for custom catalog components
+ - Explicit v0.9 spec alignment callout
+
+- **`apps/website/content/docs/chat/a2ui/overview.mdx`** — Add "Functions" section:
+ - All 14 basic catalog functions with signatures
+ - Grouped by category: validation, formatting, logic, navigation
+ - Reference to v0.9 `basic_catalog.json`
+
+- **Catalog component docs** — Document `validationResult` prop contract for custom component authors
+
+## Spec Alignment
+
+### v0.9 `common_types.json`
+- `CheckRule` = `{ condition: DynamicBoolean, message: string }` — our `A2uiCheckRule` matches exactly
+- `Checkable` = `{ checks?: CheckRule[] }` — our `A2uiComponent.checks` matches
+- `FunctionCall` = `{ call: string, args: Record, returnType?: string }` — our `A2uiFunctionCall` matches
+
+### v0.9 `basic_catalog.json`
+- All 14 functions implemented: `required`, `regex`, `length`, `numeric`, `email`, `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`, `openUrl`
+- `Checkable` components: `TextField`, `CheckBox`, `ChoicePicker`, `Slider`, `DateTimeInput`, `Button`
+
+### Renderer contract
+- Input components validate continuously (via signal reactivity in `surfaceToSpec`)
+- Button auto-disables when checks fail
+- Inline error display via shared `A2uiValidationErrorsComponent`
+- `validationResult` prop is the boundary — catalog components (built-in and custom) never interact with the evaluation engine
+
+## Future Considerations (Out of Scope)
+
+- **Async validation** — Remote validation via server round-trip. Current design is synchronous/client-side only.
+- **Custom function registration** — Consumer-defined functions beyond the 14 built-ins. Requires catalog extension mechanism.
+- **Touched/dirty state** — Only show errors after user has interacted with a field. Current design shows errors immediately on any data model state.
+- **`formatString` in arbitrary props** — Currently `formatString` is a named function. The `${...}` interpolation syntax in arbitrary string props is handled by `resolveDynamic`'s template interpolation, not the `formatString` function.
diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts
index b7dc58d64..b2c38fcfc 100644
--- a/libs/a2ui/src/index.ts
+++ b/libs/a2ui/src/index.ts
@@ -3,7 +3,7 @@ export type {
A2uiTheme, A2uiPathRef, A2uiFunctionCall,
DynamicValue, DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList,
A2uiChildTemplate, A2uiChildList,
- A2uiEventAction, A2uiLocalAction, A2uiAction, A2uiCheck,
+ A2uiEventAction, A2uiLocalAction, A2uiAction, A2uiCheckRule,
A2uiComponent,
A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface,
A2uiMessage, A2uiSurface,
@@ -14,5 +14,5 @@ export type { A2uiMessageParser } from './lib/parser';
export { resolveDynamic } from './lib/resolve';
export type { A2uiScope } from './lib/resolve';
export { executeFunction } from './lib/functions';
-export { validateChecks } from './lib/validate';
+export { evaluateCheckRules } from './lib/validate';
export type { A2uiValidationResult } from './lib/validate';
diff --git a/libs/a2ui/src/lib/functions.spec.ts b/libs/a2ui/src/lib/functions.spec.ts
index 8124c2ffe..3292fb28b 100644
--- a/libs/a2ui/src/lib/functions.spec.ts
+++ b/libs/a2ui/src/lib/functions.spec.ts
@@ -5,6 +5,72 @@ import { executeFunction } from './functions';
const model = { name: 'Alice', price: 1234.5, date: '2026-04-09', count: 1 };
describe('executeFunction', () => {
+ // --- Validation functions ---
+ it('required passes for non-empty string', () => {
+ expect(executeFunction('required', { value: 'hello' }, model)).toBe(true);
+ });
+
+ it('required fails for empty string', () => {
+ expect(executeFunction('required', { value: '' }, model)).toBe(false);
+ });
+
+ it('required fails for null', () => {
+ expect(executeFunction('required', { value: null }, model)).toBe(false);
+ });
+
+ it('required fails for undefined', () => {
+ expect(executeFunction('required', { value: undefined }, model)).toBe(false);
+ });
+
+ it('regex passes for matching pattern', () => {
+ expect(executeFunction('regex', { value: 'abc123', pattern: '^[a-z]+\\d+$' }, model)).toBe(true);
+ });
+
+ it('regex fails for non-matching pattern', () => {
+ expect(executeFunction('regex', { value: '!!!', pattern: '^\\w+$' }, model)).toBe(false);
+ });
+
+ it('length passes within range', () => {
+ expect(executeFunction('length', { value: 'hello', min: 3, max: 10 }, model)).toBe(true);
+ });
+
+ it('length fails below min', () => {
+ expect(executeFunction('length', { value: 'hi', min: 3 }, model)).toBe(false);
+ });
+
+ it('length fails above max', () => {
+ expect(executeFunction('length', { value: 'toolong', max: 3 }, model)).toBe(false);
+ });
+
+ it('numeric passes in range', () => {
+ expect(executeFunction('numeric', { value: 5, min: 0, max: 10 }, model)).toBe(true);
+ });
+
+ it('numeric fails out of range', () => {
+ expect(executeFunction('numeric', { value: 20, min: 0, max: 10 }, model)).toBe(false);
+ });
+
+ it('numeric fails for NaN', () => {
+ expect(executeFunction('numeric', { value: 'abc' }, model)).toBe(false);
+ });
+
+ it('email passes for valid email', () => {
+ expect(executeFunction('email', { value: 'a@b.com' }, model)).toBe(true);
+ });
+
+ it('email fails for invalid email', () => {
+ expect(executeFunction('email', { value: 'not-email' }, model)).toBe(false);
+ });
+
+ // --- Formatting functions ---
+ it('formatString interpolates args', () => {
+ expect(executeFunction('formatString', { template: 'Hello ${name}!', name: 'Alice' }, model)).toBe('Hello Alice!');
+ });
+
+ it('formatString handles missing args', () => {
+ expect(executeFunction('formatString', { template: 'Hello ${name}!' }, model)).toBe('Hello !');
+ });
+
it('formatNumber with grouping', () => {
expect(executeFunction('formatNumber', { value: 1234, grouping: true }, model)).toMatch(/1.234/);
});
@@ -13,6 +79,17 @@ describe('executeFunction', () => {
expect(executeFunction('formatNumber', { value: 1234.5, precision: 2 }, model)).toBe('1234.50');
});
+ it('formatCurrency formats as USD by default', () => {
+ const result = executeFunction('formatCurrency', { value: 9.99 }, model) as string;
+ expect(result).toContain('9.99');
+ });
+
+ it('formatDate returns a date string', () => {
+ const result = executeFunction('formatDate', { value: '2026-04-09' }, model);
+ expect(typeof result).toBe('string');
+ expect(result).toBeTruthy();
+ });
+
it('pluralize singular', () => {
expect(executeFunction('pluralize', { count: 1, singular: 'item', plural: 'items' }, model)).toBe('item');
});
@@ -21,22 +98,45 @@ describe('executeFunction', () => {
expect(executeFunction('pluralize', { count: 3, singular: 'item', plural: 'items' }, model)).toBe('items');
});
- it('and returns true when all truthy', () => {
+ // --- Logic functions ---
+ it('and returns true when all truthy (object args)', () => {
expect(executeFunction('and', { a: true, b: true }, model)).toBe(true);
});
- it('and returns false when any falsy', () => {
+ it('and returns false when any falsy (object args)', () => {
expect(executeFunction('and', { a: true, b: false }, model)).toBe(false);
});
+ it('and returns true for values array all truthy', () => {
+ expect(executeFunction('and', { values: [true, true, true] }, model)).toBe(true);
+ });
+
+ it('and returns false for values array with falsy', () => {
+ expect(executeFunction('and', { values: [true, false, true] }, model)).toBe(false);
+ });
+
it('or returns true when any truthy', () => {
expect(executeFunction('or', { a: false, b: true }, model)).toBe(true);
});
+ it('or returns true for values array with any truthy', () => {
+ expect(executeFunction('or', { values: [false, true, false] }, model)).toBe(true);
+ });
+
+ it('or returns false for values array all falsy', () => {
+ expect(executeFunction('or', { values: [false, false] }, model)).toBe(false);
+ });
+
it('not inverts boolean', () => {
expect(executeFunction('not', { value: true }, model)).toBe(false);
});
+ // --- Navigation ---
+ it('openUrl returns null (no window in test)', () => {
+ expect(executeFunction('openUrl', { url: 'https://example.com' }, model)).toBeNull();
+ });
+
+ // --- Unknown ---
it('returns null for unknown function', () => {
expect(executeFunction('unknownFn', {}, model)).toBeNull();
});
diff --git a/libs/a2ui/src/lib/functions.ts b/libs/a2ui/src/lib/functions.ts
index 83c81b77d..01d689ab1 100644
--- a/libs/a2ui/src/lib/functions.ts
+++ b/libs/a2ui/src/lib/functions.ts
@@ -3,6 +3,24 @@
type FnExecutor = (args: Record, model: Record) => unknown;
const FUNCTIONS: Record = {
+ // Validation functions
+ required: (args) => args['value'] != null && String(args['value']).trim() !== '',
+ regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'] ?? '')),
+ length: (args) => {
+ const len = String(args['value'] ?? '').length;
+ return len >= Number(args['min'] ?? 0) && len <= Number(args['max'] ?? Infinity);
+ },
+ numeric: (args) => {
+ const n = Number(args['value']);
+ return !isNaN(n) && n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity);
+ },
+ email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'] ?? '')),
+
+ // Formatting functions
+ formatString: (args) => {
+ const template = String(args['template'] ?? '');
+ return template.replace(/\$\{(\w+)\}/g, (_, key) => String(args[key] ?? ''));
+ },
formatNumber: (args) => {
const n = Number(args['value']);
const precision = Number(args['precision'] ?? 0);
@@ -19,21 +37,31 @@ const FUNCTIONS: Record = {
},
formatDate: (args) => {
return new Date(String(args['value'])).toLocaleDateString(
- String(args['locale'] ?? undefined),
+ args['locale'] != null ? String(args['locale']) : undefined,
);
},
pluralize: (args) => {
return Number(args['count']) === 1 ? String(args['singular']) : String(args['plural']);
},
+
+ // Logic functions
+ and: (args) => {
+ const values = args['values'];
+ return Array.isArray(values) ? values.every(Boolean) : Object.values(args).every(Boolean);
+ },
+ or: (args) => {
+ const values = args['values'];
+ return Array.isArray(values) ? values.some(Boolean) : Object.values(args).some(Boolean);
+ },
+ not: (args) => !args['value'],
+
+ // Navigation
openUrl: (args) => {
if (typeof globalThis.window !== 'undefined') {
globalThis.window.open(String(args['url']), '_blank');
}
return null;
},
- and: (args) => Object.values(args).every(Boolean),
- or: (args) => Object.values(args).some(Boolean),
- not: (args) => !args['value'],
};
export function executeFunction(
diff --git a/libs/a2ui/src/lib/resolve.spec.ts b/libs/a2ui/src/lib/resolve.spec.ts
index 21a84e9a9..c1f848b3e 100644
--- a/libs/a2ui/src/lib/resolve.spec.ts
+++ b/libs/a2ui/src/lib/resolve.spec.ts
@@ -64,4 +64,21 @@ describe('resolveDynamic', () => {
it('returns undefined for non-existent paths', () => {
expect(resolveDynamic({ path: '/missing' }, model)).toBeUndefined();
});
+
+ it('resolves arrays by recursing into each element', () => {
+ const arr = [
+ { path: '/user/name' },
+ 'literal',
+ 42,
+ ];
+ expect(resolveDynamic(arr, model)).toEqual(['Alice', 'literal', 42]);
+ });
+
+ it('resolves nested function calls in arrays', () => {
+ const arr = [
+ { call: 'pluralize', args: { count: 1, singular: 'cat', plural: 'cats' } },
+ { call: 'pluralize', args: { count: 2, singular: 'dog', plural: 'dogs' } },
+ ];
+ expect(resolveDynamic(arr, model)).toEqual(['cat', 'dogs']);
+ });
});
diff --git a/libs/a2ui/src/lib/resolve.ts b/libs/a2ui/src/lib/resolve.ts
index 4aa56fd05..d29da4275 100644
--- a/libs/a2ui/src/lib/resolve.ts
+++ b/libs/a2ui/src/lib/resolve.ts
@@ -46,6 +46,11 @@ export function resolveDynamic(
): unknown {
if (value == null) return value;
+ // Array — recurse into each element
+ if (Array.isArray(value)) {
+ return value.map(item => resolveDynamic(item, model, scope));
+ }
+
// Path reference
if (isPathRef(value)) {
return resolvePathRef(value, model, scope);
diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts
index d8203274f..225626546 100644
--- a/libs/a2ui/src/lib/types.ts
+++ b/libs/a2ui/src/lib/types.ts
@@ -48,11 +48,10 @@ export interface A2uiLocalAction {
export type A2uiAction = A2uiEventAction | A2uiLocalAction;
-// --- Validation (Phase 2 — type definitions only) ---
+// --- Validation (v0.9 CheckRule) ---
-export interface A2uiCheck {
- call: string;
- args: Record;
+export interface A2uiCheckRule {
+ condition: DynamicBoolean;
message: string;
}
@@ -63,7 +62,7 @@ export interface A2uiComponent {
component: string;
children?: A2uiChildList;
action?: A2uiAction;
- checks?: A2uiCheck[];
+ checks?: A2uiCheckRule[];
[key: string]: unknown;
}
diff --git a/libs/a2ui/src/lib/validate.spec.ts b/libs/a2ui/src/lib/validate.spec.ts
index 5eb35807a..2f19647a9 100644
--- a/libs/a2ui/src/lib/validate.spec.ts
+++ b/libs/a2ui/src/lib/validate.spec.ts
@@ -1,71 +1,111 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import { describe, it, expect } from 'vitest';
-import { validateChecks } from './validate';
-import type { A2uiCheck } from './types';
+import { evaluateCheckRules } from './validate';
+import type { A2uiCheckRule } from './types';
-describe('validateChecks', () => {
- it('required passes for non-empty value', () => {
- const checks: A2uiCheck[] = [{ call: 'required', args: { value: 'hello' }, message: 'Required' }];
- expect(validateChecks(checks)).toEqual({ valid: true, errors: [] });
- });
-
- it('required fails for empty string', () => {
- const checks: A2uiCheck[] = [{ call: 'required', args: { value: '' }, message: 'Required' }];
- expect(validateChecks(checks)).toEqual({ valid: false, errors: ['Required'] });
- });
+describe('evaluateCheckRules', () => {
+ const model = { name: 'Alice', email: 'alice@example.com', zip: '', agreed: true };
- it('required fails for null', () => {
- const checks: A2uiCheck[] = [{ call: 'required', args: { value: null }, message: 'Required' }];
- expect(validateChecks(checks)).toEqual({ valid: false, errors: ['Required'] });
+ it('passes when condition evaluates to true', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Name required' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
});
- it('regex passes for matching pattern', () => {
- const checks: A2uiCheck[] = [{ call: 'regex', args: { value: 'abc123', pattern: '^[a-z]+\\d+$' }, message: 'Bad format' }];
- expect(validateChecks(checks)).toEqual({ valid: true, errors: [] });
+ it('fails when condition evaluates to false', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'required', args: { value: { path: '/zip' } } }, message: 'Zip required' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: false, errors: ['Zip required'] });
});
- it('regex fails for non-matching', () => {
- const checks: A2uiCheck[] = [{ call: 'regex', args: { value: '!!!', pattern: '^\\w+$' }, message: 'Bad' }];
- expect(validateChecks(checks).valid).toBe(false);
+ it('resolves path references in condition args', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'email', args: { value: { path: '/email' } } }, message: 'Invalid email' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
});
- it('length passes within range', () => {
- const checks: A2uiCheck[] = [{ call: 'length', args: { value: 'hello', min: 3, max: 10 }, message: 'Length' }];
- expect(validateChecks(checks).valid).toBe(true);
+ it('supports boolean literal conditions', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: true, message: 'Always passes' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
});
- it('length fails below min', () => {
- const checks: A2uiCheck[] = [{ call: 'length', args: { value: 'hi', min: 3 }, message: 'Too short' }];
- expect(validateChecks(checks).valid).toBe(false);
+ it('supports path ref conditions (boolean in data model)', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { path: '/agreed' }, message: 'Must agree' },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
});
- it('numeric passes in range', () => {
- const checks: A2uiCheck[] = [{ call: 'numeric', args: { value: 5, min: 0, max: 10 }, message: 'Range' }];
- expect(validateChecks(checks).valid).toBe(true);
+ it('handles falsy path ref conditions', () => {
+ const modelWithFalse = { ...model, agreed: false };
+ const checks: A2uiCheckRule[] = [
+ { condition: { path: '/agreed' }, message: 'Must agree' },
+ ];
+ expect(evaluateCheckRules(checks, modelWithFalse)).toEqual({ valid: false, errors: ['Must agree'] });
});
- it('email passes for valid email', () => {
- const checks: A2uiCheck[] = [{ call: 'email', args: { value: 'a@b.com' }, message: 'Email' }];
- expect(validateChecks(checks).valid).toBe(true);
+ it('supports nested and/or composition', () => {
+ const checks: A2uiCheckRule[] = [
+ {
+ condition: {
+ call: 'and',
+ args: {
+ values: [
+ { call: 'required', args: { value: { path: '/name' } } },
+ { call: 'email', args: { value: { path: '/email' } } },
+ ],
+ },
+ },
+ message: 'Name and valid email required',
+ },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
});
- it('email fails for invalid', () => {
- const checks: A2uiCheck[] = [{ call: 'email', args: { value: 'not-email' }, message: 'Email' }];
- expect(validateChecks(checks).valid).toBe(false);
+ it('nested and fails when inner condition fails', () => {
+ const checks: A2uiCheckRule[] = [
+ {
+ condition: {
+ call: 'and',
+ args: {
+ values: [
+ { call: 'required', args: { value: { path: '/name' } } },
+ { call: 'required', args: { value: { path: '/zip' } } },
+ ],
+ },
+ },
+ message: 'All fields required',
+ },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: false, errors: ['All fields required'] });
});
- it('collects multiple errors', () => {
- const checks: A2uiCheck[] = [
- { call: 'required', args: { value: '' }, message: 'Required' },
- { call: 'email', args: { value: '' }, message: 'Email' },
+ it('collects multiple errors from multiple checks', () => {
+ const checks: A2uiCheckRule[] = [
+ { condition: { call: 'required', args: { value: { path: '/zip' } } }, message: 'Zip required' },
+ { condition: false, message: 'Always fails' },
];
- const result = validateChecks(checks);
+ const result = evaluateCheckRules(checks, model);
expect(result.valid).toBe(false);
- expect(result.errors).toHaveLength(2);
+ expect(result.errors).toEqual(['Zip required', 'Always fails']);
});
- it('ignores unknown check functions', () => {
- const checks: A2uiCheck[] = [{ call: 'unknownCheck', args: {}, message: 'Unknown' }];
- expect(validateChecks(checks).valid).toBe(true);
+ it('returns valid for empty checks array', () => {
+ expect(evaluateCheckRules([], model)).toEqual({ valid: true, errors: [] });
+ });
+
+ it('supports regex with path-resolved value', () => {
+ const checks: A2uiCheckRule[] = [
+ {
+ condition: { call: 'regex', args: { value: { path: '/name' }, pattern: '^[A-Z]' } },
+ message: 'Must start with uppercase',
+ },
+ ];
+ expect(evaluateCheckRules(checks, model)).toEqual({ valid: true, errors: [] });
});
});
diff --git a/libs/a2ui/src/lib/validate.ts b/libs/a2ui/src/lib/validate.ts
index a7d3e564e..d9f4cdf25 100644
--- a/libs/a2ui/src/lib/validate.ts
+++ b/libs/a2ui/src/lib/validate.ts
@@ -1,37 +1,20 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import type { A2uiCheck } from './types';
+import type { A2uiCheckRule } from './types';
+import { resolveDynamic } from './resolve';
export interface A2uiValidationResult {
valid: boolean;
errors: string[];
}
-type Validator = (args: Record) => boolean;
-
-const VALIDATORS: Record = {
- required: (args) => args['value'] != null && String(args['value']).trim() !== '',
- regex: (args) => new RegExp(String(args['pattern'])).test(String(args['value'] ?? '')),
- length: (args) => {
- const len = String(args['value'] ?? '').length;
- const min = Number(args['min'] ?? 0);
- const max = Number(args['max'] ?? Infinity);
- return len >= min && len <= max;
- },
- numeric: (args) => {
- const n = Number(args['value']);
- return !isNaN(n) && n >= Number(args['min'] ?? -Infinity) && n <= Number(args['max'] ?? Infinity);
- },
- email: (args) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(args['value'] ?? '')),
-};
-
-export function validateChecks(checks: A2uiCheck[]): A2uiValidationResult {
+export function evaluateCheckRules(
+ checks: A2uiCheckRule[],
+ model: Record,
+): A2uiValidationResult {
const errors: string[] = [];
for (const check of checks) {
- const validator = VALIDATORS[check.call];
- if (!validator) continue;
- if (!validator(check.args)) {
- errors.push(check.message);
- }
+ const result = resolveDynamic(check.condition, model);
+ if (!result) errors.push(check.message);
}
return { valid: errors.length === 0, errors };
}
diff --git a/libs/chat/src/lib/a2ui/catalog/button.component.ts b/libs/chat/src/lib/a2ui/catalog/button.component.ts
index 1ed60686b..3d22803eb 100644
--- a/libs/chat/src/lib/a2ui/catalog/button.component.ts
+++ b/libs/chat/src/lib/a2ui/catalog/button.component.ts
@@ -1,33 +1,30 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import { Component, input } from '@angular/core';
-import type { A2uiCheck } from '@cacheplane/a2ui';
-import { validateChecks } from '@cacheplane/a2ui';
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
@Component({
selector: 'a2ui-button',
standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
template: `
+
`,
})
export class A2uiButtonComponent {
readonly label = input('');
readonly variant = input('primary');
readonly disabled = input(false);
- readonly checks = input([]);
+ readonly validationResult = input({ valid: true, errors: [] });
readonly emit = input<(event: string) => void>(() => { /* noop */ });
- isValid(): boolean {
- const c = this.checks();
- if (!c || c.length === 0) return true;
- return validateChecks(c).valid;
- }
-
handleClick(): void {
this.emit()('click');
}
diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts
index 04264d98d..281582224 100644
--- a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts
+++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts
@@ -1,19 +1,27 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import { Component, input } from '@angular/core';
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
@Component({
selector: 'a2ui-check-box',
standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
template: `
-
+
+
+
+
`,
})
export class A2uiCheckBoxComponent {
readonly label = input('');
readonly checked = input(false);
+ readonly validationResult = input({ valid: true, errors: [] });
readonly _bindings = input>({});
readonly emit = input<(event: string) => void>(() => { /* noop */ });
diff --git a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts
index aee84a36d..ea658972d 100644
--- a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts
+++ b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts
@@ -1,17 +1,28 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import { Component, input } from '@angular/core';
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
@Component({
selector: 'a2ui-choice-picker',
standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
template: `
- @if (label()) {
}
-
`,
})
@@ -19,6 +30,7 @@ export class A2uiChoicePickerComponent {
readonly label = input('');
readonly options = input([]);
readonly selected = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
readonly _bindings = input>({});
readonly emit = input<(event: string) => void>(() => { /* noop */ });
diff --git a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts
index 7b4e3e5cf..f27f96f7d 100644
--- a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts
+++ b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts
@@ -1,22 +1,30 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import { Component, input } from '@angular/core';
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
@Component({
selector: 'a2ui-date-time-input',
standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (label()) {
-
+
}
+
`,
})
@@ -26,6 +34,7 @@ export class A2uiDateTimeInputComponent {
readonly inputType = input<'date' | 'time' | 'datetime-local'>('date');
readonly min = input('');
readonly max = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
readonly _bindings = input>({});
readonly emit = input<(event: string) => void>(() => { /* noop */ });
diff --git a/libs/chat/src/lib/a2ui/catalog/slider.component.ts b/libs/chat/src/lib/a2ui/catalog/slider.component.ts
index 908a00032..3e8d7e980 100644
--- a/libs/chat/src/lib/a2ui/catalog/slider.component.ts
+++ b/libs/chat/src/lib/a2ui/catalog/slider.component.ts
@@ -1,13 +1,17 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import { Component, input } from '@angular/core';
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
@Component({
selector: 'a2ui-slider',
standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (label()) {
-
+
}
+
`,
})
@@ -27,6 +32,7 @@ export class A2uiSliderComponent {
readonly min = input(0);
readonly max = input(100);
readonly step = input(1);
+ readonly validationResult = input({ valid: true, errors: [] });
readonly _bindings = input>({});
readonly emit = input<(event: string) => void>(() => { /* noop */ });
diff --git a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts
index 2c9a19f38..fc053bbf4 100644
--- a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts
+++ b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts
@@ -1,20 +1,27 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
-import { Component, input } from '@angular/core';
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+import { A2uiValidationErrorsComponent } from './validation-errors.component';
@Component({
selector: 'a2ui-text-field',
standalone: true,
+ imports: [A2uiValidationErrorsComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
template: `
- @if (label()) {
}
+ @if (label()) {
}
+
`,
})
@@ -22,6 +29,7 @@ export class A2uiTextFieldComponent {
readonly label = input('');
readonly value = input('');
readonly placeholder = input('');
+ readonly validationResult = input({ valid: true, errors: [] });
readonly _bindings = input>({});
readonly emit = input<(event: string) => void>(() => { /* noop */ });
diff --git a/libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts b/libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts
new file mode 100644
index 000000000..6cf6449c1
--- /dev/null
+++ b/libs/chat/src/lib/a2ui/catalog/validation-errors.component.ts
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import type { A2uiValidationResult } from '@cacheplane/a2ui';
+
+@Component({
+ selector: 'a2ui-validation-errors',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+ @if (!result().valid) {
+ @for (error of result().errors; track error) {
+ {{ error }}
+ }
+ }
+ `,
+})
+export class A2uiValidationErrorsComponent {
+ readonly result = input({ valid: true, errors: [] });
+}
diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts
index c46c88b47..021aa045a 100644
--- a/libs/chat/src/lib/a2ui/surface.component.spec.ts
+++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts
@@ -153,3 +153,96 @@ describe('A2uiSurfaceComponent — consumer handlers', () => {
});
});
});
+
+describe('surfaceToSpec — validation', () => {
+ function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface {
+ const map = new Map();
+ for (const c of components) map.set(c.id, c);
+ return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel };
+ }
+
+ it('evaluates checks and attaches validationResult prop', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'TextField', label: 'Name',
+ value: { path: '/name' },
+ checks: [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Name required' },
+ ],
+ },
+ ],
+ { name: 'Alice' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toEqual({ valid: true, errors: [] });
+ });
+
+ it('attaches failing validationResult when check fails', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'TextField', label: 'Name',
+ value: { path: '/name' },
+ checks: [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Name required' },
+ ],
+ },
+ ],
+ { name: '' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toEqual({ valid: false, errors: ['Name required'] });
+ });
+
+ it('evaluates composite and condition', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'Button', label: 'Submit',
+ checks: [
+ {
+ condition: {
+ call: 'and',
+ args: {
+ values: [
+ { call: 'required', args: { value: { path: '/name' } } },
+ { call: 'email', args: { value: { path: '/email' } } },
+ ],
+ },
+ },
+ message: 'All fields required',
+ },
+ ],
+ },
+ ],
+ { name: 'Alice', email: 'alice@example.com' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toEqual({ valid: true, errors: [] });
+ });
+
+ it('does not attach validationResult when no checks defined', () => {
+ const surface = makeSurface([
+ { id: 'root', component: 'Text', text: 'Hello' },
+ ]);
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['validationResult']).toBeUndefined();
+ });
+
+ it('does not pass raw checks as props', () => {
+ const surface = makeSurface(
+ [
+ {
+ id: 'root', component: 'TextField', label: 'Name',
+ checks: [
+ { condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Required' },
+ ],
+ },
+ ],
+ { name: 'Alice' },
+ );
+ const spec = surfaceToSpec(surface)!;
+ expect(spec.elements['root'].props['checks']).toBeUndefined();
+ });
+});
diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts
index 1d2bf7529..9091c606f 100644
--- a/libs/chat/src/lib/a2ui/surface.component.ts
+++ b/libs/chat/src/lib/a2ui/surface.component.ts
@@ -4,7 +4,7 @@ import {
} from '@angular/core';
import type { Spec } from '@json-render/core';
import type { A2uiSurface, A2uiChildTemplate } from '@cacheplane/a2ui';
-import { resolveDynamic, getByPointer } from '@cacheplane/a2ui';
+import { resolveDynamic, getByPointer, evaluateCheckRules } from '@cacheplane/a2ui';
import { RenderSpecComponent, toRenderRegistry } from '@cacheplane/render';
import type { ViewRegistry, RenderEvent } from '@cacheplane/render';
@@ -57,9 +57,9 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null {
};
}
}
- // Pass checks through
+ // Evaluate checks and attach pre-computed validation result
if (comp.checks) {
- props['checks'] = comp.checks;
+ props['validationResult'] = evaluateCheckRules(comp.checks, surface.dataModel);
}
// Map children
diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts
index 472503c24..0f513baef 100644
--- a/libs/chat/src/public-api.ts
+++ b/libs/chat/src/public-api.ts
@@ -67,6 +67,7 @@ export { createA2uiSurfaceStore } from './lib/a2ui/surface-store';
export type { A2uiSurfaceStore } from './lib/a2ui/surface-store';
export { A2uiSurfaceComponent } from './lib/a2ui/surface.component';
export { a2uiBasicCatalog } from './lib/a2ui/catalog/index';
+export { A2uiValidationErrorsComponent } from './lib/a2ui/catalog/validation-errors.component';
// Test utilities
export { createMockAgentRef } from './lib/testing/mock-agent-ref';