From 8dbbad59cd5971803029de9176423a446ce92c46 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Thu, 2 Jul 2026 12:11:46 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20dspack=20surface=20=E2=86=92=20A2UI?= =?UTF-8?q?=20emitter=20+=20library=20exports=20(M1=20PR-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - input contract upgraded to dspack v0.3 (intents/rules/examples governance blocks; catalog emission verified byte-identical — the transformer ignores the new blocks per dspack conformance rules) - src/targets/a2ui/surface.ts: compiles a dspack surface (CSR, surface schema v0.1) into A2UI v0.9 messages. Projection knowledge is data in the profile (surfacePlan directives); compound composition flattens per the MAPPING.md casualties; synthesized actions and every drop are recorded as warnings. Unknown components raise EmitSurfaceError (CLI exit 4). - CLI --emit-surface: emits + instance-validates (gate A3) against the freshly generated catalog. - library exports (src/index.ts + exports map + tsc build with declarations); pack-and-install boundary test (npm run test:pack) proves the package is consumable via the exports map only — publish-ready, consumed as a pinned git dependency until the publish decision at M1 exit. - surface/delete-account.dsurface.json: the contract's worked example as a standalone golden CSR; CI workflow runs tests, both catalog emissions, surface emission, and the pack test. Verify: npm test && npm run test:pack && npm run transform -- --in input/shadcn-ui.dspack.json --a2ui-version 0.9.1 --out out --emit-surface surface/delete-account.dsurface.json Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 26 +++ README.md | 35 ++++ input/shadcn-ui.dspack.json | 99 ++++++++- out/delete-account.surface.json | 43 ++++ out/validation-report.v0_9_1.json | 2 +- out/validation-report.v1_0.json | 2 +- package.json | 23 ++- scripts/pack-test.sh | 49 +++++ src/cli.ts | 41 +++- src/index.ts | 26 +++ src/surface-emit.test.ts | 167 ++++++++++++++++ src/targets/a2ui/surface.ts | 287 +++++++++++++++++++++++++++ src/transform/profiles.ts | 65 ++++++ src/types.ts | 22 ++ surface/delete-account.dsurface.json | 53 +++++ tsconfig.json | 1 + 16 files changed, 931 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 out/delete-account.surface.json create mode 100755 scripts/pack-test.sh create mode 100644 src/index.ts create mode 100644 src/surface-emit.test.ts create mode 100644 src/targets/a2ui/surface.ts create mode 100644 surface/delete-account.dsurface.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3ba671d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm install --prefix demo + - name: Unit + gate tests (A1–A3, surface emitter, ingestion) + run: npm test + - name: Catalog emission (both A2UI versions) + surface emission + run: | + npm run transform -- --in input/shadcn-ui.dspack.json --a2ui-version 0.9.1 --out out --emit-surface surface/delete-account.dsurface.json + npm run transform -- --in input/shadcn-ui.dspack.json --a2ui-version 1.0 --out out + - name: Pack-and-install boundary test + run: npm run test:pack diff --git a/README.md b/README.md index e41b287..4f56f5b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,41 @@ The CLI exits non-zero if any gate fails. Both emitted versions currently pass a three. The instance gate is proven non-vacuous in the test suite (it rejects a malformed Button). +In the AF governance-pipeline naming these three checks are the **emitter gates +A1/A2/A3** (schema-compile, catalog-shape, instance); the protocol-neutral +**surface gates S1/S2/S3** (surface schema, contract vocabulary, governance lint) +run upstream in [dspack-gen](https://github.com/aestheticfunction/dspack-gen), +before anything reaches this emitter. + +## Surface emission (dspack surface → A2UI) + +The input contract is now **dspack v0.3**, whose `examples[]` embed *dspack +surface* documents — a protocol-neutral component tree in dspack vocabulary +(schema: `dspack.surface.v0_1.schema.json` in the dspack repo). This repo +compiles those surfaces to A2UI v0.9 messages: + +```bash +npm run transform -- --in input/shadcn-ui.dspack.json --a2ui-version 0.9.1 --out out \ + --emit-surface surface/delete-account.dsurface.json +``` + +emits `out/delete-account.surface.json` (createSurface with dspack-token theme + +updateComponents) and instance-validates it against the freshly generated catalog +(gate A3). Exit codes: `1` catalog gates failed, `3` strict-coverage, `4` the +surface could not be emitted (unknown component / structure the profile cannot +project). The projection is data in the profile (`surfacePlan` directives in +`src/transform/profiles.ts`); the engine has no component-specific code. + +**Honest scope:** compound composition flattens per the documented casualties in +MAPPING.md (e.g. an `alert-dialog` subtree collapses onto +`triggerLabel/title/description/cancelLabel/confirmLabel`); A2UI's required +declarative actions are *synthesized* (deterministic event-name slug) and every +synthesis/drop is emitted as a warning — nothing is silent. + +The package is also consumable as a library (`transform`, `emitSurface`, +`validateCatalog`, `extractInstances` via the exports map — verified by +`npm run test:pack`); dspack-gen consumes it as a pinned git dependency. + ## The render demo (headline deliverable) ```bash diff --git a/input/shadcn-ui.dspack.json b/input/shadcn-ui.dspack.json index 83d8c74..aa94223 100644 --- a/input/shadcn-ui.dspack.json +++ b/input/shadcn-ui.dspack.json @@ -1,6 +1,6 @@ { - "$schema": "../schema/dspack.v0.2.schema.json", - "dspack": "0.2", + "$schema": "../schema/dspack.v0.3.schema.json", + "dspack": "0.3", "name": "shadcn/ui", "description": "A collection of reusable components built with Radix UI and Tailwind CSS. Components are copied into your project, not installed as a dependency.", "version": "2.0.0", @@ -1007,6 +1007,101 @@ "tags": ["accessibility", "semantics", "component-misuse"] } ], + "intents": [ + { + "id": "destructive-action", + "name": "Destructive action", + "description": "The requested UI performs an irreversible or high-consequence operation: deleting records or accounts, revoking access, removing members.", + "relatedPatterns": ["destructive-action-confirmation"] + } + ], + "rules": [ + { + "id": "rule.destructive-requires-alertdialog", + "type": "component-choice", + "severity": "must", + "appliesTo": { "intents": ["destructive-action"] }, + "require": ["alert-dialog"], + "forbid": ["dialog"], + "rationale": "Dialog can be dismissed by clicking the overlay or pressing Escape, so a user can bypass a destructive confirmation without making a conscious choice. AlertDialog forces an explicit confirm/cancel decision and is announced with greater urgency by screen readers.", + "examples": ["ex.delete-account-confirmation"], + "tags": ["safety", "accessibility"] + }, + { + "id": "rule.alertdialog-requires-cancel", + "type": "required-composition", + "severity": "must", + "component": "alert-dialog", + "requiredSubComponents": [ + { "id": "alert-dialog-cancel", "min": 1 }, + { "id": "alert-dialog-title", "min": 1 } + ], + "rationale": "A confirmation without an explicit cancel action and a title naming the consequence funnels the user toward the destructive action; the title is also required for aria-labelledby.", + "examples": ["ex.delete-account-confirmation"], + "tags": ["safety", "accessibility"] + }, + { + "id": "rule.button-no-interactive-descendants", + "type": "forbidden-composition", + "severity": "must", + "component": "button", + "forbiddenDescendants": ["button", "input"], + "rationale": "Nested interactive elements create ambiguous click targets and are an accessibility violation: screen readers cannot determine intent and click handling varies across browsers.", + "examples": [], + "tags": ["accessibility", "interaction"] + } + ], + "examples": [ + { + "id": "ex.delete-account-confirmation", + "intent": "destructive-action", + "name": "Delete account confirmation", + "prompt": "a screen to delete my account", + "description": "Card with a destructive entry point; AlertDialog confirmation with explicit consequence text, cancel-before-confirm, destructive confirm variant.", + "surface": { + "dspackSurface": "0.1", + "system": "shadcn/ui", + "intent": "destructive-action", + "root": { + "component": "card", + "children": [ + { + "component": "alert-dialog", + "children": [ + { + "component": "alert-dialog-trigger", + "children": [ + { + "component": "button", + "props": { "variant": "destructive" }, + "text": "Delete account" + } + ] + }, + { + "component": "alert-dialog-content", + "children": [ + { "component": "alert-dialog-title", "text": "Delete your account?" }, + { + "component": "alert-dialog-description", + "text": "This will permanently delete your account and all associated data. This action cannot be undone." + }, + { + "component": "alert-dialog-footer", + "children": [ + { "component": "alert-dialog-cancel", "text": "Cancel" }, + { "component": "alert-dialog-action", "text": "Delete account" } + ] + } + ] + } + ] + } + ] + } + } + } + ], "frameworkBindings": { "react": { "name": "React", diff --git a/out/delete-account.surface.json b/out/delete-account.surface.json new file mode 100644 index 0000000..090b05e --- /dev/null +++ b/out/delete-account.surface.json @@ -0,0 +1,43 @@ +{ + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "destructive_action", + "catalogId": "https://rdombrowski.dev/catalogs/shadcn-ui/v0_9_1/catalog.json", + "theme": { + "agentDisplayName": "shadcn/ui via dspack", + "primaryColor": "#0f172a" + } + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "destructive_action", + "components": [ + { + "id": "card", + "component": "Card", + "child": "alertdialog" + }, + { + "id": "alertdialog", + "component": "AlertDialog", + "triggerLabel": "Delete account", + "title": "Delete your account?", + "description": "This will permanently delete your account and all associated data. This action cannot be undone.", + "cancelLabel": "Cancel", + "confirmLabel": "Delete account", + "action": { + "event": { + "name": "delete_account", + "context": {} + } + } + } + ] + } + } + ] +} diff --git a/out/validation-report.v0_9_1.json b/out/validation-report.v0_9_1.json index 287a416..329bc85 100644 --- a/out/validation-report.v0_9_1.json +++ b/out/validation-report.v0_9_1.json @@ -1,6 +1,6 @@ { "a2uiVersion": "0.9.1", - "generatedAt": "2026-06-25T14:24:30.585Z", + "generatedAt": "2026-07-02T16:10:54.590Z", "validation": { "pass": true, "gates": [ diff --git a/out/validation-report.v1_0.json b/out/validation-report.v1_0.json index 3deed80..d35f983 100644 --- a/out/validation-report.v1_0.json +++ b/out/validation-report.v1_0.json @@ -1,6 +1,6 @@ { "a2uiVersion": "1.0", - "generatedAt": "2026-06-25T14:24:30.828Z", + "generatedAt": "2026-07-02T16:10:54.921Z", "validation": { "pass": true, "gates": [ diff --git a/package.json b/package.json index f6caa9c..dc59963 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,29 @@ { - "name": "dspack-to-a2ui", - "version": "0.1.0", + "name": "@aestheticfunction/dspack-to-a2ui", + "version": "0.2.0", "private": true, "type": "module", - "description": "Proof-of-concept: compile a dspack design-system contract into a valid A2UI catalog and render it through the React A2UI renderer.", + "description": "dspack → A2UI emitter: compile a dspack design-system contract into a valid A2UI catalog, and dspack surfaces into A2UI surface messages, validated by the A1–A3 gates.", "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "LICENSE", + "README.md" + ], "scripts": { "transform": "tsx src/cli.ts", + "build": "tsc -p tsconfig.json && mkdir -p dist/validate/meta && cp src/validate/meta/*.json dist/validate/meta/", + "prepare": "npm run build", "build:catalogs": "tsx src/cli.ts --in input/shadcn-ui.dspack.json --a2ui-version 0.9.1 --out out && tsx src/cli.ts --in input/shadcn-ui.dspack.json --a2ui-version 1.0 --out out", - "test": "vitest run" + "test": "vitest run", + "test:pack": "bash scripts/pack-test.sh" }, "dependencies": { "ajv": "^8.17.1", diff --git a/scripts/pack-test.sh b/scripts/pack-test.sh new file mode 100755 index 0000000..3009184 --- /dev/null +++ b/scripts/pack-test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Pack-and-install boundary test (PR-2 acceptance): the package must be +# consumable exactly as published/git-dep'd — exports map only, no deep +# imports, dist + meta files present in the tarball. +set -euo pipefail +cd "$(dirname "$0")/.." + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +npm run build >/dev/null +TARBALL="$(npm pack --pack-destination "$WORK" 2>/dev/null | tail -1)" +echo "packed: $TARBALL" + +cd "$WORK" +npm init -y >/dev/null 2>&1 +npm install --no-fund --no-audit "./$TARBALL" >/dev/null + +cat > smoke.mjs <<'EOF' +import { readFileSync } from "node:fs"; +import { + transform, + emitSurface, + validateCatalog, + extractInstances, + shadcnProfile, + EmitSurfaceError, +} from "@aestheticfunction/dspack-to-a2ui"; + +const root = process.env.REPO_ROOT; +const doc = JSON.parse(readFileSync(`${root}/input/shadcn-ui.dspack.json`, "utf8")); +const csr = JSON.parse(readFileSync(`${root}/surface/delete-account.dsurface.json`, "utf8")); + +const { messages } = emitSurface(csr, doc); +const { validation } = transform(doc, "0.9.1", { messages }); +if (!validation.pass) throw new Error("gates failed from packed install"); +if (extractInstances({ messages }).length === 0) throw new Error("no instances extracted"); +if (typeof validateCatalog !== "function" || !shadcnProfile.catalogIdBase) throw new Error("exports missing"); + +let threw = false; +try { + emitSurface({ ...csr, root: { component: "carousel" } }, doc); +} catch (e) { + threw = e instanceof EmitSurfaceError; +} +if (!threw) throw new Error("typed error not thrown from packed install"); +console.log("pack-and-install smoke: OK (A1-A3 pass; typed errors intact)"); +EOF +REPO_ROOT="$OLDPWD" node smoke.mjs diff --git a/src/cli.ts b/src/cli.ts index 9037c80..1008ba9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,20 +3,28 @@ * dspack-to-a2ui CLI. * * tsx src/cli.ts --in --a2ui-version <0.9.1|1.0> --out [--surface ] + * [--emit-surface ] * * Emits a versioned A2UI catalog + a validation/fidelity report. Exits non-zero * if the hard gate (catalog schema validation) fails, so it is CI-friendly. + * + * With --emit-surface, additionally compiles a dspack surface document (CSR) + * into A2UI surface messages (out/.surface.json), instance-validated + * against the freshly generated catalog (gate A3). A malformed or + * out-of-vocabulary surface exits 4. */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import type { A2uiVersion, DspackDoc } from "./types.js"; +import { basename, join, resolve } from "node:path"; +import type { A2uiVersion, DspackDoc, DspackSurface } from "./types.js"; import { transform } from "./transform/index.js"; +import { emitSurface, EmitSurfaceError } from "./targets/a2ui/surface.js"; interface Args { in: string; version: A2uiVersion; out: string; surface: string; + emitSurface?: string; strictCoverage: boolean; } @@ -52,6 +60,7 @@ function parseArgs(argv: string[]): Args { version: version as A2uiVersion, out: m.get("out") ?? "out", surface: m.get("surface") ?? "surface/settings-card.surface.json", + emitSurface: m.get("emit-surface"), strictCoverage: m.get("strict-coverage") === "true", }; } @@ -89,6 +98,34 @@ function main(): void { if (!validation.pass) process.exit(1); if (args.strictCoverage && unclassified.length) process.exit(3); + + if (args.emitSurface) { + const csr = JSON.parse(readFileSync(resolve(args.emitSurface), "utf8")) as DspackSurface; + let emitted; + try { + emitted = emitSurface(csr, doc); + } catch (e) { + if (e instanceof EmitSurfaceError) { + console.error(`${tag} EMIT-SURFACE FAILED: ${e.message}`); + process.exit(4); + } + throw e; + } + const name = basename(args.emitSurface).replace(/\.dsurface\.json$|\.json$/, ""); + const outPath = join(base, `${name}.surface.json`); + writeFileSync(outPath, JSON.stringify({ messages: emitted.messages }, null, 2) + "\n"); + for (const w of emitted.warnings) console.log(`${tag} note ${w.code}: ${w.message}`); + // Gate A3 over the emitted surface: its instances must validate against the + // catalog generated in this same run. + const check = transform(doc, args.version, { messages: emitted.messages }); + const instanceGate = check.validation.gates.find((g) => g.name === "instance"); + console.log(`${tag} emitted surface -> ${outPath}`); + console.log(`${tag} ${instanceGate?.pass ? "PASS" : "FAIL"} instance (emitted surface vs generated catalog)`); + if (!instanceGate?.pass) { + for (const err of instanceGate?.errors ?? []) console.error(`${tag} ${err}`); + process.exit(4); + } + } } main(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9ff26f1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +/** + * Public library surface of dspack-to-a2ui (the A2UI emitter target). + * + * Consumers (dspack-gen) depend on exactly this module; everything not + * re-exported here is internal. The CLI (src/cli.ts) is a thin wrapper over + * the same functions. + */ +export { transform, transformFromJson, type TransformOutput } from "./transform/index.js"; +export { shadcnProfile, type Profile, type ComponentPlan, type SurfacePlanDirectives } from "./transform/profiles.js"; +export { validateCatalog, extractInstances, type ValidationReport, type GateResult } from "./validate/ajv.js"; +export { + emitSurface, + slug, + EmitSurfaceError, + type EmitSurfaceResult, + type EmitSurfaceOptions, +} from "./targets/a2ui/surface.js"; +export type { + A2uiCatalog, + A2uiVersion, + DspackDoc, + DspackSurface, + SurfaceNode, + Json, + Warning, +} from "./types.js"; diff --git a/src/surface-emit.test.ts b/src/surface-emit.test.ts new file mode 100644 index 0000000..353d876 --- /dev/null +++ b/src/surface-emit.test.ts @@ -0,0 +1,167 @@ +/** + * PR-2 acceptance gates: the dspack surface → A2UI surface emitter. + * + * - The contract's worked example (ex.delete-account-confirmation) emits a + * surface whose instances pass all emitter gates (A1 schema-compile / + * A2 catalog-shape / A3 instance) against the freshly generated catalog, + * for both A2UI versions. + * - Compound flattening lands the documented projection (title/description/ + * cancelLabel/confirmLabel/triggerLabel + synthesized action). + * - Unknown components fail with a typed error (never silently dropped). + * - A CSR violating the emitted component's required props FAILS gate A3 — + * the gate is proven non-vacuous. + * - Emission is deterministic. + */ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import type { DspackDoc, DspackSurface, Json } from "./types.js"; +import { transform } from "./transform/index.js"; +import { emitSurface, EmitSurfaceError } from "./targets/a2ui/surface.js"; + +const doc = JSON.parse(readFileSync("input/shadcn-ui.dspack.json", "utf8")) as DspackDoc & { + examples: Array<{ id: string; surface: DspackSurface }>; +}; +const workedExample = doc.examples.find((e) => e.id === "ex.delete-account-confirmation")!; + +function componentsOf(messages: Json[]): Json[] { + const update = messages.find((m) => m.updateComponents) as Json; + return (update.updateComponents as Json).components as Json[]; +} + +describe("emitSurface: worked example (ex.delete-account-confirmation)", () => { + const { messages, warnings } = emitSurface(workedExample.surface, doc); + const components = componentsOf(messages); + + it("emits createSurface with the versioned catalogId and dspack-token theme", () => { + const create = (messages[0].createSurface ?? {}) as Json; + expect(create.catalogId).toBe("https://rdombrowski.dev/catalogs/shadcn-ui/v0_9_1/catalog.json"); + expect((create.theme as Json).primaryColor).toBe("#0f172a"); + }); + + it("flattens the AlertDialog compound per the documented casualty mapping", () => { + const dialog = components.find((c) => c.component === "AlertDialog")!; + expect(dialog).toMatchObject({ + triggerLabel: "Delete account", + title: "Delete your account?", + cancelLabel: "Cancel", + confirmLabel: "Delete account", + }); + expect(String(dialog.description)).toMatch(/permanently delete/); + expect(dialog.action).toEqual({ event: { name: "delete_account", context: {} } }); + }); + + it("projects Card onto its single-child slot", () => { + const card = components.find((c) => c.component === "Card")!; + const dialog = components.find((c) => c.component === "AlertDialog")!; + expect(card.child).toBe(dialog.id); + expect(components[0]).toBe(card); // root first (pre-order) + }); + + it("passes gates A1–A3 against both generated catalogs", () => { + for (const version of ["0.9.1", "1.0"] as const) { + const { validation } = transform(doc, version, { messages }); + const gates = Object.fromEntries(validation.gates.map((g) => [g.name, g.pass])); + expect(gates, `gates for ${version}`).toEqual({ + "schema-compile + no-external-ref": true, + "catalog-shape": true, + instance: true, + }); + } + }); + + it("records every synthesis as a warning — nothing silent", () => { + const codes = warnings.map((w) => w.code); + expect(codes).toContain("surface-synthesized-action"); + expect(codes).toContain("surface-composition-flattened"); + }); + + it("is deterministic", () => { + const again = emitSurface(workedExample.surface, doc); + expect(again.messages).toEqual(messages); + }); +}); + +describe("emitSurface: general projection", () => { + const settingsLike: DspackSurface = { + dspackSurface: "0.1", + system: "shadcn/ui", + intent: "destructive-action", + root: { + component: "card", + children: [ + { component: "input", id: "email_field", props: { type: "email" }, text: "Email address" }, + { component: "button", id: "save_btn", props: { variant: "default" }, text: "Save changes" }, + ], + }, + }; + + it("wraps multiple card children in a synthesized Column and buttons get Text children", () => { + const { messages } = emitSurface(settingsLike, doc); + const components = componentsOf(messages); + const card = components.find((c) => c.component === "Card")!; + const column = components.find((c) => c.component === "Column")!; + expect(card.child).toBe(column.id); + expect(column.children).toEqual(["email_field", "save_btn"]); + + const field = components.find((c) => c.id === "email_field")!; + expect(field.component).toBe("TextField"); + expect(field.variant).toBe("shortText"); // email -> shortText projection + expect(field.label).toBe("Email address"); + + const button = components.find((c) => c.id === "save_btn")!; + expect(button.variant).toBe("primary"); // default -> primary projection + const label = components.find((c) => c.id === button.child)!; + expect(label.component).toBe("Text"); + expect(label.text).toBe("Save changes"); + expect(button.action).toEqual({ event: { name: "save_btn", context: {} } }); + + const { validation } = transform(doc, "0.9.1", { messages }); + expect(validation.pass).toBe(true); + }); +}); + +describe("emitSurface: failure modes", () => { + it("throws a typed error on unknown components", () => { + const bad: DspackSurface = { + dspackSurface: "0.1", + system: "shadcn/ui", + intent: "destructive-action", + root: { component: "carousel" }, + }; + expect(() => emitSurface(bad, doc)).toThrowError(EmitSurfaceError); + expect(() => emitSurface(bad, doc)).toThrowError(/unknown component 'carousel'/); + }); + + it("throws a typed error on a system/contract mismatch", () => { + const bad = { ...workedExample.surface, system: "someone-elses-system" }; + expect(() => emitSurface(bad, doc)).toThrowError(/does not match contract name/); + }); + + it("gate A3 is non-vacuous: an AlertDialog missing its title fails instance validation", () => { + const missingTitle: DspackSurface = { + dspackSurface: "0.1", + system: "shadcn/ui", + intent: "destructive-action", + root: { + component: "alert-dialog", + children: [ + { + component: "alert-dialog-trigger", + children: [{ component: "button", props: { variant: "destructive" }, text: "Delete" }], + }, + { + component: "alert-dialog-content", + children: [ + { component: "alert-dialog-footer", children: [{ component: "alert-dialog-cancel", text: "Cancel" }] }, + ], + }, + ], + }, + }; + const { messages } = emitSurface(missingTitle, doc); + const { validation } = transform(doc, "0.9.1", { messages }); + const instance = validation.gates.find((g) => g.name === "instance")!; + expect(instance.pass).toBe(false); + expect((instance.errors ?? []).join("\n")).toMatch(/title/); + }); +}); diff --git a/src/targets/a2ui/surface.ts b/src/targets/a2ui/surface.ts new file mode 100644 index 0000000..18139b8 --- /dev/null +++ b/src/targets/a2ui/surface.ts @@ -0,0 +1,287 @@ +/** + * dspack surface → A2UI v0.9 surface emitter. + * + * Compiles a dspack surface document (the protocol-neutral component tree + * defined by dspack.surface.v0_1.schema.json) into the A2UI message array + * shape used by surface/*.surface.json: one `createSurface` (catalogId + + * dspack-token theme) followed by one `updateComponents` with a flat, + * id-referenced component list. + * + * Deterministic by construction: same surface + contract + profile => same + * messages. All projection knowledge is data in the profile's `surfacePlan` + * directives; this engine contains no component-name-specific code. + * + * Honest scope (mirrors MAPPING.md): + * - Compound composition flattens per the documented casualty mapping + * (`subText` / `subButtonText` consume the node's whole subtree). + * - A2UI requires declarative actions the surface format does not express; + * they are synthesized (deterministic event-name slug) and recorded as + * warnings, not silently invented. + * - The message envelope is A2UI v0.9 (`version: "v0.9"`), the version the + * maintained renderers speak. The emitted component instances themselves + * are version-independent and instance-validate (gate A3) against both + * generated catalogs. + */ +import type { DspackDoc, DspackSurface, Json, SurfaceNode, Warning } from "../../types.js"; +import { shadcnProfile, type ComponentPlan, type Profile } from "../../transform/profiles.js"; +import { toHex6 } from "../../transform/color.js"; + +export class EmitSurfaceError extends Error { + constructor( + message: string, + readonly path: string, + ) { + super(`${message} (at ${path})`); + this.name = "EmitSurfaceError"; + } +} + +export interface EmitSurfaceResult { + /** The A2UI v0.9 message array (createSurface + updateComponents). */ + messages: Json[]; + /** Every synthesis/drop performed — nothing is silent. */ + warnings: Warning[]; +} + +export interface EmitSurfaceOptions { + profile?: Profile; + /** Defaults to a slug of the surface intent. */ + surfaceId?: string; +} + +export function emitSurface( + surface: DspackSurface, + doc: DspackDoc, + options: EmitSurfaceOptions = {}, +): EmitSurfaceResult { + const profile = options.profile ?? shadcnProfile; + if (surface.dspackSurface !== "0.1") { + throw new EmitSurfaceError( + `unsupported dspackSurface version '${surface.dspackSurface}' (this emitter targets 0.1)`, + "$", + ); + } + if (surface.system !== doc.name) { + throw new EmitSurfaceError( + `surface.system '${surface.system}' does not match contract name '${doc.name}'`, + "$.system", + ); + } + + const byDspackId = new Map(); + for (const plan of profile.components) { + if (plan.dspackId) byDspackId.set(plan.dspackId, plan); + } + const emitter = new SurfaceEmitter(profile, byDspackId); + const rootId = emitter.emitNode(surface.root, "$.root"); + + const surfaceId = options.surfaceId ?? slug(surface.intent); + const theme: Json = { agentDisplayName: `${doc.name} via dspack` }; + const primaryHex = primaryColor(doc, profile); + if (primaryHex) theme.primaryColor = primaryHex; + + const messages: Json[] = [ + { + version: "v0.9", + createSurface: { + surfaceId, + catalogId: `${profile.catalogIdBase}/v0_9_1/catalog.json`, + theme, + }, + }, + { + version: "v0.9", + updateComponents: { surfaceId, components: emitter.components }, + }, + ]; + void rootId; // root is components[0] by construction (pre-order emission) + return { messages, warnings: emitter.warnings }; +} + +function primaryColor(doc: DspackDoc, profile: Profile): string | null { + const { category, name } = profile.primaryColorToken; + const raw = doc.tokens?.[category]?.values?.[name]?.value; + return typeof raw === "string" ? toHex6(raw) : null; +} + +export function slug(value: string): string { + const s = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return s || "surface"; +} + +class SurfaceEmitter { + readonly components: Json[] = []; + readonly warnings: Warning[] = []; + private readonly usedIds = new Set(); + + constructor( + private readonly profile: Profile, + private readonly byDspackId: Map, + ) {} + + /** Emits the component for `node` (and its subtree) and returns its instance id. */ + emitNode(node: SurfaceNode, path: string): string { + const plan = this.byDspackId.get(node.component); + if (!plan) { + throw new EmitSurfaceError( + `unknown component '${node.component}': not a mapped component of the '${this.profile.catalogTitle}' profile ` + + `(sub-components are consumed by their compound parent and cannot be emitted standalone)`, + path, + ); + } + + const id = this.allocateId(node.id ?? plan.a2ui.toLowerCase(), path); + // Reserve the slot so parent components precede children in the flat list. + const index = this.components.length; + this.components.push({}); + const instance: Json = { id, component: plan.a2ui }; + + this.applyPropMap(node, plan, instance, path); + const sp = plan.surfacePlan ?? {}; + const consumesSubtree = Boolean(sp.subText || sp.subButtonText); + + if (sp.structuralPassthrough) { + for (const key of sp.structuralPassthrough) { + const value = node.props?.[key]; + if (value !== undefined) instance[key] = value as Json[keyof Json]; + } + } + if (sp.subText || sp.subButtonText) this.applySubContent(node, sp.subText ?? {}, sp.subButtonText ?? {}, instance, path); + if (sp.textProp && node.text !== undefined) instance[sp.textProp] = node.text; + if (sp.textChildProp && node.text !== undefined) { + instance[sp.textChildProp] = this.emitTextPrimitive(node.text, `${id}_label`, path); + } + if (sp.actionProp) { + const eventName = slug(node.id ?? (instance.confirmLabel as string) ?? node.text ?? node.component); + instance[sp.actionProp] = { event: { name: eventName, context: {} } }; + this.warnings.push({ + code: "surface-synthesized-action", + message: `${path}: A2UI requires a declarative action on ${plan.a2ui}; synthesized event '${eventName}'.`, + }); + } + + if (!consumesSubtree) { + const childNodes = collectChildren(node); + if (childNodes.length > 0) { + const childIds = childNodes.map((child, i) => this.emitNode(child.node, `${path}${child.suffix}[${i}]`)); + if (sp.childrenProp) { + instance[sp.childrenProp] = childIds; + } else if (sp.childProp) { + instance[sp.childProp] = childIds.length === 1 ? childIds[0] : this.wrapInColumn(childIds, id); + } else { + throw new EmitSurfaceError( + `component '${node.component}' has children but its surface plan declares no child slot`, + path, + ); + } + } + } + + this.components[index] = instance; + return id; + } + + /** CSR props -> A2UI props via the profile's existing PropPlan projections. */ + private applyPropMap(node: SurfaceNode, plan: ComponentPlan, instance: Json, path: string): void { + for (const [prop, raw] of Object.entries(node.props ?? {})) { + if (plan.surfacePlan?.structuralPassthrough?.includes(prop)) continue; + const pp = plan.propMap?.[prop]; + if (!pp) { + this.warnings.push({ + code: "surface-prop-dropped", + message: `${path}: prop '${prop}' on '${node.component}' has no A2UI projection; dropped.`, + }); + continue; + } + const value = pp.valueMap ? (pp.valueMap[String(raw)] ?? pp.default) : raw; + if (value === undefined) { + this.warnings.push({ + code: "surface-prop-value-dropped", + message: `${path}: value '${String(raw)}' of prop '${prop}' has no projection and no default; dropped.`, + }); + continue; + } + instance[pp.a2ui] = value as Json[keyof Json]; + } + } + + /** + * Compound flattening: pull text out of named sub-components anywhere in the + * subtree. The subtree is consumed — the documented composition casualty. + */ + private applySubContent( + node: SurfaceNode, + subText: Record, + subButtonText: Record, + instance: Json, + path: string, + ): void { + const visit = (n: SurfaceNode, insideSub: string | null): void => { + const textProp = subText[n.component]; + if (textProp !== undefined && n.text !== undefined && instance[textProp] === undefined) { + instance[textProp] = n.text; + } + const buttonProp = insideSub ? subButtonText[insideSub] : undefined; + if (buttonProp !== undefined && n.text !== undefined && instance[buttonProp] === undefined && n !== node) { + // First text found under the tracked sub-component (typically its button). + if (!(n.component in subText)) instance[buttonProp] = n.text; + } + const nextInside = subButtonText[n.component] !== undefined ? n.component : insideSub; + for (const child of collectChildren(n)) visit(child.node, nextInside); + }; + visit(node, null); + this.warnings.push({ + code: "surface-composition-flattened", + message: `${path}: compound '${node.component}' subtree flattened onto emitted props (documented casualty; nested props beyond text are not carried).`, + }); + } + + private emitTextPrimitive(text: string, preferredId: string, path: string): string { + const { textComponent, textProp } = this.profile.surfaceSynthesis; + const id = this.allocateId(preferredId, path); + this.components.push({ id, component: textComponent, [textProp]: text }); + return id; + } + + private wrapInColumn(childIds: string[], parentId: string): string { + const { wrapComponent, wrapChildrenProp } = this.profile.surfaceSynthesis; + const id = this.allocateId(`${parentId}_col`, "$"); + this.components.push({ + id, + component: wrapComponent, + [wrapChildrenProp]: childIds, + }); + return id; + } + + private allocateId(preferred: string, path: string): string { + let id = slug(preferred); + let n = 2; + while (this.usedIds.has(id)) id = `${slug(preferred)}_${n++}`; + if (id !== slug(preferred)) { + this.warnings.push({ + code: "surface-id-deduplicated", + message: `${path}: node id '${preferred}' already used; emitted as '${id}'.`, + }); + } + this.usedIds.add(id); + return id; + } +} + +interface ChildRef { + node: SurfaceNode; + suffix: string; +} + +/** Ordered children: `children` first, then slots in sorted-key order (deterministic). */ +function collectChildren(node: SurfaceNode): ChildRef[] { + const refs: ChildRef[] = (node.children ?? []).map((n) => ({ node: n, suffix: ".children" })); + for (const key of Object.keys(node.slots ?? {}).sort()) { + for (const n of node.slots![key]) refs.push({ node: n, suffix: `.slots.${key}` }); + } + return refs; +} diff --git a/src/transform/profiles.ts b/src/transform/profiles.ts index 26590f9..f88e3c6 100644 --- a/src/transform/profiles.ts +++ b/src/transform/profiles.ts @@ -32,6 +32,18 @@ export interface Profile { casualtyComponents: CasualtyComponent[]; /** dspack component ids deliberately left out (not mapped, not a casualty). */ intentionallyOmitted?: string[]; + /** + * Which synthesized A2UI primitives the surface emitter uses when a dspack + * surface needs structure the source vocabulary does not express: text + * leaves become `textComponent` instances; multiple children in a + * single-child slot are wrapped in `wrapComponent`. + */ + surfaceSynthesis: { + textComponent: string; + textProp: string; + wrapComponent: string; + wrapChildrenProp: string; + }; } export interface ComponentPlan { @@ -48,6 +60,38 @@ export interface ComponentPlan { propMap?: Record; /** A2UI required property names (the `component` const is added automatically). */ required: string[]; + /** How the surface emitter projects a dspack-surface node onto this component. */ + surfacePlan?: SurfacePlanDirectives; +} + +/** + * Data-only directives for projecting a dspack-surface (CSR) node onto an + * emitted A2UI component instance. Compound composition that A2UI cannot + * represent is flattened here, per the documented casualty mapping in + * MAPPING.md — the emitter consumes the node's whole subtree when sub-content + * directives (`subText`/`subButtonText`) are present. + */ +export interface SurfacePlanDirectives { + /** Descendant sub-component id -> A2UI prop receiving that node's `text`. */ + subText?: Record; + /** + * Descendant sub-component id -> A2UI prop receiving the `text` of the first + * button found under that sub-component (e.g. AlertDialogTrigger's button + * label -> triggerLabel). The button's own props are a documented casualty. + */ + subButtonText?: Record; + /** Synthesize a declarative A2UI Action into this prop (event name is a deterministic slug). */ + actionProp?: string; + /** Node `text` becomes a synthesized text-primitive child referenced by this ComponentId prop. */ + textChildProp?: string; + /** Node `text` becomes this DynamicString prop directly. */ + textProp?: string; + /** Children emit as components; exactly one child id in this prop (>1 children are wrapped). */ + childProp?: string; + /** Children emit as components; their ids form a ChildList in this prop. */ + childrenProp?: string; + /** CSR props copied verbatim into same-named structural slots (e.g. Table columns/rows). */ + structuralPassthrough?: string[]; } export interface StructuralSlot { @@ -86,6 +130,12 @@ export const shadcnProfile: Profile = { catalogIdBase: "https://rdombrowski.dev/catalogs/shadcn-ui", instructions: "For layout, use the Column component to organize other components.", primaryColorToken: { category: "color", name: "primary" }, + surfaceSynthesis: { + textComponent: "Text", + textProp: "text", + wrapComponent: "Column", + wrapChildrenProp: "children", + }, components: [ { @@ -129,6 +179,7 @@ export const shadcnProfile: Profile = { }, }, required: ["child", "action"], + surfacePlan: { textChildProp: "child", actionProp: "action" }, }, { @@ -146,6 +197,7 @@ export const shadcnProfile: Profile = { }, }, required: ["child"], + surfacePlan: { childProp: "child" }, }, { @@ -183,6 +235,7 @@ export const shadcnProfile: Profile = { }, }, required: ["label"], + surfacePlan: { textProp: "label" }, }, { @@ -207,6 +260,7 @@ export const shadcnProfile: Profile = { }, }, required: ["label"], + surfacePlan: { textProp: "label" }, }, { @@ -234,6 +288,7 @@ export const shadcnProfile: Profile = { }, }, required: ["columns", "rows"], + surfacePlan: { structuralPassthrough: ["caption", "columns", "rows"] }, }, { @@ -275,6 +330,16 @@ export const shadcnProfile: Profile = { }, }, required: ["triggerLabel", "title", "action"], + surfacePlan: { + subText: { + "alert-dialog-title": "title", + "alert-dialog-description": "description", + "alert-dialog-cancel": "cancelLabel", + "alert-dialog-action": "confirmLabel", + }, + subButtonText: { "alert-dialog-trigger": "triggerLabel" }, + actionProp: "action", + }, }, ], diff --git a/src/types.ts b/src/types.ts index 581c154..e575149 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,28 @@ export interface DspackProp { [k: string]: unknown; } +// --------------------------------------------------------------------------- +// dspack surface (v0.1) — the protocol-neutral component tree this repo's +// surface emitter compiles to A2UI messages. Schema: +// dspack repo, schema/dspack.surface.v0_1.schema.json. +// --------------------------------------------------------------------------- + +export interface DspackSurface { + dspackSurface: string; + system: string; + intent: string; + root: SurfaceNode; +} + +export interface SurfaceNode { + component: string; + id?: string; + props?: Record; + text?: string; + children?: SurfaceNode[]; + slots?: Record; +} + // --------------------------------------------------------------------------- // A2UI catalog — JSON-Schema-shaped output (kept loose: it *is* a JSON Schema) // --------------------------------------------------------------------------- diff --git a/surface/delete-account.dsurface.json b/surface/delete-account.dsurface.json new file mode 100644 index 0000000..07db943 --- /dev/null +++ b/surface/delete-account.dsurface.json @@ -0,0 +1,53 @@ +{ + "dspackSurface": "0.1", + "system": "shadcn/ui", + "intent": "destructive-action", + "root": { + "component": "card", + "children": [ + { + "component": "alert-dialog", + "children": [ + { + "component": "alert-dialog-trigger", + "children": [ + { + "component": "button", + "props": { + "variant": "destructive" + }, + "text": "Delete account" + } + ] + }, + { + "component": "alert-dialog-content", + "children": [ + { + "component": "alert-dialog-title", + "text": "Delete your account?" + }, + { + "component": "alert-dialog-description", + "text": "This will permanently delete your account and all associated data. This action cannot be undone." + }, + { + "component": "alert-dialog-footer", + "children": [ + { + "component": "alert-dialog-cancel", + "text": "Cancel" + }, + { + "component": "alert-dialog-action", + "text": "Delete account" + } + ] + } + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 7f5140e..37f8dab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "skipLibCheck": true, "types": ["node"], + "declaration": true, "outDir": "dist" }, "include": ["src"], From e68a17e233f45192e2f1e1a8093c16ef10e9f62f Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Thu, 2 Jul 2026 12:55:56 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20surface=20root=20emits=20under=20id?= =?UTF-8?q?=20'root'=20=E2=80=94=20the=20A2UI=20render=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via the live demo: A2uiSurface begins rendering at component id 'root' (as the hand-authored surfaces do); the emitter previously derived the root id from the component name, leaving the renderer at '[Loading root...]'. Gates A1-A3 don't check ids, so only rendering caught it — exactly what the render step of the demo exists to prove. Co-Authored-By: Claude Fable 5 --- src/surface-emit.test.ts | 3 ++- src/targets/a2ui/surface.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/surface-emit.test.ts b/src/surface-emit.test.ts index 353d876..64fead4 100644 --- a/src/surface-emit.test.ts +++ b/src/surface-emit.test.ts @@ -50,11 +50,12 @@ describe("emitSurface: worked example (ex.delete-account-confirmation)", () => { expect(dialog.action).toEqual({ event: { name: "delete_account", context: {} } }); }); - it("projects Card onto its single-child slot", () => { + it("projects Card onto its single-child slot; the root emits under id 'root' (A2UI render entry)", () => { const card = components.find((c) => c.component === "Card")!; const dialog = components.find((c) => c.component === "AlertDialog")!; expect(card.child).toBe(dialog.id); expect(components[0]).toBe(card); // root first (pre-order) + expect(card.id).toBe("root"); }); it("passes gates A1–A3 against both generated catalogs", () => { diff --git a/src/targets/a2ui/surface.ts b/src/targets/a2ui/surface.ts index 18139b8..b3d6365 100644 --- a/src/targets/a2ui/surface.ts +++ b/src/targets/a2ui/surface.ts @@ -133,7 +133,9 @@ class SurfaceEmitter { ); } - const id = this.allocateId(node.id ?? plan.a2ui.toLowerCase(), path); + // A2UI renderers begin at the component with id "root" (see the + // hand-authored surfaces); the surface root always emits under that id. + const id = path === "$.root" ? this.allocateId("root", path) : this.allocateId(node.id ?? plan.a2ui.toLowerCase(), path); // Reserve the slot so parent components precede children in the flat list. const index = this.components.length; this.components.push({}); From 608bb69d3c79874fb888763d6bc4df131bcd73aa Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Thu, 2 Jul 2026 15:51:38 -0400 Subject: [PATCH 3/3] fix(contract): link ex.delete-account-confirmation to rule.button-no-interactive-descendants (input copy) Mirrors the same one-line fix on dspack#9 (see that commit for the design grounds: ADR-7 repair template carries linked examples; this was the one rule without a corrected reference). Repair-feedback-only change: the example is already in the destructive-action few-shot, so generation context is unchanged. Co-Authored-By: Claude Fable 5 --- input/shadcn-ui.dspack.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/input/shadcn-ui.dspack.json b/input/shadcn-ui.dspack.json index aa94223..535356c 100644 --- a/input/shadcn-ui.dspack.json +++ b/input/shadcn-ui.dspack.json @@ -1047,7 +1047,7 @@ "component": "button", "forbiddenDescendants": ["button", "input"], "rationale": "Nested interactive elements create ambiguous click targets and are an accessibility violation: screen readers cannot determine intent and click handling varies across browsers.", - "examples": [], + "examples": ["ex.delete-account-confirmation"], "tags": ["accessibility", "interaction"] } ],