From 1925558fc5991bb92ebd7f9d32e3265cc4968897 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 13:55:00 -0400 Subject: [PATCH 01/23] =?UTF-8?q?test:=20a=20real=20test=20foundation=20?= =?UTF-8?q?=E2=80=94=20vitest,=20first=2029=20tests,=20wired=20into=20CI?= =?UTF-8?q?=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three suites over the pure logic where regressions would be silent: seededShuffle (determinism, true permutation, forbidden-order avoidance — the exact properties the hand-rolled shuffles it replaced got wrong), reachesGrade (K parsing, permissive-read semantics, the never-gate-on- missing-data rule), and a registry ↔ schema-union sync test that enforces what the codebase can only document today: adding a widget kind means updating both the open registry and the static widgetKind enum, and nothing at compile time checks they agree. Now something does. Co-authored-by: Claude Fable 5 --- package.json | 7 ++++++- pnpm-lock.yaml | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2421704b..96848610 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ "mcp:build": "node mcp/build.mjs", "mcp:serve": "node mcp/server-http.mjs", "predev": "node mcp/build.mjs", - "prebuild": "node mcp/build.mjs" + "prebuild": "node mcp/build.mjs", + "conformance": "node scripts/a2ui-conformance.mjs", + "conformance:update": "node scripts/a2ui-conformance.mjs --update" }, "dependencies": { + "@ag-ui/core": "^0.0.58", "@ai-sdk/amazon-bedrock": "^5.0.66", "@ai-sdk/anthropic": "^4.0.44", "@ai-sdk/openai": "^4.0.50", @@ -54,6 +57,8 @@ "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "esbuild": "^0.28.2", "eslint": "^9", "eslint-config-next": "16.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f31d0617..0ab183c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@ag-ui/core': + specifier: ^0.0.58 + version: 0.0.58 '@ai-sdk/amazon-bedrock': specifier: ^5.0.66 version: 5.0.66(zod@4.4.3) @@ -102,6 +105,12 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.5(@types/react@19.2.18) + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -126,6 +135,9 @@ importers: packages: + '@ag-ui/core@0.0.58': + resolution: {integrity: sha512-XgGb7YmhV+yMBaEmlrpsd5S+nUxq0JgSegss2t4gIFR1j7w3w0ibtKfRgcQHWeMvwZxcT5S28VEEarqtgxYYHw==} + '@ai-sdk/amazon-bedrock@5.0.66': resolution: {integrity: sha512-QyXME04hUaufpXh/lqt4/c8NH1rEtWLr5hDoMMJenJUh4ORJ9epdgqBQ0lC/qFh0GvqpAZKo+doBw05PJBiLnQ==} engines: {node: '>=22'} @@ -4172,6 +4184,10 @@ packages: snapshots: + '@ag-ui/core@0.0.58': + dependencies: + zod: 3.25.76 + '@ai-sdk/amazon-bedrock@5.0.66(zod@4.4.3)': dependencies: '@ai-sdk/anthropic': 4.0.44(zod@4.4.3) From 2dd6dc48594d68a48791b37cb9ede9765a559f11 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 12:40:53 -0400 Subject: [PATCH 02/23] feat: real AG-UI transport and schema-verified A2UI conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards conformance as verifiable increments, not claims: - spec/a2ui/v1_0/: Google's actual A2UI v1.0 schemas, vendored verbatim (agent_to_renderer, common_types, the basic catalog; upstream commit recorded). The conformance target is upstream's opinion, not ours. - src/lib/a2learn/: the a2learn layer as code — a versioned ActivityManifest builder (standard + verification, pedagogy, language, provenance, the exact invoke arguments), and the A2UI boundary mapper. Two kinds map today (markdown-card, flashcard — the reading shapes the basic catalog expresses without loss); flashcard's tap-to-flip is honestly degraded to front/back stacked until an a2learn catalog extension models the state. A2UI_SUPPORTED_KINDS is proven by fixture, never asserted. - /api/pathway/agui: the pathway build as a typed AG-UI run beside the app's NDJSON — RUN_STARTED, one CUSTOM a2learn. per domain event, STATE_SNAPSHOT with the assembled result, RUN_FINISHED / RUN_ERROR. Every emitted object typed against @ag-ui/core, so protocol drift fails the build. - pnpm conformance (+ CI workflow): validates every fixture surface against the vendored schemas via Ajv, checks the flat-tree invariants the schema cannot state (unique ids, resolvable refs, root exists), and diffs against committed goldens so mapper changes surface in review. Negative controls verified: missing required props, unknown components, and a missing version each fail. Co-Authored-By: Claude Fable 5 --- .github/workflows/conformance.yml | 19 + scripts/a2ui-conformance.mjs | 148 ++ spec/a2learn/fixtures/flashcard.spec.json | 9 + spec/a2learn/fixtures/flashcard.surface.json | 106 ++ spec/a2learn/fixtures/markdown-card.spec.json | 7 + .../fixtures/markdown-card.surface.json | 66 + spec/a2ui/v1_0/README.md | 15 + spec/a2ui/v1_0/agent_to_renderer.json | 216 +++ spec/a2ui/v1_0/catalogs/basic/catalog.json | 1346 +++++++++++++++++ spec/a2ui/v1_0/common_types.json | 395 +++++ src/app/api/pathway/agui/route.ts | 113 ++ src/lib/a2learn/a2ui.ts | 125 ++ src/lib/a2learn/manifest.ts | 77 + 13 files changed, 2642 insertions(+) create mode 100644 .github/workflows/conformance.yml create mode 100644 scripts/a2ui-conformance.mjs create mode 100644 spec/a2learn/fixtures/flashcard.spec.json create mode 100644 spec/a2learn/fixtures/flashcard.surface.json create mode 100644 spec/a2learn/fixtures/markdown-card.spec.json create mode 100644 spec/a2learn/fixtures/markdown-card.surface.json create mode 100644 spec/a2ui/v1_0/README.md create mode 100644 spec/a2ui/v1_0/agent_to_renderer.json create mode 100644 spec/a2ui/v1_0/catalogs/basic/catalog.json create mode 100644 spec/a2ui/v1_0/common_types.json create mode 100644 src/app/api/pathway/agui/route.ts create mode 100644 src/lib/a2learn/a2ui.ts create mode 100644 src/lib/a2learn/manifest.ts diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 00000000..4a08aca6 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,19 @@ +# A2UI conformance: the boundary mapper's surfaces must validate against the +# vendored Google A2UI v1.0 schemas and match their committed goldens. +name: conformance +on: + pull_request: + push: + branches: [main] +jobs: + a2ui: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm conformance diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs new file mode 100644 index 00000000..698c05a2 --- /dev/null +++ b/scripts/a2ui-conformance.mjs @@ -0,0 +1,148 @@ +/** + * A2UI conformance: prove the boundary mapper's output against Google's own + * v1.0 schemas, not against our opinion of them. + * + * pnpm conformance validate fixtures + compare golden surfaces + * pnpm conformance:update regenerate the golden surfaces + * + * Three layers of checking, strictest first: + * 1. Schema: every emitted message validates against the vendored + * `agent_to_renderer.json` (+ common_types + the basic catalog). + * 2. Structure: component ids are unique, and every child reference + * resolves — the flat-tree invariants the schema alone cannot state. + * 3. Drift: output matches the committed golden files, so a mapper change + * shows up in review as a surface diff, never silently. + */ +import { build } from 'esbuild'; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +import { Ajv2020 } from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const specDir = join(root, 'spec', 'a2ui', 'v1_0'); +const fixtureDir = join(root, 'spec', 'a2learn', 'fixtures'); +const update = process.argv.includes('--update'); + +// --- load the mapper (TS, with @/ aliases) via a throwaway esbuild bundle --- +const bundle = await build({ + entryPoints: [join(root, 'src', 'lib', 'a2learn', 'a2ui.ts')], + bundle: true, + format: 'esm', + write: false, + tsconfig: join(root, 'tsconfig.json'), + logLevel: 'error', +}); +const tmp = join(root, 'node_modules', '.a2learn-conformance.mjs'); +writeFileSync(tmp, bundle.outputFiles[0].text); +const { toA2UISurface, A2UI_SUPPORTED_KINDS } = await import(pathToFileURL(tmp).href); + +// --- assemble the validator from the vendored schemas --- +const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); +const messageSchema = readJson(join(specDir, 'agent_to_renderer.json')); +const commonTypes = readJson(join(specDir, 'common_types.json')); +const basicCatalog = readJson(join(specDir, 'catalogs', 'basic', 'catalog.json')); + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +ajv.addSchema(commonTypes); +ajv.addSchema(basicCatalog); +// `agent_to_renderer.json` refs `catalog.json#...` relative to its own $id; +// the surface's actual catalog is bound at runtime. Alias the basic catalog +// to that resolved URL so validation uses the catalog our surfaces target. +ajv.addSchema({ ...basicCatalog, $id: 'https://a2ui.org/specification/v1_0/catalog.json' }); +const validate = ajv.compile(messageSchema); + +// --- structural invariants the schema cannot express --- +function structuralProblems(message) { + const problems = []; + const components = message.createSurface?.components ?? []; + const ids = new Set(); + for (const c of components) { + if (ids.has(c.id)) problems.push(`duplicate component id: ${c.id}`); + ids.add(c.id); + } + if (!ids.has('root')) problems.push("no 'root' component — createSurface implies Surface{child:'root'}"); + for (const c of components) { + const refs = [ + ...(typeof c.child === 'string' ? [c.child] : []), + ...(Array.isArray(c.children) ? c.children : []), + ]; + for (const ref of refs) { + if (!ids.has(ref)) problems.push(`${c.id} references missing component: ${ref}`); + } + } + return problems; +} + +// --- run every fixture --- +mkdirSync(fixtureDir, { recursive: true }); +const specs = readdirSync(fixtureDir).filter((f) => f.endsWith('.spec.json')); +if (!specs.length) { + console.error(`No fixtures in ${fixtureDir} — add .spec.json widget specs.`); + process.exit(1); +} + +let failed = false; +const covered = new Set(); + +for (const file of specs) { + const name = file.replace(/\.spec\.json$/, ''); + const spec = readJson(join(fixtureDir, file)); + const surface = toA2UISurface(spec, `a2learn-fixture-${name}`); + + if (!surface) { + console.error(`✗ ${name}: kind "${spec.kind}" is not mapped (A2UI_SUPPORTED_KINDS: ${A2UI_SUPPORTED_KINDS.join(', ')})`); + failed = true; + continue; + } + covered.add(spec.kind); + + if (!validate(surface)) { + console.error(`✗ ${name}: schema validation failed`); + for (const err of validate.errors ?? []) console.error(` ${err.instancePath} ${err.message}`); + failed = true; + continue; + } + + const structural = structuralProblems(surface); + if (structural.length) { + console.error(`✗ ${name}: ${structural.join('; ')}`); + failed = true; + continue; + } + + const goldenPath = join(fixtureDir, `${name}.surface.json`); + const rendered = `${JSON.stringify(surface, null, 2)}\n`; + if (update) { + writeFileSync(goldenPath, rendered); + console.log(`✓ ${name}: valid — golden updated`); + } else { + let golden = null; + try { + golden = readFileSync(goldenPath, 'utf8'); + } catch { + console.error(`✗ ${name}: no golden surface — run \`pnpm conformance:update\` and commit it`); + failed = true; + continue; + } + if (golden !== rendered) { + console.error(`✗ ${name}: surface drifted from golden — review, then \`pnpm conformance:update\``); + failed = true; + continue; + } + console.log(`✓ ${name}: schema-valid, structurally sound, matches golden`); + } +} + +// Every claimed kind must have at least one fixture — support is proven, not asserted. +for (const kind of A2UI_SUPPORTED_KINDS) { + if (!covered.has(kind)) { + console.error(`✗ A2UI_SUPPORTED_KINDS claims "${kind}" but no fixture covers it`); + failed = true; + } +} + +process.exit(failed ? 1 : 0); diff --git a/spec/a2learn/fixtures/flashcard.spec.json b/spec/a2learn/fixtures/flashcard.spec.json new file mode 100644 index 00000000..ca1d09ee --- /dev/null +++ b/spec/a2learn/fixtures/flashcard.spec.json @@ -0,0 +1,9 @@ +{ + "kind": "flashcard", + "learningComponentId": "lc-fractions-2", + "prompt": "Match each fraction word to what it means.", + "cards": [ + { "id": "c1", "front": { "text": "numerator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many parts you have", "markdown": null, "imageUrl": null, "imageAlt": null } }, + { "id": "c2", "front": { "text": "denominator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many equal parts make the whole", "markdown": null, "imageUrl": null, "imageAlt": null } } + ] +} diff --git a/spec/a2learn/fixtures/flashcard.surface.json b/spec/a2learn/fixtures/flashcard.surface.json new file mode 100644 index 00000000..0bee3b76 --- /dev/null +++ b/spec/a2learn/fixtures/flashcard.surface.json @@ -0,0 +1,106 @@ +{ + "version": "v1.0", + "createSurface": { + "surfaceId": "a2learn-fixture-flashcard", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "prompt", + "card-0", + "card-1", + "done" + ] + }, + { + "id": "prompt", + "component": "Text", + "text": "Match each fraction word to what it means." + }, + { + "id": "card-0", + "component": "Card", + "child": "card-0-col" + }, + { + "id": "card-0-col", + "component": "Column", + "children": [ + "card-0-front", + "card-0-divider", + "card-0-back" + ] + }, + { + "id": "card-0-front", + "component": "Text", + "text": "**numerator**" + }, + { + "id": "card-0-divider", + "component": "Divider" + }, + { + "id": "card-0-back", + "component": "Text", + "text": "how many parts you have" + }, + { + "id": "card-1", + "component": "Card", + "child": "card-1-col" + }, + { + "id": "card-1-col", + "component": "Column", + "children": [ + "card-1-front", + "card-1-divider", + "card-1-back" + ] + }, + { + "id": "card-1-front", + "component": "Text", + "text": "**denominator**" + }, + { + "id": "card-1-divider", + "component": "Divider" + }, + { + "id": "card-1-back", + "component": "Text", + "text": "how many equal parts make the whole" + }, + { + "id": "done", + "component": "Button", + "variant": "primary", + "child": "done-label", + "action": { + "event": { + "name": "a2learn.widget_completed", + "userMessage": "Finished the activity.", + "context": { + "kind": "flashcard", + "correct": true + } + } + } + }, + { + "id": "done-label", + "component": "Text", + "text": "Done" + } + ], + "dataModel": { + "a2learn": { + "kind": "flashcard" + } + } + } +} diff --git a/spec/a2learn/fixtures/markdown-card.spec.json b/spec/a2learn/fixtures/markdown-card.spec.json new file mode 100644 index 00000000..55048a6c --- /dev/null +++ b/spec/a2learn/fixtures/markdown-card.spec.json @@ -0,0 +1,7 @@ +{ + "kind": "markdown-card", + "learningComponentId": "lc-fractions-1", + "title": "Why the denominator matters", + "body": "When you split a whole into **equal parts**, the denominator tells you *how many parts* make the whole.\n\n- 1/3 means the whole is split into 3 equal parts\n- 1/4 means it is split into 4\n\n> More parts means each part is smaller — that is why 1/3 is bigger than 1/4.", + "tip": "Sketch both fractions on the same-size bar before comparing them." +} diff --git a/spec/a2learn/fixtures/markdown-card.surface.json b/spec/a2learn/fixtures/markdown-card.surface.json new file mode 100644 index 00000000..63971cbc --- /dev/null +++ b/spec/a2learn/fixtures/markdown-card.surface.json @@ -0,0 +1,66 @@ +{ + "version": "v1.0", + "createSurface": { + "surfaceId": "a2learn-fixture-markdown-card", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Card", + "child": "layout" + }, + { + "id": "layout", + "component": "Column", + "children": [ + "title", + "body", + "tip", + "done" + ] + }, + { + "id": "title", + "component": "Text", + "text": "**Why the denominator matters**" + }, + { + "id": "body", + "component": "Text", + "text": "When you split a whole into **equal parts**, the denominator tells you *how many parts* make the whole.\n\n- 1/3 means the whole is split into 3 equal parts\n- 1/4 means it is split into 4\n\n> More parts means each part is smaller — that is why 1/3 is bigger than 1/4." + }, + { + "id": "tip", + "component": "Text", + "text": "Tip: Sketch both fractions on the same-size bar before comparing them.", + "variant": "caption" + }, + { + "id": "done", + "component": "Button", + "variant": "primary", + "child": "done-label", + "action": { + "event": { + "name": "a2learn.widget_completed", + "userMessage": "Finished the activity.", + "context": { + "kind": "markdown-card", + "correct": true + } + } + } + }, + { + "id": "done-label", + "component": "Text", + "text": "Got it" + } + ], + "dataModel": { + "a2learn": { + "kind": "markdown-card" + } + } + } +} diff --git a/spec/a2ui/v1_0/README.md b/spec/a2ui/v1_0/README.md new file mode 100644 index 00000000..17611311 --- /dev/null +++ b/spec/a2ui/v1_0/README.md @@ -0,0 +1,15 @@ +# Vendored A2UI v1.0 schemas + +Copied verbatim from [google/A2UI](https://github.com/google/A2UI) +(`specification/v1_0`, upstream commit `abcff1515fad`, Apache-2.0): + +- `agent_to_renderer.json` — the message envelope (`createSurface`, + `updateComponents`, `updateDataModel`, …) +- `common_types.json` — component envelope, actions, data binding +- `catalogs/basic/catalog.json` — the standard component catalog (Text, + Card, Column, Button, …) + +These are the conformance target for `src/lib/a2learn/a2ui.ts`: every +surface the boundary mapper emits is validated against these exact schemas +by `pnpm conformance` (and CI). Update by re-copying from upstream and +recording the new commit here — never by editing in place. diff --git a/spec/a2ui/v1_0/agent_to_renderer.json b/spec/a2ui/v1_0/agent_to_renderer.json new file mode 100644 index 00000000..6d8ba1fc --- /dev/null +++ b/spec/a2ui/v1_0/agent_to_renderer.json @@ -0,0 +1,216 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v1_0/agent_to_renderer.json", + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.", + "type": "object", + "oneOf": [ + {"$ref": "#/$defs/CreateSurfaceMessage"}, + {"$ref": "#/$defs/UpdateComponentsMessage"}, + {"$ref": "#/$defs/UpdateDataModelMessage"}, + {"$ref": "#/$defs/DeleteSurfaceMessage"}, + {"$ref": "#/$defs/CallRendererFunctionMessage"}, + {"$ref": "#/$defs/AgentFunctionResponseMessage"} + ], + "$defs": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v1.0" + }, + "createSurface": { + "type": "object", + "description": "Signals the renderer to create a new surface and begin rendering it. Creating a surface implicitly instantiates the canonical 'Surface' container component ('common_types.json#/$defs/Surface') with 'child': 'root'. It is an error to try to create a surface with an existing ID without first deleting it; surfaceId MUST be globally unique for the renderer's lifetime. When this message is sent, the renderer expects 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId to define the component tree.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered. It must be globally unique for the renderer's lifetime." + }, + "catalogId": { + "description": "A string that uniquely identifies the default catalog for this surface. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. 'mycompany.com:somecatalog'. Components and function calls that do not explicitly specify a catalogId will use this surface-level default catalogId.", + "type": "string" + }, + "sendDataModel": { + "type": "boolean", + "description": "If true, the renderer will send the full data model of this surface in the metadata of every A2A message sent to the agent that created the surface. Defaults to false." + }, + "components": { + "$ref": "#/$defs/ComponentsList" + }, + "dataModel": { + "type": "object", + "description": "The initial root data model object for the surface.", + "additionalProperties": true + }, + "metadata": { + "type": "object", + "description": "Optional surface-level metadata.", + "properties": { + "extensions": { + "$ref": "common_types.json#/$defs/Extensions" + } + }, + "additionalProperties": false + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["createSurface", "version"], + "additionalProperties": false + }, + "Component": { + "description": "A UI component for the surface, combining baseline envelope properties with the catalog component definition.", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "catalog.json#/$defs/anyComponent" + }, + { + "type": "object", + "properties": { + "component": { + "not": { + "const": "Surface" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "ComponentsList": { + "type": "array", + "description": "A list containing UI components for the surface.", + "minItems": 1, + "items": { + "$ref": "#/$defs/Component" + } + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": { + "const": "v1.0" + }, + "updateComponents": { + "type": "object", + "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent for this surfaceId.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. It must be globally unique for the renderer's lifetime." + }, + "components": { + "$ref": "#/$defs/ComponentsList" + } + }, + "required": ["surfaceId", "components"], + "additionalProperties": false + } + }, + "required": ["updateComponents", "version"], + "additionalProperties": false + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": { + "const": "v1.0" + }, + "updateDataModel": { + "type": "object", + "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent for this surfaceId.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to. It must be globally unique for the renderer's lifetime." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." + }, + "value": { + "description": "The data to be updated in the data model. To delete the key/value at 'path', set 'value' explicitly to null." + } + }, + "required": ["surfaceId", "value"], + "additionalProperties": false + } + }, + "required": ["updateDataModel", "version"], + "additionalProperties": false + }, + "DeleteSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v1.0" + }, + "deleteSurface": { + "type": "object", + "description": "Signals the renderer to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent for this surfaceId.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted. It must be globally unique for the renderer's lifetime." + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["deleteSurface", "version"], + "additionalProperties": false + }, + "CallRendererFunctionMessage": { + "type": "object", + "properties": { + "version": { + "const": "v1.0" + }, + "callRendererFunction": { + "type": "object", + "description": "Signals the renderer to execute a function locally on behalf of the agent.", + "properties": { + "functionCallId": { + "$ref": "common_types.json#/$defs/CallId", + "description": "Unique ID for this instance of the function call. The renderer MUST copy this ID into the return response." + }, + "callFunction": { + "allOf": [ + { + "$ref": "common_types.json#/$defs/FunctionCall" + }, + { + "required": ["catalogId"] + } + ] + } + }, + "required": ["functionCallId", "callFunction"], + "additionalProperties": false + } + }, + "required": ["callRendererFunction", "version"], + "additionalProperties": false + }, + "AgentFunctionResponseMessage": { + "type": "object", + "properties": { + "version": { + "const": "v1.0" + }, + "agentFunctionResponse": { + "$ref": "common_types.json#/$defs/FunctionResponse" + } + }, + "required": ["agentFunctionResponse", "version"], + "additionalProperties": false + } + } +} diff --git a/spec/a2ui/v1_0/catalogs/basic/catalog.json b/spec/a2ui/v1_0/catalogs/basic/catalog.json new file mode 100644 index 00000000..66ad59b4 --- /dev/null +++ b/spec/a2ui/v1_0/catalogs/basic/catalog.json @@ -0,0 +1,1346 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "protocolVersion": "1.0", + "title": "A2UI Basic Catalog", + "description": "Unified catalog of basic A2UI components and functions.", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "instructions": "For layout, use the Row and Column components to organize other components.\n\n## Catalog Guidelines\n\n1. String Concatenation & Formatting: A2UI does not support binary operators like '+' or formatting symbols. To concatenate strings or dynamically inject data bindings into text, you must use the catalog function `formatString(value)` where the value string contains placeholders formatted as `${expression}`:\n formatString(\"Hello ${/user/name}\")\n\n2. Strict Hierarchy: You must strictly adhere to the requested component nesting and hierarchy. If the prompt specifies that a component is 'inside' or 'contained in' another component, you MUST place it as a child of that specific component, not as a sibling or in a different container.\n\n3. Validation Checks: When components support validation checks, specify any custom error messages directly as the 'message' inside the check. Do NOT create separate text-display components to display validation errors.\n\n## Examples\n\nExample 1: Dynamic text form\n```json\n[\n {\n \"version\": \"v1.0\",\n \"createSurface\": {\n \"surfaceId\": \"main\",\n \"components\": [\n {\n \"id\": \"root\",\n \"component\": \"Column\",\n \"children\": [\"repField\", \"valueField\"]\n },\n {\n \"id\": \"repField\",\n \"component\": \"TextField\",\n \"label\": \"Representative\",\n \"value\": {\"path\": \"/form/rep\"},\n \"placeholder\": \"Enter name\"\n },\n {\n \"id\": \"valueField\",\n \"component\": \"TextField\",\n \"label\": \"Deal Value\",\n \"value\": {\"path\": \"/form/value\"},\n \"placeholder\": \"0.00\",\n \"variant\": \"number\",\n \"checks\": [\n {\"call\": \"required\"}\n ]\n }\n ],\n \"dataModel\": {\n \"form\": {\n \"rep\": \"John Doe\",\n \"value\": 1500.00\n }\n }\n }\n }\n]\n```\n\nExample 2: Dynamic list with templates\n```json\n[\n {\n \"version\": \"v1.0\",\n \"createSurface\": {\n \"surfaceId\": \"main\",\n \"components\": [\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"child\": \"breedList\"\n },\n {\n \"id\": \"breedList\",\n \"component\": \"List\",\n \"children\": {\n \"path\": \"/breeds\",\n \"componentId\": \"breedTemplate\"\n },\n \"direction\": \"horizontal\"\n },\n {\n \"id\": \"breedTemplate\",\n \"component\": \"Image\",\n \"url\": {\"path\": \"url\"}\n }\n ],\n \"dataModel\": {\n \"breeds\": [\n {\n \"url\": \"https://example.com/poodle.jpg\"\n },\n {\n \"url\": \"https://example.com/lab.jpg\"\n }\n ]\n }\n }\n }\n]\n```", + "components": { + "Text": { + "type": "object", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The text content to display. While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation." + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": ["caption", "body"], + "default": "body" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "text"] + }, + "Image": { + "type": "object", + "properties": { + "component": { + "const": "Image" + }, + "url": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The URL of the image to display." + }, + "description": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "Accessibility text for the image." + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": ["contain", "cover", "fill", "none", "scaleDown"], + "default": "fill" + }, + "variant": { + "type": "string", + "description": "A hint for the image size and style.", + "enum": ["icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"], + "default": "mediumFeature" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "url"] + }, + "Icon": { + "type": "object", + "properties": { + "component": { + "const": "Icon" + }, + "name": { + "description": "The name of the icon to display.", + "oneOf": [ + { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "fastForward", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "pause", + "payment", + "person", + "phone", + "photo", + "play", + "print", + "refresh", + "rewind", + "search", + "send", + "settings", + "share", + "shoppingCart", + "skipNext", + "skipPrevious", + "star", + "starHalf", + "starOff", + "stop", + "upload", + "visibility", + "visibilityOff", + "volumeDown", + "volumeMute", + "volumeOff", + "volumeUp", + "warning" + ] + }, + { + "type": "object", + "properties": { + "svgPath": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + } + }, + "required": ["svgPath"], + "additionalProperties": false + }, + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DataBinding" + } + ] + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "name"] + }, + "Video": { + "type": "object", + "properties": { + "component": { + "const": "Video" + }, + "url": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The URL of the video to display." + }, + "posterUrl": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The URL of the poster image to display before the video plays." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "url"] + }, + "AudioPlayer": { + "type": "object", + "properties": { + "component": { + "const": "AudioPlayer" + }, + "url": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The URL of the audio to be played." + }, + "description": { + "description": "A description of the audio, such as a title or summary.", + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "url"] + }, + "Row": { + "type": "object", + "description": "A layout component that arranges its children horizontally. To create a grid layout, nest Columns within this Row.", + "properties": { + "component": { + "const": "Row" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList" + }, + "justify": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). Use 'spaceBetween' to push items to the edges, or 'start'/'end'/'center' to pack them together.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start", + "stretch" + ], + "default": "start" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This is similar to the CSS 'align-items' property, but uses camelCase values (e.g., 'start').", + "enum": ["start", "center", "end", "stretch"], + "default": "stretch" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "children"] + }, + "Column": { + "type": "object", + "description": "A layout component that arranges its children vertically. To create a grid layout, nest Rows within this Column.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList" + }, + "justify": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). Use 'spaceBetween' to push items to the edges (e.g. header at top, footer at bottom), or 'start'/'end'/'center' to pack them together.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly", + "stretch" + ], + "default": "start" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This is similar to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"], + "default": "stretch" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "children"] + }, + "List": { + "type": "object", + "properties": { + "component": { + "const": "List" + }, + "children": { + "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list.", + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList" + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"], + "default": "vertical" + }, + "align": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"], + "default": "stretch" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "children"] + }, + "Card": { + "type": "object", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", + "description": "The ID of the single child component to be rendered inside the card. To display multiple elements, you MUST wrap them in a layout component (like Column or Row) and pass that container's ID here. Do NOT pass multiple IDs or a non-existent ID." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "child"] + }, + "Tabs": { + "type": "object", + "properties": { + "component": { + "const": "Tabs" + }, + "tabs": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "title": { + "description": "The tab title.", + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + "child": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", + "description": "The ID of the child component." + } + }, + "required": ["title", "child"], + "additionalProperties": false + } + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "tabs"] + }, + "Modal": { + "type": "object", + "properties": { + "component": { + "const": "Modal" + }, + "trigger": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." + }, + "content": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", + "description": "The ID of the component to be displayed inside the modal." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "trigger", "content"] + }, + "Divider": { + "type": "object", + "properties": { + "component": { + "const": "Divider" + }, + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"], + "default": "horizontal" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component"] + }, + "Button": { + "type": "object", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", + "description": "The ID of the child component. Use a 'Text' component for a labeled button. Only use an 'Icon' if the requirements explicitly ask for an icon-only button." + }, + "variant": { + "type": "string", + "description": "A hint for the button style. If omitted, a default button style is used. 'primary' indicates this is the main call-to-action button. 'borderless' means the button has no visual border or background, making its child content appear like a clickable link.", + "enum": ["default", "primary", "borderless"], + "default": "default" + }, + "action": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Action" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "child", "action"] + } + ] + }, + "TextField": { + "type": "object", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "TextField" + }, + "label": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The text label for the input field." + }, + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The value of the text field." + }, + "placeholder": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The placeholder text for the input field." + }, + "variant": { + "type": "string", + "description": "The type of input field to display.", + "enum": ["longText", "number", "shortText", "obscured"], + "default": "shortText" + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "label"] + } + ] + }, + "CheckBox": { + "type": "object", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "CheckBox" + }, + "label": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The text to display next to the checkbox." + }, + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", + "description": "The current state of the checkbox (true for checked, false for unchecked)." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "label", "value"] + } + ] + }, + "ChoicePicker": { + "type": "object", + "description": "A component that allows selecting one or more options from a list.", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "ChoicePicker" + }, + "label": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The label for the group of options." + }, + "variant": { + "type": "string", + "description": "A hint for how the choice picker should be displayed and behave.", + "enum": ["multipleSelection", "mutuallyExclusive"], + "default": "mutuallyExclusive" + }, + "options": { + "type": "array", + "description": "The list of available options to choose from.", + "items": { + "type": "object", + "properties": { + "label": { + "description": "The text to display for this option.", + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + "value": { + "type": "string", + "description": "The stable value associated with this option." + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicStringList", + "description": "The list of currently selected values. This should be bound to a string array in the data model." + }, + "displayStyle": { + "type": "string", + "description": "The display style of the component.", + "enum": ["checkbox", "chips"], + "default": "checkbox" + }, + "filterable": { + "type": "boolean", + "description": "If true, displays a search input to filter the options.", + "default": false + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "options", "value"] + } + ] + }, + "Slider": { + "type": "object", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Slider" + }, + "label": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The label for the slider." + }, + "min": { + "type": "number", + "description": "The minimum value of the slider.", + "default": 0 + }, + "max": { + "type": "number", + "description": "The maximum value of the slider." + }, + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", + "description": "The current value of the slider." + }, + "steps": { + "type": "integer", + "minimum": 1, + "description": "The number of discrete divisions in the slider range. If specified, the slider will snap to discrete values." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "value", "max"] + } + ] + }, + "DateTimeInput": { + "type": "object", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "DateTimeInput" + }, + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The selected date and/or time value in ISO 8601 format. If not yet set, initialize with an empty string." + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date.", + "default": false + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time.", + "default": false + }, + "min": { + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + { + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "format": "date" + }, + { + "format": "time" + }, + { + "format": "date-time" + } + ] + } + } + ], + "description": "The minimum allowed date/time in ISO 8601 format." + }, + "max": { + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + { + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "format": "date" + }, + { + "format": "time" + }, + { + "format": "date-time" + } + ] + } + } + ], + "description": "The maximum allowed date/time in ISO 8601 format." + }, + "label": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The text label for the input field." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + } + }, + "required": ["component", "value"] + } + ] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined, or empty.", + "returnType": "validationResult", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": { + "description": "The value to check." + } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "regex": { + "type": "object", + "description": "Checks that the value matches a regular expression string.", + "returnType": "validationResult", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "regex" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + "pattern": { + "type": "string", + "description": "The regex pattern to match against." + } + }, + "required": ["value", "pattern"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "length": { + "type": "object", + "description": "Checks string length constraints.", + "returnType": "validationResult", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "length" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + }, + "min": { + "type": "integer", + "minimum": 0, + "description": "The minimum allowed length." + }, + "max": { + "type": "integer", + "minimum": 0, + "description": "The maximum allowed length." + } + }, + "required": ["value"], + "anyOf": [ + { + "required": ["min"] + }, + { + "required": ["max"] + } + ], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "numeric": { + "type": "object", + "description": "Checks numeric range constraints.", + "returnType": "validationResult", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "numeric" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber" + }, + "min": { + "type": "number", + "description": "The minimum allowed value." + }, + "max": { + "type": "number", + "description": "The maximum allowed value." + } + }, + "required": ["value"], + "anyOf": [ + { + "required": ["min"] + }, + { + "required": ["max"] + } + ], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "email": { + "type": "object", + "description": "Checks that the value is a valid email address.", + "returnType": "validationResult", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "email" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "formatString": { + "type": "object", + "description": "Performs string interpolation of data model values and other functions in the catalog functions list and returns the resulting string. The value string can contain interpolated expressions in the `${expression}` format. Supported expression types include: JSON Pointer paths to the data model (e.g., `${/absolute/path}` or `${relative/path}`), and renderer-side function calls (e.g., `${now()}`). Function arguments must be named (e.g., `${formatDate(value:${/currentDate}, format:'MM-dd')}`). To include a literal `${` sequence, escape it as `\\${`.", + "returnType": "string", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" + } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "formatNumber": { + "type": "object", + "description": "Formats a number with the specified grouping and decimal precision.", + "returnType": "string", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "formatNumber" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", + "description": "The number to format." + }, + "decimals": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + "grouping": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "formatCurrency": { + "type": "object", + "description": "Formats a number as a currency string.", + "returnType": "string", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "formatCurrency" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", + "description": "The monetary amount." + }, + "currency": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The ISO 4217 currency code (e.g., 'USD', 'EUR')." + }, + "decimals": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", + "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." + }, + "grouping": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", + "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." + } + }, + "required": ["currency", "value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "formatDate": { + "type": "object", + "description": "Formats a timestamp into a string using a pattern.", + "returnType": "string", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "formatDate" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicValue", + "description": "The date to format." + }, + "format": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "A Unicode TR35 date pattern string.\n\nToken Reference:\n- Year: 'yy' (26), 'yyyy' (2026)\n- Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January)\n- Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday)\n- Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM\n- Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time\n- Minute: 'mm' (00-59)\n- Second: 'ss' (00-59)\n- Period: 'a' (AM/PM)\n\nExamples:\n- 'MMM dd, yyyy' -> 'Jan 16, 2026'\n- 'HH:mm' -> '14:30' (Military)\n- 'h:mm a' -> '2:30 PM'\n- 'EEEE, d MMMM' -> 'Friday, 16 January'" + } + }, + "required": ["format", "value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "pluralize": { + "type": "object", + "description": "Returns a localized string based on the Common Locale Data Repository (CLDR) plural category of the count (zero, one, two, few, many, other). Requires an 'other' fallback. For English, just use 'one' and 'other'.", + "returnType": "string", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "pluralize" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", + "description": "The numeric value used to determine the plural category." + }, + "zero": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "String for the 'zero' category (e.g., 0 items)." + }, + "one": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "String for the 'one' category (e.g., 1 item)." + }, + "two": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "String for the 'two' category (used in Arabic, Welsh, etc.)." + }, + "few": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "String for the 'few' category (e.g., small groups in Slavic languages)." + }, + "many": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "String for the 'many' category (e.g., large groups in various languages)." + }, + "other": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", + "description": "The default/fallback string (used for general plural cases)." + } + }, + "required": ["value", "other"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "openUrl": { + "type": "object", + "description": "Opens the specified URL in a browser or handler (requires user activation). This function has no return value.", + "returnType": "void", + "requiresUserActivation": true, + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "description": "The URL to open.", + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DataBinding" + }, + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCall" + } + ] + } + }, + "required": ["url"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "and": { + "type": "object", + "description": "Performs a logical AND operation on a list of boolean values.", + "returnType": "boolean", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "and" + }, + "args": { + "type": "object", + "properties": { + "values": { + "type": "array", + "description": "The list of boolean values to evaluate.", + "items": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean" + }, + "minItems": 2 + } + }, + "required": ["values"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "or": { + "type": "object", + "description": "Performs a logical OR operation on a list of boolean values.", + "returnType": "boolean", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "or" + }, + "args": { + "type": "object", + "properties": { + "values": { + "type": "array", + "description": "The list of boolean values to evaluate.", + "items": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean" + }, + "minItems": 2 + } + }, + "required": ["values"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + }, + "not": { + "type": "object", + "description": "Performs a logical NOT operation on a boolean value.", + "returnType": "boolean", + "allOf": [ + { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" + }, + { + "type": "object", + "properties": { + "call": { + "const": "not" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", + "description": "The boolean value to negate." + } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["call", "args"] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/Image" + }, + { + "$ref": "#/components/Icon" + }, + { + "$ref": "#/components/Video" + }, + { + "$ref": "#/components/AudioPlayer" + }, + { + "$ref": "#/components/Row" + }, + { + "$ref": "#/components/Column" + }, + { + "$ref": "#/components/List" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/Tabs" + }, + { + "$ref": "#/components/Modal" + }, + { + "$ref": "#/components/Divider" + }, + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/TextField" + }, + { + "$ref": "#/components/CheckBox" + }, + { + "$ref": "#/components/ChoicePicker" + }, + { + "$ref": "#/components/Slider" + }, + { + "$ref": "#/components/DateTimeInput" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/required" + }, + { + "$ref": "#/functions/regex" + }, + { + "$ref": "#/functions/length" + }, + { + "$ref": "#/functions/numeric" + }, + { + "$ref": "#/functions/email" + }, + { + "$ref": "#/functions/formatString" + }, + { + "$ref": "#/functions/formatNumber" + }, + { + "$ref": "#/functions/formatCurrency" + }, + { + "$ref": "#/functions/formatDate" + }, + { + "$ref": "#/functions/pluralize" + }, + { + "$ref": "#/functions/openUrl" + }, + { + "$ref": "#/functions/and" + }, + { + "$ref": "#/functions/or" + }, + { + "$ref": "#/functions/not" + } + ] + } + } +} diff --git a/spec/a2ui/v1_0/common_types.json b/spec/a2ui/v1_0/common_types.json new file mode 100644 index 00000000..c2d5f39d --- /dev/null +++ b/spec/a2ui/v1_0/common_types.json @@ -0,0 +1,395 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v1_0/common_types.json", + "title": "A2UI Common Types", + "description": "Common type definitions used across A2UI schemas.", + "$defs": { + "ComponentId": { + "type": "string", + "description": "The unique identifier for a component, used for both definitions and references within the same surface." + }, + "CallId": { + "type": "string", + "description": "The unique identifier for a function call." + }, + "AccessibilityAttributes": { + "type": "object", + "description": "Attributes to enhance accessibility when using assistive technologies like screen readers or model understanding.", + "properties": { + "label": { + "$ref": "#/$defs/DynamicString", + "description": "A short string, typically 1 to 3 words, used by assistive technologies to convey the purpose or intent of an element. For example, an input field might have an accessible label of 'User ID' or a button might be labeled 'Submit'." + }, + "description": { + "$ref": "#/$defs/DynamicString", + "description": "Additional information provided by assistive technologies about an element such as instructions, format requirements, or result of an action. For example, a mute button might have a label of 'Mute' and a description of 'Silences notifications about this conversation'." + }, + "live": { + "type": "string", + "enum": ["off", "polite", "assertive"], + "default": "off", + "description": "Controls screen reader announcements for dynamic updates (WAI-ARIA aria-live). 'polite' waits for user pause; 'assertive' interrupts immediately for alerts." + }, + "hidden": { + "$ref": "#/$defs/DynamicBoolean", + "description": "Hides the element and its children from assistive technologies when true. Default is false." + } + }, + "additionalProperties": false + }, + "Extensions": { + "type": "object", + "description": "Optional extension metadata. Keys MUST be Unicode identifiers (UAX #31). Keys starting with 'a2ui_' are reserved for official extensions.", + "patternProperties": { + "^[\\p{XID_Start}_][\\p{XID_Continue}]*$": {} + }, + "additionalProperties": false + }, + "ComponentCommon": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/ComponentId" + }, + "catalogId": { + "type": "string", + "description": "The catalog ID for this component, overriding any surface-level default catalogId." + }, + "accessibility": { + "$ref": "#/$defs/AccessibilityAttributes" + }, + "metadata": { + "type": "object", + "description": "Optional component-level metadata for vendor extensions.", + "properties": { + "extensions": { + "$ref": "#/$defs/Extensions" + } + }, + "additionalProperties": false + } + }, + "required": ["id"] + }, + "Child": { + "$ref": "#/$defs/ComponentId", + "description": "A reference to a single child component ID." + }, + "ChildList": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/ComponentId" + }, + "description": "A static list of child component IDs." + }, + { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. The `componentId` is the component to use as a template.", + "properties": { + "componentId": { + "$ref": "#/$defs/ComponentId" + }, + "path": { + "type": "string", + "description": "The path to the list of component property objects in the data model." + } + }, + "required": ["componentId", "path"], + "additionalProperties": false + } + ] + }, + "DataBinding": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "A JSON Pointer path to a value in the data model." + } + }, + "required": ["path"], + "additionalProperties": false + }, + "DynamicValue": { + "description": "A value that can be a literal, a path, or a function call returning any type.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "object", + "not": { + "anyOf": [ + { + "required": ["path"] + }, + { + "required": ["call"] + } + ] + } + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicString": { + "description": "Represents a string", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicNumber": { + "description": "Represents a value that can be either a literal number, a path to a number in the data model, or a function call returning a number.", + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicBoolean": { + "description": "A boolean value that can be a literal, a path, or a function call returning a boolean.", + "oneOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicStringList": { + "description": "Represents a value that can be either a literal array of strings, a path to a string array in the data model, or a function call returning a string array.", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "FunctionCommon": { + "type": "object", + "properties": { + "catalogId": { + "type": "string", + "description": "The catalog ID for this function, overriding any surface-level default catalogId." + } + } + }, + "IndexSystemFunction": { + "type": "object", + "description": "Returns the 0-based index of the current item when rendering a dynamic list from a template. This function MUST ONLY be available when evaluating template items within a list context.", + "returnType": "number", + "properties": { + "call": { + "const": "@index" + }, + "args": { + "type": "object", + "properties": { + "offset": { + "$ref": "#/$defs/DynamicNumber", + "description": "Optional. An offset to add to the 0-based index (e.g., 1 for 1-based indexing). Defaults to 0.", + "default": 0 + } + }, + "unevaluatedProperties": false + } + }, + "required": ["call"], + "unevaluatedProperties": false + }, + "FunctionCall": { + "type": "object", + "description": "Invokes a named function.", + "properties": { + "call": { + "type": "string", + "description": "The name of the function to call." + }, + "catalogId": { + "type": "string", + "description": "The catalog ID for this function, overriding any surface-level default catalogId." + }, + "args": { + "type": "object", + "description": "Arguments passed to the function.", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/DynamicValue" + }, + { + "type": "object", + "description": "A literal object argument (e.g. configuration)." + } + ] + } + } + }, + "required": ["call"], + "oneOf": [ + {"$ref": "catalog.json#/$defs/anyFunction"}, + {"$ref": "#/$defs/IndexSystemFunction"} + ], + "unevaluatedProperties": false + }, + "CheckRule": { + "type": "object", + "description": "A single validation check rule applied to an input component. The condition function or path evaluates to a ValidationResult object.", + "properties": { + "condition": { + "oneOf": [{"$ref": "#/$defs/DataBinding"}, {"$ref": "#/$defs/FunctionCall"}], + "description": "Path or function call evaluating to a ValidationResult object." + }, + "message": { + "type": "string", + "description": "Optional fallback error message." + } + }, + "required": ["condition"], + "additionalProperties": false + }, + "Checkable": { + "description": "Properties for components that support renderer-side checks.", + "type": "object", + "properties": { + "checks": { + "type": "array", + "description": "A list of checks to perform. These are function calls that must return a boolean indicating validity.", + "items": { + "$ref": "#/$defs/CheckRule" + } + } + } + }, + "Action": { + "description": "Defines an interaction handler that can either trigger an agent-side event or execute a local renderer-side function.", + "oneOf": [ + { + "type": "object", + "description": "Triggers an agent-side event.", + "properties": { + "event": { + "type": "object", + "description": "The event to dispatch to the agent.", + "properties": { + "name": { + "type": "string", + "description": "The name of the action to be dispatched to the agent." + }, + "userMessage": { + "$ref": "#/$defs/DynamicString", + "description": "An optional human-readable message describing the action performed by the user, to present in conversation history or user feedback." + }, + "context": { + "type": "object", + "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.", + "additionalProperties": { + "$ref": "#/$defs/DynamicValue" + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["event"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Executes a renderer or agent-side function.", + "properties": { + "functionCall": { + "$ref": "#/$defs/FunctionCall" + } + }, + "required": ["functionCall"], + "additionalProperties": false + } + ] + }, + "Surface": { + "title": "Surface Container Component", + "description": "The reserved canonical container component representing an A2UI surface. The Surface component is immutable and always has 'child': 'root'.", + "type": "object", + "allowedParents": [], + "properties": { + "component": { + "const": "Surface" + }, + "child": { + "const": "root" + } + }, + "additionalProperties": false + }, + "FunctionResponse": { + "type": "object", + "description": "The return response matching a callAgentFunction or callRendererFunction invocation.", + "properties": { + "functionCallId": { + "$ref": "#/$defs/CallId", + "description": "The unique ID matching the initiating function call." + }, + "value": { + "description": "The return value of the function." + }, + "error": { + "type": "object", + "description": "An error object indicating failure of the function execution.", + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"} + }, + "required": ["code", "message"], + "additionalProperties": false + } + }, + "required": ["functionCallId"], + "oneOf": [{"required": ["value"]}, {"required": ["error"]}], + "additionalProperties": false + } + } +} diff --git a/src/app/api/pathway/agui/route.ts b/src/app/api/pathway/agui/route.ts new file mode 100644 index 00000000..8496c1e7 --- /dev/null +++ b/src/app/api/pathway/agui/route.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; + +import { + EventType, + type CustomEvent, + type RunErrorEvent, + type RunFinishedEvent, + type RunStartedEvent, + type StateSnapshotEvent, +} from '@ag-ui/core'; + +import type { Anchor } from '@/lib/pathway/events'; +import { streamPathway } from '@/lib/pathway/generate'; +import type { PathwayPlan } from '@/lib/pathway/schema'; + +export const maxDuration = 120; + +/** + * The pathway build as an AG-UI run — the standards-conformant transport + * beside the app's own NDJSON at `/api/pathway`. + * + * Same pipeline, same events, different envelope: any AG-UI client can + * drive a build without learning our vocabulary. The mapping is deliberate + * and total: + * + * - the run brackets as `RUN_STARTED` / `RUN_FINISHED` (or `RUN_ERROR`); + * - every domain event crosses as a `CUSTOM` event named + * `a2learn.` with the untouched event as its value — domain + * semantics belong to the a2learn layer, not to the transport; + * - one `STATE_SNAPSHOT` lands before the finish with the assembled + * result, for clients that want the artifact without replaying events. + * + * Conformance is compile-checked: every emitted object is typed against + * `@ag-ui/core`'s event interfaces, so drifting from the protocol fails + * the build rather than a partner's integration. + */ + +function encode(event: unknown): string { + return `${JSON.stringify(event)}\n`; +} + +export async function POST(request: Request) { + let body: { topic?: unknown; gradeHint?: unknown; threadId?: unknown }; + try { + body = await request.json(); + } catch { + const error: RunErrorEvent = { type: EventType.RUN_ERROR, message: 'Expected a JSON body.' }; + return new Response(encode(error), { status: 400, headers: { 'Content-Type': 'application/x-ndjson' } }); + } + + const topic = typeof body.topic === 'string' ? body.topic.trim() : ''; + if (!topic) { + const error: RunErrorEvent = { type: EventType.RUN_ERROR, message: 'A topic is required.' }; + return new Response(encode(error), { status: 400, headers: { 'Content-Type': 'application/x-ndjson' } }); + } + + const gradeHint = typeof body.gradeHint === 'string' && body.gradeHint.trim() ? body.gradeHint.trim() : undefined; + const threadId = typeof body.threadId === 'string' && body.threadId ? body.threadId : 'a2learn-pathway'; + const runId = randomUUID(); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const emit = (event: object) => controller.enqueue(encoder.encode(encode(event))); + + const started: RunStartedEvent = { type: EventType.RUN_STARTED, threadId, runId }; + emit(started); + + try { + let anchor: Anchor | null = null; + let plan: PathwayPlan | null = null; + const stepWidgets: Record = {}; + const rejected: string[] = []; + + for await (const event of streamPathway(topic, gradeHint, null)) { + if (event.type === 'anchor') anchor = event.anchor; + if (event.type === 'plan') plan = event.plan; + if (event.type === 'step-widget') stepWidgets[event.stepIndex] = event.widget; + if (event.type === 'verdict' && !event.resolved) rejected.push(event.code); + if (event.type === 'error') throw new Error(event.message); + + const custom: CustomEvent = { type: EventType.CUSTOM, name: `a2learn.${event.type}`, value: event }; + emit(custom); + } + + const snapshot: StateSnapshotEvent = { + type: EventType.STATE_SNAPSHOT, + snapshot: { anchor, plan, stepWidgets, rejectedCodes: rejected }, + }; + emit(snapshot); + + const finished: RunFinishedEvent = { type: EventType.RUN_FINISHED, threadId, runId }; + emit(finished); + } catch (error) { + const failed: RunErrorEvent = { + type: EventType.RUN_ERROR, + message: error instanceof Error ? error.message : 'Pathway generation failed.', + }; + emit(failed); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache, no-transform', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts new file mode 100644 index 00000000..a3fc1bde --- /dev/null +++ b/src/lib/a2learn/a2ui.ts @@ -0,0 +1,125 @@ +import type { FlashcardSpec, MarkdownCardSpec } from '@/lib/pathway/schema'; + +/** + * The boundary mapper: a widget spec, expressed as a Google A2UI v1.0 + * `createSurface` message against the standard `basic` catalog. + * + * This is where the industry-standard claim becomes checkable: every surface + * this module emits validates against the vendored upstream schemas in + * `spec/a2ui/v1_0/` (`pnpm conformance`, enforced in CI). The app's own wire + * format stays the flat authoring schema; A2UI is produced here, at the + * boundary, per the a2learn format's alignment rule. + * + * Coverage is deliberately incremental and honestly labeled: + * `A2UI_SUPPORTED_KINDS` names what maps today. The first two kinds are the + * *reading* shapes — mapping loses no interaction because the basic catalog + * can express them fully. Interactive kinds (drag-sort, draft-meter …) need + * either A2UI's function/template machinery or an a2learn catalog extension; + * they join this list one verifiable fixture at a time, never by claim. + */ + +/** The standard catalog every surface here targets. */ +export const A2UI_BASIC_CATALOG = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json'; + +export const A2UI_SUPPORTED_KINDS = ['markdown-card', 'flashcard'] as const; + +export type A2UIComponent = { id: string; component: string } & Record; + +export type A2UISurfaceMessage = { + version: 'v1.0'; + createSurface: { + surfaceId: string; + catalogId: string; + components: A2UIComponent[]; + dataModel?: Record; + }; +}; + +function completedAction(kind: string) { + return { + event: { + name: 'a2learn.widget_completed', + userMessage: 'Finished the activity.', + context: { kind, correct: true }, + }, + }; +} + +/** + * markdown-card: title, markdown body, optional tip, a done button. The + * basic catalog's `Text` accepts simple Markdown, so the reading surface + * maps without loss. + */ +function markdownCardSurface(spec: MarkdownCardSpec, surfaceId: string): A2UISurfaceMessage { + const components: A2UIComponent[] = [ + { id: 'root', component: 'Card', child: 'layout' }, + { + id: 'layout', + component: 'Column', + children: ['title', 'body', ...(spec.tip ? ['tip'] : []), 'done'], + }, + { id: 'title', component: 'Text', text: `**${spec.title}**` }, + { id: 'body', component: 'Text', text: spec.body }, + ...(spec.tip + ? [{ id: 'tip', component: 'Text', text: `Tip: ${spec.tip}`, variant: 'caption' } as A2UIComponent] + : []), + { id: 'done', component: 'Button', variant: 'primary', child: 'done-label', action: completedAction(spec.kind) }, + { id: 'done-label', component: 'Text', text: 'Got it' }, + ]; + + return { + version: 'v1.0', + createSurface: { + surfaceId, + catalogId: A2UI_BASIC_CATALOG, + components, + dataModel: { a2learn: { kind: spec.kind } }, + }, + }; +} + +/** + * flashcard: the prompt, then each card as front/back stacked in a Card. + * Static children rather than a template: both sides visible is the honest + * degradation the basic catalog supports — tap-to-flip needs local state + * the standard catalog does not model, and arrives with an a2learn catalog + * extension rather than a pretend mapping. + */ +function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMessage { + const sideText = (side: FlashcardSpec['cards'][number]['front']): string => + [side.text, side.markdown].filter(Boolean).join('\n\n') || '—'; + + const cardComponents: A2UIComponent[] = spec.cards.flatMap((card, i) => [ + { id: `card-${i}`, component: 'Card', child: `card-${i}-col` }, + { id: `card-${i}-col`, component: 'Column', children: [`card-${i}-front`, `card-${i}-divider`, `card-${i}-back`] }, + { id: `card-${i}-front`, component: 'Text', text: `**${sideText(card.front)}**` }, + { id: `card-${i}-divider`, component: 'Divider' }, + { id: `card-${i}-back`, component: 'Text', text: sideText(card.back) }, + ]); + + const components: A2UIComponent[] = [ + { id: 'root', component: 'Column', children: ['prompt', ...spec.cards.map((_, i) => `card-${i}`), 'done'] }, + { id: 'prompt', component: 'Text', text: spec.prompt }, + ...cardComponents, + { id: 'done', component: 'Button', variant: 'primary', child: 'done-label', action: completedAction(spec.kind) }, + { id: 'done-label', component: 'Text', text: 'Done' }, + ]; + + return { + version: 'v1.0', + createSurface: { + surfaceId, + catalogId: A2UI_BASIC_CATALOG, + components, + dataModel: { a2learn: { kind: spec.kind } }, + }, + }; +} + +/** Null for kinds not yet mapped — callers state the gap rather than guess. */ +export function toA2UISurface(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { + const kind = (spec as { kind?: string })?.kind; + if (kind === 'markdown-card') return markdownCardSurface(spec as MarkdownCardSpec, surfaceId); + if (kind === 'flashcard') return flashcardSurface(spec as FlashcardSpec, surfaceId); + return null; +} diff --git a/src/lib/a2learn/manifest.ts b/src/lib/a2learn/manifest.ts new file mode 100644 index 00000000..b38d459c --- /dev/null +++ b/src/lib/a2learn/manifest.ts @@ -0,0 +1,77 @@ +import type { StandardRef } from '@/lib/standards/types'; + +/** + * The a2learn layer, as code rather than only prose. + * + * An activity travels as two documents: a *surface* (how it renders — see + * `a2ui.ts`, a profile of Google's A2UI) and this *manifest* (what it + * teaches, whether it measures, where it came from, and exactly how to + * build it). The manifest is the half an agent reads before deciding, and + * the half a marketplace listing is made of. + * + * Format-versioned independently of the app: consumers pin `a2learn`, not + * our release number. + */ + +export const A2LEARN_VERSION = '0.1.0'; + +export type ActivityManifest = { + /** Manifest format version — semver, bumped by RFC, not by refactor. */ + a2learn: string; + title: string; + /** The verified standard this activity teaches — or the honest absence. */ + standard: { + code: string; + verified: boolean; + /** Which graph verified it, e.g. "Learning Commons". */ + source: string; + description?: string; + }; + pedagogy: { + /** Widget kind from the registry. */ + kind: string; + /** Whether completing this activity records a real verdict. */ + assesses: boolean; + }; + /** BCP-47. Generation is English-first today; the field exists so the + format never has to break to say otherwise. */ + language: string; + provenance: { + generatedAt: string; + /** Codes proposed and rejected by the graph — kept on the record. */ + rejectedCodes: string[]; + }; + /** The exact call that manufactures this activity — find → invoke. */ + invoke: { + tool: 'show_widget'; + arguments: Record; + }; +}; + +export function buildManifest(input: { + title: string; + standard: StandardRef; + kind: string; + assesses: boolean; + language?: string; + rejectedCodes?: string[]; + invokeArguments: Record; +}): ActivityManifest { + return { + a2learn: A2LEARN_VERSION, + title: input.title, + standard: { + code: input.standard.code, + verified: input.standard.verified, + source: input.standard.sourceLabel, + description: input.standard.description, + }, + pedagogy: { kind: input.kind, assesses: input.assesses }, + language: input.language ?? 'en', + provenance: { + generatedAt: new Date().toISOString(), + rejectedCodes: input.rejectedCodes ?? [], + }, + invoke: { tool: 'show_widget', arguments: input.invokeArguments }, + }; +} From f2c4df2d5cb2a405824a258322d05684f11acffc Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 13:27:06 -0400 Subject: [PATCH 03/23] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20de?= =?UTF-8?q?rived=20support=20list,=20no=20false=20claims,=20lifecycle=20sa?= =?UTF-8?q?fety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the pre-merge review pass: - a2ui mapper: the dispatch table IS the support list (A2UI_SUPPORTED_KINDS derives from MAPPERS, typed on WidgetKind — list and dispatch cannot drift); flashcard sides render text/markdown/images as separate components (an image-only side is legal and no longer degrades to a dash); no emphasis wrapping (bold can't span joined lines); successMessage carried as a caption; completedAction drops the unconditional correct:true — neither mapped kind assesses, and the mapper must never assert mastery nothing measured. Envelope and done button shared so N kinds can't fork them. - Event namespace (a2learn.) owned by the a2learn layer, imported by the transport and the mapper both. - AG-UI route: doc comment states the real scope (no profile/persistence yet — phase-2 convergence on runPathway); 400s emit RUN_STARTED before RUN_ERROR so standard client middleware sees a legal sequence; a cancelled consumer gets a quiet teardown (closed guard + cancel hook) instead of enqueue-after-close throw cascades; dead in-band error branch removed. - Conformance gains a first layer: fixtures must parse against the app's real zod widget schema, so goldens prove the mapper against specs the pipeline can produce (the flashcard fixture was missing its required successMessage — caught immediately). catalogId is asserted explicitly (the ajv alias made validation catalog-blind), template-form children resolve, failures go through one fail() helper, and the bundle imports from a data URL instead of writing into node_modules. - Workflow: permissions, concurrency, timeout, .nvmrc; package.json em-dash churn reverted. Co-Authored-By: Claude Fable 5 --- .github/workflows/conformance.yml | 10 +- scripts/a2ui-conformance.mjs | 106 +++++++---- spec/a2learn/fixtures/flashcard.spec.json | 4 +- spec/a2learn/fixtures/flashcard.surface.json | 69 +++++-- .../fixtures/markdown-card.surface.json | 5 +- src/app/api/pathway/agui/route.ts | 73 ++++++-- src/lib/a2learn/a2ui.ts | 176 ++++++++++++------ src/lib/a2learn/manifest.ts | 17 +- 8 files changed, 330 insertions(+), 130 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4a08aca6..0b7befb2 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -1,19 +1,27 @@ # A2UI conformance: the boundary mapper's surfaces must validate against the # vendored Google A2UI v1.0 schemas and match their committed goldens. +# Standalone workflow while the ci.yml-owning branch stack is in flight; +# fold into ci.yml's verify job after it merges. name: conformance on: pull_request: push: branches: [main] +permissions: + contents: read +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: a2ui: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm conformance diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs index 698c05a2..de5cf2f8 100644 --- a/scripts/a2ui-conformance.mjs +++ b/scripts/a2ui-conformance.mjs @@ -5,18 +5,23 @@ * pnpm conformance validate fixtures + compare golden surfaces * pnpm conformance:update regenerate the golden surfaces * - * Three layers of checking, strictest first: - * 1. Schema: every emitted message validates against the vendored - * `agent_to_renderer.json` (+ common_types + the basic catalog). - * 2. Structure: component ids are unique, and every child reference - * resolves — the flat-tree invariants the schema alone cannot state. - * 3. Drift: output matches the committed golden files, so a mapper change + * Four layers of checking, strictest first: + * 1. Input: every fixture spec must parse against the app's real widget + * schema (zod) — the gate proves the mapper handles specs the pipeline + * can actually produce, not hand-written fiction. + * 2. Schema: every emitted message validates against the vendored + * `agent_to_renderer.json` (+ common_types + the basic catalog), and + * must declare the exact catalog it was validated against. + * 3. Structure: component ids are unique, and every child reference — + * including the template form — resolves; the flat-tree invariants the + * schema alone cannot state. + * 4. Drift: output matches the committed golden files, so a mapper change * shows up in review as a surface diff, never silently. */ import { build } from 'esbuild'; -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { pathToFileURL, fileURLToPath } from 'node:url'; +import { fileURLToPath } from 'node:url'; import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; @@ -26,18 +31,26 @@ const specDir = join(root, 'spec', 'a2ui', 'v1_0'); const fixtureDir = join(root, 'spec', 'a2learn', 'fixtures'); const update = process.argv.includes('--update'); -// --- load the mapper (TS, with @/ aliases) via a throwaway esbuild bundle --- +// --- load the mapper AND the widget schema via one in-memory bundle --- const bundle = await build({ - entryPoints: [join(root, 'src', 'lib', 'a2learn', 'a2ui.ts')], + stdin: { + contents: ` + export { toA2UISurface, A2UI_SUPPORTED_KINDS, A2UI_BASIC_CATALOG } from '@/lib/a2learn/a2ui'; + export { widgetSpec } from '@/lib/pathway/schema'; + `, + resolveDir: root, + loader: 'ts', + }, bundle: true, format: 'esm', write: false, tsconfig: join(root, 'tsconfig.json'), logLevel: 'error', }); -const tmp = join(root, 'node_modules', '.a2learn-conformance.mjs'); -writeFileSync(tmp, bundle.outputFiles[0].text); -const { toA2UISurface, A2UI_SUPPORTED_KINDS } = await import(pathToFileURL(tmp).href); +// Imported from a data URL — nothing written to disk, nothing to clean up, +// and two concurrent runs cannot read each other's bundle. +const moduleUrl = `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}`; +const { toA2UISurface, A2UI_SUPPORTED_KINDS, A2UI_BASIC_CATALOG, widgetSpec } = await import(moduleUrl); // --- assemble the validator from the vendored schemas --- const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); @@ -51,7 +64,9 @@ ajv.addSchema(commonTypes); ajv.addSchema(basicCatalog); // `agent_to_renderer.json` refs `catalog.json#...` relative to its own $id; // the surface's actual catalog is bound at runtime. Alias the basic catalog -// to that resolved URL so validation uses the catalog our surfaces target. +// to that resolved URL so validation uses the catalog our surfaces target — +// and because the alias makes validation catalog-blind, layer 2 separately +// asserts each surface *declares* the catalog it was validated against. ajv.addSchema({ ...basicCatalog, $id: 'https://a2ui.org/specification/v1_0/catalog.json' }); const validate = ajv.compile(messageSchema); @@ -66,10 +81,15 @@ function structuralProblems(message) { } if (!ids.has('root')) problems.push("no 'root' component — createSurface implies Surface{child:'root'}"); for (const c of components) { - const refs = [ - ...(typeof c.child === 'string' ? [c.child] : []), - ...(Array.isArray(c.children) ? c.children : []), - ]; + const refs = []; + if (typeof c.child === 'string') refs.push(c.child); + if (Array.isArray(c.children)) refs.push(...c.children); + // ChildList's template form: { componentId, path } — the componentId is + // a reference too, and a typo there is exactly the silent-empty-list bug + // this layer exists to catch. + if (c.children && !Array.isArray(c.children) && typeof c.children === 'object') { + if (typeof c.children.componentId === 'string') refs.push(c.children.componentId); + } for (const ref of refs) { if (!ids.has(ref)) problems.push(`${c.id} references missing component: ${ref}`); } @@ -78,42 +98,59 @@ function structuralProblems(message) { } // --- run every fixture --- -mkdirSync(fixtureDir, { recursive: true }); +let failed = false; +const covered = new Set(); + +function fail(name, msg) { + console.error(`✗ ${name}: ${msg}`); + failed = true; +} + const specs = readdirSync(fixtureDir).filter((f) => f.endsWith('.spec.json')); if (!specs.length) { console.error(`No fixtures in ${fixtureDir} — add .spec.json widget specs.`); process.exit(1); } -let failed = false; -const covered = new Set(); - for (const file of specs) { const name = file.replace(/\.spec\.json$/, ''); const spec = readJson(join(fixtureDir, file)); - const surface = toA2UISurface(spec, `a2learn-fixture-${name}`); + + // Layer 1: the fixture must be a spec the pipeline could actually produce. + const parsed = widgetSpec.safeParse(spec); + if (!parsed.success) { + fail(name, `fixture is not a valid widget spec: ${parsed.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`); + continue; + } + + const surface = toA2UISurface(parsed.data, `a2learn-fixture-${name}`); if (!surface) { - console.error(`✗ ${name}: kind "${spec.kind}" is not mapped (A2UI_SUPPORTED_KINDS: ${A2UI_SUPPORTED_KINDS.join(', ')})`); - failed = true; + fail(name, `kind "${spec.kind}" is not mapped (A2UI_SUPPORTED_KINDS: ${A2UI_SUPPORTED_KINDS.join(', ')})`); continue; } covered.add(spec.kind); + // Layer 2: upstream schema validity, plus the catalog binding the alias + // above cannot check. if (!validate(surface)) { - console.error(`✗ ${name}: schema validation failed`); + fail(name, 'schema validation failed'); for (const err of validate.errors ?? []) console.error(` ${err.instancePath} ${err.message}`); - failed = true; + continue; + } + if (surface.createSurface.catalogId !== A2UI_BASIC_CATALOG) { + fail(name, `catalogId "${surface.createSurface.catalogId}" is not the catalog this suite validates against`); continue; } + // Layer 3. const structural = structuralProblems(surface); if (structural.length) { - console.error(`✗ ${name}: ${structural.join('; ')}`); - failed = true; + fail(name, structural.join('; ')); continue; } + // Layer 4. const goldenPath = join(fixtureDir, `${name}.surface.json`); const rendered = `${JSON.stringify(surface, null, 2)}\n`; if (update) { @@ -124,24 +161,21 @@ for (const file of specs) { try { golden = readFileSync(goldenPath, 'utf8'); } catch { - console.error(`✗ ${name}: no golden surface — run \`pnpm conformance:update\` and commit it`); - failed = true; + fail(name, 'no golden surface — run `pnpm conformance:update` and commit it'); continue; } if (golden !== rendered) { - console.error(`✗ ${name}: surface drifted from golden — review, then \`pnpm conformance:update\``); - failed = true; + fail(name, 'surface drifted from golden — review, then `pnpm conformance:update`'); continue; } - console.log(`✓ ${name}: schema-valid, structurally sound, matches golden`); + console.log(`✓ ${name}: spec-valid, schema-valid, structurally sound, matches golden`); } } // Every claimed kind must have at least one fixture — support is proven, not asserted. for (const kind of A2UI_SUPPORTED_KINDS) { if (!covered.has(kind)) { - console.error(`✗ A2UI_SUPPORTED_KINDS claims "${kind}" but no fixture covers it`); - failed = true; + fail('coverage', `A2UI_SUPPORTED_KINDS claims "${kind}" but no fixture covers it`); } } diff --git a/spec/a2learn/fixtures/flashcard.spec.json b/spec/a2learn/fixtures/flashcard.spec.json index ca1d09ee..7dffe036 100644 --- a/spec/a2learn/fixtures/flashcard.spec.json +++ b/spec/a2learn/fixtures/flashcard.spec.json @@ -2,8 +2,10 @@ "kind": "flashcard", "learningComponentId": "lc-fractions-2", "prompt": "Match each fraction word to what it means.", + "successMessage": "You matched every card — the words are yours now.", "cards": [ { "id": "c1", "front": { "text": "numerator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many parts you have", "markdown": null, "imageUrl": null, "imageAlt": null } }, - { "id": "c2", "front": { "text": "denominator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many equal parts make the whole", "markdown": null, "imageUrl": null, "imageAlt": null } } + { "id": "c2", "front": { "text": "denominator", "markdown": null, "imageUrl": null, "imageAlt": null }, "back": { "text": "how many equal parts make the whole", "markdown": "- more parts means **smaller** parts", "imageUrl": null, "imageAlt": null } }, + { "id": "c3", "front": { "text": null, "markdown": null, "imageUrl": "https://example.org/fraction-bar.png", "imageAlt": "a bar split into three equal parts" }, "back": { "text": "one third", "markdown": null, "imageUrl": null, "imageAlt": null } } ] } diff --git a/spec/a2learn/fixtures/flashcard.surface.json b/spec/a2learn/fixtures/flashcard.surface.json index 0bee3b76..dca8a053 100644 --- a/spec/a2learn/fixtures/flashcard.surface.json +++ b/spec/a2learn/fixtures/flashcard.surface.json @@ -11,7 +11,9 @@ "prompt", "card-0", "card-1", - "done" + "card-2", + "done", + "success" ] }, { @@ -28,22 +30,22 @@ "id": "card-0-col", "component": "Column", "children": [ - "card-0-front", + "card-0-front-text", "card-0-divider", - "card-0-back" + "card-0-back-text" ] }, { - "id": "card-0-front", + "id": "card-0-front-text", "component": "Text", - "text": "**numerator**" + "text": "numerator" }, { "id": "card-0-divider", "component": "Divider" }, { - "id": "card-0-back", + "id": "card-0-back-text", "component": "Text", "text": "how many parts you have" }, @@ -56,25 +58,61 @@ "id": "card-1-col", "component": "Column", "children": [ - "card-1-front", + "card-1-front-text", "card-1-divider", - "card-1-back" + "card-1-back-text", + "card-1-back-md" ] }, { - "id": "card-1-front", + "id": "card-1-front-text", "component": "Text", - "text": "**denominator**" + "text": "denominator" }, { "id": "card-1-divider", "component": "Divider" }, { - "id": "card-1-back", + "id": "card-1-back-text", "component": "Text", "text": "how many equal parts make the whole" }, + { + "id": "card-1-back-md", + "component": "Text", + "text": "- more parts means **smaller** parts" + }, + { + "id": "card-2", + "component": "Card", + "child": "card-2-col" + }, + { + "id": "card-2-col", + "component": "Column", + "children": [ + "card-2-front-image", + "card-2-divider", + "card-2-back-text" + ] + }, + { + "id": "card-2-front-image", + "component": "Image", + "url": "https://example.org/fraction-bar.png", + "description": "a bar split into three equal parts", + "fit": "contain" + }, + { + "id": "card-2-divider", + "component": "Divider" + }, + { + "id": "card-2-back-text", + "component": "Text", + "text": "one third" + }, { "id": "done", "component": "Button", @@ -85,8 +123,7 @@ "name": "a2learn.widget_completed", "userMessage": "Finished the activity.", "context": { - "kind": "flashcard", - "correct": true + "kind": "flashcard" } } } @@ -95,6 +132,12 @@ "id": "done-label", "component": "Text", "text": "Done" + }, + { + "id": "success", + "component": "Text", + "text": "When you're done: You matched every card — the words are yours now.", + "variant": "caption" } ], "dataModel": { diff --git a/spec/a2learn/fixtures/markdown-card.surface.json b/spec/a2learn/fixtures/markdown-card.surface.json index 63971cbc..f9b3549e 100644 --- a/spec/a2learn/fixtures/markdown-card.surface.json +++ b/spec/a2learn/fixtures/markdown-card.surface.json @@ -22,7 +22,7 @@ { "id": "title", "component": "Text", - "text": "**Why the denominator matters**" + "text": "Why the denominator matters" }, { "id": "body", @@ -45,8 +45,7 @@ "name": "a2learn.widget_completed", "userMessage": "Finished the activity.", "context": { - "kind": "markdown-card", - "correct": true + "kind": "markdown-card" } } } diff --git a/src/app/api/pathway/agui/route.ts b/src/app/api/pathway/agui/route.ts index 8496c1e7..1f57cb42 100644 --- a/src/app/api/pathway/agui/route.ts +++ b/src/app/api/pathway/agui/route.ts @@ -9,6 +9,7 @@ import { type StateSnapshotEvent, } from '@ag-ui/core'; +import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; import type { Anchor } from '@/lib/pathway/events'; import { streamPathway } from '@/lib/pathway/generate'; import type { PathwayPlan } from '@/lib/pathway/schema'; @@ -19,39 +20,58 @@ export const maxDuration = 120; * The pathway build as an AG-UI run — the standards-conformant transport * beside the app's own NDJSON at `/api/pathway`. * - * Same pipeline, same events, different envelope: any AG-UI client can - * drive a build without learning our vocabulary. The mapping is deliberate - * and total: + * Same pipeline, different envelope: * * - the run brackets as `RUN_STARTED` / `RUN_FINISHED` (or `RUN_ERROR`); - * - every domain event crosses as a `CUSTOM` event named + * - every event the pipeline yields crosses as a `CUSTOM` event named * `a2learn.` with the untouched event as its value — domain * semantics belong to the a2learn layer, not to the transport; * - one `STATE_SNAPSHOT` lands before the finish with the assembled * result, for clients that want the artifact without replaying events. * - * Conformance is compile-checked: every emitted object is typed against - * `@ag-ui/core`'s event interfaces, so drifting from the protocol fails - * the build rather than a partner's integration. + * Two honest scope limits, phase-2 work rather than accidents: this + * transport takes topic + gradeHint only (no student profile, teacher note, + * or lesson-plan context yet), and it does not persist a session — no share + * link comes back. Both converge on the shared `runPathway` collector when + * the build_pathway branch lands, so the two transports stop diverging. + * + * Event shapes are compile-checked against `@ag-ui/core`, so drifting from + * the protocol fails the build rather than a partner's integration. */ function encode(event: unknown): string { return `${JSON.stringify(event)}\n`; } +/** + * Even a rejected request speaks legal AG-UI: standard client middleware + * asserts the first event is RUN_STARTED, so a bare RUN_ERROR would surface + * as a protocol violation in the partner's SDK instead of a message. + */ +function refusalStream(message: string): string { + const runId = randomUUID(); + const started: RunStartedEvent = { type: EventType.RUN_STARTED, threadId: 'a2learn-pathway', runId }; + const error: RunErrorEvent = { type: EventType.RUN_ERROR, message }; + return encode(started) + encode(error); +} + export async function POST(request: Request) { let body: { topic?: unknown; gradeHint?: unknown; threadId?: unknown }; try { body = await request.json(); } catch { - const error: RunErrorEvent = { type: EventType.RUN_ERROR, message: 'Expected a JSON body.' }; - return new Response(encode(error), { status: 400, headers: { 'Content-Type': 'application/x-ndjson' } }); + return new Response(refusalStream('Expected a JSON body.'), { + status: 400, + headers: { 'Content-Type': 'application/x-ndjson' }, + }); } const topic = typeof body.topic === 'string' ? body.topic.trim() : ''; if (!topic) { - const error: RunErrorEvent = { type: EventType.RUN_ERROR, message: 'A topic is required.' }; - return new Response(encode(error), { status: 400, headers: { 'Content-Type': 'application/x-ndjson' } }); + return new Response(refusalStream('A topic is required.'), { + status: 400, + headers: { 'Content-Type': 'application/x-ndjson' }, + }); } const gradeHint = typeof body.gradeHint === 'string' && body.gradeHint.trim() ? body.gradeHint.trim() : undefined; @@ -59,9 +79,19 @@ export async function POST(request: Request) { const runId = randomUUID(); const encoder = new TextEncoder(); + // A cancelled consumer (closed tab, aborted fetch) must produce a quiet + // teardown, not enqueue-after-close throws cascading through catch and + // finally. Generation itself still runs to completion — threading an + // AbortSignal through streamPathway is a repo-wide gap shared with the + // NDJSON route, tracked separately. + let closed = false; + const stream = new ReadableStream({ async start(controller) { - const emit = (event: object) => controller.enqueue(encoder.encode(encode(event))); + const emit = (event: object) => { + if (closed) return; + controller.enqueue(encoder.encode(encode(event))); + }; const started: RunStartedEvent = { type: EventType.RUN_STARTED, threadId, runId }; emit(started); @@ -72,14 +102,21 @@ export async function POST(request: Request) { const stepWidgets: Record = {}; const rejected: string[] = []; + // No in-band error branch: the generator throws on failure (it never + // yields an error event), and the catch below turns that into + // RUN_ERROR. If it ever gains recoverable in-band errors, they cross + // like any other event. for await (const event of streamPathway(topic, gradeHint, null)) { if (event.type === 'anchor') anchor = event.anchor; if (event.type === 'plan') plan = event.plan; if (event.type === 'step-widget') stepWidgets[event.stepIndex] = event.widget; if (event.type === 'verdict' && !event.resolved) rejected.push(event.code); - if (event.type === 'error') throw new Error(event.message); - const custom: CustomEvent = { type: EventType.CUSTOM, name: `a2learn.${event.type}`, value: event }; + const custom: CustomEvent = { + type: EventType.CUSTOM, + name: `${A2LEARN_EVENT_PREFIX}${event.type}`, + value: event, + }; emit(custom); } @@ -98,9 +135,15 @@ export async function POST(request: Request) { }; emit(failed); } finally { - controller.close(); + if (!closed) { + closed = true; + controller.close(); + } } }, + cancel() { + closed = true; + }, }); return new Response(stream, { diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index a3fc1bde..92904919 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -1,4 +1,6 @@ -import type { FlashcardSpec, MarkdownCardSpec } from '@/lib/pathway/schema'; +import type { FlashcardSpec, MarkdownCardSpec, WidgetKind } from '@/lib/pathway/schema'; + +import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; /** * The boundary mapper: a widget spec, expressed as a Google A2UI v1.0 @@ -10,19 +12,23 @@ import type { FlashcardSpec, MarkdownCardSpec } from '@/lib/pathway/schema'; * format stays the flat authoring schema; A2UI is produced here, at the * boundary, per the a2learn format's alignment rule. * - * Coverage is deliberately incremental and honestly labeled: - * `A2UI_SUPPORTED_KINDS` names what maps today. The first two kinds are the - * *reading* shapes — mapping loses no interaction because the basic catalog - * can express them fully. Interactive kinds (drag-sort, draft-meter …) need - * either A2UI's function/template machinery or an a2learn catalog extension; - * they join this list one verifiable fixture at a time, never by claim. + * Coverage is deliberately incremental and honestly labeled: a kind is + * supported when it has an entry in `MAPPERS` below — `A2UI_SUPPORTED_KINDS` + * is derived from that map, so the list and the dispatch cannot drift, and + * the conformance suite requires a fixture per supported kind. The first two + * kinds are the reading shapes. Known fidelity limits, stated rather than + * papered over: flashcard's tap-to-flip is degraded to front/back stacked + * (the basic catalog models no local state — an a2learn catalog extension + * will), and a markdown body using links or tables renders them as literal + * Markdown in strict renderers (the catalog's `Text` scopes itself to simple + * Markdown). The durable home for this mapping is an optional field on the + * widget registry entry — tracked with the registry-owned-semantics + * refactor — so adding a kind stays one file. */ /** The standard catalog every surface here targets. */ export const A2UI_BASIC_CATALOG = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json'; -export const A2UI_SUPPORTED_KINDS = ['markdown-card', 'flashcard'] as const; - export type A2UIComponent = { id: string; component: string } & Record; export type A2UISurfaceMessage = { @@ -35,21 +41,73 @@ export type A2UISurfaceMessage = { }; }; -function completedAction(kind: string) { +/** + * The completion action a surface's done button dispatches. Neither mapped + * kind assesses (`assesses: false` on both registry entries), so the context + * deliberately carries no `correct` claim — completion is not mastery, and + * an A2UI consumer must not be told otherwise. When an assessing kind joins + * this file, its action gains a verdict from real checking, gated on the + * registry's `assesses` flag — never asserted by the mapper. + */ +function completedAction(kind: WidgetKind) { return { event: { - name: 'a2learn.widget_completed', + name: `${A2LEARN_EVENT_PREFIX}widget_completed`, userMessage: 'Finished the activity.', - context: { kind, correct: true }, + context: { kind }, }, }; } +/** The shared envelope, so a catalog or version change lands in one place. */ +function surface(surfaceId: string, kind: WidgetKind, components: A2UIComponent[]): A2UISurfaceMessage { + return { + version: 'v1.0', + createSurface: { + surfaceId, + catalogId: A2UI_BASIC_CATALOG, + components, + dataModel: { a2learn: { kind } }, + }, + }; +} + +function doneButton(kind: WidgetKind, label: string): A2UIComponent[] { + return [ + { id: 'done', component: 'Button', variant: 'primary', child: 'done-label', action: completedAction(kind) }, + { id: 'done-label', component: 'Text', text: label }, + ]; +} + /** - * markdown-card: title, markdown body, optional tip, a done button. The - * basic catalog's `Text` accepts simple Markdown, so the reading surface - * maps without loss. + * One flashcard side as components: text and/or markdown as `Text`, an image + * as `Image` with its alt text — an image-only side is a legal spec per the + * schema and must not degrade to a placeholder dash. Content is emitted + * verbatim in separate components, never joined or wrapped in emphasis + * markers (bold cannot span the blank line a join introduces). */ +function sideComponents( + prefix: string, + side: FlashcardSpec['cards'][number]['front'], +): { ids: string[]; components: A2UIComponent[] } { + const components: A2UIComponent[] = []; + if (side.text) components.push({ id: `${prefix}-text`, component: 'Text', text: side.text }); + if (side.markdown) components.push({ id: `${prefix}-md`, component: 'Text', text: side.markdown }); + if (side.imageUrl) { + components.push({ + id: `${prefix}-image`, + component: 'Image', + url: side.imageUrl, + description: side.imageAlt ?? '', + fit: 'contain', + }); + } + if (components.length === 0) { + components.push({ id: `${prefix}-empty`, component: 'Text', text: '—', variant: 'caption' }); + } + return { ids: components.map((c) => c.id), components }; +} + function markdownCardSurface(spec: MarkdownCardSpec, surfaceId: string): A2UISurfaceMessage { const components: A2UIComponent[] = [ { id: 'root', component: 'Card', child: 'layout' }, @@ -58,68 +116,68 @@ function markdownCardSurface(spec: MarkdownCardSpec, surfaceId: string): A2UISur component: 'Column', children: ['title', 'body', ...(spec.tip ? ['tip'] : []), 'done'], }, - { id: 'title', component: 'Text', text: `**${spec.title}**` }, + // Title verbatim — no added emphasis markers, which break when the + // model's own text already contains Markdown syntax. + { id: 'title', component: 'Text', text: spec.title }, { id: 'body', component: 'Text', text: spec.body }, ...(spec.tip ? [{ id: 'tip', component: 'Text', text: `Tip: ${spec.tip}`, variant: 'caption' } as A2UIComponent] : []), - { id: 'done', component: 'Button', variant: 'primary', child: 'done-label', action: completedAction(spec.kind) }, - { id: 'done-label', component: 'Text', text: 'Got it' }, + ...doneButton(spec.kind, 'Got it'), ]; - return { - version: 'v1.0', - createSurface: { - surfaceId, - catalogId: A2UI_BASIC_CATALOG, - components, - dataModel: { a2learn: { kind: spec.kind } }, - }, - }; + return surface(surfaceId, spec.kind, components); } -/** - * flashcard: the prompt, then each card as front/back stacked in a Card. - * Static children rather than a template: both sides visible is the honest - * degradation the basic catalog supports — tap-to-flip needs local state - * the standard catalog does not model, and arrives with an a2learn catalog - * extension rather than a pretend mapping. - */ function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMessage { - const sideText = (side: FlashcardSpec['cards'][number]['front']): string => - [side.text, side.markdown].filter(Boolean).join('\n\n') || '—'; + const cardComponents: A2UIComponent[] = []; + const cardIds: string[] = []; - const cardComponents: A2UIComponent[] = spec.cards.flatMap((card, i) => [ - { id: `card-${i}`, component: 'Card', child: `card-${i}-col` }, - { id: `card-${i}-col`, component: 'Column', children: [`card-${i}-front`, `card-${i}-divider`, `card-${i}-back`] }, - { id: `card-${i}-front`, component: 'Text', text: `**${sideText(card.front)}**` }, - { id: `card-${i}-divider`, component: 'Divider' }, - { id: `card-${i}-back`, component: 'Text', text: sideText(card.back) }, - ]); + spec.cards.forEach((card, i) => { + const front = sideComponents(`card-${i}-front`, card.front); + const back = sideComponents(`card-${i}-back`, card.back); + cardIds.push(`card-${i}`); + cardComponents.push( + { id: `card-${i}`, component: 'Card', child: `card-${i}-col` }, + { + id: `card-${i}-col`, + component: 'Column', + children: [...front.ids, `card-${i}-divider`, ...back.ids], + }, + ...front.components, + { id: `card-${i}-divider`, component: 'Divider' }, + ...back.components, + ); + }); const components: A2UIComponent[] = [ - { id: 'root', component: 'Column', children: ['prompt', ...spec.cards.map((_, i) => `card-${i}`), 'done'] }, + { id: 'root', component: 'Column', children: ['prompt', ...cardIds, 'done', 'success'] }, { id: 'prompt', component: 'Text', text: spec.prompt }, ...cardComponents, - { id: 'done', component: 'Button', variant: 'primary', child: 'done-label', action: completedAction(spec.kind) }, - { id: 'done-label', component: 'Text', text: 'Done' }, + ...doneButton(spec.kind, 'Done'), + // The author wrote this for the end of the deck; a static surface shows + // it as a caption rather than dropping it. + { id: 'success', component: 'Text', text: `When you're done: ${spec.successMessage}`, variant: 'caption' }, ]; - return { - version: 'v1.0', - createSurface: { - surfaceId, - catalogId: A2UI_BASIC_CATALOG, - components, - dataModel: { a2learn: { kind: spec.kind } }, - }, - }; + return surface(surfaceId, spec.kind, components); } +/** + * The dispatch table IS the support list — `A2UI_SUPPORTED_KINDS` derives + * from it, so claiming a kind without mapping it (or vice versa) is + * impossible by construction. + */ +const MAPPERS: Partial A2UISurfaceMessage>> = { + 'markdown-card': markdownCardSurface, + flashcard: flashcardSurface, +}; + +export const A2UI_SUPPORTED_KINDS = Object.keys(MAPPERS) as WidgetKind[]; + /** Null for kinds not yet mapped — callers state the gap rather than guess. */ export function toA2UISurface(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { - const kind = (spec as { kind?: string })?.kind; - if (kind === 'markdown-card') return markdownCardSurface(spec as MarkdownCardSpec, surfaceId); - if (kind === 'flashcard') return flashcardSurface(spec as FlashcardSpec, surfaceId); - return null; + const kind = (spec as { kind?: string })?.kind as WidgetKind | undefined; + const mapper = kind ? MAPPERS[kind] : undefined; + return mapper ? mapper(spec as never, surfaceId) : null; } diff --git a/src/lib/a2learn/manifest.ts b/src/lib/a2learn/manifest.ts index b38d459c..4b6527f2 100644 --- a/src/lib/a2learn/manifest.ts +++ b/src/lib/a2learn/manifest.ts @@ -1,3 +1,4 @@ +import type { WidgetKind } from '@/lib/pathway/schema'; import type { StandardRef } from '@/lib/standards/types'; /** @@ -15,6 +16,10 @@ import type { StandardRef } from '@/lib/standards/types'; export const A2LEARN_VERSION = '0.1.0'; +/** Namespace for a2learn event names on any transport (AG-UI custom events, + * A2UI actions): `a2learn.`. Owned here, not in a route. */ +export const A2LEARN_EVENT_PREFIX = 'a2learn.'; + export type ActivityManifest = { /** Manifest format version — semver, bumped by RFC, not by refactor. */ a2learn: string; @@ -29,7 +34,7 @@ export type ActivityManifest = { }; pedagogy: { /** Widget kind from the registry. */ - kind: string; + kind: WidgetKind; /** Whether completing this activity records a real verdict. */ assesses: boolean; }; @@ -48,10 +53,18 @@ export type ActivityManifest = { }; }; +/** + * No producer calls this yet — it gains one in phase 2, when find_activity's + * listing shape converges here. Until then the conformance suite pins the + * A2UI half; this half is pinned by tsc only. `assesses` must equal the + * registry entry's flag for `kind` — the caller that owns the registry + * lookup passes it, so this module stays importable without dragging every + * widget definition into its bundle. + */ export function buildManifest(input: { title: string; standard: StandardRef; - kind: string; + kind: WidgetKind; assesses: boolean; language?: string; rejectedCodes?: string[]; From 3f522f3aaaeef5049fa91880df6c01a1474d60ef Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:16:00 -0400 Subject: [PATCH 04/23] ci: conformance folds into the verify job, as its own comment promised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone workflow existed only because ci.yml lived in a branch still in flight; that branch is on main now, so the gate moves into the verify job it always belonged to and conformance.yml deletes. Same command, same trigger set — one less workflow to keep in sync. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 ++++ .github/workflows/conformance.yml | 27 --------------------------- 2 files changed, 4 insertions(+), 27 deletions(-) delete mode 100644 .github/workflows/conformance.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 976674f2..494aefff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: - run: pnpm test + # A2UI conformance: the boundary mapper's surfaces must validate against + # the vendored Google A2UI v1.0 schemas and match their committed goldens. + - run: pnpm conformance + # `next build` typechecks too, and it needs no secrets — verified by # building with no .env file present. - run: pnpm build diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml deleted file mode 100644 index 0b7befb2..00000000 --- a/.github/workflows/conformance.yml +++ /dev/null @@ -1,27 +0,0 @@ -# A2UI conformance: the boundary mapper's surfaces must validate against the -# vendored Google A2UI v1.0 schemas and match their committed goldens. -# Standalone workflow while the ci.yml-owning branch stack is in flight; -# fold into ci.yml's verify job after it merges. -name: conformance -on: - pull_request: - push: - branches: [main] -permissions: - contents: read -concurrency: - group: conformance-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} -jobs: - a2ui: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm conformance From 4937705dd86f54ae56dbae7e7aa60be9de847d43 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:54:10 -0400 Subject: [PATCH 05/23] feat: the A2UI boundary becomes functional, not just proven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two delivery surfaces join the conformance suite. /demo/a2ui maps the fixture specs live through toA2UISurface and draws them with a minimal renderer of the six component types the mapper emits — deterministic, offline, and honest about fidelity (markdown as literal text, no flip; the page says so and links the native demos). The done button dispatches its real completion action and the page shows the payload the agent would receive. /api/widget grows format: "a2ui", returning the built widget's surface alongside the spec; an unmappable kind is refused with the mapped-kinds list before a model call is spent on it. A component outside the emitted slice renders as a visible gap, never silently dropped. Verified live: demo page markers, the 422-style refusal, and a generated flashcard mapping to a 41-component surface. Co-Authored-By: Claude Fable 5 --- src/app/api/widget/route.ts | 31 +++++- src/app/demo/a2ui/page.tsx | 102 ++++++++++++++++++ src/components/a2ui/A2UISurfaceView.tsx | 133 ++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/app/demo/a2ui/page.tsx create mode 100644 src/components/a2ui/A2UISurfaceView.tsx diff --git a/src/app/api/widget/route.ts b/src/app/api/widget/route.ts index 85d5e10d..8786ecc5 100644 --- a/src/app/api/widget/route.ts +++ b/src/app/api/widget/route.ts @@ -1,3 +1,6 @@ +import { randomUUID } from 'node:crypto'; + +import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; import { buildWidget, WidgetBuildError } from '@/lib/widgets/build'; export const maxDuration = 60; @@ -21,7 +24,7 @@ export async function OPTIONS() { const json = (body: unknown, status = 200) => Response.json(body, { status, headers: CORS }); export async function POST(request: Request) { - let body: { standardCode?: unknown; kind?: unknown; jurisdiction?: unknown }; + let body: { standardCode?: unknown; kind?: unknown; jurisdiction?: unknown; format?: unknown }; try { body = await request.json(); @@ -29,12 +32,38 @@ export async function POST(request: Request) { return json({ error: 'Expected a JSON body.' }, 400); } + // `format: "a2ui"` asks for the built widget's A2UI surface alongside the + // spec. Refuse an unmappable kind before spending a model call on it. + const wantsA2UI = body.format === 'a2ui'; + if (wantsA2UI && body.kind && !A2UI_SUPPORTED_KINDS.includes(String(body.kind) as never)) { + return json( + { + error: `Kind "${String(body.kind)}" has no A2UI mapping yet. Mapped kinds: ${A2UI_SUPPORTED_KINDS.join(', ')}.`, + }, + 422, + ); + } + try { const built = await buildWidget({ standardCode: String(body.standardCode ?? ''), kind: String(body.kind ?? ''), jurisdiction: typeof body.jurisdiction === 'string' ? body.jurisdiction : undefined, }); + if (wantsA2UI) { + const surface = toA2UISurface(built.widget, `a2learn-${randomUUID()}`); + if (!surface) { + // The build chose an unmapped kind (possible when `kind` was omitted). + return json( + { + ...built, + a2ui: null, + a2uiNote: `Kind "${built.widget.kind}" has no A2UI mapping yet. Mapped kinds: ${A2UI_SUPPORTED_KINDS.join(', ')}.`, + }, + ); + } + return json({ ...built, a2ui: surface }); + } return json(built); } catch (error) { if (error instanceof WidgetBuildError) return json({ error: error.message }, error.status); diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx new file mode 100644 index 00000000..62b24f6a --- /dev/null +++ b/src/app/demo/a2ui/page.tsx @@ -0,0 +1,102 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import Link from 'next/link'; + +import { A2UISurfaceDemo } from '@/components/a2ui/A2UISurfaceView'; +import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; + +/** + * The A2UI boundary, functional: the same fixture specs the conformance + * suite proves (`spec/a2learn/fixtures/`), mapped live through + * `toA2UISurface` at render time and drawn by a minimal renderer of the + * emitted catalog slice. Deterministic and offline — no model call, so what + * this page shows is exactly what the conformance gate guarantees. + */ + +export const metadata = { title: 'A2UI surfaces — demo' }; + +function fixtureSpecs(): { name: string; spec: Record }[] { + const dir = join(process.cwd(), 'spec', 'a2learn', 'fixtures'); + return readdirSync(dir) + .filter((file) => file.endsWith('.spec.json')) + .map((file) => ({ + name: file.replace(/\.spec\.json$/, ''), + spec: JSON.parse(readFileSync(join(dir, file), 'utf8')), + })); +} + +export default function A2UIDemoPage() { + const pairs = fixtureSpecs().map(({ name, spec }) => ({ + name, + spec, + surface: toA2UISurface(spec, `demo-${name}`), + })); + + return ( +
+

+ + ← all demos + +

+

A2UI surfaces

+

+ Widget specs project through the boundary mapper to{' '} + + Google's A2UI + {' '} + basic catalog — the same surfaces the conformance suite validates against the vendored + upstream schemas, mapped live on this page and rendered by a minimal renderer of the six + component types the mapper emits. +

+

+ Fidelity is deliberately strict-renderer: no tap-to-flip, markdown drawn as literal text. + The full interactions live in the{' '} + + native widget demos + + ; this page shows the honest portable projection. Mapped kinds today:{' '} + {A2UI_SUPPORTED_KINDS.join(', ')}. +

+ +
+ {pairs.map(({ name, spec, surface }) => ( +
+

{name}

+

+ + native rendering of this kind → + +

+ {surface ? ( + <> + +
+ + widget spec (input) + +
+                    {JSON.stringify(spec, null, 2)}
+                  
+
+
+ + A2UI createSurface message (output) + +
+                    {JSON.stringify(surface, null, 2)}
+                  
+
+ + ) : ( +

+ ⚠ not mapped — {String(spec.kind)} has no A2UI mapping yet. +

+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx new file mode 100644 index 00000000..f093c321 --- /dev/null +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useState } from 'react'; + +import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; + +/** + * A minimal renderer for the slice of the A2UI basic catalog the boundary + * mapper emits: Column, Card, Text, Divider, Image, Button. This is not a + * general A2UI client — it exists so the app can *show* a mapped surface + * being a real UI, not just JSON that validates. A component outside the + * emitted slice renders as a visible gap, never silently dropped. + * + * Fidelity is deliberately strict-renderer: Text draws its string as plain + * text, so a markdown body shows literal `**` marks — the exact degradation + * the mapper's header documents. Honesty over polish applies to demos too. + */ + +type Props = { + surface: A2UISurfaceMessage; + /** Called when a Button's action fires — the demo shows the payload. */ + onAction?: (action: Record) => void; +}; + +export function A2UISurfaceView({ surface, onAction }: Props) { + const byId = new Map(surface.createSurface.components.map((c) => [c.id, c])); + + function render(id: string, seen: Set): React.ReactNode { + const component = byId.get(id); + if (!component) return ; + if (seen.has(id)) return ; + const path = new Set(seen).add(id); + + const children = () => childIds(component).map((childId) => render(childId, path)); + + switch (component.component) { + case 'Column': + return ( +
+ {children()} +
+ ); + case 'Card': + return ( +
+ {children()} +
+ ); + case 'Text': { + const caption = component.variant === 'caption'; + return ( +

+ {String(component.text ?? '')} +

+ ); + } + case 'Divider': + return
; + case 'Image': + return ( + // eslint-disable-next-line @next/next/no-img-element -- arbitrary external URL from a surface; next/image needs configured hosts + {String(component.alt + ); + case 'Button': + return ( + + ); + default: + return ; + } + } + + return
{render('root', new Set())}
; +} + +/** Child references, in the shapes the mapper emits (string child / id list). */ +function childIds(component: A2UIComponent): string[] { + const ids: string[] = []; + if (typeof component.child === 'string') ids.push(component.child); + if (Array.isArray(component.children)) { + for (const ref of component.children) if (typeof ref === 'string') ids.push(ref); + } + return ids; +} + +function Gap({ label }: { label: string }) { + return ( +

+ ⚠ {label} +

+ ); +} + +/** The demo's action panel: what the conversation would hear. */ +export function ActionLog({ action }: { action: Record | null }) { + return action ? ( +
+

✓ action dispatched — what the agent would receive:

+
{JSON.stringify(action, null, 2)}
+
+ ) : ( +

+ Nothing dispatched yet — finish the activity to see its completion action. +

+ ); +} + +/** Small stateful wrapper so a server page can compose surface + log. */ +export function A2UISurfaceDemo({ surface }: { surface: A2UISurfaceMessage }) { + const [action, setAction] = useState | null>(null); + return ( +
+ + +
+ ); +} From 5dfaf283bc612f263181eb6dcd49be4681c8736f Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:59:41 -0400 Subject: [PATCH 06/23] feat: the demo puts native and A2UI renderings side by side The gap between the two renderings was the point of the page, but a single column made it read as a bug. Now each fixture shows the native widget with its full mechanics beside the basic-catalog projection, and the copy names what the projection costs and buys. A broken image no longer collapses to silence: the renderer falls back to the catalog's description field (checked on mount too, since an image that failed before hydration never fires onError). Verified visually via Playwright. Co-Authored-By: Claude Fable 5 --- src/app/demo/a2ui/page.tsx | 17 ++++----- src/components/a2ui/A2UIComparison.tsx | 50 +++++++++++++++++++++++++ src/components/a2ui/A2UISurfaceView.tsx | 34 +++++++++++++---- 3 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 src/components/a2ui/A2UIComparison.tsx diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx index 62b24f6a..fe81c50c 100644 --- a/src/app/demo/a2ui/page.tsx +++ b/src/app/demo/a2ui/page.tsx @@ -3,7 +3,7 @@ import { join } from 'node:path'; import Link from 'next/link'; -import { A2UISurfaceDemo } from '@/components/a2ui/A2UISurfaceView'; +import { A2UIComparison } from '@/components/a2ui/A2UIComparison'; import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; /** @@ -51,12 +51,11 @@ export default function A2UIDemoPage() { component types the mapper emits.

- Fidelity is deliberately strict-renderer: no tap-to-flip, markdown drawn as literal text. - The full interactions live in the{' '} - - native widget demos - - ; this page shows the honest portable projection. Mapped kinds today:{' '} + The columns are deliberately different — that difference is the exhibit. The native widget + has its full mechanics; the projection is what survives translation into a catalog any + A2UI renderer can draw: no tap-to-flip (the basic catalog models no local state), markdown + as literal text, front and back stacked in the open. What the projection buys is + portability; what it costs is shown, not hidden. Mapped kinds today:{' '} {A2UI_SUPPORTED_KINDS.join(', ')}.

@@ -66,12 +65,12 @@ export default function A2UIDemoPage() {

{name}

- native rendering of this kind → + full demo of this kind →

{surface ? ( <> - +
widget spec (input) diff --git a/src/components/a2ui/A2UIComparison.tsx b/src/components/a2ui/A2UIComparison.tsx new file mode 100644 index 00000000..85cb1382 --- /dev/null +++ b/src/components/a2ui/A2UIComparison.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { Flashcard } from '@/components/widgets/Flashcard'; +import { MarkdownCard } from '@/components/widgets/MarkdownCard'; +import { A2UISurfaceDemo } from '@/components/a2ui/A2UISurfaceView'; +import type { A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; +import type { FlashcardSpec, MarkdownCardSpec } from '@/lib/pathway/schema'; + +/** + * The two tiers of the same spec, side by side: the native widget with its + * full mechanics, and the A2UI basic-catalog projection any compliant + * renderer can draw. The visible gap between the columns is the exhibit — + * what portability costs, stated in UI instead of prose. + */ + +type Props = { spec: Record; surface: A2UISurfaceMessage }; + +function NativeWidget({ spec }: { spec: Record }) { + switch (spec.kind) { + case 'flashcard': + return ; + case 'markdown-card': + return ; + default: + return ( +

+ No native preview wired for {String(spec.kind)}. +

+ ); + } +} + +export function A2UIComparison({ spec, surface }: Props) { + return ( +
+
+

+ Native widget — full mechanics +

+ +
+
+

+ A2UI basic catalog — portable projection +

+ +
+
+ ); +} diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index f093c321..d024100f 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; @@ -57,14 +57,9 @@ export function A2UISurfaceView({ surface, onAction }: Props) { case 'Divider': return
; case 'Image': + // The catalog's accessibility field is `description`, not `alt`. return ( - // eslint-disable-next-line @next/next/no-img-element -- arbitrary external URL from a surface; next/image needs configured hosts - {String(component.alt + ); case 'Button': return ( @@ -99,6 +94,29 @@ function childIds(component: A2UIComponent): string[] { return ids; } +/** A broken image collapses to nothing; the alt text is the honest fallback. */ +function SurfaceImage({ url, alt }: { url: string; alt: string }) { + const [failed, setFailed] = useState(false); + const ref = useRef(null); + // An image that broke before hydration already fired its error event; + // onError alone would miss it, and the gap would render as silence. + useEffect(() => { + const img = ref.current; + if (img?.complete && img.naturalWidth === 0) setFailed(true); + }, []); + if (failed) { + return ( +

+ 🖼 image unavailable — {alt || 'no description provided'} +

+ ); + } + return ( + // eslint-disable-next-line @next/next/no-img-element -- arbitrary external URL from a surface; next/image needs configured hosts + {alt} setFailed(true)} /> + ); +} + function Gap({ label }: { label: string }) { return (

From 6fefd2c470ef675e6d9c565eefd612cf652ebbd4 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:07:45 -0400 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20parity=20column=20=E2=80=94=20the?= =?UTF-8?q?=20a2learn=20catalog=20renders=20the=20exact=20native=20experie?= =?UTF-8?q?nce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo's left column now renders an a2learn-catalog surface (draft, urn-identified, #98): its one component is the widget kind itself, so the registry's own React component is the catalog renderer and parity with the native experience — flip animation, completion reporting, the loop the MCP Apps shell speaks — holds by construction for every kind. Completing the widget dispatches a real a2learn.widget_completed action into the visible panel. The basic-catalog projection improves honestly too: flashcard front/back becomes Tabs (the catalog's one renderer-local interaction), so the reveal survives projection even if the animation does not. Goldens regenerated; the mapper header's no-local-state claim corrected. Draft means draft: nothing outside this repo should program against the a2learn catalog shape until #98 lands the versioned contract, and the module says so. Co-Authored-By: Claude Fable 5 --- spec/a2learn/fixtures/flashcard.surface.json | 93 +++++++++++++++----- src/app/demo/a2ui/page.tsx | 31 ++++--- src/components/a2ui/A2UIComparison.tsx | 86 ++++++++++++------ src/components/a2ui/A2UISurfaceView.tsx | 49 +++++++++++ src/lib/a2learn/a2ui.ts | 28 +++--- src/lib/a2learn/catalog.ts | 51 +++++++++++ 6 files changed, 266 insertions(+), 72 deletions(-) create mode 100644 src/lib/a2learn/catalog.ts diff --git a/spec/a2learn/fixtures/flashcard.surface.json b/spec/a2learn/fixtures/flashcard.surface.json index dca8a053..60909435 100644 --- a/spec/a2learn/fixtures/flashcard.surface.json +++ b/spec/a2learn/fixtures/flashcard.surface.json @@ -24,14 +24,33 @@ { "id": "card-0", "component": "Card", - "child": "card-0-col" + "child": "card-0-tabs" + }, + { + "id": "card-0-tabs", + "component": "Tabs", + "tabs": [ + { + "title": "Front", + "child": "card-0-front-col" + }, + { + "title": "Back", + "child": "card-0-back-col" + } + ] + }, + { + "id": "card-0-front-col", + "component": "Column", + "children": [ + "card-0-front-text" + ] }, { - "id": "card-0-col", + "id": "card-0-back-col", "component": "Column", "children": [ - "card-0-front-text", - "card-0-divider", "card-0-back-text" ] }, @@ -40,10 +59,6 @@ "component": "Text", "text": "numerator" }, - { - "id": "card-0-divider", - "component": "Divider" - }, { "id": "card-0-back-text", "component": "Text", @@ -52,14 +67,33 @@ { "id": "card-1", "component": "Card", - "child": "card-1-col" + "child": "card-1-tabs" + }, + { + "id": "card-1-tabs", + "component": "Tabs", + "tabs": [ + { + "title": "Front", + "child": "card-1-front-col" + }, + { + "title": "Back", + "child": "card-1-back-col" + } + ] }, { - "id": "card-1-col", + "id": "card-1-front-col", + "component": "Column", + "children": [ + "card-1-front-text" + ] + }, + { + "id": "card-1-back-col", "component": "Column", "children": [ - "card-1-front-text", - "card-1-divider", "card-1-back-text", "card-1-back-md" ] @@ -69,10 +103,6 @@ "component": "Text", "text": "denominator" }, - { - "id": "card-1-divider", - "component": "Divider" - }, { "id": "card-1-back-text", "component": "Text", @@ -86,14 +116,33 @@ { "id": "card-2", "component": "Card", - "child": "card-2-col" + "child": "card-2-tabs" + }, + { + "id": "card-2-tabs", + "component": "Tabs", + "tabs": [ + { + "title": "Front", + "child": "card-2-front-col" + }, + { + "title": "Back", + "child": "card-2-back-col" + } + ] }, { - "id": "card-2-col", + "id": "card-2-front-col", + "component": "Column", + "children": [ + "card-2-front-image" + ] + }, + { + "id": "card-2-back-col", "component": "Column", "children": [ - "card-2-front-image", - "card-2-divider", "card-2-back-text" ] }, @@ -104,10 +153,6 @@ "description": "a bar split into three equal parts", "fit": "contain" }, - { - "id": "card-2-divider", - "component": "Divider" - }, { "id": "card-2-back-text", "component": "Text", diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx index fe81c50c..0148228b 100644 --- a/src/app/demo/a2ui/page.tsx +++ b/src/app/demo/a2ui/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { A2UIComparison } from '@/components/a2ui/A2UIComparison'; import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; +import { toA2LearnSurface } from '@/lib/a2learn/catalog'; /** * The A2UI boundary, functional: the same fixture specs the conformance @@ -30,6 +31,7 @@ export default function A2UIDemoPage() { const pairs = fixtureSpecs().map(({ name, spec }) => ({ name, spec, + a2learnSurface: toA2LearnSurface(spec, `demo-a2learn-${name}`), surface: toA2UISurface(spec, `demo-${name}`), })); @@ -51,16 +53,17 @@ export default function A2UIDemoPage() { component types the mapper emits.

- The columns are deliberately different — that difference is the exhibit. The native widget - has its full mechanics; the projection is what survives translation into a catalog any - A2UI renderer can draw: no tap-to-flip (the basic catalog models no local state), markdown - as literal text, front and back stacked in the open. What the projection buys is - portability; what it costs is shown, not hidden. Mapped kinds today:{' '} - {A2UI_SUPPORTED_KINDS.join(', ')}. + Both columns are A2UI surfaces — the two tiers of the same spec. The left is the a2learn + catalog (draft): its component is the widget kind itself, the registry is the renderer, so + it is the exact native experience — flip animation, completion reporting, the same loop + the MCP Apps shell speaks. The right is the basic-catalog projection any generic A2UI + renderer can draw: the reveal survives as tabs, markdown as literal text. The remaining + gap between the columns is what the versioned a2learn catalog (#98) closes for hosts + beyond this app. Basic-mapped kinds today: {A2UI_SUPPORTED_KINDS.join(', ')}.

- {pairs.map(({ name, spec, surface }) => ( + {pairs.map(({ name, spec, a2learnSurface, surface }) => (

{name}

@@ -68,9 +71,9 @@ export default function A2UIDemoPage() { full demo of this kind →

- {surface ? ( + {surface && a2learnSurface ? ( <> - +
widget spec (input) @@ -81,7 +84,15 @@ export default function A2UIDemoPage() {
- A2UI createSurface message (output) + a2learn-catalog createSurface message (full fidelity) + +
+                    {JSON.stringify(a2learnSurface, null, 2)}
+                  
+
+
+ + basic-catalog createSurface message (fallback)
                     {JSON.stringify(surface, null, 2)}
diff --git a/src/components/a2ui/A2UIComparison.tsx b/src/components/a2ui/A2UIComparison.tsx
index 85cb1382..2305ae03 100644
--- a/src/components/a2ui/A2UIComparison.tsx
+++ b/src/components/a2ui/A2UIComparison.tsx
@@ -1,49 +1,79 @@
 'use client';
 
-import { Flashcard } from '@/components/widgets/Flashcard';
-import { MarkdownCard } from '@/components/widgets/MarkdownCard';
-import { A2UISurfaceDemo } from '@/components/a2ui/A2UISurfaceView';
+import { useState } from 'react';
+
+import { A2UISurfaceDemo, ActionLog } from '@/components/a2ui/A2UISurfaceView';
+import { WidgetRenderer } from '@/components/widgets/registry';
 import type { A2UISurfaceMessage } from '@/lib/a2learn/a2ui';
-import type { FlashcardSpec, MarkdownCardSpec } from '@/lib/pathway/schema';
+import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest';
 
 /**
- * The two tiers of the same spec, side by side: the native widget with its
- * full mechanics, and the A2UI basic-catalog projection any compliant
- * renderer can draw. The visible gap between the columns is the exhibit —
- * what portability costs, stated in UI instead of prose.
+ * The two tiers of the same spec, side by side — and both columns are A2UI.
+ *
+ * The left column renders the *a2learn catalog* surface (draft, #98): its
+ * one component is the widget kind itself, so the registry's own React
+ * component is the catalog renderer and parity with the native experience
+ * is by construction — mechanics, completion reporting and all, the same
+ * loop the MCP Apps shell speaks. The right column renders the *basic
+ * catalog* projection any generic A2UI renderer can draw. The remaining gap
+ * between the columns is exactly what the versioned a2learn catalog exists
+ * to close for hosts beyond this app.
  */
 
-type Props = { spec: Record; surface: A2UISurfaceMessage };
+type Props = {
+  a2learnSurface: A2UISurfaceMessage;
+  basicSurface: A2UISurfaceMessage;
+};
 
-function NativeWidget({ spec }: { spec: Record }) {
-  switch (spec.kind) {
-    case 'flashcard':
-      return ;
-    case 'markdown-card':
-      return ;
-    default:
-      return (
-        

- No native preview wired for {String(spec.kind)}. -

- ); +/** Renders an a2learn-catalog surface: `a2learn:` → the registry. */ +function A2LearnSurfaceView({ + surface, + onAction, +}: { + surface: A2UISurfaceMessage; + onAction: (action: Record) => void; +}) { + const root = surface.createSurface.components.find((c) => c.id === 'root'); + if (!root || typeof root.component !== 'string' || !root.component.startsWith('a2learn:')) { + return ( +

+ ⚠ not an a2learn-catalog surface. +

+ ); } + const kind = root.component.slice('a2learn:'.length); + return ( + + onAction({ + event: { + name: `${A2LEARN_EVENT_PREFIX}widget_completed`, + userMessage: 'Finished the activity.', + context: { kind }, + }, + }) + } + /> + ); } -export function A2UIComparison({ spec, surface }: Props) { +export function A2UIComparison({ a2learnSurface, basicSurface }: Props) { + const [action, setAction] = useState | null>(null); return (
-
-

- Native widget — full mechanics +

+

+ a2learn catalog (draft) — full fidelity, registry as renderer

- + +

- A2UI basic catalog — portable projection + A2UI basic catalog — universal fallback

- +
); diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index d024100f..231e4ec7 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -56,6 +56,19 @@ export function A2UISurfaceView({ surface, onAction }: Props) { } case 'Divider': return
; + case 'Tabs': { + const tabs = Array.isArray(component.tabs) ? (component.tabs as { title?: unknown; child?: unknown }[]) : []; + return ( + ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + }))} + render={(childId) => render(childId, path)} + /> + ); + } case 'Image': // The catalog's accessibility field is `description`, not `alt`. return ( @@ -94,6 +107,42 @@ function childIds(component: A2UIComponent): string[] { return ids; } +/** Tab switching is renderer-local state — the one interaction the basic + * catalog gives back (this is how the flashcard reveal survives projection). */ +function TabsView({ + tabs, + render, +}: { + tabs: { title: string; child: string | null }[]; + render: (childId: string) => React.ReactNode; +}) { + const [active, setActive] = useState(0); + const current = tabs[active]; + return ( +
+
+ {tabs.map((tab, i) => ( + + ))} +
+ {current?.child ? render(current.child) : null} +
+ ); +} + /** A broken image collapses to nothing; the alt text is the honest fallback. */ function SurfaceImage({ url, alt }: { url: string; alt: string }) { const [failed, setFailed] = useState(false); diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index 92904919..25b3f474 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -17,11 +17,12 @@ import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; * is derived from that map, so the list and the dispatch cannot drift, and * the conformance suite requires a fixture per supported kind. The first two * kinds are the reading shapes. Known fidelity limits, stated rather than - * papered over: flashcard's tap-to-flip is degraded to front/back stacked - * (the basic catalog models no local state — an a2learn catalog extension - * will), and a markdown body using links or tables renders them as literal - * Markdown in strict renderers (the catalog's `Text` scopes itself to simple - * Markdown). The durable home for this mapping is an optional field on the + * papered over: flashcard's flip becomes front/back Tabs — the reveal + * survives as renderer-local tab switching, the animation does not — and a + * markdown body using links or tables renders them as literal Markdown in + * strict renderers (the catalog's `Text` scopes itself to simple Markdown). + * Full mechanics for every kind is the a2learn catalog's job (#98), not + * this projection's. The durable home for this mapping is an optional field on the * widget registry entry — tracked with the registry-owned-semantics * refactor — so adding a kind stays one file. */ @@ -137,15 +138,22 @@ function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMe const front = sideComponents(`card-${i}-front`, card.front); const back = sideComponents(`card-${i}-back`, card.back); cardIds.push(`card-${i}`); + // Tabs, not a flat stack: the basic catalog cannot express a flip + // animation, but tab switching is renderer-local, so front/back stays a + // deliberate reveal instead of both sides lying in the open. cardComponents.push( - { id: `card-${i}`, component: 'Card', child: `card-${i}-col` }, + { id: `card-${i}`, component: 'Card', child: `card-${i}-tabs` }, { - id: `card-${i}-col`, - component: 'Column', - children: [...front.ids, `card-${i}-divider`, ...back.ids], + id: `card-${i}-tabs`, + component: 'Tabs', + tabs: [ + { title: 'Front', child: `card-${i}-front-col` }, + { title: 'Back', child: `card-${i}-back-col` }, + ], }, + { id: `card-${i}-front-col`, component: 'Column', children: front.ids }, + { id: `card-${i}-back-col`, component: 'Column', children: back.ids }, ...front.components, - { id: `card-${i}-divider`, component: 'Divider' }, ...back.components, ); }); diff --git a/src/lib/a2learn/catalog.ts b/src/lib/a2learn/catalog.ts new file mode 100644 index 00000000..a9f398b9 --- /dev/null +++ b/src/lib/a2learn/catalog.ts @@ -0,0 +1,51 @@ +import type { A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; + +/** + * The a2learn catalog, in draft: the registry projected as an A2UI custom + * catalog, one component per widget kind, the kind's spec as its properties. + * + * This is the full-fidelity tier of the two-tier delivery story (#98). The + * basic-catalog mapping in `a2ui.ts` is what any A2UI renderer can draw; a + * surface written against *this* catalog renders with complete mechanics on + * any host that implements it — the app's own component registry is the + * reference implementation. + * + * Draft means draft: the id is a URN, not a fetchable schema URL, and no + * conformance suite validates these surfaces yet. The versioned catalog + * contract — served schema, vendoring rules, props schemas generated from + * the registry's Zod specs so catalog and registry cannot drift — lands via + * #98 and is a contract change in its own right. Until then nothing outside + * this repo should program against this shape, and the demo that renders it + * says so. + */ +export const A2LEARN_CATALOG_DRAFT = 'urn:a2learn:catalog:draft'; + +/** Component name for a kind: `a2learn:flashcard`, `a2learn:drag-sort`, … */ +export function catalogComponentFor(kind: string): string { + return `a2learn:${kind}`; +} + +/** + * Any valid widget spec projects — parity with the registry is by + * construction, not per-kind mapping work. Returns null only when the value + * has no `kind` to dispatch on. + */ +export function toA2LearnSurface(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { + if (!spec || typeof spec !== 'object' || !('kind' in spec) || typeof spec.kind !== 'string') { + return null; + } + return { + version: 'v1.0', + createSurface: { + surfaceId, + catalogId: A2LEARN_CATALOG_DRAFT, + components: [ + { + id: 'root', + component: catalogComponentFor(spec.kind), + spec, + }, + ], + }, + }; +} From b1aafb5820d4a41016f1e6bb5f44e3e785fb50d8 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 16:19:41 -0400 Subject: [PATCH 08/23] test: conformance gains the negative controls the doctrine demands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every layer now proves it can reject before its green is trusted: the schema validator must refuse an empty message and a malformed component, the structural checker must report a duplicate id, a dangling ref, and a missing root, and the zod gate must refuse an unknown kind. Probing for these found a real softness worth recording in the script: the upstream schema accepts a createSurface with no components — the structural layer is the only thing standing there, so it gets its own control. Co-Authored-By: Claude Fable 5 --- scripts/a2ui-conformance.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs index de5cf2f8..4528be0c 100644 --- a/scripts/a2ui-conformance.mjs +++ b/scripts/a2ui-conformance.mjs @@ -70,6 +70,21 @@ ajv.addSchema(basicCatalog); ajv.addSchema({ ...basicCatalog, $id: 'https://a2ui.org/specification/v1_0/catalog.json' }); const validate = ajv.compile(messageSchema); +// --- negative controls: prove each layer can reject before trusting green --- +// The $id alias above is exactly the plumbing that could silently vacuate +// validation; these fail-closed checks run every time, not just in review. +// Empirical note: the upstream schema accepts a createSurface with no +// components at all — only the structural layer catches that, which is why +// both layers get their own control here. +if (validate({})) { + console.error('✗ negative control: schema validator accepted an empty message — validation is vacuous'); + process.exit(1); +} +if (validate({ createSurface: { surfaceId: 's', catalogId: 'c', components: [{ bogus: true }] } })) { + console.error('✗ negative control: schema validator accepted a malformed component — validation is vacuous'); + process.exit(1); +} + // --- structural invariants the schema cannot express --- function structuralProblems(message) { const problems = []; @@ -97,6 +112,25 @@ function structuralProblems(message) { return problems; } +// Structural layer: a duplicate id, a dangling child ref, and a missing root +// must all be reported, or layer 3 is a no-op wearing a checkmark. +{ + const problems = structuralProblems({ + createSurface: { components: [{ id: 'a', child: 'ghost' }, { id: 'a' }] }, + }); + const wants = ['duplicate component id', 'missing component', "no 'root'"]; + if (!wants.every((w) => problems.some((p) => p.includes(w)))) { + console.error(`✗ negative control: structural checker missed known defects (got: ${problems.join('; ') || 'nothing'})`); + process.exit(1); + } +} + +// Input layer: the zod gate must reject a spec the pipeline could never produce. +if (widgetSpec.safeParse({ kind: 'no-such-kind' }).success) { + console.error('✗ negative control: widgetSpec accepted an unknown kind — the fixture gate is open'); + process.exit(1); +} + // --- run every fixture --- let failed = false; const covered = new Set(); From b737e829fd9ceca0d232097b65ea54092d4a12b8 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:07:57 -0400 Subject: [PATCH 09/23] test: the structural layer follows Tabs child refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs nest their references in tabs[].child, one level deeper than the shapes layer 3 walked — a dangling tab child would have passed silently. The negative control now includes one, so the checker proves it looks. Co-Authored-By: Claude Fable 5 --- scripts/a2ui-conformance.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs index 4528be0c..2a19b6f6 100644 --- a/scripts/a2ui-conformance.mjs +++ b/scripts/a2ui-conformance.mjs @@ -105,6 +105,12 @@ function structuralProblems(message) { if (c.children && !Array.isArray(c.children) && typeof c.children === 'object') { if (typeof c.children.componentId === 'string') refs.push(c.children.componentId); } + // Tabs nest their child refs one level down: tabs[].child. + if (Array.isArray(c.tabs)) { + for (const tab of c.tabs) { + if (tab && typeof tab.child === 'string') refs.push(tab.child); + } + } for (const ref of refs) { if (!ids.has(ref)) problems.push(`${c.id} references missing component: ${ref}`); } @@ -116,9 +122,15 @@ function structuralProblems(message) { // must all be reported, or layer 3 is a no-op wearing a checkmark. { const problems = structuralProblems({ - createSurface: { components: [{ id: 'a', child: 'ghost' }, { id: 'a' }] }, + createSurface: { + components: [ + { id: 'a', child: 'ghost' }, + { id: 'a' }, + { id: 'b', tabs: [{ title: 'T', child: 'tab-ghost' }] }, + ], + }, }); - const wants = ['duplicate component id', 'missing component', "no 'root'"]; + const wants = ['duplicate component id', 'missing component: ghost', 'missing component: tab-ghost', "no 'root'"]; if (!wants.every((w) => problems.some((p) => p.includes(w)))) { console.error(`✗ negative control: structural checker missed known defects (got: ${problems.join('; ') || 'nothing'})`); process.exit(1); From 46cfa49815f9ae8979f56b2302b9655c83c271e6 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:13:24 -0400 Subject: [PATCH 10/23] design: the basic-catalog renderer speaks the app's design language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentation is the renderer's half of the A2UI protocol — the surface carries semantics, the host draws them in its own idiom. Our demo renderer was being generic to the point of foreignness, which made the projection look broken rather than portable. Now it draws like the app: cards get the native card treatment, a tab switch turns the panel over with a flip (the whole panel is tappable to advance), and a horizontal List pages like a deck, one card in view. The flashcard mapping uses that List for its deck — the catalog had the word for it all along. None of this exceeds the surface's semantics: no invented state, no smuggled progress. What still separates the two columns is what the basic catalog cannot say — per-card progress, checked completion, markdown — which is the versioned a2learn catalog's job (#98). Co-Authored-By: Claude Fable 5 --- spec/a2learn/fixtures/flashcard.surface.json | 14 +++- src/app/demo/a2ui/page.tsx | 19 +++-- src/components/a2ui/A2UISurfaceView.tsx | 85 ++++++++++++++++---- src/lib/a2learn/a2ui.ts | 5 +- 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/spec/a2learn/fixtures/flashcard.surface.json b/spec/a2learn/fixtures/flashcard.surface.json index 60909435..41d540ea 100644 --- a/spec/a2learn/fixtures/flashcard.surface.json +++ b/spec/a2learn/fixtures/flashcard.surface.json @@ -9,9 +9,7 @@ "component": "Column", "children": [ "prompt", - "card-0", - "card-1", - "card-2", + "deck", "done", "success" ] @@ -21,6 +19,16 @@ "component": "Text", "text": "Match each fraction word to what it means." }, + { + "id": "deck", + "component": "List", + "direction": "horizontal", + "children": [ + "card-0", + "card-1", + "card-2" + ] + }, { "id": "card-0", "component": "Card", diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx index 0148228b..1047a316 100644 --- a/src/app/demo/a2ui/page.tsx +++ b/src/app/demo/a2ui/page.tsx @@ -10,7 +10,7 @@ import { toA2LearnSurface } from '@/lib/a2learn/catalog'; /** * The A2UI boundary, functional: the same fixture specs the conformance * suite proves (`spec/a2learn/fixtures/`), mapped live through - * `toA2UISurface` at render time and drawn by a minimal renderer of the + * `toA2UISurface` at render time and drawn by the app's renderer of the * emitted catalog slice. Deterministic and offline — no model call, so what * this page shows is exactly what the conformance gate guarantees. */ @@ -49,17 +49,20 @@ export default function A2UIDemoPage() { Google's A2UI {' '} basic catalog — the same surfaces the conformance suite validates against the vendored - upstream schemas, mapped live on this page and rendered by a minimal renderer of the six - component types the mapper emits. + upstream schemas, mapped live on this page and rendered by this app's renderer of the + catalog slice the mapper emits.

- Both columns are A2UI surfaces — the two tiers of the same spec. The left is the a2learn + Both columns are A2UI surfaces — the two tiers of the same spec. The first is the a2learn catalog (draft): its component is the widget kind itself, the registry is the renderer, so it is the exact native experience — flip animation, completion reporting, the same loop - the MCP Apps shell speaks. The right is the basic-catalog projection any generic A2UI - renderer can draw: the reveal survives as tabs, markdown as literal text. The remaining - gap between the columns is what the versioned a2learn catalog (#98) closes for hosts - beyond this app. Basic-mapped kinds today: {A2UI_SUPPORTED_KINDS.join(', ')}. + the MCP Apps shell speaks. The second is the basic-catalog projection any A2UI renderer + can draw — and presentation is the renderer's half of the protocol, so this + one draws it in the app's own design language: the deck pages horizontally, a tab + switch turns the card over. What stays behind in the projection is semantics the catalog + cannot carry — per-card progress, checked completion, markdown formatting — and that gap + is what the versioned a2learn catalog (#98) closes for hosts beyond this app. + Basic-mapped kinds today: {A2UI_SUPPORTED_KINDS.join(', ')}.

diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index 231e4ec7..516dac55 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -1,19 +1,25 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; +import { motion } from 'motion/react'; import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; /** - * A minimal renderer for the slice of the A2UI basic catalog the boundary - * mapper emits: Column, Card, Text, Divider, Image, Button. This is not a - * general A2UI client — it exists so the app can *show* a mapped surface - * being a real UI, not just JSON that validates. A component outside the - * emitted slice renders as a visible gap, never silently dropped. + * A renderer for the slice of the A2UI basic catalog the boundary mapper + * emits: Column, Card, Text, Divider, Image, Button, Tabs, List. This is + * not a general A2UI client — it exists so the app can *show* a mapped + * surface being a real UI, not just JSON that validates. A component + * outside the emitted slice renders as a visible gap, never silently + * dropped. * - * Fidelity is deliberately strict-renderer: Text draws its string as plain - * text, so a markdown body shows literal `**` marks — the exact degradation - * the mapper's header documents. Honesty over polish applies to demos too. + * Presentation is the renderer's job — that's A2UI's core split: the + * surface carries semantics, the host draws them in its own design + * language. So this renderer animates tab switches, styles cards like the + * app's cards, and pages horizontal lists like a deck. What it never does + * is exceed the semantics: Text draws its string as plain text (a markdown + * body shows literal `**` marks — the mapper documents exactly that), and + * no component gains state the surface didn't model. */ type Props = { @@ -40,9 +46,39 @@ export function A2UISurfaceView({ surface, onAction }: Props) { {children()}
); + case 'Row': + return ( +
+ {children()} +
+ ); + case 'List': { + const horizontal = component.direction === 'horizontal'; + if (!horizontal) { + return ( +
+ {children()} +
+ ); + } + // A horizontal list pages like a deck: full-width snap targets, one + // in view at a time, swipe or scroll to move through. + return ( +
+ {childIds(component).map((childId) => ( +
+ {render(childId, path)} +
+ ))} +
+ ); + } case 'Card': return ( -
+
{children()}
); @@ -107,8 +143,13 @@ function childIds(component: A2UIComponent): string[] { return ids; } -/** Tab switching is renderer-local state — the one interaction the basic - * catalog gives back (this is how the flashcard reveal survives projection). */ +/** + * Tab switching is renderer-local state — the one interaction the basic + * catalog gives back (this is how the flashcard reveal survives projection). + * Presentation is ours to choose, so a switch flips: the panel turns over + * like a card, and the whole panel is tappable to advance to the next tab — + * semantics unchanged, affordance native. + */ function TabsView({ tabs, render, @@ -118,9 +159,10 @@ function TabsView({ }) { const [active, setActive] = useState(0); const current = tabs[active]; + const advance = () => setActive((i) => (i + 1) % tabs.length); return ( -
-
+
+
{tabs.map((tab, i) => ( ))}
- {current?.child ? render(current.child) : null} + + {current?.child ? render(current.child) : null} +
); } diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index 25b3f474..41569523 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -159,8 +159,11 @@ function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMe }); const components: A2UIComponent[] = [ - { id: 'root', component: 'Column', children: ['prompt', ...cardIds, 'done', 'success'] }, + { id: 'root', component: 'Column', children: ['prompt', 'deck', 'done', 'success'] }, { id: 'prompt', component: 'Text', text: spec.prompt }, + // A horizontal List is the catalog's word for a deck: renderers page or + // scroll it, so cards present one at a time instead of as a wall. + { id: 'deck', component: 'List', direction: 'horizontal', children: cardIds }, ...cardComponents, ...doneButton(spec.kind, 'Done'), // The author wrote this for the end of the deck; a static surface shows From e2be2386bd2974418e009665d000755419f056a9 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:19:43 -0400 Subject: [PATCH 11/23] design: the flip is a real flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Card whose sole child is a two-tab Tabs is a thing with two faces, and the renderer now draws it as one: a single container rotating in perspective, two backface-hidden faces sharing a grid cell so the taller face sets the height, tap anywhere to turn it over. The caption names the other face from the surface's own tab titles — no flashcard vocabulary baked into the renderer, and no semantics beyond what the surface already modeled. The pill-tab presentation remains for Tabs in any other composition. Co-Authored-By: Claude Fable 5 --- src/components/a2ui/A2UISurfaceView.tsx | 62 ++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index 516dac55..a768a5b1 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -73,7 +73,25 @@ export function A2UISurfaceView({ surface, onAction }: Props) {
); } - case 'Card': + case 'Card': { + // Compositional presentation: a Card whose sole child is a two-tab + // Tabs is a thing with two faces — draw it as a real flip card. The + // semantics are untouched (same components, same two panels); only + // the affordance is native. + const child = typeof component.child === 'string' ? byId.get(component.child) : undefined; + if (child?.component === 'Tabs' && Array.isArray(child.tabs) && child.tabs.length === 2) { + const faces = (child.tabs as { title?: unknown; child?: unknown }[]).map((tab) => ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + })); + return ( + render(childId, path)} + /> + ); + } return (
); + } case 'Text': { const caption = component.variant === 'caption'; return ( @@ -143,6 +162,47 @@ function childIds(component: A2UIComponent): string[] { return ids; } +type FlipFace = { title: string; child: string | null }; + +/** + * A genuine 3D flip: one container rotating in perspective, two + * backface-hidden faces stacked in the same grid cell (so the tallest face + * sets the height), tap anywhere to turn it over. The caption names the + * other face using the surface's own tab titles — no flashcard vocabulary + * baked into the renderer. + */ +function FlipCard({ + faces, + render, +}: { + faces: [FlipFace, FlipFace]; + render: (childId: string) => React.ReactNode; +}) { + const [flipped, setFlipped] = useState(false); + const faceClass = + 'col-start-1 row-start-1 flex min-h-44 flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-card p-6 text-center shadow-sm [backface-visibility:hidden]'; + return ( +
+
+ setFlipped((f) => !f)} + > +
{faces[0].child ? render(faces[0].child) : null}
+
+ {faces[1].child ? render(faces[1].child) : null} +
+
+
+

+ Tap to see {flipped ? faces[0].title.toLowerCase() : faces[1].title.toLowerCase()} +

+
+ ); +} + /** * Tab switching is renderer-local state — the one interaction the basic * catalog gives back (this is how the flashcard reveal survives projection). From fa298b995f5aa81d39957f117112f881991ab769 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:25:42 -0400 Subject: [PATCH 12/23] feat: step-reveal joins the A2UI mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third mapped kind, and the first whose fixture exercises Tabs beyond two faces: each step is a tab (title, body, optional why as caption) inside a card, so the walk-through survives as navigation. The gating does not — tabs browse freely where the native widget enforces one-at-a-time disclosure — and the mapper says so instead of hiding it. The a2learn-catalog column needed zero changes: parity by construction, as claimed. Co-Authored-By: Claude Fable 5 --- spec/a2learn/fixtures/step-reveal.spec.json | 25 ++++ .../a2learn/fixtures/step-reveal.surface.json | 121 ++++++++++++++++++ src/lib/a2learn/a2ui.ts | 38 +++++- 3 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 spec/a2learn/fixtures/step-reveal.spec.json create mode 100644 spec/a2learn/fixtures/step-reveal.surface.json diff --git a/spec/a2learn/fixtures/step-reveal.spec.json b/spec/a2learn/fixtures/step-reveal.spec.json new file mode 100644 index 00000000..664807ad --- /dev/null +++ b/spec/a2learn/fixtures/step-reveal.spec.json @@ -0,0 +1,25 @@ +{ + "kind": "step-reveal", + "learningComponentId": "lc-fractions-3", + "prompt": "Let's compare 2/6 and 5/6 together, one step at a time.", + "steps": [ + { + "id": "s1", + "title": "Step 1: Check the denominators", + "body": "Both fractions have the same denominator, **6**. That means both wholes are cut into six equal parts — so the pieces are the same size.", + "why": "Comparing only works when the pieces are the same size." + }, + { + "id": "s2", + "title": "Step 2: Compare the numerators", + "body": "Now look at the numerators: 2 and 5. Since every piece is the same size, having **5 pieces** is more than having **2 pieces**.", + "why": null + }, + { + "id": "s3", + "title": "Step 3: Write the comparison", + "body": "So 5/6 is greater than 2/6, and we write it as **5/6 > 2/6**. Same-size pieces, more of them — that's the whole comparison.", + "why": "The symbol always opens toward the bigger amount." + } + ] +} diff --git a/spec/a2learn/fixtures/step-reveal.surface.json b/spec/a2learn/fixtures/step-reveal.surface.json new file mode 100644 index 00000000..1044ffbb --- /dev/null +++ b/spec/a2learn/fixtures/step-reveal.surface.json @@ -0,0 +1,121 @@ +{ + "version": "v1.0", + "createSurface": { + "surfaceId": "a2learn-fixture-step-reveal", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "prompt", + "steps-card", + "done" + ] + }, + { + "id": "prompt", + "component": "Text", + "text": "Let's compare 2/6 and 5/6 together, one step at a time." + }, + { + "id": "steps-card", + "component": "Card", + "child": "steps" + }, + { + "id": "steps", + "component": "Tabs", + "tabs": [ + { + "title": "Step 1: Check the denominators", + "child": "step-0-col" + }, + { + "title": "Step 2: Compare the numerators", + "child": "step-1-col" + }, + { + "title": "Step 3: Write the comparison", + "child": "step-2-col" + } + ] + }, + { + "id": "step-0-body", + "component": "Text", + "text": "Both fractions have the same denominator, **6**. That means both wholes are cut into six equal parts — so the pieces are the same size." + }, + { + "id": "step-0-why", + "component": "Text", + "text": "Why: Comparing only works when the pieces are the same size.", + "variant": "caption" + }, + { + "id": "step-0-col", + "component": "Column", + "children": [ + "step-0-body", + "step-0-why" + ] + }, + { + "id": "step-1-body", + "component": "Text", + "text": "Now look at the numerators: 2 and 5. Since every piece is the same size, having **5 pieces** is more than having **2 pieces**." + }, + { + "id": "step-1-col", + "component": "Column", + "children": [ + "step-1-body" + ] + }, + { + "id": "step-2-body", + "component": "Text", + "text": "So 5/6 is greater than 2/6, and we write it as **5/6 > 2/6**. Same-size pieces, more of them — that's the whole comparison." + }, + { + "id": "step-2-why", + "component": "Text", + "text": "Why: The symbol always opens toward the bigger amount.", + "variant": "caption" + }, + { + "id": "step-2-col", + "component": "Column", + "children": [ + "step-2-body", + "step-2-why" + ] + }, + { + "id": "done", + "component": "Button", + "variant": "primary", + "child": "done-label", + "action": { + "event": { + "name": "a2learn.widget_completed", + "userMessage": "Finished the activity.", + "context": { + "kind": "step-reveal" + } + } + } + }, + { + "id": "done-label", + "component": "Text", + "text": "Done" + } + ], + "dataModel": { + "a2learn": { + "kind": "step-reveal" + } + } + } +} diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index 41569523..3d654eeb 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -1,4 +1,4 @@ -import type { FlashcardSpec, MarkdownCardSpec, WidgetKind } from '@/lib/pathway/schema'; +import type { FlashcardSpec, MarkdownCardSpec, StepRevealSpec, WidgetKind } from '@/lib/pathway/schema'; import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; @@ -15,7 +15,7 @@ import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; * Coverage is deliberately incremental and honestly labeled: a kind is * supported when it has an entry in `MAPPERS` below — `A2UI_SUPPORTED_KINDS` * is derived from that map, so the list and the dispatch cannot drift, and - * the conformance suite requires a fixture per supported kind. The first two + * the conformance suite requires a fixture per supported kind. The mapped * kinds are the reading shapes. Known fidelity limits, stated rather than * papered over: flashcard's flip becomes front/back Tabs — the reveal * survives as renderer-local tab switching, the animation does not — and a @@ -43,8 +43,8 @@ export type A2UISurfaceMessage = { }; /** - * The completion action a surface's done button dispatches. Neither mapped - * kind assesses (`assesses: false` on both registry entries), so the context + * The completion action a surface's done button dispatches. None of the + * mapped kinds assess (`assesses: false` on every one's registry entry), so the context * deliberately carries no `correct` claim — completion is not mastery, and * an A2UI consumer must not be told otherwise. When an assessing kind joins * this file, its action gains a verdict from real checking, gated on the @@ -174,6 +174,35 @@ function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMe return surface(surfaceId, spec.kind, components); } +function stepRevealSurface(spec: StepRevealSpec, surfaceId: string): A2UISurfaceMessage { + // The reveal survives as navigation, not as gating: Tabs let a reader walk + // the steps in order or jump around, where the native widget enforces + // one-at-a-time disclosure. Sequence stays visible in the tab titles; the + // discipline does not survive projection, and that's stated, not hidden. + const tabs = spec.steps.map((step, i) => ({ title: step.title, child: `step-${i}-col` })); + const stepComponents: A2UIComponent[] = spec.steps.flatMap((step, i) => { + const children = [`step-${i}-body`]; + const parts: A2UIComponent[] = [{ id: `step-${i}-body`, component: 'Text', text: step.body }]; + if (step.why) { + children.push(`step-${i}-why`); + parts.push({ id: `step-${i}-why`, component: 'Text', text: `Why: ${step.why}`, variant: 'caption' }); + } + parts.push({ id: `step-${i}-col`, component: 'Column', children }); + return parts; + }); + + const components: A2UIComponent[] = [ + { id: 'root', component: 'Column', children: ['prompt', 'steps-card', 'done'] }, + { id: 'prompt', component: 'Text', text: spec.prompt }, + { id: 'steps-card', component: 'Card', child: 'steps' }, + { id: 'steps', component: 'Tabs', tabs }, + ...stepComponents, + ...doneButton(spec.kind, 'Done'), + ]; + + return surface(surfaceId, spec.kind, components); +} + /** * The dispatch table IS the support list — `A2UI_SUPPORTED_KINDS` derives * from it, so claiming a kind without mapping it (or vice versa) is @@ -182,6 +211,7 @@ function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMe const MAPPERS: Partial A2UISurfaceMessage>> = { 'markdown-card': markdownCardSurface, flashcard: flashcardSurface, + 'step-reveal': stepRevealSurface, }; export const A2UI_SUPPORTED_KINDS = Object.keys(MAPPERS) as WidgetKind[]; From bb64e413e0e69ae2c9c8244e941c9578b5202813 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:25:43 -0400 Subject: [PATCH 13/23] design: the deck gets Back/Next and position dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer affordances for a horizontal List: paging buttons in the app's own idiom, dots for position, snap-scroll kept so swiping still works, scroll position read back so the two stay in sync. Buttons disable at the ends. Position is presentation of where the reader is in a list the surface already modeled — no new state invented. Co-Authored-By: Claude Fable 5 --- src/components/a2ui/A2UISurfaceView.tsx | 93 ++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index a768a5b1..cc82cac2 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -61,16 +61,17 @@ export function A2UISurfaceView({ surface, onAction }: Props) {
); } - // A horizontal list pages like a deck: full-width snap targets, one - // in view at a time, swipe or scroll to move through. + // A horizontal list pages like a deck: swipe, or use the paging + // controls — position affordances are presentation, not state the + // surface didn't model. return ( -
- {childIds(component).map((childId) => ( -
- {render(childId, path)} -
- ))} -
+ ({ + id: childId, + node: render(childId, path), + }))} + /> ); } case 'Card': { @@ -162,6 +163,80 @@ function childIds(component: A2UIComponent): string[] { return ids; } +/** + * A horizontal List as a paged deck: snap-scroll stays (swipe works), and + * Back/Next plus position dots ride along as renderer affordances. Scrolling + * and the buttons stay in sync by reading the scroll position back. + */ +function DeckView({ items }: { items: { id: string; node: React.ReactNode }[] }) { + const [index, setIndex] = useState(0); + const scroller = useRef(null); + + const go = (next: number) => { + const el = scroller.current; + const clamped = Math.max(0, Math.min(items.length - 1, next)); + const target = el?.children[clamped] as HTMLElement | undefined; + if (el && target) el.scrollTo({ left: target.offsetLeft - el.offsetLeft, behavior: 'smooth' }); + setIndex(clamped); + }; + + const onScroll = () => { + const el = scroller.current; + if (!el || el.clientWidth === 0) return; + setIndex(Math.max(0, Math.min(items.length - 1, Math.round(el.scrollLeft / el.clientWidth)))); + }; + + return ( +
+
+ {items.map((item) => ( +
+ {item.node} +
+ ))} +
+ {items.length > 1 && ( + <> +
+ {items.map((item, i) => ( + + ))} +
+
+ + +
+ + )} +
+ ); +} + type FlipFace = { title: string; child: string | null }; /** From cb16db166fbb488605d24edf71c351380aad4232 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:27:34 -0400 Subject: [PATCH 14/23] design: deck pages slide in place, matching the native motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native flashcard changes cards with a 320ms slide-and-fade keyed on the card, not a scroll — the deck now does the same: one item in view, remounted per page with the identical curve and offset, direction-aware. The snap-scroll container goes away, which also removes the deck's only horizontal scroll surface. Co-Authored-By: Claude Fable 5 --- src/components/a2ui/A2UISurfaceView.tsx | 35 +++++++++---------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index cc82cac2..f17c002e 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -164,41 +164,30 @@ function childIds(component: A2UIComponent): string[] { } /** - * A horizontal List as a paged deck: snap-scroll stays (swipe works), and - * Back/Next plus position dots ride along as renderer affordances. Scrolling - * and the buttons stay in sync by reading the scroll position back. + * A horizontal List as a paged deck: one item in view, Back/Next and + * position dots as renderer affordances, and page changes slide in place — + * the same 320ms slide-and-fade the native widgets use, not a scroll. */ function DeckView({ items }: { items: { id: string; node: React.ReactNode }[] }) { const [index, setIndex] = useState(0); - const scroller = useRef(null); + const [direction, setDirection] = useState(0); const go = (next: number) => { - const el = scroller.current; const clamped = Math.max(0, Math.min(items.length - 1, next)); - const target = el?.children[clamped] as HTMLElement | undefined; - if (el && target) el.scrollTo({ left: target.offsetLeft - el.offsetLeft, behavior: 'smooth' }); + setDirection(Math.sign(clamped - index)); setIndex(clamped); }; - const onScroll = () => { - const el = scroller.current; - if (!el || el.clientWidth === 0) return; - setIndex(Math.max(0, Math.min(items.length - 1, Math.round(el.scrollLeft / el.clientWidth)))); - }; - return (
-
- {items.map((item) => ( -
- {item.node} -
- ))} -
+ {items[index]?.node} + {items.length > 1 && ( <>
From abff7e1db976efaff84461264cccd273df26f4ea Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:29:43 -0400 Subject: [PATCH 15/23] design: the deck's final Next is the done button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native flashcard ends on its own CTA — the last card's Next becomes the completion action. The deck now composes the same way: a horizontal List immediately followed by an action Button folds that button into the paging controls as the final page's primary action, label taken from the surface's own button text. Same button, same action, presented where the reader finishes; nothing dropped, nothing added. A deck without a trailing button (or a button elsewhere, like step-reveal's) is left exactly as the surface laid it out. Co-Authored-By: Claude Fable 5 --- src/components/a2ui/A2UISurfaceView.tsx | 78 +++++++++++++++++++++---- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index f17c002e..729d0871 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -43,7 +43,7 @@ export function A2UISurfaceView({ surface, onAction }: Props) { case 'Column': return (
- {children()} + {columnChildren(component, path)}
); case 'Row': @@ -150,6 +150,47 @@ export function A2UISurfaceView({ surface, onAction }: Props) { } } + /** + * A Column's children, with one compositional merge: a horizontal List + * (a deck) immediately followed by an action Button folds that button + * into the deck's paging controls as the final page's action — the same + * button, the same action, presented where the reader finishes, the way + * the native widgets end on their own CTA. Nothing is dropped or added. + */ + function columnChildren(component: A2UIComponent, path: Set): React.ReactNode[] { + const ids = childIds(component); + const nodes: React.ReactNode[] = []; + for (let i = 0; i < ids.length; i++) { + const child = byId.get(ids[i]); + const following = i + 1 < ids.length ? byId.get(ids[i + 1]) : undefined; + if ( + child?.component === 'List' && + child.direction === 'horizontal' && + following?.component === 'Button' && + following.action && + typeof following.action === 'object' + ) { + const labelSource = + typeof following.child === 'string' ? byId.get(following.child) : undefined; + const action = following.action as Record; + nodes.push( + ({ id: childId, node: render(childId, path) }))} + finale={{ + label: String(labelSource?.text ?? 'Done'), + onClick: () => onAction?.(action), + }} + />, + ); + i++; // the button is folded into the deck — don't render it twice + continue; + } + nodes.push(render(ids[i], path)); + } + return nodes; + } + return
{render('root', new Set())}
; } @@ -168,7 +209,14 @@ function childIds(component: A2UIComponent): string[] { * position dots as renderer affordances, and page changes slide in place — * the same 320ms slide-and-fade the native widgets use, not a scroll. */ -function DeckView({ items }: { items: { id: string; node: React.ReactNode }[] }) { +function DeckView({ + items, + finale, +}: { + items: { id: string; node: React.ReactNode }[]; + /** Rendered in Next's place on the last page — the deck's own completion action. */ + finale?: { label: string; onClick: () => void }; +}) { const [index, setIndex] = useState(0); const [direction, setDirection] = useState(0); @@ -211,14 +259,24 @@ function DeckView({ items }: { items: { id: string; node: React.ReactNode }[] }) > ← Back - + {finale && index === items.length - 1 ? ( + + ) : ( + + )}
)} From 5cd14f4802946dd3a2dd84b17e94cac6bf703384 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:31:34 -0400 Subject: [PATCH 16/23] fix: Text renders the simple Markdown the catalog specifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog's Text is specified to support simple Markdown (without HTML, images, or links) — drawing literal ** marks was under-rendering the spec, not honesty. The renderer now uses the same react-markdown stack as the native widgets, scoped exactly to the catalog: GFM formatting renders, raw HTML is ignored, links and images unwrap to their text. The fidelity notes shrink accordingly — what a body actually loses in projection is links and images, and the docs now say that instead of overclaiming a plain-text ceiling. Co-Authored-By: Claude Fable 5 --- src/app/demo/a2ui/page.tsx | 2 +- src/components/a2ui/A2UISurfaceView.tsx | 34 ++++++++++++++++++++----- src/lib/a2learn/a2ui.ts | 7 ++--- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx index 1047a316..cd2c5f23 100644 --- a/src/app/demo/a2ui/page.tsx +++ b/src/app/demo/a2ui/page.tsx @@ -60,7 +60,7 @@ export default function A2UIDemoPage() { can draw — and presentation is the renderer's half of the protocol, so this one draws it in the app's own design language: the deck pages horizontally, a tab switch turns the card over. What stays behind in the projection is semantics the catalog - cannot carry — per-card progress, checked completion, markdown formatting — and that gap + cannot carry — per-card progress, checked completion, links and images in text — and that gap is what the versioned a2learn catalog (#98) closes for hosts beyond this app. Basic-mapped kinds today: {A2UI_SUPPORTED_KINDS.join(', ')}.

diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index 729d0871..8e19cd22 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { motion } from 'motion/react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; @@ -16,10 +18,10 @@ import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; * Presentation is the renderer's job — that's A2UI's core split: the * surface carries semantics, the host draws them in its own design * language. So this renderer animates tab switches, styles cards like the - * app's cards, and pages horizontal lists like a deck. What it never does - * is exceed the semantics: Text draws its string as plain text (a markdown - * body shows literal `**` marks — the mapper documents exactly that), and - * no component gains state the surface didn't model. + * app's cards, pages horizontal lists like a deck, and renders Text's + * simple-Markdown scope the way the catalog defines it (no HTML, links and + * images unwrapped to their text). What it never does is exceed the + * semantics: no component gains state the surface didn't model. */ type Props = { @@ -104,10 +106,28 @@ export function A2UISurfaceView({ surface, onAction }: Props) { } case 'Text': { const caption = component.variant === 'caption'; + // The catalog's Text supports "simple Markdown formatting (without + // HTML, images, or links)" — so render exactly that: GFM emphasis, + // lists and headings; raw HTML ignored, links and images unwrapped + // to their text. Drawing literal ** marks would under-render the + // spec, not respect it. return ( -

- {String(component.text ?? '')} -

+
+ + {String(component.text ?? '')} + +
); } case 'Divider': diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index 3d654eeb..4d31a4d2 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -18,9 +18,10 @@ import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; * the conformance suite requires a fixture per supported kind. The mapped * kinds are the reading shapes. Known fidelity limits, stated rather than * papered over: flashcard's flip becomes front/back Tabs — the reveal - * survives as renderer-local tab switching, the animation does not — and a - * markdown body using links or tables renders them as literal Markdown in - * strict renderers (the catalog's `Text` scopes itself to simple Markdown). + * survives as renderer-local tab switching, the animation does not — and + * the catalog's `Text` scopes itself to simple Markdown without HTML, + * links, or images, so a body leaning on those loses them in any + * spec-faithful renderer. * Full mechanics for every kind is the a2learn catalog's job (#98), not * this projection's. The durable home for this mapping is an optional field on the * widget registry entry — tracked with the registry-owned-semantics From 1e92689c1ad5a70af3a20620fb9a0a515548dbb7 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:33:32 -0400 Subject: [PATCH 17/23] fix: step-reveal maps to a deck, not tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three long tab titles crowding one row communicated nothing about stepping through — the wrong catalog word. A walk-through is a sequence, and the catalog's sequence primitive is the horizontal List: one Card per step (title, divider, body, why as caption), paged by the renderer with Back/Next, and the trailing done button folding in as the final step's action. No new primitive needed — new primitives are the a2learn catalog's business (#98), not the vendored basic catalog's. The gating note stands: a deck pages freely where the native widget disciplines one-at-a-time. Co-Authored-By: Claude Fable 5 --- .../a2learn/fixtures/step-reveal.surface.json | 73 ++++++++++++++----- src/lib/a2learn/a2ui.ts | 25 ++++--- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/spec/a2learn/fixtures/step-reveal.surface.json b/spec/a2learn/fixtures/step-reveal.surface.json index 1044ffbb..e3ad7347 100644 --- a/spec/a2learn/fixtures/step-reveal.surface.json +++ b/spec/a2learn/fixtures/step-reveal.surface.json @@ -9,7 +9,7 @@ "component": "Column", "children": [ "prompt", - "steps-card", + "steps", "done" ] }, @@ -19,27 +19,28 @@ "text": "Let's compare 2/6 and 5/6 together, one step at a time." }, { - "id": "steps-card", + "id": "steps", + "component": "List", + "direction": "horizontal", + "children": [ + "step-0", + "step-1", + "step-2" + ] + }, + { + "id": "step-0", "component": "Card", - "child": "steps" + "child": "step-0-col" }, { - "id": "steps", - "component": "Tabs", - "tabs": [ - { - "title": "Step 1: Check the denominators", - "child": "step-0-col" - }, - { - "title": "Step 2: Compare the numerators", - "child": "step-1-col" - }, - { - "title": "Step 3: Write the comparison", - "child": "step-2-col" - } - ] + "id": "step-0-title", + "component": "Text", + "text": "Step 1: Check the denominators" + }, + { + "id": "step-0-divider", + "component": "Divider" }, { "id": "step-0-body", @@ -56,10 +57,26 @@ "id": "step-0-col", "component": "Column", "children": [ + "step-0-title", + "step-0-divider", "step-0-body", "step-0-why" ] }, + { + "id": "step-1", + "component": "Card", + "child": "step-1-col" + }, + { + "id": "step-1-title", + "component": "Text", + "text": "Step 2: Compare the numerators" + }, + { + "id": "step-1-divider", + "component": "Divider" + }, { "id": "step-1-body", "component": "Text", @@ -69,9 +86,25 @@ "id": "step-1-col", "component": "Column", "children": [ + "step-1-title", + "step-1-divider", "step-1-body" ] }, + { + "id": "step-2", + "component": "Card", + "child": "step-2-col" + }, + { + "id": "step-2-title", + "component": "Text", + "text": "Step 3: Write the comparison" + }, + { + "id": "step-2-divider", + "component": "Divider" + }, { "id": "step-2-body", "component": "Text", @@ -87,6 +120,8 @@ "id": "step-2-col", "component": "Column", "children": [ + "step-2-title", + "step-2-divider", "step-2-body", "step-2-why" ] diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index 4d31a4d2..68e65c26 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -176,14 +176,20 @@ function flashcardSurface(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMe } function stepRevealSurface(spec: StepRevealSpec, surfaceId: string): A2UISurfaceMessage { - // The reveal survives as navigation, not as gating: Tabs let a reader walk - // the steps in order or jump around, where the native widget enforces - // one-at-a-time disclosure. Sequence stays visible in the tab titles; the - // discipline does not survive projection, and that's stated, not hidden. - const tabs = spec.steps.map((step, i) => ({ title: step.title, child: `step-${i}-col` })); + // A walk-through is a sequence, and the catalog's word for a sequence is a + // horizontal List — one Card per step, paged by the renderer. What doesn't + // survive projection is the gating: a deck pages freely in both directions, + // where the native widget enforces one-at-a-time disclosure. + const stepIds = spec.steps.map((_, i) => `step-${i}`); const stepComponents: A2UIComponent[] = spec.steps.flatMap((step, i) => { - const children = [`step-${i}-body`]; - const parts: A2UIComponent[] = [{ id: `step-${i}-body`, component: 'Text', text: step.body }]; + const children = [`step-${i}-title`, `step-${i}-divider`, `step-${i}-body`]; + const parts: A2UIComponent[] = [ + { id: `step-${i}`, component: 'Card', child: `step-${i}-col` }, + // Title verbatim — same rule as markdown-card: no added emphasis markers. + { id: `step-${i}-title`, component: 'Text', text: step.title }, + { id: `step-${i}-divider`, component: 'Divider' }, + { id: `step-${i}-body`, component: 'Text', text: step.body }, + ]; if (step.why) { children.push(`step-${i}-why`); parts.push({ id: `step-${i}-why`, component: 'Text', text: `Why: ${step.why}`, variant: 'caption' }); @@ -193,10 +199,9 @@ function stepRevealSurface(spec: StepRevealSpec, surfaceId: string): A2UISurface }); const components: A2UIComponent[] = [ - { id: 'root', component: 'Column', children: ['prompt', 'steps-card', 'done'] }, + { id: 'root', component: 'Column', children: ['prompt', 'steps', 'done'] }, { id: 'prompt', component: 'Text', text: spec.prompt }, - { id: 'steps-card', component: 'Card', child: 'steps' }, - { id: 'steps', component: 'Tabs', tabs }, + { id: 'steps', component: 'List', direction: 'horizontal', children: stepIds }, ...stepComponents, ...doneButton(spec.kind, 'Done'), ]; From 694929ceaac3f485dbee7250dbe33fda99d84ffa Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:40:40 -0400 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20generic=20interaction=20primitive?= =?UTF-8?q?s=20=E2=80=94=20behavior=20as=20data,=20composed=20on=20the=20f?= =?UTF-8?q?ly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parameterized primitives join the draft a2learn catalog, replacing the idea of one primitive per widget kind: a2learn:Sequence (children plus a traversal policy — linear or free, gated or all, accumulate or replace — and a completeAction fired past the end) and a2learn:Reveal (one thing with N faces). step-reveal composes as Sequence(linear, gated, accumulate); a flashcard deck as Sequence(free, all, replace) of Reveal(front, back) — and any other policy mix is pure data, no new component code, which is what lets a planner compose novel activity shapes at generation time. Invariant 1 holds: generated output is a schema-shaped composition tree; every primitive's implementation is human-written renderer code. Content inside compositions reuses the basic catalog verbatim (draft catalog = basic ∪ primitives). The demo shows all three tiers per composed kind: full fidelity, behavior-as-data, universal fallback — gating visibly survives at the primitives tier and not below. Co-Authored-By: Claude Fable 5 --- src/app/demo/a2ui/page.tsx | 37 ++++-- src/components/a2ui/A2UIComparison.tsx | 14 ++- src/components/a2ui/A2UISurfaceView.tsx | 103 +++++++++++++++ src/lib/a2learn/primitives.ts | 161 ++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 src/lib/a2learn/primitives.ts diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx index cd2c5f23..8e59086b 100644 --- a/src/app/demo/a2ui/page.tsx +++ b/src/app/demo/a2ui/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { A2UIComparison } from '@/components/a2ui/A2UIComparison'; import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; import { toA2LearnSurface } from '@/lib/a2learn/catalog'; +import { toA2LearnComposition } from '@/lib/a2learn/primitives'; /** * The A2UI boundary, functional: the same fixture specs the conformance @@ -32,6 +33,7 @@ export default function A2UIDemoPage() { name, spec, a2learnSurface: toA2LearnSurface(spec, `demo-a2learn-${name}`), + composition: toA2LearnComposition(spec, `demo-composed-${name}`), surface: toA2UISurface(spec, `demo-${name}`), })); @@ -53,20 +55,19 @@ export default function A2UIDemoPage() { catalog slice the mapper emits.

- Both columns are A2UI surfaces — the two tiers of the same spec. The first is the a2learn - catalog (draft): its component is the widget kind itself, the registry is the renderer, so - it is the exact native experience — flip animation, completion reporting, the same loop - the MCP Apps shell speaks. The second is the basic-catalog projection any A2UI renderer - can draw — and presentation is the renderer's half of the protocol, so this - one draws it in the app's own design language: the deck pages horizontally, a tab - switch turns the card over. What stays behind in the projection is semantics the catalog - cannot carry — per-card progress, checked completion, links and images in text — and that gap - is what the versioned a2learn catalog (#98) closes for hosts beyond this app. + Every column is an A2UI surface — the same spec at three tiers of the a2learn catalog + story. Full fidelity: the widget kind as one component, the registry as renderer — the + exact native experience. Generic primitives (where composed): behavior as data — + a2learn:Sequence with a traversal policy (linear or free, gated or all, accumulate or + replace) and a2learn:Reveal with faces — so new activity shapes compose at generation + time from existing words, no new component code. Universal fallback: the basic catalog + any A2UI renderer draws. Gating survives at the primitives tier and not below; per-card + checking survives only at the top — each tier states what it keeps. Basic-mapped kinds today: {A2UI_SUPPORTED_KINDS.join(', ')}.

- {pairs.map(({ name, spec, a2learnSurface, surface }) => ( + {pairs.map(({ name, spec, a2learnSurface, composition, surface }) => (

{name}

@@ -76,7 +77,11 @@ export default function A2UIDemoPage() {

{surface && a2learnSurface ? ( <> - +
widget spec (input) @@ -93,6 +98,16 @@ export default function A2UIDemoPage() { {JSON.stringify(a2learnSurface, null, 2)}
+ {composition && ( +
+ + primitives createSurface message (generic composition) + +
+                      {JSON.stringify(composition, null, 2)}
+                    
+
+ )}
basic-catalog createSurface message (fallback) diff --git a/src/components/a2ui/A2UIComparison.tsx b/src/components/a2ui/A2UIComparison.tsx index 2305ae03..a7cd5073 100644 --- a/src/components/a2ui/A2UIComparison.tsx +++ b/src/components/a2ui/A2UIComparison.tsx @@ -22,6 +22,8 @@ import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; type Props = { a2learnSurface: A2UISurfaceMessage; + /** Generic-primitives composition (a2learn:Sequence / a2learn:Reveal), when one exists. */ + composition?: A2UISurfaceMessage | null; basicSurface: A2UISurfaceMessage; }; @@ -58,10 +60,10 @@ function A2LearnSurfaceView({ ); } -export function A2UIComparison({ a2learnSurface, basicSurface }: Props) { +export function A2UIComparison({ a2learnSurface, composition, basicSurface }: Props) { const [action, setAction] = useState | null>(null); return ( -
+

a2learn catalog (draft) — full fidelity, registry as renderer @@ -69,6 +71,14 @@ export function A2UIComparison({ a2learnSurface, basicSurface }: Props) {

+ {composition && ( +
+

+ a2learn primitives (draft) — behavior as data +

+ +
+ )}

A2UI basic catalog — universal fallback diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index 8e19cd22..68ee513e 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; +import type { SequencePolicy } from '@/lib/a2learn/primitives'; /** * A renderer for the slice of the A2UI basic catalog the boundary mapper @@ -165,6 +166,56 @@ export function A2UISurfaceView({ surface, onAction }: Props) { {children()} ); + // --- a2learn interaction primitives (draft catalog = basic ∪ primitives) --- + case 'a2learn:Sequence': { + const policy = (component.policy ?? {}) as Partial; + const items = childIds(component).map((childId) => ({ + id: childId, + node: render(childId, path), + })); + const action = component.completeAction as Record | undefined; + const finale = action ? { label: 'Done', onClick: () => onAction?.(action) } : undefined; + if (policy.disclosure === 'gated') { + return ( + + ); + } + return ; + } + case 'a2learn:Reveal': { + const faces = (Array.isArray(component.faces) ? component.faces : []) as { + title?: unknown; + child?: unknown; + }[]; + if (faces.length === 2) { + const pair = faces.map((tab) => ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + })); + return ( + render(childId, path)} + /> + ); + } + return ( + ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + }))} + render={(childId) => render(childId, path)} + /> + ); + } default: return ; } @@ -304,6 +355,58 @@ function DeckView({ ); } +/** + * Sequence(disclosure: gated): items reveal one advance at a time — + * accumulate keeps passed items visible, replace shows only the newest. + * The advance past the last item is the completion, exactly the native + * step-reveal's discipline, driven here entirely by policy data. + */ +function GatedSequenceView({ + items, + accumulate, + finale, +}: { + items: { id: string; node: React.ReactNode }[]; + accumulate: boolean; + finale?: { label: string; onClick: () => void }; +}) { + const [revealed, setRevealed] = useState(1); + const atEnd = revealed >= items.length; + const visible = accumulate ? items.slice(0, revealed) : items.slice(revealed - 1, revealed); + return ( +

+ {visible.map((item, i) => ( + + {item.node} + + ))} + {!atEnd ? ( + + ) : finale ? ( + + ) : null} +
+ ); +} + type FlipFace = { title: string; child: string | null }; /** diff --git a/src/lib/a2learn/primitives.ts b/src/lib/a2learn/primitives.ts new file mode 100644 index 00000000..46855d15 --- /dev/null +++ b/src/lib/a2learn/primitives.ts @@ -0,0 +1,161 @@ +import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; +import type { FlashcardSpec, StepRevealSpec } from '@/lib/pathway/schema'; + +import { A2LEARN_CATALOG_DRAFT } from '@/lib/a2learn/catalog'; +import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; + +/** + * a2learn interaction primitives, in draft: *generic, parameterized* + * behavior components — not one primitive per widget kind. A kind-shaped + * primitive (a "Stepper", a "FlipDeck") just recreates the whole-widget + * problem at smaller grain; these instead name the behaviors themselves, + * with policies as data, so that new activity shapes can be *composed at + * generation time* — by a mapper today, by a planner model tomorrow — + * without anyone shipping new component code. Invariant 1 holds: what gets + * generated is a composition tree of declared primitives and basic-catalog + * content components, schema-validatable; every primitive's implementation + * is human-written renderer code. + * + * The draft set — two words, chosen because today's kinds prove them: + * + * - `a2learn:Sequence` — ordered children traversed under a policy: + * `order` ('linear' | 'free'), `disclosure` ('gated' | 'all'), + * `revealed` ('accumulate' | 'replace'), plus an optional + * `completeAction` dispatched when the learner advances past the end. + * step-reveal IS Sequence(linear, gated, accumulate); a flashcard deck + * IS Sequence(free, all, replace). + * - `a2learn:Reveal` — one thing with N faces and a tap-to-turn + * affordance. A flashcard IS Reveal(front, back). + * + * Content inside primitives is ordinary basic-catalog components — the + * draft catalog is declared as basic ∪ primitives, so a composition reuses + * every content word the basic tier already has. + */ + +export type SequencePolicy = { + /** May the reader jump around, or only move to what's next? */ + order: 'linear' | 'free'; + /** Is the next item held back until the reader advances? */ + disclosure: 'gated' | 'all'; + /** Do passed items stay visible, or does the next replace them? */ + revealed: 'accumulate' | 'replace'; +}; + +export const SEQUENCE = 'a2learn:Sequence'; +export const REVEAL = 'a2learn:Reveal'; + +function completedAction(kind: string) { + return { + event: { + name: `${A2LEARN_EVENT_PREFIX}widget_completed`, + userMessage: 'Finished the activity.', + context: { kind }, + }, + }; +} + +function compositionSurface(surfaceId: string, components: A2UIComponent[]): A2UISurfaceMessage { + return { + version: 'v1.0', + createSurface: { surfaceId, catalogId: A2LEARN_CATALOG_DRAFT, components }, + }; +} + +function stepRevealComposition(spec: StepRevealSpec, surfaceId: string): A2UISurfaceMessage { + const itemIds = spec.steps.map((_, i) => `step-${i}`); + const items: A2UIComponent[] = spec.steps.flatMap((step, i) => { + const children = [`step-${i}-title`, `step-${i}-body`]; + const parts: A2UIComponent[] = [ + { id: `step-${i}-title`, component: 'Text', text: step.title }, + { id: `step-${i}-body`, component: 'Text', text: step.body }, + ]; + if (step.why) { + children.push(`step-${i}-why`); + parts.push({ id: `step-${i}-why`, component: 'Text', text: `Why: ${step.why}`, variant: 'caption' }); + } + parts.push({ id: `step-${i}`, component: 'Column', children }); + return parts; + }); + + return compositionSurface(surfaceId, [ + { id: 'root', component: 'Column', children: ['prompt', 'walk'] }, + { id: 'prompt', component: 'Text', text: spec.prompt }, + { + id: 'walk', + component: SEQUENCE, + policy: { order: 'linear', disclosure: 'gated', revealed: 'accumulate' } satisfies SequencePolicy, + children: itemIds, + completeAction: completedAction(spec.kind), + }, + ...items, + ]); +} + +function flashcardComposition(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMessage { + const cardIds = spec.cards.map((_, i) => `card-${i}`); + const cards: A2UIComponent[] = spec.cards.flatMap((card, i) => { + const face = (side: typeof card.front, prefix: string): A2UIComponent[] => { + const parts: A2UIComponent[] = []; + if (side.text) parts.push({ id: `${prefix}-text`, component: 'Text', text: side.text }); + if (side.markdown) parts.push({ id: `${prefix}-md`, component: 'Text', text: side.markdown }); + if (side.imageUrl) { + parts.push({ + id: `${prefix}-image`, + component: 'Image', + url: side.imageUrl, + description: side.imageAlt ?? '', + fit: 'contain', + }); + } + return parts; + }; + const front = face(card.front, `card-${i}-front`); + const back = face(card.back, `card-${i}-back`); + return [ + { + id: `card-${i}`, + component: REVEAL, + faces: [ + { title: 'Front', child: `card-${i}-front-col` }, + { title: 'Back', child: `card-${i}-back-col` }, + ], + }, + { id: `card-${i}-front-col`, component: 'Column', children: front.map((c) => c.id) }, + { id: `card-${i}-back-col`, component: 'Column', children: back.map((c) => c.id) }, + ...front, + ...back, + ]; + }); + + return compositionSurface(surfaceId, [ + { id: 'root', component: 'Column', children: ['prompt', 'deck'] }, + { id: 'prompt', component: 'Text', text: spec.prompt }, + { + id: 'deck', + component: SEQUENCE, + policy: { order: 'free', disclosure: 'all', revealed: 'replace' } satisfies SequencePolicy, + children: cardIds, + completeAction: completedAction(spec.kind), + }, + ...cards, + ]); +} + +/** + * Compose a spec from generic primitives, where a composition exists. The + * point of the exercise: two primitives cover two kinds today, and a third + * kind needing Sequence(linear, gated, replace) — or any other policy mix — + * would be pure data, no new code. Returns null for kinds not yet composed; + * callers fall back and say so. + */ +export function toA2LearnComposition(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { + if (!spec || typeof spec !== 'object' || !('kind' in spec)) return null; + switch ((spec as { kind: string }).kind) { + case 'step-reveal': + return stepRevealComposition(spec as StepRevealSpec, surfaceId); + case 'flashcard': + return flashcardComposition(spec as FlashcardSpec, surfaceId); + default: + return null; + } +} From 22a73d5b232c60d75f1325eaddd9d578d00cdd22 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 17:44:38 -0400 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20a2learn:Callout=20=E2=80=94=20ped?= =?UTF-8?q?agogical=20emphasis=20with=20intent=20as=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first content primitive beside the two behavior words: a block whose intent ('why', 'tip', 'note', open set) is data the renderer maps to its design language — amber for the elaborative why, primary tint for the tip, neutral otherwise. step-reveal's why and markdown-card's tip now survive projection at the primitives tier with the emphasis the authors gave them; markdown-card gains a composition for exactly that reason. The basic fallback keeps its honest caption — the tiers keep stating what they keep. Co-Authored-By: Claude Fable 5 --- src/components/a2ui/A2UISurfaceView.tsx | 26 +++++++++++++++++++++++++ src/lib/a2learn/primitives.ts | 26 +++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx index 68ee513e..5725d212 100644 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -216,6 +216,32 @@ export function A2UISurfaceView({ surface, onAction }: Props) { /> ); } + case 'a2learn:Callout': { + // Pedagogical emphasis with intent as data — the renderer maps + // intent to its design language; unknown intents get the neutral box. + const intent = String(component.intent ?? 'note'); + const tone = + intent === 'why' + ? 'border-amber-200 bg-amber-50 dark:border-amber-800/50 dark:bg-amber-950/30' + : intent === 'tip' + ? 'border-primary/25 bg-primary/5' + : 'border-border bg-muted/40'; + const labelTone = intent === 'tip' ? 'font-semibold text-primary' : 'font-semibold'; + return ( +
+ {component.label ? {String(component.label)} : null} + {typeof component.child === 'string' ? ( + render(component.child, path) + ) : ( + + + {String(component.text ?? '')} + + + )} +
+ ); + } default: return ; } diff --git a/src/lib/a2learn/primitives.ts b/src/lib/a2learn/primitives.ts index 46855d15..d3e66d15 100644 --- a/src/lib/a2learn/primitives.ts +++ b/src/lib/a2learn/primitives.ts @@ -1,5 +1,5 @@ import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; -import type { FlashcardSpec, StepRevealSpec } from '@/lib/pathway/schema'; +import type { FlashcardSpec, MarkdownCardSpec, StepRevealSpec } from '@/lib/pathway/schema'; import { A2LEARN_CATALOG_DRAFT } from '@/lib/a2learn/catalog'; import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; @@ -43,6 +43,14 @@ export type SequencePolicy = { export const SEQUENCE = 'a2learn:Sequence'; export const REVEAL = 'a2learn:Reveal'; +/** + * `a2learn:Callout` — the first *content* primitive: a block whose + * pedagogical intent is data (`intent`: 'why' | 'tip' | 'note' today, + * open to more), with a bold `label` and markdown `text`. The native + * widgets style a "why" and a "tip" as emphasized boxes; without this + * word that emphasis degrades to a muted caption in every projection. + */ +export const CALLOUT = 'a2learn:Callout'; function completedAction(kind: string) { return { @@ -71,7 +79,7 @@ function stepRevealComposition(spec: StepRevealSpec, surfaceId: string): A2UISur ]; if (step.why) { children.push(`step-${i}-why`); - parts.push({ id: `step-${i}-why`, component: 'Text', text: `Why: ${step.why}`, variant: 'caption' }); + parts.push({ id: `step-${i}-why`, component: CALLOUT, intent: 'why', label: 'Why?', text: step.why }); } parts.push({ id: `step-${i}`, component: 'Column', children }); return parts; @@ -91,6 +99,18 @@ function stepRevealComposition(spec: StepRevealSpec, surfaceId: string): A2UISur ]); } +function markdownCardComposition(spec: MarkdownCardSpec, surfaceId: string): A2UISurfaceMessage { + const children = ['title', 'body', ...(spec.tip ? ['tip'] : [])]; + return compositionSurface(surfaceId, [ + { id: 'root', component: 'Column', children }, + { id: 'title', component: 'Text', text: spec.title }, + { id: 'body', component: 'Text', text: spec.body }, + ...(spec.tip + ? [{ id: 'tip', component: CALLOUT, intent: 'tip', label: 'Tip:', text: spec.tip } as A2UIComponent] + : []), + ]); +} + function flashcardComposition(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMessage { const cardIds = spec.cards.map((_, i) => `card-${i}`); const cards: A2UIComponent[] = spec.cards.flatMap((card, i) => { @@ -155,6 +175,8 @@ export function toA2LearnComposition(spec: unknown, surfaceId: string): A2UISurf return stepRevealComposition(spec as StepRevealSpec, surfaceId); case 'flashcard': return flashcardComposition(spec as FlashcardSpec, surfaceId); + case 'markdown-card': + return markdownCardComposition(spec as MarkdownCardSpec, surfaceId); default: return null; } From d31e1b0d879298ed7e4ae01862bb30c46f4cde21 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 18:34:13 -0400 Subject: [PATCH 20/23] =?UTF-8?q?chore:=20the=20a2ui=20demo=20page=20retir?= =?UTF-8?q?es=20=E2=80=94=20its=20parts=20fold=20into=20the=20product?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-tier comparison page did its exploration job: the mapper and conformance stand on their own gates, and the surface renderer becomes the composed kind's component in the next PR up the stack. The page, the comparison component, the whole-widget draft-catalog module, and the per-kind compositions all retire; primitives.ts stays as the catalog identity module (catalog URN + policy vocabulary) that both the renderer and the composed kind consume. Since this PR squash-merges, the demo never reaches main at all. Co-Authored-By: Claude Fable 5 --- src/app/demo/a2ui/page.tsx | 130 ------------------ src/components/a2ui/A2UIComparison.tsx | 90 ------------ src/lib/a2learn/catalog.ts | 51 ------- src/lib/a2learn/primitives.ts | 183 +++---------------------- 4 files changed, 17 insertions(+), 437 deletions(-) delete mode 100644 src/app/demo/a2ui/page.tsx delete mode 100644 src/components/a2ui/A2UIComparison.tsx delete mode 100644 src/lib/a2learn/catalog.ts diff --git a/src/app/demo/a2ui/page.tsx b/src/app/demo/a2ui/page.tsx deleted file mode 100644 index 8e59086b..00000000 --- a/src/app/demo/a2ui/page.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import Link from 'next/link'; - -import { A2UIComparison } from '@/components/a2ui/A2UIComparison'; -import { toA2UISurface, A2UI_SUPPORTED_KINDS } from '@/lib/a2learn/a2ui'; -import { toA2LearnSurface } from '@/lib/a2learn/catalog'; -import { toA2LearnComposition } from '@/lib/a2learn/primitives'; - -/** - * The A2UI boundary, functional: the same fixture specs the conformance - * suite proves (`spec/a2learn/fixtures/`), mapped live through - * `toA2UISurface` at render time and drawn by the app's renderer of the - * emitted catalog slice. Deterministic and offline — no model call, so what - * this page shows is exactly what the conformance gate guarantees. - */ - -export const metadata = { title: 'A2UI surfaces — demo' }; - -function fixtureSpecs(): { name: string; spec: Record }[] { - const dir = join(process.cwd(), 'spec', 'a2learn', 'fixtures'); - return readdirSync(dir) - .filter((file) => file.endsWith('.spec.json')) - .map((file) => ({ - name: file.replace(/\.spec\.json$/, ''), - spec: JSON.parse(readFileSync(join(dir, file), 'utf8')), - })); -} - -export default function A2UIDemoPage() { - const pairs = fixtureSpecs().map(({ name, spec }) => ({ - name, - spec, - a2learnSurface: toA2LearnSurface(spec, `demo-a2learn-${name}`), - composition: toA2LearnComposition(spec, `demo-composed-${name}`), - surface: toA2UISurface(spec, `demo-${name}`), - })); - - return ( -
-

- - ← all demos - -

-

A2UI surfaces

-

- Widget specs project through the boundary mapper to{' '} - - Google's A2UI - {' '} - basic catalog — the same surfaces the conformance suite validates against the vendored - upstream schemas, mapped live on this page and rendered by this app's renderer of the - catalog slice the mapper emits. -

-

- Every column is an A2UI surface — the same spec at three tiers of the a2learn catalog - story. Full fidelity: the widget kind as one component, the registry as renderer — the - exact native experience. Generic primitives (where composed): behavior as data — - a2learn:Sequence with a traversal policy (linear or free, gated or all, accumulate or - replace) and a2learn:Reveal with faces — so new activity shapes compose at generation - time from existing words, no new component code. Universal fallback: the basic catalog - any A2UI renderer draws. Gating survives at the primitives tier and not below; per-card - checking survives only at the top — each tier states what it keeps. - Basic-mapped kinds today: {A2UI_SUPPORTED_KINDS.join(', ')}. -

- -
- {pairs.map(({ name, spec, a2learnSurface, composition, surface }) => ( -
-

{name}

-

- - full demo of this kind → - -

- {surface && a2learnSurface ? ( - <> - -
- - widget spec (input) - -
-                    {JSON.stringify(spec, null, 2)}
-                  
-
-
- - a2learn-catalog createSurface message (full fidelity) - -
-                    {JSON.stringify(a2learnSurface, null, 2)}
-                  
-
- {composition && ( -
- - primitives createSurface message (generic composition) - -
-                      {JSON.stringify(composition, null, 2)}
-                    
-
- )} -
- - basic-catalog createSurface message (fallback) - -
-                    {JSON.stringify(surface, null, 2)}
-                  
-
- - ) : ( -

- ⚠ not mapped — {String(spec.kind)} has no A2UI mapping yet. -

- )} -
- ))} -
-
- ); -} diff --git a/src/components/a2ui/A2UIComparison.tsx b/src/components/a2ui/A2UIComparison.tsx deleted file mode 100644 index a7cd5073..00000000 --- a/src/components/a2ui/A2UIComparison.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { A2UISurfaceDemo, ActionLog } from '@/components/a2ui/A2UISurfaceView'; -import { WidgetRenderer } from '@/components/widgets/registry'; -import type { A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; -import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; - -/** - * The two tiers of the same spec, side by side — and both columns are A2UI. - * - * The left column renders the *a2learn catalog* surface (draft, #98): its - * one component is the widget kind itself, so the registry's own React - * component is the catalog renderer and parity with the native experience - * is by construction — mechanics, completion reporting and all, the same - * loop the MCP Apps shell speaks. The right column renders the *basic - * catalog* projection any generic A2UI renderer can draw. The remaining gap - * between the columns is exactly what the versioned a2learn catalog exists - * to close for hosts beyond this app. - */ - -type Props = { - a2learnSurface: A2UISurfaceMessage; - /** Generic-primitives composition (a2learn:Sequence / a2learn:Reveal), when one exists. */ - composition?: A2UISurfaceMessage | null; - basicSurface: A2UISurfaceMessage; -}; - -/** Renders an a2learn-catalog surface: `a2learn:` → the registry. */ -function A2LearnSurfaceView({ - surface, - onAction, -}: { - surface: A2UISurfaceMessage; - onAction: (action: Record) => void; -}) { - const root = surface.createSurface.components.find((c) => c.id === 'root'); - if (!root || typeof root.component !== 'string' || !root.component.startsWith('a2learn:')) { - return ( -

- ⚠ not an a2learn-catalog surface. -

- ); - } - const kind = root.component.slice('a2learn:'.length); - return ( - - onAction({ - event: { - name: `${A2LEARN_EVENT_PREFIX}widget_completed`, - userMessage: 'Finished the activity.', - context: { kind }, - }, - }) - } - /> - ); -} - -export function A2UIComparison({ a2learnSurface, composition, basicSurface }: Props) { - const [action, setAction] = useState | null>(null); - return ( -
-
-

- a2learn catalog (draft) — full fidelity, registry as renderer -

- - -
- {composition && ( -
-

- a2learn primitives (draft) — behavior as data -

- -
- )} -
-

- A2UI basic catalog — universal fallback -

- -
-
- ); -} diff --git a/src/lib/a2learn/catalog.ts b/src/lib/a2learn/catalog.ts deleted file mode 100644 index a9f398b9..00000000 --- a/src/lib/a2learn/catalog.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; - -/** - * The a2learn catalog, in draft: the registry projected as an A2UI custom - * catalog, one component per widget kind, the kind's spec as its properties. - * - * This is the full-fidelity tier of the two-tier delivery story (#98). The - * basic-catalog mapping in `a2ui.ts` is what any A2UI renderer can draw; a - * surface written against *this* catalog renders with complete mechanics on - * any host that implements it — the app's own component registry is the - * reference implementation. - * - * Draft means draft: the id is a URN, not a fetchable schema URL, and no - * conformance suite validates these surfaces yet. The versioned catalog - * contract — served schema, vendoring rules, props schemas generated from - * the registry's Zod specs so catalog and registry cannot drift — lands via - * #98 and is a contract change in its own right. Until then nothing outside - * this repo should program against this shape, and the demo that renders it - * says so. - */ -export const A2LEARN_CATALOG_DRAFT = 'urn:a2learn:catalog:draft'; - -/** Component name for a kind: `a2learn:flashcard`, `a2learn:drag-sort`, … */ -export function catalogComponentFor(kind: string): string { - return `a2learn:${kind}`; -} - -/** - * Any valid widget spec projects — parity with the registry is by - * construction, not per-kind mapping work. Returns null only when the value - * has no `kind` to dispatch on. - */ -export function toA2LearnSurface(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { - if (!spec || typeof spec !== 'object' || !('kind' in spec) || typeof spec.kind !== 'string') { - return null; - } - return { - version: 'v1.0', - createSurface: { - surfaceId, - catalogId: A2LEARN_CATALOG_DRAFT, - components: [ - { - id: 'root', - component: catalogComponentFor(spec.kind), - spec, - }, - ], - }, - }; -} diff --git a/src/lib/a2learn/primitives.ts b/src/lib/a2learn/primitives.ts index d3e66d15..6a2b3293 100644 --- a/src/lib/a2learn/primitives.ts +++ b/src/lib/a2learn/primitives.ts @@ -1,37 +1,25 @@ -import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; -import type { FlashcardSpec, MarkdownCardSpec, StepRevealSpec } from '@/lib/pathway/schema'; - -import { A2LEARN_CATALOG_DRAFT } from '@/lib/a2learn/catalog'; -import { A2LEARN_EVENT_PREFIX } from '@/lib/a2learn/manifest'; - /** - * a2learn interaction primitives, in draft: *generic, parameterized* - * behavior components — not one primitive per widget kind. A kind-shaped - * primitive (a "Stepper", a "FlipDeck") just recreates the whole-widget - * problem at smaller grain; these instead name the behaviors themselves, - * with policies as data, so that new activity shapes can be *composed at - * generation time* — by a mapper today, by a planner model tomorrow — - * without anyone shipping new component code. Invariant 1 holds: what gets - * generated is a composition tree of declared primitives and basic-catalog - * content components, schema-validatable; every primitive's implementation - * is human-written renderer code. + * a2learn interaction primitives: *generic, parameterized* pedagogy + * components — behavior and emphasis as data, so activity shapes compose at + * generation time without new component code. The product source of truth + * for the composable alphabet is `composedSpec` in `src/lib/pathway/schema.ts` + * (the `composed` widget kind); this module owns the catalog identity and the + * shared policy vocabulary the renderer speaks. * - * The draft set — two words, chosen because today's kinds prove them: + * Vocabulary, grounding, and the build plan live in issue #100; the catalog + * direction in #98. Draft means draft: the id is a URN, not a fetchable + * schema — the versioned catalog contract is #98 phase-6 work, and nothing + * outside this repo should program against these shapes yet. * - * - `a2learn:Sequence` — ordered children traversed under a policy: - * `order` ('linear' | 'free'), `disclosure` ('gated' | 'all'), - * `revealed` ('accumulate' | 'replace'), plus an optional - * `completeAction` dispatched when the learner advances past the end. - * step-reveal IS Sequence(linear, gated, accumulate); a flashcard deck - * IS Sequence(free, all, replace). - * - `a2learn:Reveal` — one thing with N faces and a tap-to-turn - * affordance. A flashcard IS Reveal(front, back). - * - * Content inside primitives is ordinary basic-catalog components — the - * draft catalog is declared as basic ∪ primitives, so a composition reuses - * every content word the basic tier already has. + * The v1 alphabet (Sequence, Reveal, Callout + basic-catalog content) is + * deliberately verdict-free. Verdict-carrying primitives (Check/Response) + * join only with the #99 evidence contract, and `assesses` on a composed + * activity stays *derived from the alphabet* — never asserted. */ +/** The draft a2learn catalog: Google A2UI's basic catalog ∪ these primitives. */ +export const A2LEARN_CATALOG_DRAFT = 'urn:a2learn:catalog:draft'; + export type SequencePolicy = { /** May the reader jump around, or only move to what's next? */ order: 'linear' | 'free'; @@ -43,141 +31,4 @@ export type SequencePolicy = { export const SEQUENCE = 'a2learn:Sequence'; export const REVEAL = 'a2learn:Reveal'; -/** - * `a2learn:Callout` — the first *content* primitive: a block whose - * pedagogical intent is data (`intent`: 'why' | 'tip' | 'note' today, - * open to more), with a bold `label` and markdown `text`. The native - * widgets style a "why" and a "tip" as emphasized boxes; without this - * word that emphasis degrades to a muted caption in every projection. - */ export const CALLOUT = 'a2learn:Callout'; - -function completedAction(kind: string) { - return { - event: { - name: `${A2LEARN_EVENT_PREFIX}widget_completed`, - userMessage: 'Finished the activity.', - context: { kind }, - }, - }; -} - -function compositionSurface(surfaceId: string, components: A2UIComponent[]): A2UISurfaceMessage { - return { - version: 'v1.0', - createSurface: { surfaceId, catalogId: A2LEARN_CATALOG_DRAFT, components }, - }; -} - -function stepRevealComposition(spec: StepRevealSpec, surfaceId: string): A2UISurfaceMessage { - const itemIds = spec.steps.map((_, i) => `step-${i}`); - const items: A2UIComponent[] = spec.steps.flatMap((step, i) => { - const children = [`step-${i}-title`, `step-${i}-body`]; - const parts: A2UIComponent[] = [ - { id: `step-${i}-title`, component: 'Text', text: step.title }, - { id: `step-${i}-body`, component: 'Text', text: step.body }, - ]; - if (step.why) { - children.push(`step-${i}-why`); - parts.push({ id: `step-${i}-why`, component: CALLOUT, intent: 'why', label: 'Why?', text: step.why }); - } - parts.push({ id: `step-${i}`, component: 'Column', children }); - return parts; - }); - - return compositionSurface(surfaceId, [ - { id: 'root', component: 'Column', children: ['prompt', 'walk'] }, - { id: 'prompt', component: 'Text', text: spec.prompt }, - { - id: 'walk', - component: SEQUENCE, - policy: { order: 'linear', disclosure: 'gated', revealed: 'accumulate' } satisfies SequencePolicy, - children: itemIds, - completeAction: completedAction(spec.kind), - }, - ...items, - ]); -} - -function markdownCardComposition(spec: MarkdownCardSpec, surfaceId: string): A2UISurfaceMessage { - const children = ['title', 'body', ...(spec.tip ? ['tip'] : [])]; - return compositionSurface(surfaceId, [ - { id: 'root', component: 'Column', children }, - { id: 'title', component: 'Text', text: spec.title }, - { id: 'body', component: 'Text', text: spec.body }, - ...(spec.tip - ? [{ id: 'tip', component: CALLOUT, intent: 'tip', label: 'Tip:', text: spec.tip } as A2UIComponent] - : []), - ]); -} - -function flashcardComposition(spec: FlashcardSpec, surfaceId: string): A2UISurfaceMessage { - const cardIds = spec.cards.map((_, i) => `card-${i}`); - const cards: A2UIComponent[] = spec.cards.flatMap((card, i) => { - const face = (side: typeof card.front, prefix: string): A2UIComponent[] => { - const parts: A2UIComponent[] = []; - if (side.text) parts.push({ id: `${prefix}-text`, component: 'Text', text: side.text }); - if (side.markdown) parts.push({ id: `${prefix}-md`, component: 'Text', text: side.markdown }); - if (side.imageUrl) { - parts.push({ - id: `${prefix}-image`, - component: 'Image', - url: side.imageUrl, - description: side.imageAlt ?? '', - fit: 'contain', - }); - } - return parts; - }; - const front = face(card.front, `card-${i}-front`); - const back = face(card.back, `card-${i}-back`); - return [ - { - id: `card-${i}`, - component: REVEAL, - faces: [ - { title: 'Front', child: `card-${i}-front-col` }, - { title: 'Back', child: `card-${i}-back-col` }, - ], - }, - { id: `card-${i}-front-col`, component: 'Column', children: front.map((c) => c.id) }, - { id: `card-${i}-back-col`, component: 'Column', children: back.map((c) => c.id) }, - ...front, - ...back, - ]; - }); - - return compositionSurface(surfaceId, [ - { id: 'root', component: 'Column', children: ['prompt', 'deck'] }, - { id: 'prompt', component: 'Text', text: spec.prompt }, - { - id: 'deck', - component: SEQUENCE, - policy: { order: 'free', disclosure: 'all', revealed: 'replace' } satisfies SequencePolicy, - children: cardIds, - completeAction: completedAction(spec.kind), - }, - ...cards, - ]); -} - -/** - * Compose a spec from generic primitives, where a composition exists. The - * point of the exercise: two primitives cover two kinds today, and a third - * kind needing Sequence(linear, gated, replace) — or any other policy mix — - * would be pure data, no new code. Returns null for kinds not yet composed; - * callers fall back and say so. - */ -export function toA2LearnComposition(spec: unknown, surfaceId: string): A2UISurfaceMessage | null { - if (!spec || typeof spec !== 'object' || !('kind' in spec)) return null; - switch ((spec as { kind: string }).kind) { - case 'step-reveal': - return stepRevealComposition(spec as StepRevealSpec, surfaceId); - case 'flashcard': - return flashcardComposition(spec as FlashcardSpec, surfaceId); - case 'markdown-card': - return markdownCardComposition(spec as MarkdownCardSpec, surfaceId); - default: - return null; - } -} From 2462b8e9b0e9f68200474d828456675b05a80f05 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 19:40:29 -0400 Subject: [PATCH 21/23] chore: the surface renderer moves up the stack with its consumer A2UISurfaceView and the catalog identity module served the retired demo here; their only consumer now is the composed kind, one PR up. This PR stays what its title says: transports, mapper, vendored schemas, and the conformance gate. Co-Authored-By: Claude Fable 5 --- src/components/a2ui/A2UISurfaceView.tsx | 584 ------------------------ src/lib/a2learn/primitives.ts | 34 -- 2 files changed, 618 deletions(-) delete mode 100644 src/components/a2ui/A2UISurfaceView.tsx delete mode 100644 src/lib/a2learn/primitives.ts diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx deleted file mode 100644 index 5725d212..00000000 --- a/src/components/a2ui/A2UISurfaceView.tsx +++ /dev/null @@ -1,584 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { motion } from 'motion/react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; - -import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; -import type { SequencePolicy } from '@/lib/a2learn/primitives'; - -/** - * A renderer for the slice of the A2UI basic catalog the boundary mapper - * emits: Column, Card, Text, Divider, Image, Button, Tabs, List. This is - * not a general A2UI client — it exists so the app can *show* a mapped - * surface being a real UI, not just JSON that validates. A component - * outside the emitted slice renders as a visible gap, never silently - * dropped. - * - * Presentation is the renderer's job — that's A2UI's core split: the - * surface carries semantics, the host draws them in its own design - * language. So this renderer animates tab switches, styles cards like the - * app's cards, pages horizontal lists like a deck, and renders Text's - * simple-Markdown scope the way the catalog defines it (no HTML, links and - * images unwrapped to their text). What it never does is exceed the - * semantics: no component gains state the surface didn't model. - */ - -type Props = { - surface: A2UISurfaceMessage; - /** Called when a Button's action fires — the demo shows the payload. */ - onAction?: (action: Record) => void; -}; - -export function A2UISurfaceView({ surface, onAction }: Props) { - const byId = new Map(surface.createSurface.components.map((c) => [c.id, c])); - - function render(id: string, seen: Set): React.ReactNode { - const component = byId.get(id); - if (!component) return ; - if (seen.has(id)) return ; - const path = new Set(seen).add(id); - - const children = () => childIds(component).map((childId) => render(childId, path)); - - switch (component.component) { - case 'Column': - return ( -
- {columnChildren(component, path)} -
- ); - case 'Row': - return ( -
- {children()} -
- ); - case 'List': { - const horizontal = component.direction === 'horizontal'; - if (!horizontal) { - return ( -
- {children()} -
- ); - } - // A horizontal list pages like a deck: swipe, or use the paging - // controls — position affordances are presentation, not state the - // surface didn't model. - return ( - ({ - id: childId, - node: render(childId, path), - }))} - /> - ); - } - case 'Card': { - // Compositional presentation: a Card whose sole child is a two-tab - // Tabs is a thing with two faces — draw it as a real flip card. The - // semantics are untouched (same components, same two panels); only - // the affordance is native. - const child = typeof component.child === 'string' ? byId.get(component.child) : undefined; - if (child?.component === 'Tabs' && Array.isArray(child.tabs) && child.tabs.length === 2) { - const faces = (child.tabs as { title?: unknown; child?: unknown }[]).map((tab) => ({ - title: String(tab.title ?? ''), - child: typeof tab.child === 'string' ? tab.child : null, - })); - return ( - render(childId, path)} - /> - ); - } - return ( -
- {children()} -
- ); - } - case 'Text': { - const caption = component.variant === 'caption'; - // The catalog's Text supports "simple Markdown formatting (without - // HTML, images, or links)" — so render exactly that: GFM emphasis, - // lists and headings; raw HTML ignored, links and images unwrapped - // to their text. Drawing literal ** marks would under-render the - // spec, not respect it. - return ( -
- - {String(component.text ?? '')} - -
- ); - } - case 'Divider': - return
; - case 'Tabs': { - const tabs = Array.isArray(component.tabs) ? (component.tabs as { title?: unknown; child?: unknown }[]) : []; - return ( - ({ - title: String(tab.title ?? ''), - child: typeof tab.child === 'string' ? tab.child : null, - }))} - render={(childId) => render(childId, path)} - /> - ); - } - case 'Image': - // The catalog's accessibility field is `description`, not `alt`. - return ( - - ); - case 'Button': - return ( - - ); - // --- a2learn interaction primitives (draft catalog = basic ∪ primitives) --- - case 'a2learn:Sequence': { - const policy = (component.policy ?? {}) as Partial; - const items = childIds(component).map((childId) => ({ - id: childId, - node: render(childId, path), - })); - const action = component.completeAction as Record | undefined; - const finale = action ? { label: 'Done', onClick: () => onAction?.(action) } : undefined; - if (policy.disclosure === 'gated') { - return ( - - ); - } - return ; - } - case 'a2learn:Reveal': { - const faces = (Array.isArray(component.faces) ? component.faces : []) as { - title?: unknown; - child?: unknown; - }[]; - if (faces.length === 2) { - const pair = faces.map((tab) => ({ - title: String(tab.title ?? ''), - child: typeof tab.child === 'string' ? tab.child : null, - })); - return ( - render(childId, path)} - /> - ); - } - return ( - ({ - title: String(tab.title ?? ''), - child: typeof tab.child === 'string' ? tab.child : null, - }))} - render={(childId) => render(childId, path)} - /> - ); - } - case 'a2learn:Callout': { - // Pedagogical emphasis with intent as data — the renderer maps - // intent to its design language; unknown intents get the neutral box. - const intent = String(component.intent ?? 'note'); - const tone = - intent === 'why' - ? 'border-amber-200 bg-amber-50 dark:border-amber-800/50 dark:bg-amber-950/30' - : intent === 'tip' - ? 'border-primary/25 bg-primary/5' - : 'border-border bg-muted/40'; - const labelTone = intent === 'tip' ? 'font-semibold text-primary' : 'font-semibold'; - return ( -
- {component.label ? {String(component.label)} : null} - {typeof component.child === 'string' ? ( - render(component.child, path) - ) : ( - - - {String(component.text ?? '')} - - - )} -
- ); - } - default: - return ; - } - } - - /** - * A Column's children, with one compositional merge: a horizontal List - * (a deck) immediately followed by an action Button folds that button - * into the deck's paging controls as the final page's action — the same - * button, the same action, presented where the reader finishes, the way - * the native widgets end on their own CTA. Nothing is dropped or added. - */ - function columnChildren(component: A2UIComponent, path: Set): React.ReactNode[] { - const ids = childIds(component); - const nodes: React.ReactNode[] = []; - for (let i = 0; i < ids.length; i++) { - const child = byId.get(ids[i]); - const following = i + 1 < ids.length ? byId.get(ids[i + 1]) : undefined; - if ( - child?.component === 'List' && - child.direction === 'horizontal' && - following?.component === 'Button' && - following.action && - typeof following.action === 'object' - ) { - const labelSource = - typeof following.child === 'string' ? byId.get(following.child) : undefined; - const action = following.action as Record; - nodes.push( - ({ id: childId, node: render(childId, path) }))} - finale={{ - label: String(labelSource?.text ?? 'Done'), - onClick: () => onAction?.(action), - }} - />, - ); - i++; // the button is folded into the deck — don't render it twice - continue; - } - nodes.push(render(ids[i], path)); - } - return nodes; - } - - return
{render('root', new Set())}
; -} - -/** Child references, in the shapes the mapper emits (string child / id list). */ -function childIds(component: A2UIComponent): string[] { - const ids: string[] = []; - if (typeof component.child === 'string') ids.push(component.child); - if (Array.isArray(component.children)) { - for (const ref of component.children) if (typeof ref === 'string') ids.push(ref); - } - return ids; -} - -/** - * A horizontal List as a paged deck: one item in view, Back/Next and - * position dots as renderer affordances, and page changes slide in place — - * the same 320ms slide-and-fade the native widgets use, not a scroll. - */ -function DeckView({ - items, - finale, -}: { - items: { id: string; node: React.ReactNode }[]; - /** Rendered in Next's place on the last page — the deck's own completion action. */ - finale?: { label: string; onClick: () => void }; -}) { - const [index, setIndex] = useState(0); - const [direction, setDirection] = useState(0); - - const go = (next: number) => { - const clamped = Math.max(0, Math.min(items.length - 1, next)); - setDirection(Math.sign(clamped - index)); - setIndex(clamped); - }; - - return ( -
- - {items[index]?.node} - - {items.length > 1 && ( - <> -
- {items.map((item, i) => ( - - ))} -
-
- - {finale && index === items.length - 1 ? ( - - ) : ( - - )} -
- - )} -
- ); -} - -/** - * Sequence(disclosure: gated): items reveal one advance at a time — - * accumulate keeps passed items visible, replace shows only the newest. - * The advance past the last item is the completion, exactly the native - * step-reveal's discipline, driven here entirely by policy data. - */ -function GatedSequenceView({ - items, - accumulate, - finale, -}: { - items: { id: string; node: React.ReactNode }[]; - accumulate: boolean; - finale?: { label: string; onClick: () => void }; -}) { - const [revealed, setRevealed] = useState(1); - const atEnd = revealed >= items.length; - const visible = accumulate ? items.slice(0, revealed) : items.slice(revealed - 1, revealed); - return ( -
- {visible.map((item, i) => ( - - {item.node} - - ))} - {!atEnd ? ( - - ) : finale ? ( - - ) : null} -
- ); -} - -type FlipFace = { title: string; child: string | null }; - -/** - * A genuine 3D flip: one container rotating in perspective, two - * backface-hidden faces stacked in the same grid cell (so the tallest face - * sets the height), tap anywhere to turn it over. The caption names the - * other face using the surface's own tab titles — no flashcard vocabulary - * baked into the renderer. - */ -function FlipCard({ - faces, - render, -}: { - faces: [FlipFace, FlipFace]; - render: (childId: string) => React.ReactNode; -}) { - const [flipped, setFlipped] = useState(false); - const faceClass = - 'col-start-1 row-start-1 flex min-h-44 flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-card p-6 text-center shadow-sm [backface-visibility:hidden]'; - return ( -
-
- setFlipped((f) => !f)} - > -
{faces[0].child ? render(faces[0].child) : null}
-
- {faces[1].child ? render(faces[1].child) : null} -
-
-
-

- Tap to see {flipped ? faces[0].title.toLowerCase() : faces[1].title.toLowerCase()} -

-
- ); -} - -/** - * Tab switching is renderer-local state — the one interaction the basic - * catalog gives back (this is how the flashcard reveal survives projection). - * Presentation is ours to choose, so a switch flips: the panel turns over - * like a card, and the whole panel is tappable to advance to the next tab — - * semantics unchanged, affordance native. - */ -function TabsView({ - tabs, - render, -}: { - tabs: { title: string; child: string | null }[]; - render: (childId: string) => React.ReactNode; -}) { - const [active, setActive] = useState(0); - const current = tabs[active]; - const advance = () => setActive((i) => (i + 1) % tabs.length); - return ( -
-
- {tabs.map((tab, i) => ( - - ))} -
- - {current?.child ? render(current.child) : null} - -
- ); -} - -/** A broken image collapses to nothing; the alt text is the honest fallback. */ -function SurfaceImage({ url, alt }: { url: string; alt: string }) { - const [failed, setFailed] = useState(false); - const ref = useRef(null); - // An image that broke before hydration already fired its error event; - // onError alone would miss it, and the gap would render as silence. - useEffect(() => { - const img = ref.current; - if (img?.complete && img.naturalWidth === 0) setFailed(true); - }, []); - if (failed) { - return ( -

- 🖼 image unavailable — {alt || 'no description provided'} -

- ); - } - return ( - // eslint-disable-next-line @next/next/no-img-element -- arbitrary external URL from a surface; next/image needs configured hosts - {alt} setFailed(true)} /> - ); -} - -function Gap({ label }: { label: string }) { - return ( -

- ⚠ {label} -

- ); -} - -/** The demo's action panel: what the conversation would hear. */ -export function ActionLog({ action }: { action: Record | null }) { - return action ? ( -
-

✓ action dispatched — what the agent would receive:

-
{JSON.stringify(action, null, 2)}
-
- ) : ( -

- Nothing dispatched yet — finish the activity to see its completion action. -

- ); -} - -/** Small stateful wrapper so a server page can compose surface + log. */ -export function A2UISurfaceDemo({ surface }: { surface: A2UISurfaceMessage }) { - const [action, setAction] = useState | null>(null); - return ( -
- - -
- ); -} diff --git a/src/lib/a2learn/primitives.ts b/src/lib/a2learn/primitives.ts deleted file mode 100644 index 6a2b3293..00000000 --- a/src/lib/a2learn/primitives.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * a2learn interaction primitives: *generic, parameterized* pedagogy - * components — behavior and emphasis as data, so activity shapes compose at - * generation time without new component code. The product source of truth - * for the composable alphabet is `composedSpec` in `src/lib/pathway/schema.ts` - * (the `composed` widget kind); this module owns the catalog identity and the - * shared policy vocabulary the renderer speaks. - * - * Vocabulary, grounding, and the build plan live in issue #100; the catalog - * direction in #98. Draft means draft: the id is a URN, not a fetchable - * schema — the versioned catalog contract is #98 phase-6 work, and nothing - * outside this repo should program against these shapes yet. - * - * The v1 alphabet (Sequence, Reveal, Callout + basic-catalog content) is - * deliberately verdict-free. Verdict-carrying primitives (Check/Response) - * join only with the #99 evidence contract, and `assesses` on a composed - * activity stays *derived from the alphabet* — never asserted. - */ - -/** The draft a2learn catalog: Google A2UI's basic catalog ∪ these primitives. */ -export const A2LEARN_CATALOG_DRAFT = 'urn:a2learn:catalog:draft'; - -export type SequencePolicy = { - /** May the reader jump around, or only move to what's next? */ - order: 'linear' | 'free'; - /** Is the next item held back until the reader advances? */ - disclosure: 'gated' | 'all'; - /** Do passed items stay visible, or does the next replace them? */ - revealed: 'accumulate' | 'replace'; -}; - -export const SEQUENCE = 'a2learn:Sequence'; -export const REVEAL = 'a2learn:Reveal'; -export const CALLOUT = 'a2learn:Callout'; From b3944b0237c77d6395e7e1adcb24012359b8f060 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 19:52:14 -0400 Subject: [PATCH 22/23] chore: fetch and pin the A2UI spec instead of committing copies of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's schema files leave the tree. What the repo owns now is spec/a2ui/manifest.json — upstream repo, pinned commit, SHA-256 per file — and a fetch script that materializes the files into the gitignored spec/a2ui/v1_0/, refusing any bytes that miss the pin (verified: a tampered checksum aborts the fetch). pnpm conformance fetches automatically when files are missing, so CI needs no extra step; determinism is the same as vendoring, the copied bulk is gone. Along the way the old README's provenance was audited: the files WERE verbatim from google/A2UI@abcff1515fad — its stated path just omitted the json/ segment for the envelope schemas. The manifest now records the exact upstream path per file, so provenance is machine-checked instead of prose-claimed. Updating the spec version is editing the manifest: a reviewable contract change, exactly as re-vendoring was. Our own fixtures and goldens under spec/a2learn/ stay committed. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + scripts/a2ui-conformance.mjs | 5 + scripts/fetch-a2ui-spec.mjs | 51 + spec/a2ui/README.md | 15 + spec/a2ui/manifest.json | 19 + spec/a2ui/v1_0/README.md | 15 - spec/a2ui/v1_0/agent_to_renderer.json | 216 ---- spec/a2ui/v1_0/catalogs/basic/catalog.json | 1346 -------------------- spec/a2ui/v1_0/common_types.json | 395 ------ 9 files changed, 93 insertions(+), 1972 deletions(-) create mode 100644 scripts/fetch-a2ui-spec.mjs create mode 100644 spec/a2ui/README.md create mode 100644 spec/a2ui/manifest.json delete mode 100644 spec/a2ui/v1_0/README.md delete mode 100644 spec/a2ui/v1_0/agent_to_renderer.json delete mode 100644 spec/a2ui/v1_0/catalogs/basic/catalog.json delete mode 100644 spec/a2ui/v1_0/common_types.json diff --git a/.gitignore b/.gitignore index a3642441..fb1bdcc1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ mcp/dist/ # Built by mcp/build.mjs (predev/prebuild) — never committed. public/widget-shell.html + +# Fetched A2UI spec files — pinned by spec/a2ui/manifest.json, never committed +spec/a2ui/v1_0/ diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs index 2a19b6f6..e0245a9d 100644 --- a/scripts/a2ui-conformance.mjs +++ b/scripts/a2ui-conformance.mjs @@ -27,6 +27,11 @@ import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +// The spec files are fetched and checksum-pinned, not committed — see +// spec/a2ui/manifest.json. Materialize them before anything reads them. +const { ensureA2UISpec } = await import('./fetch-a2ui-spec.mjs'); +await ensureA2UISpec(); const specDir = join(root, 'spec', 'a2ui', 'v1_0'); const fixtureDir = join(root, 'spec', 'a2learn', 'fixtures'); const update = process.argv.includes('--update'); diff --git a/scripts/fetch-a2ui-spec.mjs b/scripts/fetch-a2ui-spec.mjs new file mode 100644 index 00000000..83ee6662 --- /dev/null +++ b/scripts/fetch-a2ui-spec.mjs @@ -0,0 +1,51 @@ +/** + * Fetch the pinned A2UI spec files instead of committing copies of them. + * + * `spec/a2ui/manifest.json` is what the repo owns: the upstream repo, a + * commit, and a SHA-256 per file. This script materializes the files into + * the gitignored `spec/a2ui/v1_0/` and refuses anything whose bytes don't + * match the pin — same determinism as vendoring, none of the copied bulk. + * Bumping the spec version is editing the manifest, which is a reviewable + * contract change, exactly as re-vendoring was. + * + * node scripts/fetch-a2ui-spec.mjs fetch whatever is missing/stale + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const specDir = join(root, 'spec', 'a2ui', 'v1_0'); +const manifest = JSON.parse(readFileSync(join(root, 'spec', 'a2ui', 'manifest.json'), 'utf8')); + +const sha256 = (buf) => createHash('sha256').update(buf).digest('hex'); + +export async function ensureA2UISpec() { + const repoPath = new URL(manifest.source).pathname.replace(/^\//, ''); + for (const [local, entry] of Object.entries(manifest.files)) { + const target = join(specDir, local); + if (existsSync(target) && sha256(readFileSync(target)) === entry.sha256) continue; + + const url = `https://raw.githubusercontent.com/${repoPath}/${manifest.commit}/${entry.upstream}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`fetching ${url} failed: ${res.status} ${res.statusText}`); + } + const body = Buffer.from(await res.arrayBuffer()); + const actual = sha256(body); + if (actual !== entry.sha256) { + throw new Error( + `${local}: upstream bytes do not match the pinned checksum\n expected ${entry.sha256}\n received ${actual}\nRefusing to write — the manifest pin is the contract.`, + ); + } + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, body); + console.log(`✓ ${local} (${body.length} bytes, checksum verified)`); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await ensureA2UISpec(); + console.log('A2UI spec present and pinned.'); +} diff --git a/spec/a2ui/README.md b/spec/a2ui/README.md new file mode 100644 index 00000000..7ee83cbc --- /dev/null +++ b/spec/a2ui/README.md @@ -0,0 +1,15 @@ +# A2UI v1.0 spec — pinned, not vendored + +The conformance target for `src/lib/a2learn/a2ui.ts` is Google's A2UI v1.0 +spec ([google/A2UI](https://github.com/google/A2UI), Apache-2.0). We do not +commit copies of their files; `manifest.json` pins an upstream commit and a +SHA-256 per file, and `scripts/fetch-a2ui-spec.mjs` materializes them into +the gitignored `v1_0/` — refusing any bytes that don't match the pin. + +`pnpm conformance` fetches automatically when files are missing. To update +the spec version: edit `manifest.json` (new commit + new checksums) — a +reviewable contract change, exactly as re-vendoring was — never by editing +fetched files in place. + +The fixtures and golden surfaces under `spec/a2learn/` are ours and stay +committed. diff --git a/spec/a2ui/manifest.json b/spec/a2ui/manifest.json new file mode 100644 index 00000000..bf35a2e7 --- /dev/null +++ b/spec/a2ui/manifest.json @@ -0,0 +1,19 @@ +{ + "source": "https://github.com/google/A2UI", + "commit": "abcff1515fad", + "license": "Apache-2.0", + "files": { + "agent_to_renderer.json": { + "upstream": "specification/v1_0/json/agent_to_renderer.json", + "sha256": "468c8e544dbe0b02d5d5586ebcdca399ecfa6d07a7b875d887089ce3bd2df160" + }, + "common_types.json": { + "upstream": "specification/v1_0/json/common_types.json", + "sha256": "169ff987a7f8fb93a040ca33352ae4aeb684ff9742bff8cf5925b5cd2612e7d8" + }, + "catalogs/basic/catalog.json": { + "upstream": "specification/v1_0/catalogs/basic/catalog.json", + "sha256": "29e01ac2cf69dc5860ad060f5a60c67fa5cdaa8a78ecab1018f531b178fa5c00" + } + } +} diff --git a/spec/a2ui/v1_0/README.md b/spec/a2ui/v1_0/README.md deleted file mode 100644 index 17611311..00000000 --- a/spec/a2ui/v1_0/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Vendored A2UI v1.0 schemas - -Copied verbatim from [google/A2UI](https://github.com/google/A2UI) -(`specification/v1_0`, upstream commit `abcff1515fad`, Apache-2.0): - -- `agent_to_renderer.json` — the message envelope (`createSurface`, - `updateComponents`, `updateDataModel`, …) -- `common_types.json` — component envelope, actions, data binding -- `catalogs/basic/catalog.json` — the standard component catalog (Text, - Card, Column, Button, …) - -These are the conformance target for `src/lib/a2learn/a2ui.ts`: every -surface the boundary mapper emits is validated against these exact schemas -by `pnpm conformance` (and CI). Update by re-copying from upstream and -recording the new commit here — never by editing in place. diff --git a/spec/a2ui/v1_0/agent_to_renderer.json b/spec/a2ui/v1_0/agent_to_renderer.json deleted file mode 100644 index 6d8ba1fc..00000000 --- a/spec/a2ui/v1_0/agent_to_renderer.json +++ /dev/null @@ -1,216 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://a2ui.org/specification/v1_0/agent_to_renderer.json", - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.", - "type": "object", - "oneOf": [ - {"$ref": "#/$defs/CreateSurfaceMessage"}, - {"$ref": "#/$defs/UpdateComponentsMessage"}, - {"$ref": "#/$defs/UpdateDataModelMessage"}, - {"$ref": "#/$defs/DeleteSurfaceMessage"}, - {"$ref": "#/$defs/CallRendererFunctionMessage"}, - {"$ref": "#/$defs/AgentFunctionResponseMessage"} - ], - "$defs": { - "CreateSurfaceMessage": { - "type": "object", - "properties": { - "version": { - "const": "v1.0" - }, - "createSurface": { - "type": "object", - "description": "Signals the renderer to create a new surface and begin rendering it. Creating a surface implicitly instantiates the canonical 'Surface' container component ('common_types.json#/$defs/Surface') with 'child': 'root'. It is an error to try to create a surface with an existing ID without first deleting it; surfaceId MUST be globally unique for the renderer's lifetime. When this message is sent, the renderer expects 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId to define the component tree.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered. It must be globally unique for the renderer's lifetime." - }, - "catalogId": { - "description": "A string that uniquely identifies the default catalog for this surface. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. 'mycompany.com:somecatalog'. Components and function calls that do not explicitly specify a catalogId will use this surface-level default catalogId.", - "type": "string" - }, - "sendDataModel": { - "type": "boolean", - "description": "If true, the renderer will send the full data model of this surface in the metadata of every A2A message sent to the agent that created the surface. Defaults to false." - }, - "components": { - "$ref": "#/$defs/ComponentsList" - }, - "dataModel": { - "type": "object", - "description": "The initial root data model object for the surface.", - "additionalProperties": true - }, - "metadata": { - "type": "object", - "description": "Optional surface-level metadata.", - "properties": { - "extensions": { - "$ref": "common_types.json#/$defs/Extensions" - } - }, - "additionalProperties": false - } - }, - "required": ["surfaceId"], - "additionalProperties": false - } - }, - "required": ["createSurface", "version"], - "additionalProperties": false - }, - "Component": { - "description": "A UI component for the surface, combining baseline envelope properties with the catalog component definition.", - "allOf": [ - { - "$ref": "common_types.json#/$defs/ComponentCommon" - }, - { - "$ref": "catalog.json#/$defs/anyComponent" - }, - { - "type": "object", - "properties": { - "component": { - "not": { - "const": "Surface" - } - } - } - } - ], - "unevaluatedProperties": false - }, - "ComponentsList": { - "type": "array", - "description": "A list containing UI components for the surface.", - "minItems": 1, - "items": { - "$ref": "#/$defs/Component" - } - }, - "UpdateComponentsMessage": { - "type": "object", - "properties": { - "version": { - "const": "v1.0" - }, - "updateComponents": { - "type": "object", - "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent for this surfaceId.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated. It must be globally unique for the renderer's lifetime." - }, - "components": { - "$ref": "#/$defs/ComponentsList" - } - }, - "required": ["surfaceId", "components"], - "additionalProperties": false - } - }, - "required": ["updateComponents", "version"], - "additionalProperties": false - }, - "UpdateDataModelMessage": { - "type": "object", - "properties": { - "version": { - "const": "v1.0" - }, - "updateDataModel": { - "type": "object", - "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent for this surfaceId.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to. It must be globally unique for the renderer's lifetime." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." - }, - "value": { - "description": "The data to be updated in the data model. To delete the key/value at 'path', set 'value' explicitly to null." - } - }, - "required": ["surfaceId", "value"], - "additionalProperties": false - } - }, - "required": ["updateDataModel", "version"], - "additionalProperties": false - }, - "DeleteSurfaceMessage": { - "type": "object", - "properties": { - "version": { - "const": "v1.0" - }, - "deleteSurface": { - "type": "object", - "description": "Signals the renderer to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent for this surfaceId.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted. It must be globally unique for the renderer's lifetime." - } - }, - "required": ["surfaceId"], - "additionalProperties": false - } - }, - "required": ["deleteSurface", "version"], - "additionalProperties": false - }, - "CallRendererFunctionMessage": { - "type": "object", - "properties": { - "version": { - "const": "v1.0" - }, - "callRendererFunction": { - "type": "object", - "description": "Signals the renderer to execute a function locally on behalf of the agent.", - "properties": { - "functionCallId": { - "$ref": "common_types.json#/$defs/CallId", - "description": "Unique ID for this instance of the function call. The renderer MUST copy this ID into the return response." - }, - "callFunction": { - "allOf": [ - { - "$ref": "common_types.json#/$defs/FunctionCall" - }, - { - "required": ["catalogId"] - } - ] - } - }, - "required": ["functionCallId", "callFunction"], - "additionalProperties": false - } - }, - "required": ["callRendererFunction", "version"], - "additionalProperties": false - }, - "AgentFunctionResponseMessage": { - "type": "object", - "properties": { - "version": { - "const": "v1.0" - }, - "agentFunctionResponse": { - "$ref": "common_types.json#/$defs/FunctionResponse" - } - }, - "required": ["agentFunctionResponse", "version"], - "additionalProperties": false - } - } -} diff --git a/spec/a2ui/v1_0/catalogs/basic/catalog.json b/spec/a2ui/v1_0/catalogs/basic/catalog.json deleted file mode 100644 index 66ad59b4..00000000 --- a/spec/a2ui/v1_0/catalogs/basic/catalog.json +++ /dev/null @@ -1,1346 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", - "protocolVersion": "1.0", - "title": "A2UI Basic Catalog", - "description": "Unified catalog of basic A2UI components and functions.", - "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", - "instructions": "For layout, use the Row and Column components to organize other components.\n\n## Catalog Guidelines\n\n1. String Concatenation & Formatting: A2UI does not support binary operators like '+' or formatting symbols. To concatenate strings or dynamically inject data bindings into text, you must use the catalog function `formatString(value)` where the value string contains placeholders formatted as `${expression}`:\n formatString(\"Hello ${/user/name}\")\n\n2. Strict Hierarchy: You must strictly adhere to the requested component nesting and hierarchy. If the prompt specifies that a component is 'inside' or 'contained in' another component, you MUST place it as a child of that specific component, not as a sibling or in a different container.\n\n3. Validation Checks: When components support validation checks, specify any custom error messages directly as the 'message' inside the check. Do NOT create separate text-display components to display validation errors.\n\n## Examples\n\nExample 1: Dynamic text form\n```json\n[\n {\n \"version\": \"v1.0\",\n \"createSurface\": {\n \"surfaceId\": \"main\",\n \"components\": [\n {\n \"id\": \"root\",\n \"component\": \"Column\",\n \"children\": [\"repField\", \"valueField\"]\n },\n {\n \"id\": \"repField\",\n \"component\": \"TextField\",\n \"label\": \"Representative\",\n \"value\": {\"path\": \"/form/rep\"},\n \"placeholder\": \"Enter name\"\n },\n {\n \"id\": \"valueField\",\n \"component\": \"TextField\",\n \"label\": \"Deal Value\",\n \"value\": {\"path\": \"/form/value\"},\n \"placeholder\": \"0.00\",\n \"variant\": \"number\",\n \"checks\": [\n {\"call\": \"required\"}\n ]\n }\n ],\n \"dataModel\": {\n \"form\": {\n \"rep\": \"John Doe\",\n \"value\": 1500.00\n }\n }\n }\n }\n]\n```\n\nExample 2: Dynamic list with templates\n```json\n[\n {\n \"version\": \"v1.0\",\n \"createSurface\": {\n \"surfaceId\": \"main\",\n \"components\": [\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"child\": \"breedList\"\n },\n {\n \"id\": \"breedList\",\n \"component\": \"List\",\n \"children\": {\n \"path\": \"/breeds\",\n \"componentId\": \"breedTemplate\"\n },\n \"direction\": \"horizontal\"\n },\n {\n \"id\": \"breedTemplate\",\n \"component\": \"Image\",\n \"url\": {\"path\": \"url\"}\n }\n ],\n \"dataModel\": {\n \"breeds\": [\n {\n \"url\": \"https://example.com/poodle.jpg\"\n },\n {\n \"url\": \"https://example.com/lab.jpg\"\n }\n ]\n }\n }\n }\n]\n```", - "components": { - "Text": { - "type": "object", - "properties": { - "component": { - "const": "Text" - }, - "text": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The text content to display. While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation." - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": ["caption", "body"], - "default": "body" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "text"] - }, - "Image": { - "type": "object", - "properties": { - "component": { - "const": "Image" - }, - "url": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The URL of the image to display." - }, - "description": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "Accessibility text for the image." - }, - "fit": { - "type": "string", - "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", - "enum": ["contain", "cover", "fill", "none", "scaleDown"], - "default": "fill" - }, - "variant": { - "type": "string", - "description": "A hint for the image size and style.", - "enum": ["icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"], - "default": "mediumFeature" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "url"] - }, - "Icon": { - "type": "object", - "properties": { - "component": { - "const": "Icon" - }, - "name": { - "description": "The name of the icon to display.", - "oneOf": [ - { - "type": "string", - "enum": [ - "accountCircle", - "add", - "arrowBack", - "arrowForward", - "attachFile", - "calendarToday", - "call", - "camera", - "check", - "close", - "delete", - "download", - "edit", - "event", - "error", - "fastForward", - "favorite", - "favoriteOff", - "folder", - "help", - "home", - "info", - "locationOn", - "lock", - "lockOpen", - "mail", - "menu", - "moreVert", - "moreHoriz", - "notificationsOff", - "notifications", - "pause", - "payment", - "person", - "phone", - "photo", - "play", - "print", - "refresh", - "rewind", - "search", - "send", - "settings", - "share", - "shoppingCart", - "skipNext", - "skipPrevious", - "star", - "starHalf", - "starOff", - "stop", - "upload", - "visibility", - "visibilityOff", - "volumeDown", - "volumeMute", - "volumeOff", - "volumeUp", - "warning" - ] - }, - { - "type": "object", - "properties": { - "svgPath": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - } - }, - "required": ["svgPath"], - "additionalProperties": false - }, - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DataBinding" - } - ] - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "name"] - }, - "Video": { - "type": "object", - "properties": { - "component": { - "const": "Video" - }, - "url": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The URL of the video to display." - }, - "posterUrl": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The URL of the poster image to display before the video plays." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "url"] - }, - "AudioPlayer": { - "type": "object", - "properties": { - "component": { - "const": "AudioPlayer" - }, - "url": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The URL of the audio to be played." - }, - "description": { - "description": "A description of the audio, such as a title or summary.", - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "url"] - }, - "Row": { - "type": "object", - "description": "A layout component that arranges its children horizontally. To create a grid layout, nest Columns within this Row.", - "properties": { - "component": { - "const": "Row" - }, - "children": { - "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList" - }, - "justify": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (horizontally). Use 'spaceBetween' to push items to the edges, or 'start'/'end'/'center' to pack them together.", - "enum": [ - "center", - "end", - "spaceAround", - "spaceBetween", - "spaceEvenly", - "start", - "stretch" - ], - "default": "start" - }, - "align": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (vertically). This is similar to the CSS 'align-items' property, but uses camelCase values (e.g., 'start').", - "enum": ["start", "center", "end", "stretch"], - "default": "stretch" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "children"] - }, - "Column": { - "type": "object", - "description": "A layout component that arranges its children vertically. To create a grid layout, nest Rows within this Column.", - "properties": { - "component": { - "const": "Column" - }, - "children": { - "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list. Children cannot be defined inline, they must be referred to by ID.", - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList" - }, - "justify": { - "type": "string", - "description": "Defines the arrangement of children along the main axis (vertically). Use 'spaceBetween' to push items to the edges (e.g. header at top, footer at bottom), or 'start'/'end'/'center' to pack them together.", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly", - "stretch" - ], - "default": "start" - }, - "align": { - "type": "string", - "description": "Defines the alignment of children along the cross axis (horizontally). This is similar to the CSS 'align-items' property.", - "enum": ["center", "end", "start", "stretch"], - "default": "stretch" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "children"] - }, - "List": { - "type": "object", - "properties": { - "component": { - "const": "List" - }, - "children": { - "description": "Defines the children. Use an array of strings for a fixed set of children, or a template object to generate children from a data list.", - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/ChildList" - }, - "direction": { - "type": "string", - "description": "The direction in which the list items are laid out.", - "enum": ["vertical", "horizontal"], - "default": "vertical" - }, - "align": { - "type": "string", - "description": "Defines the alignment of children along the cross axis.", - "enum": ["start", "center", "end", "stretch"], - "default": "stretch" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "children"] - }, - "Card": { - "type": "object", - "properties": { - "component": { - "const": "Card" - }, - "child": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", - "description": "The ID of the single child component to be rendered inside the card. To display multiple elements, you MUST wrap them in a layout component (like Column or Row) and pass that container's ID here. Do NOT pass multiple IDs or a non-existent ID." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "child"] - }, - "Tabs": { - "type": "object", - "properties": { - "component": { - "const": "Tabs" - }, - "tabs": { - "type": "array", - "description": "An array of objects, where each object defines a tab with a title and a child component.", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "title": { - "description": "The tab title.", - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - "child": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", - "description": "The ID of the child component." - } - }, - "required": ["title", "child"], - "additionalProperties": false - } - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "tabs"] - }, - "Modal": { - "type": "object", - "properties": { - "component": { - "const": "Modal" - }, - "trigger": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", - "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." - }, - "content": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", - "description": "The ID of the component to be displayed inside the modal." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "trigger", "content"] - }, - "Divider": { - "type": "object", - "properties": { - "component": { - "const": "Divider" - }, - "axis": { - "type": "string", - "description": "The orientation of the divider.", - "enum": ["horizontal", "vertical"], - "default": "horizontal" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component"] - }, - "Button": { - "type": "object", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" - }, - { - "type": "object", - "properties": { - "component": { - "const": "Button" - }, - "child": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Child", - "description": "The ID of the child component. Use a 'Text' component for a labeled button. Only use an 'Icon' if the requirements explicitly ask for an icon-only button." - }, - "variant": { - "type": "string", - "description": "A hint for the button style. If omitted, a default button style is used. 'primary' indicates this is the main call-to-action button. 'borderless' means the button has no visual border or background, making its child content appear like a clickable link.", - "enum": ["default", "primary", "borderless"], - "default": "default" - }, - "action": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Action" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "child", "action"] - } - ] - }, - "TextField": { - "type": "object", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" - }, - { - "type": "object", - "properties": { - "component": { - "const": "TextField" - }, - "label": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The text label for the input field." - }, - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The value of the text field." - }, - "placeholder": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The placeholder text for the input field." - }, - "variant": { - "type": "string", - "description": "The type of input field to display.", - "enum": ["longText", "number", "shortText", "obscured"], - "default": "shortText" - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "label"] - } - ] - }, - "CheckBox": { - "type": "object", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" - }, - { - "type": "object", - "properties": { - "component": { - "const": "CheckBox" - }, - "label": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The text to display next to the checkbox." - }, - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", - "description": "The current state of the checkbox (true for checked, false for unchecked)." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "label", "value"] - } - ] - }, - "ChoicePicker": { - "type": "object", - "description": "A component that allows selecting one or more options from a list.", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" - }, - { - "type": "object", - "properties": { - "component": { - "const": "ChoicePicker" - }, - "label": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The label for the group of options." - }, - "variant": { - "type": "string", - "description": "A hint for how the choice picker should be displayed and behave.", - "enum": ["multipleSelection", "mutuallyExclusive"], - "default": "mutuallyExclusive" - }, - "options": { - "type": "array", - "description": "The list of available options to choose from.", - "items": { - "type": "object", - "properties": { - "label": { - "description": "The text to display for this option.", - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - "value": { - "type": "string", - "description": "The stable value associated with this option." - } - }, - "required": ["label", "value"], - "additionalProperties": false - } - }, - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicStringList", - "description": "The list of currently selected values. This should be bound to a string array in the data model." - }, - "displayStyle": { - "type": "string", - "description": "The display style of the component.", - "enum": ["checkbox", "chips"], - "default": "checkbox" - }, - "filterable": { - "type": "boolean", - "description": "If true, displays a search input to filter the options.", - "default": false - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "options", "value"] - } - ] - }, - "Slider": { - "type": "object", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" - }, - { - "type": "object", - "properties": { - "component": { - "const": "Slider" - }, - "label": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The label for the slider." - }, - "min": { - "type": "number", - "description": "The minimum value of the slider.", - "default": 0 - }, - "max": { - "type": "number", - "description": "The maximum value of the slider." - }, - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", - "description": "The current value of the slider." - }, - "steps": { - "type": "integer", - "minimum": 1, - "description": "The number of discrete divisions in the slider range. If specified, the slider will snap to discrete values." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "value", "max"] - } - ] - }, - "DateTimeInput": { - "type": "object", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/Checkable" - }, - { - "type": "object", - "properties": { - "component": { - "const": "DateTimeInput" - }, - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The selected date and/or time value in ISO 8601 format. If not yet set, initialize with an empty string." - }, - "enableDate": { - "type": "boolean", - "description": "If true, allows the user to select a date.", - "default": false - }, - "enableTime": { - "type": "boolean", - "description": "If true, allows the user to select a time.", - "default": false - }, - "min": { - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - { - "if": { - "type": "string" - }, - "then": { - "oneOf": [ - { - "format": "date" - }, - { - "format": "time" - }, - { - "format": "date-time" - } - ] - } - } - ], - "description": "The minimum allowed date/time in ISO 8601 format." - }, - "max": { - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - { - "if": { - "type": "string" - }, - "then": { - "oneOf": [ - { - "format": "date" - }, - { - "format": "time" - }, - { - "format": "date-time" - } - ] - } - } - ], - "description": "The maximum allowed date/time in ISO 8601 format." - }, - "label": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The text label for the input field." - }, - "weight": { - "type": "number", - "description": "The relative weight of this component within a Row or Column. This is similar to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." - } - }, - "required": ["component", "value"] - } - ] - } - }, - "functions": { - "required": { - "type": "object", - "description": "Checks that the value is not null, undefined, or empty.", - "returnType": "validationResult", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "required" - }, - "args": { - "type": "object", - "properties": { - "value": { - "description": "The value to check." - } - }, - "required": ["value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "regex": { - "type": "object", - "description": "Checks that the value matches a regular expression string.", - "returnType": "validationResult", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "regex" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - "pattern": { - "type": "string", - "description": "The regex pattern to match against." - } - }, - "required": ["value", "pattern"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "length": { - "type": "object", - "description": "Checks string length constraints.", - "returnType": "validationResult", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "length" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - }, - "min": { - "type": "integer", - "minimum": 0, - "description": "The minimum allowed length." - }, - "max": { - "type": "integer", - "minimum": 0, - "description": "The maximum allowed length." - } - }, - "required": ["value"], - "anyOf": [ - { - "required": ["min"] - }, - { - "required": ["max"] - } - ], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "numeric": { - "type": "object", - "description": "Checks numeric range constraints.", - "returnType": "validationResult", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "numeric" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber" - }, - "min": { - "type": "number", - "description": "The minimum allowed value." - }, - "max": { - "type": "number", - "description": "The maximum allowed value." - } - }, - "required": ["value"], - "anyOf": [ - { - "required": ["min"] - }, - { - "required": ["max"] - } - ], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "email": { - "type": "object", - "description": "Checks that the value is a valid email address.", - "returnType": "validationResult", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "email" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - } - }, - "required": ["value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "formatString": { - "type": "object", - "description": "Performs string interpolation of data model values and other functions in the catalog functions list and returns the resulting string. The value string can contain interpolated expressions in the `${expression}` format. Supported expression types include: JSON Pointer paths to the data model (e.g., `${/absolute/path}` or `${relative/path}`), and renderer-side function calls (e.g., `${now()}`). Function arguments must be named (e.g., `${formatDate(value:${/currentDate}, format:'MM-dd')}`). To include a literal `${` sequence, escape it as `\\${`.", - "returnType": "string", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "formatString" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString" - } - }, - "required": ["value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "formatNumber": { - "type": "object", - "description": "Formats a number with the specified grouping and decimal precision.", - "returnType": "string", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "formatNumber" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", - "description": "The number to format." - }, - "decimals": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", - "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." - }, - "grouping": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", - "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." - } - }, - "required": ["value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "formatCurrency": { - "type": "object", - "description": "Formats a number as a currency string.", - "returnType": "string", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "formatCurrency" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", - "description": "The monetary amount." - }, - "currency": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The ISO 4217 currency code (e.g., 'USD', 'EUR')." - }, - "decimals": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", - "description": "Optional. The number of decimal places to show. Defaults to 0 or 2 depending on locale." - }, - "grouping": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", - "description": "Optional. If true, uses locale-specific grouping separators (e.g. '1,000'). If false, returns raw digits (e.g. '1000'). Defaults to true." - } - }, - "required": ["currency", "value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "formatDate": { - "type": "object", - "description": "Formats a timestamp into a string using a pattern.", - "returnType": "string", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "formatDate" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicValue", - "description": "The date to format." - }, - "format": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "A Unicode TR35 date pattern string.\n\nToken Reference:\n- Year: 'yy' (26), 'yyyy' (2026)\n- Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January)\n- Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday)\n- Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM\n- Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time\n- Minute: 'mm' (00-59)\n- Second: 'ss' (00-59)\n- Period: 'a' (AM/PM)\n\nExamples:\n- 'MMM dd, yyyy' -> 'Jan 16, 2026'\n- 'HH:mm' -> '14:30' (Military)\n- 'h:mm a' -> '2:30 PM'\n- 'EEEE, d MMMM' -> 'Friday, 16 January'" - } - }, - "required": ["format", "value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "pluralize": { - "type": "object", - "description": "Returns a localized string based on the Common Locale Data Repository (CLDR) plural category of the count (zero, one, two, few, many, other). Requires an 'other' fallback. For English, just use 'one' and 'other'.", - "returnType": "string", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "pluralize" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicNumber", - "description": "The numeric value used to determine the plural category." - }, - "zero": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "String for the 'zero' category (e.g., 0 items)." - }, - "one": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "String for the 'one' category (e.g., 1 item)." - }, - "two": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "String for the 'two' category (used in Arabic, Welsh, etc.)." - }, - "few": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "String for the 'few' category (e.g., small groups in Slavic languages)." - }, - "many": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "String for the 'many' category (e.g., large groups in various languages)." - }, - "other": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicString", - "description": "The default/fallback string (used for general plural cases)." - } - }, - "required": ["value", "other"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "openUrl": { - "type": "object", - "description": "Opens the specified URL in a browser or handler (requires user activation). This function has no return value.", - "returnType": "void", - "requiresUserActivation": true, - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "openUrl" - }, - "args": { - "type": "object", - "properties": { - "url": { - "description": "The URL to open.", - "oneOf": [ - { - "type": "string", - "format": "uri" - }, - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DataBinding" - }, - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCall" - } - ] - } - }, - "required": ["url"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "and": { - "type": "object", - "description": "Performs a logical AND operation on a list of boolean values.", - "returnType": "boolean", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "and" - }, - "args": { - "type": "object", - "properties": { - "values": { - "type": "array", - "description": "The list of boolean values to evaluate.", - "items": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean" - }, - "minItems": 2 - } - }, - "required": ["values"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "or": { - "type": "object", - "description": "Performs a logical OR operation on a list of boolean values.", - "returnType": "boolean", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "or" - }, - "args": { - "type": "object", - "properties": { - "values": { - "type": "array", - "description": "The list of boolean values to evaluate.", - "items": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean" - }, - "minItems": 2 - } - }, - "required": ["values"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - }, - "not": { - "type": "object", - "description": "Performs a logical NOT operation on a boolean value.", - "returnType": "boolean", - "allOf": [ - { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/FunctionCommon" - }, - { - "type": "object", - "properties": { - "call": { - "const": "not" - }, - "args": { - "type": "object", - "properties": { - "value": { - "$ref": "https://a2ui.org/specification/v1_0/common_types.json#/$defs/DynamicBoolean", - "description": "The boolean value to negate." - } - }, - "required": ["value"], - "unevaluatedProperties": false - } - }, - "required": ["call", "args"] - } - ], - "unevaluatedProperties": false - } - }, - "$defs": { - "anyComponent": { - "oneOf": [ - { - "$ref": "#/components/Text" - }, - { - "$ref": "#/components/Image" - }, - { - "$ref": "#/components/Icon" - }, - { - "$ref": "#/components/Video" - }, - { - "$ref": "#/components/AudioPlayer" - }, - { - "$ref": "#/components/Row" - }, - { - "$ref": "#/components/Column" - }, - { - "$ref": "#/components/List" - }, - { - "$ref": "#/components/Card" - }, - { - "$ref": "#/components/Tabs" - }, - { - "$ref": "#/components/Modal" - }, - { - "$ref": "#/components/Divider" - }, - { - "$ref": "#/components/Button" - }, - { - "$ref": "#/components/TextField" - }, - { - "$ref": "#/components/CheckBox" - }, - { - "$ref": "#/components/ChoicePicker" - }, - { - "$ref": "#/components/Slider" - }, - { - "$ref": "#/components/DateTimeInput" - } - ], - "discriminator": { - "propertyName": "component" - } - }, - "anyFunction": { - "oneOf": [ - { - "$ref": "#/functions/required" - }, - { - "$ref": "#/functions/regex" - }, - { - "$ref": "#/functions/length" - }, - { - "$ref": "#/functions/numeric" - }, - { - "$ref": "#/functions/email" - }, - { - "$ref": "#/functions/formatString" - }, - { - "$ref": "#/functions/formatNumber" - }, - { - "$ref": "#/functions/formatCurrency" - }, - { - "$ref": "#/functions/formatDate" - }, - { - "$ref": "#/functions/pluralize" - }, - { - "$ref": "#/functions/openUrl" - }, - { - "$ref": "#/functions/and" - }, - { - "$ref": "#/functions/or" - }, - { - "$ref": "#/functions/not" - } - ] - } - } -} diff --git a/spec/a2ui/v1_0/common_types.json b/spec/a2ui/v1_0/common_types.json deleted file mode 100644 index c2d5f39d..00000000 --- a/spec/a2ui/v1_0/common_types.json +++ /dev/null @@ -1,395 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://a2ui.org/specification/v1_0/common_types.json", - "title": "A2UI Common Types", - "description": "Common type definitions used across A2UI schemas.", - "$defs": { - "ComponentId": { - "type": "string", - "description": "The unique identifier for a component, used for both definitions and references within the same surface." - }, - "CallId": { - "type": "string", - "description": "The unique identifier for a function call." - }, - "AccessibilityAttributes": { - "type": "object", - "description": "Attributes to enhance accessibility when using assistive technologies like screen readers or model understanding.", - "properties": { - "label": { - "$ref": "#/$defs/DynamicString", - "description": "A short string, typically 1 to 3 words, used by assistive technologies to convey the purpose or intent of an element. For example, an input field might have an accessible label of 'User ID' or a button might be labeled 'Submit'." - }, - "description": { - "$ref": "#/$defs/DynamicString", - "description": "Additional information provided by assistive technologies about an element such as instructions, format requirements, or result of an action. For example, a mute button might have a label of 'Mute' and a description of 'Silences notifications about this conversation'." - }, - "live": { - "type": "string", - "enum": ["off", "polite", "assertive"], - "default": "off", - "description": "Controls screen reader announcements for dynamic updates (WAI-ARIA aria-live). 'polite' waits for user pause; 'assertive' interrupts immediately for alerts." - }, - "hidden": { - "$ref": "#/$defs/DynamicBoolean", - "description": "Hides the element and its children from assistive technologies when true. Default is false." - } - }, - "additionalProperties": false - }, - "Extensions": { - "type": "object", - "description": "Optional extension metadata. Keys MUST be Unicode identifiers (UAX #31). Keys starting with 'a2ui_' are reserved for official extensions.", - "patternProperties": { - "^[\\p{XID_Start}_][\\p{XID_Continue}]*$": {} - }, - "additionalProperties": false - }, - "ComponentCommon": { - "type": "object", - "properties": { - "id": { - "$ref": "#/$defs/ComponentId" - }, - "catalogId": { - "type": "string", - "description": "The catalog ID for this component, overriding any surface-level default catalogId." - }, - "accessibility": { - "$ref": "#/$defs/AccessibilityAttributes" - }, - "metadata": { - "type": "object", - "description": "Optional component-level metadata for vendor extensions.", - "properties": { - "extensions": { - "$ref": "#/$defs/Extensions" - } - }, - "additionalProperties": false - } - }, - "required": ["id"] - }, - "Child": { - "$ref": "#/$defs/ComponentId", - "description": "A reference to a single child component ID." - }, - "ChildList": { - "oneOf": [ - { - "type": "array", - "items": { - "$ref": "#/$defs/ComponentId" - }, - "description": "A static list of child component IDs." - }, - { - "type": "object", - "description": "A template for generating a dynamic list of children from a data model list. The `componentId` is the component to use as a template.", - "properties": { - "componentId": { - "$ref": "#/$defs/ComponentId" - }, - "path": { - "type": "string", - "description": "The path to the list of component property objects in the data model." - } - }, - "required": ["componentId", "path"], - "additionalProperties": false - } - ] - }, - "DataBinding": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "A JSON Pointer path to a value in the data model." - } - }, - "required": ["path"], - "additionalProperties": false - }, - "DynamicValue": { - "description": "A value that can be a literal, a path, or a function call returning any type.", - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "array" - }, - { - "type": "object", - "not": { - "anyOf": [ - { - "required": ["path"] - }, - { - "required": ["call"] - } - ] - } - }, - { - "$ref": "#/$defs/DataBinding" - }, - { - "$ref": "#/$defs/FunctionCall" - } - ] - }, - "DynamicString": { - "description": "Represents a string", - "oneOf": [ - { - "type": "string" - }, - { - "$ref": "#/$defs/DataBinding" - }, - { - "$ref": "#/$defs/FunctionCall" - } - ] - }, - "DynamicNumber": { - "description": "Represents a value that can be either a literal number, a path to a number in the data model, or a function call returning a number.", - "oneOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/DataBinding" - }, - { - "$ref": "#/$defs/FunctionCall" - } - ] - }, - "DynamicBoolean": { - "description": "A boolean value that can be a literal, a path, or a function call returning a boolean.", - "oneOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/$defs/DataBinding" - }, - { - "$ref": "#/$defs/FunctionCall" - } - ] - }, - "DynamicStringList": { - "description": "Represents a value that can be either a literal array of strings, a path to a string array in the data model, or a function call returning a string array.", - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "$ref": "#/$defs/DataBinding" - }, - { - "$ref": "#/$defs/FunctionCall" - } - ] - }, - "FunctionCommon": { - "type": "object", - "properties": { - "catalogId": { - "type": "string", - "description": "The catalog ID for this function, overriding any surface-level default catalogId." - } - } - }, - "IndexSystemFunction": { - "type": "object", - "description": "Returns the 0-based index of the current item when rendering a dynamic list from a template. This function MUST ONLY be available when evaluating template items within a list context.", - "returnType": "number", - "properties": { - "call": { - "const": "@index" - }, - "args": { - "type": "object", - "properties": { - "offset": { - "$ref": "#/$defs/DynamicNumber", - "description": "Optional. An offset to add to the 0-based index (e.g., 1 for 1-based indexing). Defaults to 0.", - "default": 0 - } - }, - "unevaluatedProperties": false - } - }, - "required": ["call"], - "unevaluatedProperties": false - }, - "FunctionCall": { - "type": "object", - "description": "Invokes a named function.", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "catalogId": { - "type": "string", - "description": "The catalog ID for this function, overriding any surface-level default catalogId." - }, - "args": { - "type": "object", - "description": "Arguments passed to the function.", - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/$defs/DynamicValue" - }, - { - "type": "object", - "description": "A literal object argument (e.g. configuration)." - } - ] - } - } - }, - "required": ["call"], - "oneOf": [ - {"$ref": "catalog.json#/$defs/anyFunction"}, - {"$ref": "#/$defs/IndexSystemFunction"} - ], - "unevaluatedProperties": false - }, - "CheckRule": { - "type": "object", - "description": "A single validation check rule applied to an input component. The condition function or path evaluates to a ValidationResult object.", - "properties": { - "condition": { - "oneOf": [{"$ref": "#/$defs/DataBinding"}, {"$ref": "#/$defs/FunctionCall"}], - "description": "Path or function call evaluating to a ValidationResult object." - }, - "message": { - "type": "string", - "description": "Optional fallback error message." - } - }, - "required": ["condition"], - "additionalProperties": false - }, - "Checkable": { - "description": "Properties for components that support renderer-side checks.", - "type": "object", - "properties": { - "checks": { - "type": "array", - "description": "A list of checks to perform. These are function calls that must return a boolean indicating validity.", - "items": { - "$ref": "#/$defs/CheckRule" - } - } - } - }, - "Action": { - "description": "Defines an interaction handler that can either trigger an agent-side event or execute a local renderer-side function.", - "oneOf": [ - { - "type": "object", - "description": "Triggers an agent-side event.", - "properties": { - "event": { - "type": "object", - "description": "The event to dispatch to the agent.", - "properties": { - "name": { - "type": "string", - "description": "The name of the action to be dispatched to the agent." - }, - "userMessage": { - "$ref": "#/$defs/DynamicString", - "description": "An optional human-readable message describing the action performed by the user, to present in conversation history or user feedback." - }, - "context": { - "type": "object", - "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.", - "additionalProperties": { - "$ref": "#/$defs/DynamicValue" - } - } - }, - "required": ["name"], - "additionalProperties": false - } - }, - "required": ["event"], - "additionalProperties": false - }, - { - "type": "object", - "description": "Executes a renderer or agent-side function.", - "properties": { - "functionCall": { - "$ref": "#/$defs/FunctionCall" - } - }, - "required": ["functionCall"], - "additionalProperties": false - } - ] - }, - "Surface": { - "title": "Surface Container Component", - "description": "The reserved canonical container component representing an A2UI surface. The Surface component is immutable and always has 'child': 'root'.", - "type": "object", - "allowedParents": [], - "properties": { - "component": { - "const": "Surface" - }, - "child": { - "const": "root" - } - }, - "additionalProperties": false - }, - "FunctionResponse": { - "type": "object", - "description": "The return response matching a callAgentFunction or callRendererFunction invocation.", - "properties": { - "functionCallId": { - "$ref": "#/$defs/CallId", - "description": "The unique ID matching the initiating function call." - }, - "value": { - "description": "The return value of the function." - }, - "error": { - "type": "object", - "description": "An error object indicating failure of the function execution.", - "properties": { - "code": {"type": "string"}, - "message": {"type": "string"} - }, - "required": ["code", "message"], - "additionalProperties": false - } - }, - "required": ["functionCallId"], - "oneOf": [{"required": ["value"]}, {"required": ["error"]}], - "additionalProperties": false - } - } -} From 6aba8212687e52e2e71a682753ba9e72352a9efe Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Sun, 30 Aug 2026 20:03:38 -0400 Subject: [PATCH 23/23] =?UTF-8?q?chore:=20conform=20to=20the=20released=20?= =?UTF-8?q?spec=20=E2=80=94=20@a2ui/web=5Fcore=20is=20the=20schema=20sourc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam's call, and the right one: interop is with shipped renderers, not with the spec repo's HEAD. The one incompatibility was ours — the emitted action event carried userMessage, a newer-spec addition the released schemas close out (additionalProperties: false). The event now carries the intersection every revision accepts: name + context. Prose for a model reader belongs to the MCP Apps channel, not this boundary. With that fixed, the official package replaces the fetch-and-pin machinery it briefly had: @a2ui/web_core pinned exact as a devDependency, conformance reading its schemas from node_modules, the lockfile integrity hash serving as the checksum. The manifest and fetch script retire after one commit of life; a spec bump is now an ordinary dependency bump the conformance gate passes judgment on. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 - package.json | 1 + pnpm-lock.yaml | 28 +++++++++- scripts/a2ui-conformance.mjs | 11 ++-- scripts/fetch-a2ui-spec.mjs | 51 ------------------- spec/a2learn/fixtures/flashcard.surface.json | 1 - .../fixtures/markdown-card.surface.json | 1 - .../a2learn/fixtures/step-reveal.surface.json | 1 - spec/a2ui/README.md | 21 ++++---- spec/a2ui/manifest.json | 19 ------- src/lib/a2learn/a2ui.ts | 6 ++- 11 files changed, 49 insertions(+), 93 deletions(-) delete mode 100644 scripts/fetch-a2ui-spec.mjs delete mode 100644 spec/a2ui/manifest.json diff --git a/.gitignore b/.gitignore index fb1bdcc1..e0a27ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,3 @@ mcp/dist/ # Built by mcp/build.mjs (predev/prebuild) — never committed. public/widget-shell.html -# Fetched A2UI spec files — pinned by spec/a2ui/manifest.json, never committed -spec/a2ui/v1_0/ diff --git a/package.json b/package.json index 96848610..7384165f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@a2ui/web_core": "0.10.6", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/postcss": "^4", "@types/node": "^24", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ab183c1..704f731a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 4.0.50(zod@4.4.3) '@base-ui/react': specifier: ^1.7.0 - version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.0(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -90,6 +90,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@a2ui/web_core': + specifier: 0.10.6 + version: 0.10.6 '@tailwindcss/cli': specifier: ^4.3.3 version: 4.3.3 @@ -135,6 +138,9 @@ importers: packages: + '@a2ui/web_core@0.10.6': + resolution: {integrity: sha512-kTwir93Fk5eupl4FZ6CkhJDrisR2fnQgEIlDCTp06uzx2PvUhItbq1E+h7hFyeHGTu/NMci9Tqn7Is1IVRpufw==} + '@ag-ui/core@0.0.58': resolution: {integrity: sha512-XgGb7YmhV+yMBaEmlrpsd5S+nUxq0JgSegss2t4gIFR1j7w3w0ibtKfRgcQHWeMvwZxcT5S28VEEarqtgxYYHw==} @@ -1003,6 +1009,9 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@rolldown/binding-android-arm-eabi@1.2.6': resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1912,6 +1921,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -4184,6 +4196,13 @@ packages: snapshots: + '@a2ui/web_core@0.10.6': + dependencies: + '@preact/signals-core': 1.14.4 + date-fns: 4.4.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + '@ag-ui/core@0.0.58': dependencies: zod: 3.25.76 @@ -4421,7 +4440,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/react@1.7.0(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4432,6 +4451,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 + date-fns: 4.4.0 '@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -4980,6 +5000,8 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@preact/signals-core@1.14.4': {} + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true @@ -5796,6 +5818,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.4.0: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 diff --git a/scripts/a2ui-conformance.mjs b/scripts/a2ui-conformance.mjs index e0245a9d..2a568fae 100644 --- a/scripts/a2ui-conformance.mjs +++ b/scripts/a2ui-conformance.mjs @@ -28,11 +28,12 @@ import addFormats from 'ajv-formats'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -// The spec files are fetched and checksum-pinned, not committed — see -// spec/a2ui/manifest.json. Materialize them before anything reads them. -const { ensureA2UISpec } = await import('./fetch-a2ui-spec.mjs'); -await ensureA2UISpec(); -const specDir = join(root, 'spec', 'a2ui', 'v1_0'); +// The spec schemas come from @a2ui/web_core — the A2UI project's own +// publication, pinned exactly in package.json; the lockfile's integrity +// hash is the checksum. Nothing of the upstream spec is committed here, +// and our surfaces target the *released* revision, because interop is +// with shipped renderers, not with the spec repo's HEAD. +const specDir = join(root, 'node_modules', '@a2ui', 'web_core', 'src', 'v1_0', 'schemas'); const fixtureDir = join(root, 'spec', 'a2learn', 'fixtures'); const update = process.argv.includes('--update'); diff --git a/scripts/fetch-a2ui-spec.mjs b/scripts/fetch-a2ui-spec.mjs deleted file mode 100644 index 83ee6662..00000000 --- a/scripts/fetch-a2ui-spec.mjs +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Fetch the pinned A2UI spec files instead of committing copies of them. - * - * `spec/a2ui/manifest.json` is what the repo owns: the upstream repo, a - * commit, and a SHA-256 per file. This script materializes the files into - * the gitignored `spec/a2ui/v1_0/` and refuses anything whose bytes don't - * match the pin — same determinism as vendoring, none of the copied bulk. - * Bumping the spec version is editing the manifest, which is a reviewable - * contract change, exactly as re-vendoring was. - * - * node scripts/fetch-a2ui-spec.mjs fetch whatever is missing/stale - */ -import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const specDir = join(root, 'spec', 'a2ui', 'v1_0'); -const manifest = JSON.parse(readFileSync(join(root, 'spec', 'a2ui', 'manifest.json'), 'utf8')); - -const sha256 = (buf) => createHash('sha256').update(buf).digest('hex'); - -export async function ensureA2UISpec() { - const repoPath = new URL(manifest.source).pathname.replace(/^\//, ''); - for (const [local, entry] of Object.entries(manifest.files)) { - const target = join(specDir, local); - if (existsSync(target) && sha256(readFileSync(target)) === entry.sha256) continue; - - const url = `https://raw.githubusercontent.com/${repoPath}/${manifest.commit}/${entry.upstream}`; - const res = await fetch(url); - if (!res.ok) { - throw new Error(`fetching ${url} failed: ${res.status} ${res.statusText}`); - } - const body = Buffer.from(await res.arrayBuffer()); - const actual = sha256(body); - if (actual !== entry.sha256) { - throw new Error( - `${local}: upstream bytes do not match the pinned checksum\n expected ${entry.sha256}\n received ${actual}\nRefusing to write — the manifest pin is the contract.`, - ); - } - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, body); - console.log(`✓ ${local} (${body.length} bytes, checksum verified)`); - } -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - await ensureA2UISpec(); - console.log('A2UI spec present and pinned.'); -} diff --git a/spec/a2learn/fixtures/flashcard.surface.json b/spec/a2learn/fixtures/flashcard.surface.json index 41d540ea..2dadd492 100644 --- a/spec/a2learn/fixtures/flashcard.surface.json +++ b/spec/a2learn/fixtures/flashcard.surface.json @@ -174,7 +174,6 @@ "action": { "event": { "name": "a2learn.widget_completed", - "userMessage": "Finished the activity.", "context": { "kind": "flashcard" } diff --git a/spec/a2learn/fixtures/markdown-card.surface.json b/spec/a2learn/fixtures/markdown-card.surface.json index f9b3549e..c478c151 100644 --- a/spec/a2learn/fixtures/markdown-card.surface.json +++ b/spec/a2learn/fixtures/markdown-card.surface.json @@ -43,7 +43,6 @@ "action": { "event": { "name": "a2learn.widget_completed", - "userMessage": "Finished the activity.", "context": { "kind": "markdown-card" } diff --git a/spec/a2learn/fixtures/step-reveal.surface.json b/spec/a2learn/fixtures/step-reveal.surface.json index e3ad7347..7730e571 100644 --- a/spec/a2learn/fixtures/step-reveal.surface.json +++ b/spec/a2learn/fixtures/step-reveal.surface.json @@ -134,7 +134,6 @@ "action": { "event": { "name": "a2learn.widget_completed", - "userMessage": "Finished the activity.", "context": { "kind": "step-reveal" } diff --git a/spec/a2ui/README.md b/spec/a2ui/README.md index 7ee83cbc..61439981 100644 --- a/spec/a2ui/README.md +++ b/spec/a2ui/README.md @@ -1,15 +1,16 @@ -# A2UI v1.0 spec — pinned, not vendored +# A2UI v1.0 spec — from the official package -The conformance target for `src/lib/a2learn/a2ui.ts` is Google's A2UI v1.0 -spec ([google/A2UI](https://github.com/google/A2UI), Apache-2.0). We do not -commit copies of their files; `manifest.json` pins an upstream commit and a -SHA-256 per file, and `scripts/fetch-a2ui-spec.mjs` materializes them into -the gitignored `v1_0/` — refusing any bytes that don't match the pin. +The conformance target for `src/lib/a2learn/a2ui.ts` is the A2UI spec as +published by the project itself: the exact-pinned `@a2ui/web_core` +devDependency ships the v1.0 JSON schemas, and the lockfile's integrity +hash pins their bytes. Nothing of the upstream spec is committed in this +repo, and `pnpm conformance` reads the schemas from `node_modules`. -`pnpm conformance` fetches automatically when files are missing. To update -the spec version: edit `manifest.json` (new commit + new checksums) — a -reviewable contract change, exactly as re-vendoring was — never by editing -fetched files in place. +Our surfaces deliberately target the *released* schema revision — interop +is with shipped renderers (`@a2ui/react`, `@a2ui/lit`, and friends), not +with the spec repo's HEAD. Updating the spec version is a normal +dependency bump: raise the pin, run `pnpm conformance`, and let the gate +say what the new revision thinks of our surfaces. The fixtures and golden surfaces under `spec/a2learn/` are ours and stay committed. diff --git a/spec/a2ui/manifest.json b/spec/a2ui/manifest.json deleted file mode 100644 index bf35a2e7..00000000 --- a/spec/a2ui/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "source": "https://github.com/google/A2UI", - "commit": "abcff1515fad", - "license": "Apache-2.0", - "files": { - "agent_to_renderer.json": { - "upstream": "specification/v1_0/json/agent_to_renderer.json", - "sha256": "468c8e544dbe0b02d5d5586ebcdca399ecfa6d07a7b875d887089ce3bd2df160" - }, - "common_types.json": { - "upstream": "specification/v1_0/json/common_types.json", - "sha256": "169ff987a7f8fb93a040ca33352ae4aeb684ff9742bff8cf5925b5cd2612e7d8" - }, - "catalogs/basic/catalog.json": { - "upstream": "specification/v1_0/catalogs/basic/catalog.json", - "sha256": "29e01ac2cf69dc5860ad060f5a60c67fa5cdaa8a78ecab1018f531b178fa5c00" - } - } -} diff --git a/src/lib/a2learn/a2ui.ts b/src/lib/a2learn/a2ui.ts index 68e65c26..72f298f1 100644 --- a/src/lib/a2learn/a2ui.ts +++ b/src/lib/a2learn/a2ui.ts @@ -52,10 +52,14 @@ export type A2UISurfaceMessage = { * registry's `assesses` flag — never asserted by the mapper. */ function completedAction(kind: WidgetKind) { + // Deliberately the intersection of released and in-flight spec revisions: + // `name` + `context` only. The released schemas close the event object + // (additionalProperties: false), so anything extra — the newer spec's + // userMessage, for instance — breaks every shipped renderer. Prose for a + // model reader belongs to the MCP Apps channel, not this boundary. return { event: { name: `${A2LEARN_EVENT_PREFIX}widget_completed`, - userMessage: 'Finished the activity.', context: { kind }, }, };