Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ryandmonk marked this conversation as resolved.
- 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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 97 additions & 2 deletions input/shadcn-ui.dspack.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": ["ex.delete-account-confirmation"],
"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",
Expand Down
43 changes: 43 additions & 0 deletions out/delete-account.surface.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
}
]
}
}
]
}
2 changes: 1 addition & 1 deletion out/validation-report.v0_9_1.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion out/validation-report.v1_0.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
23 changes: 19 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
49 changes: 49 additions & 0 deletions scripts/pack-test.sh
Original file line number Diff line number Diff line change
@@ -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
41 changes: 39 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@
* dspack-to-a2ui CLI.
*
* tsx src/cli.ts --in <dspack.json> --a2ui-version <0.9.1|1.0> --out <dir> [--surface <surface.json>]
* [--emit-surface <surface.dsurface.json>]
*
* 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/<name>.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;
}

Expand Down Expand Up @@ -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",
};
}
Expand Down Expand Up @@ -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();
Loading
Loading