From 0b28bc54f54e7d826eed4ac1bec8215ec83f4745 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Mon, 3 Aug 2026 14:46:06 -0400 Subject: [PATCH 1/6] feat(composer): composer-core + wireframe-renderers packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composer-core (pure, no protocol deps, mirrors replay's discipline): the project.json manifest schema (zod, strict), x-bootstrap ledger reading on WebCrypto (sectionHash pinned byte-for-byte against real dspack-export 0.3.0 output fixtures: pristine sections verify tool-owned, enriched components verify human-owned, authored governance human-authored), the normalized ComposerFinding shape every gate reports in, and the ComposerAdapter manifests binding the three existing seams by REFERENCE (registry ids, not imports — import isolation intact; react-generic has no rendering ref, which IS the wireframe fallback and the Vue door). wireframe-renderers: wireframeRegistryFor(catalog) derives a Registry from the catalog itself — every name renders as a labeled outline (scalar props as chips, ComponentId/ChildList via buildChild, Action as inert buttons), wrapped in withProvenance so X-ray tracing works. Zero user code executes. Tested against a real emitted catalog from a non-canonical contract (acme): zero unimplemented, classification, static-markup render. Co-Authored-By: Claude Fable 5 --- .../fixtures/acme-ui.dspack.json | 247 +++++++ .../fixtures/acme-ui.enriched.dspack.json | 405 ++++++++++++ packages/composer-core/package.json | 22 + packages/composer-core/src/adapters.ts | 80 +++ .../composer-core/src/composer-core.test.ts | 112 ++++ packages/composer-core/src/findings.ts | 52 ++ packages/composer-core/src/index.ts | 26 + packages/composer-core/src/ledger.ts | 100 +++ packages/composer-core/src/project.ts | 52 ++ packages/composer-core/tsconfig.json | 4 + .../fixtures/acme.catalog.v0_9_1.json | 625 ++++++++++++++++++ packages/wireframe-renderers/package.json | 28 + .../wireframe-renderers/src/classify-props.ts | 51 ++ packages/wireframe-renderers/src/index.ts | 2 + packages/wireframe-renderers/src/registry.tsx | 89 +++ .../src/wireframe.test.tsx | 72 ++ packages/wireframe-renderers/tsconfig.json | 4 + pnpm-lock.yaml | 35 + 18 files changed, 2006 insertions(+) create mode 100644 packages/composer-core/fixtures/acme-ui.dspack.json create mode 100644 packages/composer-core/fixtures/acme-ui.enriched.dspack.json create mode 100644 packages/composer-core/package.json create mode 100644 packages/composer-core/src/adapters.ts create mode 100644 packages/composer-core/src/composer-core.test.ts create mode 100644 packages/composer-core/src/findings.ts create mode 100644 packages/composer-core/src/index.ts create mode 100644 packages/composer-core/src/ledger.ts create mode 100644 packages/composer-core/src/project.ts create mode 100644 packages/composer-core/tsconfig.json create mode 100644 packages/wireframe-renderers/fixtures/acme.catalog.v0_9_1.json create mode 100644 packages/wireframe-renderers/package.json create mode 100644 packages/wireframe-renderers/src/classify-props.ts create mode 100644 packages/wireframe-renderers/src/index.ts create mode 100644 packages/wireframe-renderers/src/registry.tsx create mode 100644 packages/wireframe-renderers/src/wireframe.test.tsx create mode 100644 packages/wireframe-renderers/tsconfig.json diff --git a/packages/composer-core/fixtures/acme-ui.dspack.json b/packages/composer-core/fixtures/acme-ui.dspack.json new file mode 100644 index 0000000..c3f1a5f --- /dev/null +++ b/packages/composer-core/fixtures/acme-ui.dspack.json @@ -0,0 +1,247 @@ +{ + "dspack": "0.4", + "name": "Acme UI", + "description": "Small non-canonical React component library used to evidence the composer spike.", + "version": "0.1.0", + "metadata": { + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", + "generatedAt": "2026-08-03T18:19:44.394Z", + "source": ".", + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "19a25ccff090fe44ae83e997cafbee2d49a6e9f3d2afec4557c86699340f6f45", + "components": "2f81ce51d4280a90e8532880a0ebb90085ba1c5aec0adf672c65531cb942cc3d", + "frameworkBindings": "1fa677ffda17075341c6b6534cc8c7cf2dbdb005e889fa4afb8155e0174100d4", + "themes": "4254e5c12a38bdee0fd751a638307fefd94088d47f5c01b0a2c0caffd0a39832", + "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } + }, + "tokens": { + "color": { + "description": "Semantic color tokens extracted from CSS custom properties. Values are the default (light) theme.", + "tier": "semantic", + "values": { + "acme-surface": { + "value": "#ffffff", + "type": "color" + }, + "acme-wash": { + "value": "#f4f4f2", + "type": "color" + }, + "acme-line": { + "value": "#d9d7d2", + "type": "color" + }, + "acme-ink": { + "value": "#1e1d1a", + "type": "color" + }, + "acme-ink-dim": { + "value": "#6d6a63", + "type": "color" + }, + "acme-brand": { + "value": "#2f5d3a", + "type": "color" + }, + "acme-danger": { + "value": "#a3332b", + "type": "color" + }, + "acme-danger-wash": { + "value": "#f9e8e6", + "type": "color" + }, + "acme-ok": { + "value": "#2f5d3a", + "type": "color" + }, + "acme-ok-wash": { + "value": "#e8f1ea", + "type": "color" + }, + "acme-warn": { + "value": "#8a6d1f", + "type": "color" + }, + "acme-warn-wash": { + "value": "#f7f0dc", + "type": "color" + } + } + } + }, + "components": { + "action-button": { + "name": "ActionButton", + "description": "The one button. Tone carries intent; density carries rhythm.", + "x-componentKey": "ui/ActionButton", + "props": { + "tone": { + "type": "enum", + "values": [ + "brand", + "danger", + "quiet", + "outline", + "plain" + ], + "default": "brand" + }, + "density": { + "type": "enum", + "values": [ + "compact", + "cozy", + "comfortable" + ], + "default": "cozy", + "propRole": "dimension" + } + } + }, + "info-card": { + "name": "InfoCard", + "description": "Structured content container. Compose with InfoCardHeader, InfoCardBody, InfoCardFooter.", + "x-componentKey": "ui/InfoCard" + }, + "info-card-header": { + "name": "InfoCardHeader", + "description": "Title strip. Exactly one per InfoCard.", + "x-componentKey": "ui/InfoCardHeader" + }, + "info-card-body": { + "name": "InfoCardBody", + "description": "Main content region.", + "x-componentKey": "ui/InfoCardBody" + }, + "info-card-footer": { + "name": "InfoCardFooter", + "description": "Action strip. Optional; holds ActionButtons.", + "x-componentKey": "ui/InfoCardFooter" + }, + "mini-stepper": { + "name": "MiniStepper", + "description": "Compact progress indicator. Steps are data, not children.", + "x-componentKey": "ui/MiniStepper" + }, + "note-field": { + "name": "NoteField", + "description": "Multi-line text entry with a mandatory visible label.", + "x-componentKey": "ui/NoteField" + }, + "tag-pill": { + "name": "TagPill", + "description": "Status marker. Hue is semantic, not decorative.", + "x-componentKey": "ui/TagPill", + "props": { + "hue": { + "type": "enum", + "values": [ + "gray", + "green", + "red", + "amber" + ], + "default": "gray" + } + } + } + }, + "frameworkBindings": { + "react": { + "name": "React", + "components": { + "action-button": { + "importPath": "./components/ui/action-button", + "exportName": "ActionButton" + }, + "info-card": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCard" + }, + "info-card-header": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardHeader" + }, + "info-card-body": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardBody" + }, + "info-card-footer": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardFooter" + }, + "mini-stepper": { + "importPath": "./components/ui/mini-stepper", + "exportName": "MiniStepper" + }, + "note-field": { + "importPath": "./components/ui/note-field", + "exportName": "NoteField" + }, + "tag-pill": { + "importPath": "./components/ui/tag-pill", + "exportName": "TagPill" + } + } + } + }, + "themes": { + "dark": { + "name": "Dark", + "description": "Dark theme overrides extracted from the .dark CSS block.", + "overrides": { + "color.acme-surface": "#191815", + "color.acme-wash": "#23221e", + "color.acme-line": "#3a3833", + "color.acme-ink": "#e9e4d9", + "color.acme-ink-dim": "#97927f" + } + } + }, + "layout": { + "breakpoints": { + "sm": { + "minWidth": "640px", + "description": "Small devices and large phones in landscape." + }, + "md": { + "minWidth": "768px", + "description": "Tablets." + }, + "lg": { + "minWidth": "1024px", + "description": "Laptops and small desktops." + }, + "xl": { + "minWidth": "1280px", + "description": "Desktops." + }, + "2xl": { + "minWidth": "1536px", + "description": "Large desktops." + } + }, + "spacingScale": { + "baseUnit": "0.25rem", + "description": "Spacing follows a 0.25rem base unit; use integer multiples of the scale." + } + } +} diff --git a/packages/composer-core/fixtures/acme-ui.enriched.dspack.json b/packages/composer-core/fixtures/acme-ui.enriched.dspack.json new file mode 100644 index 0000000..1e9f704 --- /dev/null +++ b/packages/composer-core/fixtures/acme-ui.enriched.dspack.json @@ -0,0 +1,405 @@ +{ + "dspack": "0.4", + "name": "Acme UI", + "description": "Small non-canonical React component library used to evidence the composer spike.", + "version": "0.1.0", + "metadata": { + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", + "generatedAt": "2026-08-03T18:19:44.394Z", + "source": ".", + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "19a25ccff090fe44ae83e997cafbee2d49a6e9f3d2afec4557c86699340f6f45", + "components": "2f81ce51d4280a90e8532880a0ebb90085ba1c5aec0adf672c65531cb942cc3d", + "frameworkBindings": "1fa677ffda17075341c6b6534cc8c7cf2dbdb005e889fa4afb8155e0174100d4", + "themes": "4254e5c12a38bdee0fd751a638307fefd94088d47f5c01b0a2c0caffd0a39832", + "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } + }, + "tokens": { + "color": { + "description": "Semantic color tokens extracted from CSS custom properties. Values are the default (light) theme.", + "tier": "semantic", + "values": { + "acme-surface": { + "value": "#ffffff", + "type": "color" + }, + "acme-wash": { + "value": "#f4f4f2", + "type": "color" + }, + "acme-line": { + "value": "#d9d7d2", + "type": "color" + }, + "acme-ink": { + "value": "#1e1d1a", + "type": "color" + }, + "acme-ink-dim": { + "value": "#6d6a63", + "type": "color" + }, + "acme-brand": { + "value": "#2f5d3a", + "type": "color" + }, + "acme-danger": { + "value": "#a3332b", + "type": "color" + }, + "acme-danger-wash": { + "value": "#f9e8e6", + "type": "color" + }, + "acme-ok": { + "value": "#2f5d3a", + "type": "color" + }, + "acme-ok-wash": { + "value": "#e8f1ea", + "type": "color" + }, + "acme-warn": { + "value": "#8a6d1f", + "type": "color" + }, + "acme-warn-wash": { + "value": "#f7f0dc", + "type": "color" + } + } + } + }, + "components": { + "action-button": { + "name": "ActionButton", + "description": "The one button. Tone carries intent; density carries rhythm.", + "x-componentKey": "ui/ActionButton", + "props": { + "tone": { + "type": "enum", + "values": [ + "brand", + "danger", + "quiet", + "outline", + "plain" + ], + "default": "brand", + "propRole": "choice", + "description": "Semantic intent of the action: brand for primary flows, danger for destructive ones, quiet/outline/plain for secondary emphasis." + }, + "density": { + "type": "enum", + "values": [ + "compact", + "cozy", + "comfortable" + ], + "default": "cozy", + "propRole": "dimension" + }, + "label": { + "type": "string", + "description": "Visible label. ActionButton never renders bare icons without one.", + "required": true, + "propRole": "content" + } + }, + "whenToUse": "Any user-initiated action. Choose tone by consequence, not by color preference." + }, + "info-card": { + "name": "InfoCard", + "description": "Structured content container. Compose with InfoCardHeader, InfoCardBody, InfoCardFooter.", + "x-componentKey": "ui/InfoCard", + "composition": { + "subComponents": [ + { + "id": "info-card-header", + "name": "InfoCardHeader", + "description": "Title strip. Exactly one per InfoCard.", + "acceptsChildren": "components" + }, + { + "id": "info-card-title", + "name": "InfoCardTitle", + "description": "Heading text inside the header.", + "acceptsChildren": "text" + }, + { + "id": "info-card-body", + "name": "InfoCardBody", + "description": "Main content region.", + "acceptsChildren": "components" + }, + { + "id": "info-card-footer", + "name": "InfoCardFooter", + "description": "Action strip. Optional; holds ActionButtons.", + "acceptsChildren": "components" + } + ], + "notes": "Compose header (with title), body, and optional footer inside InfoCard." + }, + "whenToUse": "Grouping related status or summary content under one heading with optional actions." + }, + "mini-stepper": { + "name": "MiniStepper", + "description": "Compact progress indicator. Steps are data, not children.", + "x-componentKey": "ui/MiniStepper", + "props": { + "steps": { + "type": "array", + "description": "Ordered steps; each entry is free-form step data (label, done).", + "required": true, + "propRole": "content" + } + } + }, + "note-field": { + "name": "NoteField", + "description": "Multi-line text entry with a mandatory visible label.", + "x-componentKey": "ui/NoteField", + "props": { + "label": { + "type": "string", + "description": "Visible field label rendered above the textarea.", + "required": true, + "propRole": "content" + }, + "resizable": { + "type": "boolean", + "description": "Allow the user to drag-resize vertically.", + "default": false, + "propRole": "flag" + } + } + }, + "tag-pill": { + "name": "TagPill", + "description": "Status marker. Hue is semantic, not decorative.", + "x-componentKey": "ui/TagPill", + "props": { + "hue": { + "type": "enum", + "values": [ + "gray", + "green", + "red", + "amber" + ], + "default": "gray", + "propRole": "choice" + }, + "label": { + "type": "string", + "description": "Short status text; keep under three words.", + "required": true, + "propRole": "content" + } + }, + "whenToUse": "Marking a state (healthy, failed, pending) inline with content." + } + }, + "frameworkBindings": { + "react": { + "name": "React", + "components": { + "action-button": { + "importPath": "./components/ui/action-button", + "exportName": "ActionButton" + }, + "info-card": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCard" + }, + "info-card-header": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardHeader" + }, + "info-card-body": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardBody" + }, + "info-card-footer": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardFooter" + }, + "mini-stepper": { + "importPath": "./components/ui/mini-stepper", + "exportName": "MiniStepper" + }, + "note-field": { + "importPath": "./components/ui/note-field", + "exportName": "NoteField" + }, + "tag-pill": { + "importPath": "./components/ui/tag-pill", + "exportName": "TagPill" + } + } + } + }, + "themes": { + "dark": { + "name": "Dark", + "description": "Dark theme overrides extracted from the .dark CSS block.", + "overrides": { + "color.acme-surface": "#191815", + "color.acme-wash": "#23221e", + "color.acme-line": "#3a3833", + "color.acme-ink": "#e9e4d9", + "color.acme-ink-dim": "#97927f" + } + } + }, + "layout": { + "breakpoints": { + "sm": { + "minWidth": "640px", + "description": "Small devices and large phones in landscape." + }, + "md": { + "minWidth": "768px", + "description": "Tablets." + }, + "lg": { + "minWidth": "1024px", + "description": "Laptops and small desktops." + }, + "xl": { + "minWidth": "1280px", + "description": "Desktops." + }, + "2xl": { + "minWidth": "1536px", + "description": "Large desktops." + } + }, + "spacingScale": { + "baseUnit": "0.25rem", + "description": "Spacing follows a 0.25rem base unit; use integer multiples of the scale." + } + }, + "intents": [ + { + "id": "status-report", + "name": "Status report", + "description": "Summarize the state of a system or process for review, with at most one acknowledging action." + } + ], + "rules": [ + { + "id": "rule.status-report.info-card-required", + "type": "component-choice", + "severity": "must", + "appliesTo": { + "intents": [ + "status-report" + ] + }, + "require": [ + "info-card" + ], + "rationale": "A status report is a bounded summary. InfoCard is the only Acme container that binds a heading, content, and actions into one reviewable unit.", + "examples": [ + "ex.status-report-basic" + ] + }, + { + "id": "rule.action-button-label", + "type": "required-props", + "severity": "must", + "component": "action-button", + "requiredProps": [ + { + "prop": "label" + } + ], + "rationale": "ActionButton never renders bare icons; an unlabeled action cannot be reviewed, announced, or audited.", + "examples": [ + "ex.status-report-basic" + ] + } + ], + "examples": [ + { + "id": "ex.status-report-basic", + "intent": "status-report", + "name": "Basic deployment status", + "prompt": "Show the current deployment status with room for operator notes and an acknowledge action.", + "description": "InfoCard with a titled header, a status pill and notes field in the body, one acknowledging action.", + "surface": { + "dspackSurface": "0.1", + "system": "Acme UI", + "intent": "status-report", + "root": { + "component": "info-card", + "id": "report", + "children": [ + { + "component": "info-card-header", + "children": [ + { + "component": "info-card-title", + "text": "Deployment status" + } + ] + }, + { + "component": "info-card-body", + "children": [ + { + "component": "tag-pill", + "id": "state", + "props": { + "hue": "green", + "label": "Healthy" + } + }, + { + "component": "note-field", + "id": "notes", + "props": { + "label": "Operator notes", + "resizable": true + } + } + ] + }, + { + "component": "info-card-footer", + "children": [ + { + "component": "action-button", + "id": "ack", + "props": { + "tone": "brand", + "label": "Acknowledge" + } + } + ] + } + ] + } + } + } + ] +} diff --git a/packages/composer-core/package.json b/packages/composer-core/package.json new file mode 100644 index 0000000..fb2c144 --- /dev/null +++ b/packages/composer-core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@dspack-studio/composer-core", + "version": "0.1.0", + "private": true, + "description": "Pure data layer of the catalog composer: the project manifest schema, x-bootstrap ledger reading (WebCrypto, isomorphic), the normalized finding shape, and the adapter manifests binding the three existing seams (discovery / mapping / rendering) by reference. No protocol dependencies, no React, no Node-only APIs.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "zod": "3.25.76" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/packages/composer-core/src/adapters.ts b/packages/composer-core/src/adapters.ts new file mode 100644 index 0000000..872516a --- /dev/null +++ b/packages/composer-core/src/adapters.ts @@ -0,0 +1,80 @@ +/** + * ComposerAdapter manifests: one adapter = REFERENCES across the three seams + * that already exist in the ecosystem — discovery (dspack-export + * FrameworkAdapter), mapping (dspack-emit Profile), rendering (a2ui-ingest + * Registry). The composer binds; it never re-implements a seam. + * + * Manifests are pure data. Rendering names a registry by id — the APP maps + * ids to real registry modules (dynamic import), preserving the import- + * isolation rule (design-system imports only in their own renderer package; + * nothing here may import one). + */ + +export interface ComposerAdapter { + id: string; + displayName: string; + /** Absent = the project was imported with an existing contract, not bootstrapped. */ + discovery?: DiscoveryRef; + mapping: MappingRef; + /** Absent = wireframe-only preview (the Vue door: catalog, validation, and export are unaffected). */ + rendering?: RenderingRef; + drift?: DriftRef; +} + +export interface DiscoveryRef { + /** dspack-export FrameworkAdapter id (resolved by its own resolveAdapter). */ + frameworkAdapterId: "react" | "vue"; + /** Discovery runs Babel + react-docgen: Node-only, never in the browser. */ + runtime: "agent"; +} + +export interface MappingRef { + /** + * "scaffold" = mechanical draft via dspack-emit scaffoldProfile; + * a registry id string = clone a known profile as the seed. + */ + seed: "scaffold" | "astryx-profile" | "shadcn-profile"; +} + +export interface RenderingRef { + kind: "registry"; + /** Declared data so a future "vue" is a value, not a refactor. */ + target: "react"; + /** Registry id the app resolves: "astryx" | "shadcn" ("wireframe" is the universal fallback). */ + registryId: "astryx" | "shadcn"; +} + +export interface DriftRef { + /** Command template run by the agent (e.g. ["npx", "astryx", "component", "--json"]). */ + command: string[]; + runtime: "agent"; +} + +export const COMPOSER_ADAPTERS: Record = { + "react-generic": { + id: "react-generic", + displayName: "React (generic)", + discovery: { frameworkAdapterId: "react", runtime: "agent" }, + mapping: { seed: "scaffold" }, + // No rendering ref: an arbitrary React library previews as wireframe. + }, + astryx: { + id: "astryx", + displayName: "Astryx", + discovery: { frameworkAdapterId: "react", runtime: "agent" }, + mapping: { seed: "astryx-profile" }, + rendering: { kind: "registry", target: "react", registryId: "astryx" }, + drift: { command: ["npx", "astryx", "component", "--json"], runtime: "agent" }, + }, + shadcn: { + id: "shadcn", + displayName: "shadcn/ui", + discovery: { frameworkAdapterId: "react", runtime: "agent" }, + mapping: { seed: "shadcn-profile" }, + rendering: { kind: "registry", target: "react", registryId: "shadcn" }, + }, +}; + +export function composerAdapter(id: string): ComposerAdapter | undefined { + return COMPOSER_ADAPTERS[id]; +} diff --git a/packages/composer-core/src/composer-core.test.ts b/packages/composer-core/src/composer-core.test.ts new file mode 100644 index 0000000..7fe12f1 --- /dev/null +++ b/packages/composer-core/src/composer-core.test.ts @@ -0,0 +1,112 @@ +/** + * composer-core: manifest parsing, ledger reading (pinned against REAL + * dspack-export output), findings, adapter manifests. + * + * The ledger fixtures are actual dspack-export 0.3.0 artifacts from the + * composer spike: `acme-ui.dspack.json` is the pristine discovery output + * (every generated hash matches); `acme-ui.enriched.dspack.json` is the same + * document after human enrichment (components edited -> human-owned; + * governance authored -> human-authored). This pins our WebCrypto + * sectionHash to dspack-export's node:crypto sectionHash byte for byte. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseProjectManifest } from "./project.js"; +import { ledgerStatus, preservesLedger, sectionHash } from "./ledger.js"; +import { countBySeverity, finding } from "./findings.js"; +import { COMPOSER_ADAPTERS, composerAdapter } from "./adapters.js"; + +const fixture = (name: string) => + JSON.parse(readFileSync(fileURLToPath(new URL(`../fixtures/${name}`, import.meta.url)), "utf8")); + +const pristine = fixture("acme-ui.dspack.json"); +const enriched = fixture("acme-ui.enriched.dspack.json"); + +describe("project manifest", () => { + const valid = { + composerProject: "0.1", + name: "Acme UI", + adapter: "react-generic", + catalogIdBase: "https://acme.example/catalogs/acme-ui", + contractPath: "acme-ui.dspack.json", + profilePath: "acme.profile.json", + }; + + it("parses a valid manifest and applies defaults", () => { + const result = parseProjectManifest(valid); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.manifest.outDir).toBe("out"); + expect(result.manifest.previewRegistry).toBe("wireframe"); + } + }); + + it("rejects a non-https catalogIdBase with a pathed issue", () => { + const result = parseProjectManifest({ ...valid, catalogIdBase: "http://acme.example/c" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === "catalogIdBase")).toBe(true); + }); + + it("rejects unknown keys (strict manifest)", () => { + const result = parseProjectManifest({ ...valid, vibes: true }); + expect(result.ok).toBe(false); + }); +}); + +describe("ledger reading (pinned to real dspack-export output)", () => { + it("matches dspack-export's sectionHash on the pristine document", async () => { + // Every recorded hash must verify against the section content it hashes. + const recorded = pristine.metadata["x-bootstrap"].generated as Record; + for (const [section, hash] of Object.entries(recorded)) { + expect(await sectionHash(pristine[section]), section).toBe(hash); + } + }); + + it("reports pristine sections tool-owned and unbootstrapped governance absent", async () => { + const status = await ledgerStatus(pristine); + expect(status.hasLedger).toBe(true); + const byName = Object.fromEntries(status.sections.map((s) => [s.section, s.state])); + expect(byName.components).toBe("tool-owned"); + expect(byName.tokens).toBe("tool-owned"); + expect(byName.intents).toBe("absent"); + expect(status.awaitingAuthorship).toContain("intents"); + }); + + it("reports enrichment: edited components human-owned, authored governance human-authored", async () => { + const status = await ledgerStatus(enriched); + const byName = Object.fromEntries(status.sections.map((s) => [s.section, s.state])); + expect(byName.components).toBe("human-owned"); // props + composition edited after bootstrap + expect(byName.tokens).toBe("tool-owned"); // untouched + expect(byName.intents).toBe("human-authored"); // authored, never generated + expect(byName.rules).toBe("human-authored"); + }); + + it("preservesLedger guards ledger deletion, not content edits", () => { + expect(preservesLedger(pristine, enriched)).toBe(true); + const stripped = structuredClone(enriched); + delete stripped.metadata["x-bootstrap"]; + expect(preservesLedger(pristine, stripped)).toBe(false); + // A document that never had a ledger is unconstrained. + expect(preservesLedger({ metadata: {} }, { metadata: {} })).toBe(true); + }); +}); + +describe("findings", () => { + it("counts by severity", () => { + const counts = countBySeverity([ + finding("S2", "unknown-prop", "error", "$.root.props.x", "unknown prop"), + finding("fidelity", "lossy", "warn", "button.variant", "5 -> 4 projection"), + ]); + expect(counts).toEqual({ error: 1, warn: 1, info: 0 }); + }); +}); + +describe("adapter manifests", () => { + it("ships the initial trio with the documented seam bindings", () => { + expect(Object.keys(COMPOSER_ADAPTERS).sort()).toEqual(["astryx", "react-generic", "shadcn"]); + expect(composerAdapter("react-generic")?.rendering).toBeUndefined(); // wireframe fallback + expect(composerAdapter("shadcn")?.rendering?.registryId).toBe("shadcn"); + expect(composerAdapter("astryx")?.drift?.runtime).toBe("agent"); + }); +}); diff --git a/packages/composer-core/src/findings.ts b/packages/composer-core/src/findings.ts new file mode 100644 index 0000000..a6a122a --- /dev/null +++ b/packages/composer-core/src/findings.ts @@ -0,0 +1,52 @@ +/** + * The normalized finding shape every composer gate reports in. + * + * One shape for all of: dspack document validation (dspack-validate), surface + * lint S1/S2/S3 (dspack-gen/core), emit gates A1/A2/A3 + coverage + fidelity + * (dspack-emit), profile shape (loadProfile), registry coverage + * (planRegistry), and ledger integrity. `target` is a JSON path into the + * contract/profile, a surface node id, or a catalog component name — enough + * for the UI to deep-link every finding to its fix location. + */ + +export type FindingGate = + | "manifest" + | "document" + | "profile" + | "S1" + | "S2" + | "S3" + | "A1" + | "A2" + | "A3" + | "coverage" + | "fidelity" + | "registry" + | "ledger"; + +export type FindingSeverity = "error" | "warn" | "info"; + +export interface ComposerFinding { + gate: FindingGate; + code: string; + severity: FindingSeverity; + /** JSON path, surface node id, component name, or "" for document-level. */ + target: string; + message: string; +} + +export function finding( + gate: FindingGate, + code: string, + severity: FindingSeverity, + target: string, + message: string, +): ComposerFinding { + return { gate, code, severity, target, message }; +} + +export function countBySeverity(findings: ComposerFinding[]): Record { + const counts: Record = { error: 0, warn: 0, info: 0 }; + for (const f of findings) counts[f.severity]++; + return counts; +} diff --git a/packages/composer-core/src/index.ts b/packages/composer-core/src/index.ts new file mode 100644 index 0000000..9726a26 --- /dev/null +++ b/packages/composer-core/src/index.ts @@ -0,0 +1,26 @@ +export { + PROJECT_VERSION, + projectManifestSchema, + parseProjectManifest, + type ProjectManifest, + type ManifestIssue, + type ParseManifestResult, +} from "./project.js"; +export { + sectionHash, + ledgerStatus, + preservesLedger, + type LedgerStatus, + type SectionStatus, + type SectionState, +} from "./ledger.js"; +export { finding, countBySeverity, type ComposerFinding, type FindingGate, type FindingSeverity } from "./findings.js"; +export { + COMPOSER_ADAPTERS, + composerAdapter, + type ComposerAdapter, + type DiscoveryRef, + type MappingRef, + type RenderingRef, + type DriftRef, +} from "./adapters.js"; diff --git a/packages/composer-core/src/ledger.ts b/packages/composer-core/src/ledger.ts new file mode 100644 index 0000000..9aca806 --- /dev/null +++ b/packages/composer-core/src/ledger.ts @@ -0,0 +1,100 @@ +/** + * Reading the dspack-export `metadata["x-bootstrap"]` ownership ledger. + * + * The ledger's semantics belong to dspack-export (src/emit/bootstrap.ts): + * `generated` maps each tool-generated section to + * sha256(JSON.stringify(section)); `awaitingAuthorship` lists what the tool + * never writes. This module only READS that contract — a section whose + * recorded hash still matches is tool-owned; a mismatch means a human edited + * it (permanently human-owned, per the refusal table); sections without a + * recorded hash are human-authored or absent. + * + * Hashing uses WebCrypto (crypto.subtle), available in browsers and Node 20+, + * so the same code runs in the composer app and the agent. Fidelity to + * dspack-export's sectionHash is pinned by test against a real dspack-export + * output fixture. + */ + +export type SectionState = + | "tool-owned" // recorded hash matches current content + | "human-owned" // recorded hash no longer matches (edited after bootstrap) + | "human-authored" // present with no recorded hash (authored, never generated) + | "absent"; // not in the document + +export interface SectionStatus { + section: string; + state: SectionState; +} + +export interface LedgerStatus { + /** True when metadata["x-bootstrap"] exists (bootstrap provenance available). */ + hasLedger: boolean; + sections: SectionStatus[]; + /** The authorship todo list, verbatim from the ledger. */ + awaitingAuthorship: string[]; +} + +interface BootstrapLedger { + spec?: string; + generated?: Record; + awaitingAuthorship?: string[]; +} + +/** sha256 hex of JSON.stringify(value) — dspack-export's sectionHash, on WebCrypto. */ +export async function sectionHash(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** Sections the ledger can record plus the governance sections it never will. */ +const REPORTED_SECTIONS = [ + "tokens", + "components", + "frameworkBindings", + "themes", + "layout", + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", +]; + +export async function ledgerStatus(doc: Record): Promise { + const metadata = (doc.metadata ?? {}) as Record; + const ledger = (metadata["x-bootstrap"] ?? undefined) as BootstrapLedger | undefined; + const generated = ledger?.generated ?? {}; + + const sections: SectionStatus[] = []; + for (const section of REPORTED_SECTIONS) { + const value = doc[section]; + const recorded = generated[section]; + if (value === undefined) { + sections.push({ section, state: "absent" }); + } else if (recorded === undefined) { + sections.push({ section, state: "human-authored" }); + } else { + const current = await sectionHash(value); + sections.push({ section, state: current === recorded ? "tool-owned" : "human-owned" }); + } + } + + return { + hasLedger: ledger !== undefined, + sections, + awaitingAuthorship: [...(ledger?.awaitingAuthorship ?? [])], + }; +} + +/** + * Guard for saves: a write may never DROP the ledger. (Editing content is + * fine — that is how a section becomes human-owned; deleting the provenance + * record is not.) + */ +export function preservesLedger(existing: Record, incoming: Record): boolean { + const had = ((existing.metadata ?? {}) as Record)["x-bootstrap"] !== undefined; + if (!had) return true; + return ((incoming.metadata ?? {}) as Record)["x-bootstrap"] !== undefined; +} diff --git a/packages/composer-core/src/project.ts b/packages/composer-core/src/project.ts new file mode 100644 index 0000000..0a377f5 --- /dev/null +++ b/packages/composer-core/src/project.ts @@ -0,0 +1,52 @@ +/** + * The composer project manifest (`project.json` in the user's repository). + * + * A project is FILES in the user's repo; this manifest only binds them + * together and names the adapter. Paths are relative to the manifest's + * directory. The contract and profile stay the governed editable sources; + * everything under `out` is derived and regenerated. + */ +import { z } from "zod"; + +export const PROJECT_VERSION = "0.1"; + +export const projectManifestSchema = z + .object({ + composerProject: z.literal(PROJECT_VERSION), + name: z.string().min(1), + /** ComposerAdapter id (see adapters.ts): "react-generic" | "astryx" | "shadcn". */ + adapter: z.string().min(1), + /** Catalog identity root; becomes the profile's catalogIdBase. */ + catalogIdBase: z.string().url().startsWith("https://"), + contractPath: z.string().min(1), + profilePath: z.string().min(1), + /** dspack-export config used by discovery; absent = project was imported, not bootstrapped. */ + exportConfigPath: z.string().min(1).optional(), + /** Directory of authored .dsurface.json scenarios (in addition to contract examples). */ + surfacesDir: z.string().min(1).optional(), + /** Derived-artifact directory (catalogs, reports, emitted surfaces). */ + outDir: z.string().min(1).default("out"), + /** Preview registry choice; the app maps ids to real registries. */ + previewRegistry: z.enum(["wireframe", "astryx", "shadcn"]).default("wireframe"), + }) + .strict(); + +export type ProjectManifest = z.infer; + +export interface ManifestIssue { + path: string; + message: string; +} + +export type ParseManifestResult = + | { ok: true; manifest: ProjectManifest } + | { ok: false; issues: ManifestIssue[] }; + +export function parseProjectManifest(json: unknown): ParseManifestResult { + const result = projectManifestSchema.safeParse(json); + if (result.success) return { ok: true, manifest: result.data }; + return { + ok: false, + issues: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })), + }; +} diff --git a/packages/composer-core/tsconfig.json b/packages/composer-core/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/composer-core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/wireframe-renderers/fixtures/acme.catalog.v0_9_1.json b/packages/wireframe-renderers/fixtures/acme.catalog.v0_9_1.json new file mode 100644 index 0000000..cefb68f --- /dev/null +++ b/packages/wireframe-renderers/fixtures/acme.catalog.v0_9_1.json @@ -0,0 +1,625 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://acme.example/catalogs/acme-ui/v0_9_1/catalog.json", + "title": "Acme UI — A2UI catalog (compiled from dspack)", + "description": "A2UI catalog compiled from the Acme UI dspack v0.4 contract. Component shapes and variant enums are projected onto A2UI-conventional names so existing React registries can render them.", + "catalogId": "https://acme.example/catalogs/acme-ui/v0_9_1/catalog.json", + "components": { + "Button": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "action": { + "$ref": "#/$defs/Action", + "description": "The interaction dispatched when the button is activated." + }, + "label": { + "type": "string", + "description": "Visible label. Acme buttons never render bare icons.", + "x-dspack-source": { + "sourceProp": "label", + "sourceType": "string" + } + }, + "variant": { + "type": "string", + "description": "Button intent, projected from Acme tones onto the registry-conventional variant vocabulary.", + "enum": [ + "primary", + "secondary", + "ghost", + "destructive" + ], + "default": "primary", + "x-dspack-source": { + "sourceProp": "tone", + "sourceType": "enum", + "sourceEnum": [ + "brand", + "danger", + "quiet", + "outline", + "plain" + ], + "valueMap": { + "brand": "primary", + "danger": "destructive", + "outline": "secondary", + "quiet": "ghost", + "plain": "ghost" + } + } + }, + "size": { + "type": "string", + "description": "Control rhythm, projected one to one from Acme densities.", + "enum": [ + "sm", + "md", + "lg" + ], + "default": "md", + "x-dspack-source": { + "sourceProp": "density", + "sourceType": "enum", + "sourceEnum": [ + "compact", + "cozy", + "comfortable" + ], + "valueMap": { + "compact": "sm", + "cozy": "md", + "comfortable": "lg" + } + } + } + }, + "required": [ + "component", + "label", + "action" + ], + "description": "The one button. Tone carries intent; density carries rhythm.", + "x-dspack": { + "sourceId": "action-button" + } + } + ], + "unevaluatedProperties": false + }, + "Card": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "#/$defs/ComponentId", + "description": "The ID of the single child component. Wrap multiple elements in a Column and pass its ID." + } + }, + "required": [ + "component", + "child" + ], + "description": "Structured content container. Compose with InfoCardHeader, InfoCardBody, InfoCardFooter.", + "x-dspack": { + "sourceId": "info-card" + } + } + ], + "unevaluatedProperties": false + }, + "Badge": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "type": "string", + "description": "Short status text.", + "x-dspack-source": { + "sourceProp": "label", + "sourceType": "string" + } + }, + "variant": { + "type": "string", + "description": "Status hue, projected one to one onto the registry-conventional badge variants.", + "enum": [ + "secondary", + "default", + "destructive", + "outline" + ], + "default": "secondary", + "x-dspack-source": { + "sourceProp": "hue", + "sourceType": "enum", + "sourceEnum": [ + "gray", + "green", + "red", + "amber" + ], + "valueMap": { + "gray": "secondary", + "green": "default", + "red": "destructive", + "amber": "outline" + } + } + } + }, + "required": [ + "component", + "label" + ], + "description": "Status marker. Hue is semantic, not decorative.", + "x-dspack": { + "sourceId": "tag-pill" + } + } + ], + "unevaluatedProperties": false + }, + "TextField": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/Checkable" + }, + { + "type": "object", + "properties": { + "component": { + "const": "TextField" + }, + "value": { + "$ref": "#/$defs/DynamicString", + "description": "The bound value of the field." + }, + "label": { + "type": "string", + "description": "Visible field label.", + "x-dspack-source": { + "sourceProp": "label", + "sourceType": "string" + } + }, + "resizable": { + "type": "boolean", + "description": "Allow the user to drag-resize vertically.", + "x-dspack-source": { + "sourceProp": "resizable", + "sourceType": "boolean" + } + } + }, + "required": [ + "component", + "label" + ], + "description": "Multi-line text entry with a mandatory visible label.", + "x-dspack": { + "sourceId": "note-field" + } + } + ], + "unevaluatedProperties": false + }, + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "#/$defs/DynamicString", + "description": "The text content to display." + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ], + "default": "body" + } + }, + "required": [ + "component", + "text" + ], + "description": "Displays text content. Synthesized A2UI content primitive (not in the Acme contract)." + } + ], + "unevaluatedProperties": false + }, + "Column": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/ComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "$ref": "#/$defs/ChildList", + "description": "Child component IDs (or a template)." + } + }, + "required": [ + "component", + "children" + ], + "description": "Arranges children vertically. Synthesized A2UI structural primitive (the Acme contract has no layout component)." + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "ComponentId": { + "type": "string", + "description": "The unique identifier for a component, used for both definitions and references within the same surface." + }, + "AccessibilityAttributes": { + "type": "object", + "description": "Attributes to enhance accessibility when using assistive technologies like screen readers.", + "properties": { + "label": { + "$ref": "#/$defs/DynamicString" + }, + "description": { + "$ref": "#/$defs/DynamicString" + } + } + }, + "ComponentCommon": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/ComponentId" + }, + "accessibility": { + "$ref": "#/$defs/AccessibilityAttributes" + } + }, + "required": [ + "id" + ] + }, + "DataBinding": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "A JSON Pointer path to a value in the data model." + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "FunctionCall": { + "type": "object", + "description": "Invokes a named function on the client.", + "properties": { + "call": { + "type": "string", + "description": "The name of the function to call." + }, + "args": { + "type": "object", + "description": "Arguments passed to the function.", + "additionalProperties": true + }, + "returnType": { + "type": "string", + "description": "The expected return type of the function call.", + "enum": [ + "string", + "number", + "boolean", + "array", + "object", + "any", + "void" + ], + "default": "boolean" + } + }, + "required": [ + "call" + ] + }, + "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" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicString": { + "description": "Represents a string", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "string" + } + } + } + ] + } + ] + }, + "DynamicBoolean": { + "description": "Represents a boolean", + "oneOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "boolean" + } + } + } + ] + } + ] + }, + "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.", + "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 + } + ] + }, + "CheckRule": { + "type": "object", + "description": "A single validation rule applied to an input component.", + "properties": { + "condition": { + "$ref": "#/$defs/DynamicBoolean" + }, + "message": { + "type": "string", + "description": "The error message to display if the check fails." + } + }, + "required": [ + "condition", + "message" + ], + "additionalProperties": false + }, + "Checkable": { + "type": "object", + "description": "Properties for components that support client-side checks.", + "properties": { + "checks": { + "type": "array", + "description": "A list of checks to perform.", + "items": { + "$ref": "#/$defs/CheckRule" + } + } + } + }, + "Action": { + "description": "Defines an interaction handler that can either trigger a server-side event or execute a local client-side function.", + "oneOf": [ + { + "type": "object", + "description": "Triggers a server-side event.", + "properties": { + "event": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/DynamicValue" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "event" + ], + "additionalProperties": false + }, + { + "type": "object", + "description": "Executes a local client-side function.", + "properties": { + "functionCall": { + "$ref": "#/$defs/FunctionCall" + } + }, + "required": [ + "functionCall" + ], + "additionalProperties": false + } + ] + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/Badge" + }, + { + "$ref": "#/components/TextField" + }, + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/Column" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$", + "default": "#2f5d3a", + "x-dspack-source": "tokens.color.acme-brand" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true, + "x-dspack-tokens": { + "color": { + "acme-surface": "#ffffff", + "acme-wash": "#f4f4f2", + "acme-line": "#d9d7d2", + "acme-ink": "#1e1d1a", + "acme-ink-dim": "#6d6a63", + "acme-brand": "#2f5d3a", + "acme-danger": "#a3332b", + "acme-danger-wash": "#f9e8e6", + "acme-ok": "#2f5d3a", + "acme-ok-wash": "#e8f1ea", + "acme-warn": "#8a6d1f", + "acme-warn-wash": "#f7f0dc" + } + } + } + } +} \ No newline at end of file diff --git a/packages/wireframe-renderers/package.json b/packages/wireframe-renderers/package.json new file mode 100644 index 0000000..87eaa55 --- /dev/null +++ b/packages/wireframe-renderers/package.json @@ -0,0 +1,28 @@ +{ + "name": "@dspack-studio/wireframe-renderers", + "version": "0.1.0", + "private": true, + "description": "The universal preview fallback: a Registry whose visuals are derived from the catalog itself — every catalog name renders as an honest labeled wireframe (scalar props as a table, child refs as nested slots, actions as inert buttons). Zero user code executes; works for any conformant catalog, including targets with no native renderer.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@dspack-studio/a2ui-ingest": "workspace:*" + }, + "peerDependencies": { + "react": ">=19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + } +} diff --git a/packages/wireframe-renderers/src/classify-props.ts b/packages/wireframe-renderers/src/classify-props.ts new file mode 100644 index 0000000..128c202 --- /dev/null +++ b/packages/wireframe-renderers/src/classify-props.ts @@ -0,0 +1,51 @@ +/** + * Light per-prop classification for wireframe rendering, derived from the + * catalog JSON alone. This is presentation-only triage (which props are child + * references, actions, or displayable values) — the ACCEPTED schema still + * comes from buildComponentApi in a2ui-ingest; nothing here widens or narrows + * the vocabulary. + * + * Emitted catalogs compose each component as + * allOf: [{$ref ComponentCommon}, {$ref Checkable?}, { properties: {...} }]; + * classification keys on each property's $ref tail (the same Tier-1 signal + * buildComponentApi uses), so it works on any conformant inlined catalog. + */ + +export type WirePropKind = "child" | "children" | "action" | "value"; + +export interface WireProp { + name: string; + kind: WirePropKind; +} + +const SKIP = new Set(["component", "id", "accessibility", "weight"]); + +function refTail(schema: unknown): string | undefined { + const ref = (schema as { $ref?: unknown })?.$ref; + if (typeof ref !== "string") return undefined; + const tail = ref.split("/").at(-1); + return tail; +} + +export function classifyProps(catalog: Record, name: string): WireProp[] { + const component = catalog.components?.[name]; + if (!component) return []; + const layers: Array> = []; + for (const layer of component.allOf ?? [component]) { + const props = (layer as { properties?: Record }).properties; + if (props) layers.push(props); + } + const out: WireProp[] = []; + const seen = new Set(); + for (const props of layers) { + for (const [propName, schema] of Object.entries(props)) { + if (SKIP.has(propName) || seen.has(propName)) continue; + seen.add(propName); + const tail = refTail(schema); + const kind: WirePropKind = + tail === "ComponentId" ? "child" : tail === "ChildList" ? "children" : tail === "Action" ? "action" : "value"; + out.push({ name: propName, kind }); + } + } + return out; +} diff --git a/packages/wireframe-renderers/src/index.ts b/packages/wireframe-renderers/src/index.ts new file mode 100644 index 0000000..305e867 --- /dev/null +++ b/packages/wireframe-renderers/src/index.ts @@ -0,0 +1,2 @@ +export { wireframeRegistryFor } from "./registry.js"; +export { classifyProps, type WireProp, type WirePropKind } from "./classify-props.js"; diff --git a/packages/wireframe-renderers/src/registry.tsx b/packages/wireframe-renderers/src/registry.tsx new file mode 100644 index 0000000..722da93 --- /dev/null +++ b/packages/wireframe-renderers/src/registry.tsx @@ -0,0 +1,89 @@ +/** + * The wireframe registry: visuals derived from the catalog itself. + * + * `wireframeRegistryFor(catalogJson)` builds a Registry entry for EVERY + * catalog name — a labeled outline box showing scalar props as a mini table, + * rendering ComponentId/ChildList props as nested slots via buildChild, and + * Action props as inert buttons. It is the honest universal preview: any + * conformant catalog renders immediately, no user code executes, and targets + * with no native renderer (a future Vue library) degrade here instead of + * failing. + * + * The invariant holds by construction: the registry is keyed off the + * catalog's own names (a registry can never add vocabulary), and the accepted + * schema still comes from buildComponentApi via buildCatalog. + */ +import type { CSSProperties, FC } from "react"; +import { withProvenance, type Registry } from "@dspack-studio/a2ui-ingest"; +import { classifyProps, type WireProp } from "./classify-props.js"; + +const box: CSSProperties = { + border: "1px dashed var(--line, #8884)", + borderRadius: 6, + padding: "8px 10px", + margin: 2, + fontFamily: "var(--mono, ui-monospace, monospace)", + fontSize: 12, + color: "var(--fg, inherit)", + background: "transparent", +}; + +const nameStyle: CSSProperties = { opacity: 0.65, letterSpacing: "0.04em", fontSize: 10, textTransform: "uppercase" }; +const valueStyle: CSSProperties = { display: "flex", gap: 6, flexWrap: "wrap", margin: "2px 0" }; +const chip: CSSProperties = { border: "1px solid var(--line, #8884)", borderRadius: 4, padding: "0 4px", opacity: 0.9 }; +const actionStyle: CSSProperties = { ...chip, cursor: "not-allowed", background: "var(--bg-1, transparent)" }; + +function display(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function makeWireframe(name: string, wireProps: WireProp[]): FC { + const valueProps = wireProps.filter((p) => p.kind === "value"); + const childProps = wireProps.filter((p) => p.kind === "child"); + const childrenProps = wireProps.filter((p) => p.kind === "children"); + const actionProps = wireProps.filter((p) => p.kind === "action"); + + const Wireframe: FC = ({ props, buildChild }) => ( +
+
{name}
+ {valueProps.some((p) => props?.[p.name] !== undefined) && ( +
+ {valueProps + .filter((p) => props?.[p.name] !== undefined) + .map((p) => ( + + {p.name}={display(props[p.name])} + + ))} +
+ )} + {actionProps + .filter((p) => props?.[p.name] !== undefined) + .map((p) => ( + + ))} + {childProps.map((p) => + props?.[p.name] && buildChild ?
{buildChild(props[p.name])}
: null, + )} + {childrenProps.map((p) => { + const ids = props?.[p.name]; + if (!Array.isArray(ids) || !buildChild) return null; + return
{ids.map((id: string) => buildChild(id))}
; + })} +
+ ); + return Wireframe; +} + +/** Build the wireframe Registry for one catalog: every name gets a visual. */ +export function wireframeRegistryFor(catalog: Record): Registry { + const custom: Record> = {}; + for (const name of Object.keys(catalog.components ?? {})) { + custom[name] = withProvenance(name, makeWireframe(name, classifyProps(catalog, name))); + } + return { reuseBasic: new Set(), custom }; +} diff --git a/packages/wireframe-renderers/src/wireframe.test.tsx b/packages/wireframe-renderers/src/wireframe.test.tsx new file mode 100644 index 0000000..b6c7601 --- /dev/null +++ b/packages/wireframe-renderers/src/wireframe.test.tsx @@ -0,0 +1,72 @@ +/** + * The wireframe registry against a REAL emitted catalog (the acme spike + * catalog is regenerated as the composer demo's build artifact; here a + * committed copy of its v0.9.1 form from composer-core's fixtures pipeline). + * + * Verified properties: every catalog name gets a visual (planRegistry + * reports zero unimplemented); child/children/action props are classified + * from the catalog's $refs; a wireframe renders without executing any + * user code (static markup contains the component name and scalar props). + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement } from "react"; +import { planRegistry } from "@dspack-studio/a2ui-ingest"; +import { classifyProps } from "./classify-props.js"; +import { wireframeRegistryFor } from "./registry.js"; + +// The canonical shadcn catalog shape is stable and committed in contracts' +// build output during CI; for a self-contained unit test we use the acme +// fixture contract's emitted catalog captured under fixtures/. +const catalog = JSON.parse( + readFileSync(fileURLToPath(new URL("../fixtures/acme.catalog.v0_9_1.json", import.meta.url)), "utf8"), +); + +describe("wireframeRegistryFor", () => { + it("covers every catalog name (zero unimplemented)", () => { + const registry = wireframeRegistryFor(catalog); + const names = Object.keys(catalog.components); + const plan = planRegistry(names, registry); + expect(plan.unimplemented).toEqual([]); + expect(Object.keys(registry.custom).sort()).toEqual([...names].sort()); + }); + + it("classifies child/children/action props from catalog $refs", () => { + const button = Object.fromEntries(classifyProps(catalog, "Button").map((p) => [p.name, p.kind])); + expect(button.action).toBe("action"); + expect(button.label).toBe("value"); + const card = Object.fromEntries(classifyProps(catalog, "Card").map((p) => [p.name, p.kind])); + expect(card.child).toBe("child"); + const column = Object.fromEntries(classifyProps(catalog, "Column").map((p) => [p.name, p.kind])); + expect(column.children).toBe("children"); + }); + + it("renders a labeled wireframe with scalar props and an inert action", () => { + const registry = wireframeRegistryFor(catalog); + const html = renderToStaticMarkup( + createElement(registry.custom.Button, { + props: { label: "Acknowledge", variant: "primary", action: { event: { name: "ack", context: {} } } }, + buildChild: () => null, + }), + ); + expect(html).toContain("Button"); + expect(html).toContain("label=Acknowledge"); + expect(html).toContain("variant=primary"); + expect(html).toContain("action: action"); + expect(html).toContain("disabled"); + expect(html).toContain("data-a2ui-component"); // provenance wrapper intact + }); + + it("renders child references through buildChild without executing user code", () => { + const registry = wireframeRegistryFor(catalog); + const html = renderToStaticMarkup( + createElement(registry.custom.Card, { + props: { child: "inner" }, + buildChild: (id: string) => createElement("em", { key: id }, `child:${id}`), + }), + ); + expect(html).toContain("child:inner"); + }); +}); diff --git a/packages/wireframe-renderers/tsconfig.json b/packages/wireframe-renderers/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/wireframe-renderers/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39acdc2..e24e2db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,19 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/composer-core: + dependencies: + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0) + packages/contracts: dependencies: '@aestheticfunction/dspack-emit': @@ -298,6 +311,28 @@ importers: specifier: ^3.0.0 version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0) + packages/wireframe-renderers: + dependencies: + '@dspack-studio/a2ui-ingest': + specifier: workspace:* + version: link:../a2ui-ingest + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0) + packages: '@a2ui/markdown-it@0.0.4': From a8e471443f62de64927296f7eaf296c9c90ff550 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Mon, 3 Aug 2026 14:50:05 -0400 Subject: [PATCH 2/6] feat(agent): composer project routes over a local project directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /project/{connect,discover,emit,validate,save,run} — thin orchestration over published packages; the agent parses nothing itself. connect returns manifest + ledger states + surface inventory; discover shells the dspack-export CLI (its refusal table speaks verbatim); emit loads the JSON profile (loadProfile), emits every surface, runs A1-A3 for both A2UI versions, writes out/, and reports coverage/fidelity/warnings as normalized findings; validate shells dspack-validate and runs gen/core lintSurface; save is shape-gated and refuses to drop the x-bootstrap ledger; run streams AG-UI SSE generation under the PROJECT contract + profile (the scoped vocabulary from the grammar-ceiling evidence). New import-isolation rule, mirroring @a2ui/*-only-in-a2ui-ingest: @aestheticfunction/dspack-export is imported/spawned only in apps/agent. Ships apps/composer/demo-project — the Acme UI fixture: a real non-canonical 5-component React library, dspack-export-bootstrapped, human-enriched (props discovery cannot extract, compound composition, owner-authored governance), with a JSON profile exercising valueMap projections, subFlatten, and a declared casualty. TEMPORARY (paired-PR protocol): pnpm override points @aestheticfunction/dspack-emit at the local feat/profile-as-data build; replaced by ^0.4.0 from npm once dspack-emit#24 releases. 7 new route tests run the real gates against a temp copy of the demo project (27/27 agent tests green). Co-Authored-By: Claude Fable 5 --- apps/agent/package.json | 6 +- apps/agent/src/project.test.ts | 125 +++++ apps/agent/src/project.ts | 445 ++++++++++++++++++ apps/agent/src/server.ts | 5 + apps/composer/demo-project/.gitignore | 1 + .../composer/demo-project/acme-ui.dspack.json | 405 ++++++++++++++++ apps/composer/demo-project/acme.profile.json | 179 +++++++ .../components/ui/action-button.tsx | 47 ++ .../demo-project/components/ui/info-card.tsx | 41 ++ .../components/ui/mini-stepper.tsx | 23 + .../demo-project/components/ui/note-field.tsx | 37 ++ .../demo-project/components/ui/tag-pill.tsx | 33 ++ .../demo-project/dspack-export.config.json | 10 + apps/composer/demo-project/lib/utils.ts | 3 + apps/composer/demo-project/project.json | 12 + apps/composer/demo-project/styles/globals.css | 23 + .../surfaces/uses-casualty.dsurface.json | 26 + apps/composer/demo-project/tsconfig.json | 16 + package.json | 5 + pnpm-lock.yaml | 146 +++++- 20 files changed, 1572 insertions(+), 16 deletions(-) create mode 100644 apps/agent/src/project.test.ts create mode 100644 apps/agent/src/project.ts create mode 100644 apps/composer/demo-project/.gitignore create mode 100644 apps/composer/demo-project/acme-ui.dspack.json create mode 100644 apps/composer/demo-project/acme.profile.json create mode 100644 apps/composer/demo-project/components/ui/action-button.tsx create mode 100644 apps/composer/demo-project/components/ui/info-card.tsx create mode 100644 apps/composer/demo-project/components/ui/mini-stepper.tsx create mode 100644 apps/composer/demo-project/components/ui/note-field.tsx create mode 100644 apps/composer/demo-project/components/ui/tag-pill.tsx create mode 100644 apps/composer/demo-project/dspack-export.config.json create mode 100644 apps/composer/demo-project/lib/utils.ts create mode 100644 apps/composer/demo-project/project.json create mode 100644 apps/composer/demo-project/styles/globals.css create mode 100644 apps/composer/demo-project/surfaces/uses-casualty.dsurface.json create mode 100644 apps/composer/demo-project/tsconfig.json diff --git a/apps/agent/package.json b/apps/agent/package.json index 9603331..a7a2dff 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -16,7 +16,11 @@ "@aestheticfunction/dspack-gen": "^0.1.2", "@dspack-studio/agui-bridge": "workspace:*", "@dspack-studio/contracts": "workspace:*", - "@dspack-studio/replay": "workspace:*" + "@dspack-studio/replay": "workspace:*", + "@aestheticfunction/dspack-emit": "^0.3.2", + "@aestheticfunction/dspack-export": "^0.3.0", + "@aestheticfunction/dspack-spec": "^0.4.1", + "@dspack-studio/composer-core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/apps/agent/src/project.test.ts b/apps/agent/src/project.test.ts new file mode 100644 index 0000000..ea1572b --- /dev/null +++ b/apps/agent/src/project.test.ts @@ -0,0 +1,125 @@ +/** + * Composer project routes, exercised against a temp copy of the shipped demo + * project (apps/composer/demo-project — a REAL non-canonical contract + * bootstrapped by dspack-export and human-enriched, with a JSON profile). + * + * The routes are thin orchestration over published packages; these tests pin + * the orchestration: connect reports the ledger states, emit runs the real + * gates and reports the casualty surface's refusal as a finding, validate + * distinguishes contract vocabulary (mini-stepper IS in the contract, S2 + * passes) from profile casualties (emit refuses it), and save enforces + * ledger preservation. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { cpSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ServerResponse } from "node:http"; +import { handleProjectRoute } from "./project.js"; + +const demoProject = fileURLToPath(new URL("../../composer/demo-project", import.meta.url)); + +let root: string; +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "composer-project-")); + cpSync(demoProject, root, { recursive: true }); +}); + +type Reply = { status: number; payload: any }; + +async function call(route: string, body: Record): Promise { + let reply: Reply | undefined; + const json = (_res: unknown, status: number, payload: unknown) => { + reply = { status, payload }; + }; + const handled = await handleProjectRoute( + `/project/${route}`, + body, + {} as ServerResponse, + {}, + undefined, + json as never, + ); + expect(handled).toBe(true); + expect(reply, `route '${route}' must reply`).toBeDefined(); + return reply!; +} + +describe("connect", () => { + it("returns manifest, ledger states, and the surface inventory", async () => { + const { status, payload } = await call("connect", { path: root }); + expect(status).toBe(200); + expect(payload.manifest.name).toBe("Acme UI"); + const byName = Object.fromEntries(payload.ledger.sections.map((s: any) => [s.section, s.state])); + expect(byName.components).toBe("human-owned"); // enriched after bootstrap + expect(byName.tokens).toBe("tool-owned"); + expect(byName.rules).toBe("human-authored"); + expect(payload.surfaces).toContain("ex.status-report-basic"); + expect(payload.surfaces).toContain("uses-casualty"); + expect(payload.profileIssue).toBeNull(); + }); + + it("refuses a relative path and a directory without project.json", async () => { + expect((await call("connect", { path: "relative/nope" })).status).toBe(400); + expect((await call("connect", { path: tmpdir() })).status).toBe(404); + }); +}); + +describe("emit", () => { + it("runs the real gates, writes out/, and reports the casualty refusal as a finding", async () => { + const { status, payload } = await call("emit", { path: root }); + expect(status).toBe(200); + expect(Object.keys(payload.catalog.components)).toEqual(["Button", "Card", "Badge", "TextField", "Text", "Column"]); + // The good example emits; the casualty surface refuses with the authored reason. + const casualty = payload.findings.find((f: any) => f.gate === "A3" && f.code === "emit-surface"); + expect(casualty.target).toBe("uses-casualty"); + expect(casualty.message).toContain("declared casualty"); + expect(casualty.message).toContain("dropdown-menu casualty".split(" ")[0] === "dropdown-menu" ? "steps" : "steps"); + // Catalogs + reports land in out/. + const catalog = JSON.parse(readFileSync(join(root, "out", "catalog.v0_9_1.json"), "utf8")); + expect(catalog.catalogId).toContain("https://acme.example/catalogs/acme-ui"); + // ok is false because one surface refused? No: ok reflects catalog gates. + expect(payload.ok).toBe(true); + }); +}); + +describe("validate", () => { + it("passes the contract harness and distinguishes vocabulary from profile casualties", async () => { + const { status, payload } = await call("validate", { path: root }); + expect(status).toBe(200); + // mini-stepper IS contract vocabulary: S2 passes for the casualty surface; + // its refusal is emit-time (profile), not lint-time (contract). + expect(payload.findings.filter((f: any) => f.gate === "document")).toEqual([]); + expect(payload.findings.filter((f: any) => f.severity === "error")).toEqual([]); + expect(payload.ok).toBe(true); + }); +}); + +describe("save", () => { + it("refuses dropping the bootstrap ledger", async () => { + const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + const stripped = structuredClone(contract); + delete stripped.metadata["x-bootstrap"]; + const { payload } = await call("save", { path: root, kind: "contract", document: stripped }); + expect(payload.ok).toBe(false); + expect(payload.findings[0].code).toBe("ledger-dropped"); + }); + + it("accepts a harness-valid contract edit and reports the new ledger state", async () => { + const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + contract.components["tag-pill"].whenNotToUse = "Long prose; TagPill is for two-word states."; + const { payload } = await call("save", { path: root, kind: "contract", document: contract }); + expect(payload.ok).toBe(true); + const persisted = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + expect(persisted.components["tag-pill"].whenNotToUse).toContain("two-word"); + }); + + it("refuses a schema-invalid profile with pathed findings", async () => { + const profile = JSON.parse(readFileSync(join(root, "acme.profile.json"), "utf8")); + profile.components[0].propMap.tone.kind = "vibes"; + const { payload } = await call("save", { path: root, kind: "profile", document: profile }); + expect(payload.ok).toBe(false); + expect(payload.findings[0].path ?? payload.findings[0].target).toContain("propMap"); + }); +}); diff --git a/apps/agent/src/project.ts b/apps/agent/src/project.ts new file mode 100644 index 0000000..0521eeb --- /dev/null +++ b/apps/agent/src/project.ts @@ -0,0 +1,445 @@ +/** + * Composer project routes: the local agent is the bridge between the composer + * app and a user project's FILES. Every route is thin orchestration over + * published packages — the agent parses nothing itself: + * + * POST /project/connect { path } -> manifest + ledger + inventory + * POST /project/discover { path } -> dspack-export CLI (bootstrap / refusal verbatim) + * POST /project/emit { path } -> loadProfile + transformFromJson + emitSurface -> out/ + * POST /project/validate { path } -> dspack-validate CLI + dspack-gen/core lintSurface + * POST /project/save { path, kind, document } -> shape-gated, ledger-preserving atomic write + * POST /project/run { path, prompt, intent, modelRef } -> AG-UI SSE generation + * under the PROJECT contract + profile (scoped vocabulary) + * + * Security bounds: `path` must be an absolute existing directory containing + * project.json; every file access resolves inside it (the two CLI spawns and + * the contract/profile/surface reads). This is the same BYO-machine trust + * model as the rest of the agent: local process, local files, no credentials + * from the browser. dspack-export is imported/spawned ONLY here (import- + * isolation rule, mirroring @a2ui/* and @ag-ui/* confinement). + */ +import { execFile } from "node:child_process"; +import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, existsSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import type { ServerResponse } from "node:http"; +import { + loadProfile, + transformFromJson, + emitSurface, + EmitSurfaceError, + ProfileLoadError, + type Profile, + type A2uiVersion, +} from "@aestheticfunction/dspack-emit"; +import { lintSurface } from "@aestheticfunction/dspack-gen/core"; +import { runPipeline, ScriptedAdapter, adapterFor, OllamaAdapter } from "@aestheticfunction/dspack-gen"; +import { + ledgerStatus, + parseProjectManifest, + preservesLedger, + finding, + type ComposerFinding, + type ProjectManifest, +} from "@dspack-studio/composer-core"; +import { + createPipelineEventMapper, + createSseEncoder, + runErrorEvent, + type BaseEvent, + type PipelineEvent as BridgePipelineEvent, +} from "@dspack-studio/agui-bridge"; + +const require = createRequire(import.meta.url); +const execFileP = promisify(execFile); + +/** Resolve a sibling package's file without relying on its exports map. */ +function packageFile(pkg: string, rel: string): string { + return join(dirname(require.resolve(`${pkg}/package.json`)), rel); +} + +class ProjectError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +interface ProjectContext { + root: string; + manifest: ProjectManifest; + contractPath: string; + profilePath: string; + outDir: string; +} + +function inside(root: string, rel: string): string { + const abs = resolve(root, rel); + if (abs !== root && !abs.startsWith(root + sep)) { + throw new ProjectError(400, `path '${rel}' escapes the project directory`); + } + return abs; +} + +function openProject(rawPath: unknown): ProjectContext { + if (typeof rawPath !== "string" || !isAbsolute(rawPath)) { + throw new ProjectError(400, "path must be an absolute project directory"); + } + const root = resolve(rawPath); + if (!existsSync(root) || !statSync(root).isDirectory()) { + throw new ProjectError(404, `no directory at '${root}'`); + } + const manifestPath = join(root, "project.json"); + if (!existsSync(manifestPath)) { + throw new ProjectError(404, `no project.json in '${root}'`); + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + throw new ProjectError(400, "project.json is not valid JSON"); + } + const result = parseProjectManifest(parsed); + if (!result.ok) { + throw new ProjectError(400, `project.json invalid: ${result.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`); + } + const manifest = result.manifest; + return { + root, + manifest, + contractPath: inside(root, manifest.contractPath), + profilePath: inside(root, manifest.profilePath), + outDir: inside(root, manifest.outDir), + }; +} + +const readJson = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")); + +function atomicWriteJson(path: string, value: unknown): void { + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n"); + renameSync(tmp, path); +} + +/** Surfaces available for emit/preview: contract examples + surfacesDir files. */ +function projectSurfaces(ctx: ProjectContext, contract: Record): Array<{ name: string; surface: unknown }> { + const out: Array<{ name: string; surface: unknown }> = []; + for (const example of (contract.examples as Array<{ id?: string; surface?: unknown }> | undefined) ?? []) { + if (example.surface) out.push({ name: example.id ?? "example", surface: example.surface }); + } + if (ctx.manifest.surfacesDir) { + const dir = inside(ctx.root, ctx.manifest.surfacesDir); + if (existsSync(dir)) { + for (const file of readdirSync(dir).filter((f) => f.endsWith(".dsurface.json")).sort()) { + out.push({ name: file.replace(/\.dsurface\.json$/, ""), surface: readJson(join(dir, file)) }); + } + } + } + return out; +} + +// --------------------------------------------------------------------------- + +async function connect(ctx: ProjectContext) { + const contract = existsSync(ctx.contractPath) ? (readJson(ctx.contractPath) as Record) : null; + const profileExists = existsSync(ctx.profilePath); + let profileIssue: string | null = null; + if (profileExists) { + try { + loadProfile(readJson(ctx.profilePath)); + } catch (e) { + profileIssue = e instanceof Error ? e.message : String(e); + } + } + return { + manifest: ctx.manifest, + contract, + ledger: contract ? await ledgerStatus(contract) : null, + profile: profileExists ? readJson(ctx.profilePath) : null, + profileIssue, + surfaces: contract ? projectSurfaces(ctx, contract).map((s) => s.name) : [], + }; +} + +async function discover(ctx: ProjectContext) { + const configRel = ctx.manifest.exportConfigPath; + if (!configRel) { + throw new ProjectError(400, "this project has no exportConfigPath (imported contract; discovery does not apply)"); + } + const config = inside(ctx.root, configRel); + const cli = packageFile("@aestheticfunction/dspack-export", "dist/cli.js"); + try { + const { stdout } = await execFileP( + process.execPath, + [cli, "generate", "--config", config, "--out", ctx.contractPath], + { cwd: ctx.root, timeout: 120_000 }, + ); + const contract = readJson(ctx.contractPath) as Record; + return { ok: true, log: stdout.trim(), contract, ledger: await ledgerStatus(contract) }; + } catch (e) { + // dspack-export's refusal table speaks in its own words — pass it through. + const err = e as { stdout?: string; stderr?: string; message?: string }; + throw new ProjectError(409, (err.stderr || err.stdout || err.message || "discovery failed").trim()); + } +} + +function emit(ctx: ProjectContext) { + const contract = readJson(ctx.contractPath) as Record; + let profile: Profile; + try { + profile = loadProfile(readJson(ctx.profilePath)); + } catch (e) { + if (e instanceof ProfileLoadError) { + return { + ok: false as const, + findings: e.issues.map((i) => finding("profile", "schema", "error", i.path, i.message)), + }; + } + throw e; + } + + const surfaces = projectSurfaces(ctx, contract); + const emitted: Array<{ name: string; messages?: unknown[]; warnings: Array<{ code: string; message: string }>; error?: string }> = []; + const allMessages: unknown[] = []; + for (const { name, surface } of surfaces) { + try { + const result = emitSurface(surface as Parameters[0], contract as Parameters[1], { profile }); + emitted.push({ name, messages: result.messages, warnings: result.warnings as Array<{ code: string; message: string }> }); + allMessages.push(...result.messages); + } catch (e) { + if (e instanceof EmitSurfaceError) { + emitted.push({ name, warnings: [], error: e.message }); + continue; + } + throw e; + } + } + + mkdirSync(ctx.outDir, { recursive: true }); + const versions: A2uiVersion[] = ["0.9.1", "1.0"]; + const runs = versions.map((version) => { + const out = transformFromJson(contract as Parameters[0], { a2uiVersion: version, surface: { messages: allMessages }, profile }); + const seg = version === "0.9.1" ? "v0_9_1" : "v1_0"; + atomicWriteJson(join(ctx.outDir, `catalog.${seg}.json`), out.catalog); + atomicWriteJson(join(ctx.outDir, `report.${seg}.json`), out.report.json); + return { version, out }; + }); + for (const { name, messages } of emitted) { + if (messages) atomicWriteJson(join(ctx.outDir, `${name}.surface.json`), { messages }); + } + + const primary = runs[0].out; + const findings: ComposerFinding[] = []; + for (const { version, out } of runs) { + for (const gate of out.validation.gates) { + if (!gate.pass) { + const gateId = gate.name.startsWith("schema-compile") ? "A1" : gate.name === "catalog-shape" ? "A2" : "A3"; + findings.push(finding(gateId as "A1", gate.name, "error", `a2ui@${version}`, (gate.errors ?? []).join("; ") || gate.name)); + } + } + } + for (const c of primary.mapping.coverage) { + if (c.disposition === "unclassified") { + findings.push(finding("coverage", "unclassified", "error", c.id, "component is neither mapped, adapted, omitted, nor a declared casualty")); + } + } + for (const f of primary.mapping.fidelity) { + if (f.class === "lossy" || f.class === "cannot-represent") { + findings.push(finding("fidelity", f.class, "warn", f.source, f.note)); + } + } + for (const { name, warnings, error } of emitted) { + if (error) findings.push(finding("A3", "emit-surface", "error", name, error)); + for (const w of warnings) findings.push(finding("A3", w.code, "info", name, w.message)); + } + + return { + ok: runs.every((r) => r.out.validation.pass), + catalog: runs[0].out.catalog, + report: primary.report.json, + surfaces: emitted, + findings, + }; +} + +async function validate(ctx: ProjectContext) { + const findings: ComposerFinding[] = []; + const harness = packageFile("@aestheticfunction/dspack-spec", "scripts/validate.mjs"); + try { + await execFileP(process.execPath, [harness, "--file", ctx.contractPath], { timeout: 60_000 }); + } catch (e) { + const err = e as { stdout?: string; stderr?: string }; + findings.push(finding("document", "dspack-validate", "error", "", (err.stderr || err.stdout || "contract failed dspack-validate").trim().slice(0, 4000))); + } + + const contract = readJson(ctx.contractPath) as Record; + for (const { name, surface } of projectSurfaces(ctx, contract)) { + const report = lintSurface(surface, contract as Parameters[1]); + for (const gate of report.gates) { + if (gate.status === "FAIL") { + for (const error of gate.errors ?? []) findings.push(finding(gate.gate as "S1", "lint", "error", name, error)); + } + } + for (const f of report.findings) { + findings.push( + finding("S3", f.ruleId, f.level === "error" ? "error" : "warn", `${name} ${f.location.path}`, `${f.message} — ${f.rationale}`), + ); + } + } + return { ok: findings.every((f) => f.severity !== "error"), findings }; +} + +async function save(ctx: ProjectContext, body: Record) { + const kind = body.kind; + const document = body.document as Record | undefined; + if ((kind !== "contract" && kind !== "profile") || document === undefined) { + throw new ProjectError(400, "kind ('contract' | 'profile') and document are required"); + } + if (kind === "profile") { + try { + loadProfile(document); + } catch (e) { + if (e instanceof ProfileLoadError) { + return { ok: false, findings: e.issues.map((i) => finding("profile", "schema", "error", i.path, i.message)) }; + } + throw e; + } + atomicWriteJson(ctx.profilePath, document); + return { ok: true, findings: [] }; + } + // contract: the ledger is provenance — a save may never drop it. + const existing = existsSync(ctx.contractPath) ? (readJson(ctx.contractPath) as Record) : {}; + if (!preservesLedger(existing, document)) { + return { + ok: false, + findings: [finding("ledger", "ledger-dropped", "error", 'metadata["x-bootstrap"]', "a save may not remove the bootstrap ledger; edits make sections human-owned, deleting provenance is refused")], + }; + } + const tmp = join(ctx.outDir, `.contract-check-${process.pid}.json`); + mkdirSync(ctx.outDir, { recursive: true }); + writeFileSync(tmp, JSON.stringify(document, null, 2)); + const harness = packageFile("@aestheticfunction/dspack-spec", "scripts/validate.mjs"); + try { + await execFileP(process.execPath, [harness, "--file", tmp], { timeout: 60_000 }); + } catch (e) { + const err = e as { stdout?: string; stderr?: string }; + return { ok: false, findings: [finding("document", "dspack-validate", "error", "", (err.stderr || err.stdout || "contract failed dspack-validate").trim().slice(0, 4000))] }; + } + atomicWriteJson(ctx.contractPath, document); + return { ok: true, findings: [], ledger: await ledgerStatus(document) }; +} + +// --------------------------------------------------------------------------- + +/** Ollama window mirroring pipeline.ts (BYO-inference configuration). */ +const OLLAMA_OPTIONS = { num_ctx: 16384, num_predict: 4096 }; +function ollamaAdapterWithWindow(modelRef: string) { + return new OllamaAdapter({ + model: modelRef.slice("ollama:".length), + fetch: ((url: unknown, init: { body: string }) => { + const body = JSON.parse(init.body); + body.options = { ...body.options, ...OLLAMA_OPTIONS }; + return fetch(url as string, { ...init, body: JSON.stringify(body) }); + }) as typeof fetch, + }); +} + +/** AG-UI SSE generation under the PROJECT contract + profile. */ +async function runProject(ctx: ProjectContext, body: Record, res: ServerResponse, cors: Record, accept: string | undefined) { + const contract = readJson(ctx.contractPath) as Record; + const profile = loadProfile(readJson(ctx.profilePath)); + const prompt = String(body.prompt ?? ""); + const intents = (contract.intents as Array<{ id: string }> | undefined) ?? []; + const intent = String(body.intent ?? intents[0]?.id ?? ""); + const modelRef = String(body.modelRef ?? "scripted"); + + const examples = (contract.examples as Array<{ intent: string; surface: unknown }> | undefined) ?? []; + const example = examples.find((e) => e.intent === intent) ?? examples[0]; + if (modelRef === "scripted" && !example) { + throw new ProjectError(400, "scripted mode needs at least one worked example in the contract"); + } + const adapter = + modelRef === "scripted" + ? new ScriptedAdapter([{ output: example!.surface }]) + : modelRef.startsWith("ollama:") + ? ollamaAdapterWithWindow(modelRef) + : adapterFor(modelRef); + + const encoder = createSseEncoder(accept); + res.writeHead(200, { "content-type": encoder.contentType, "cache-control": "no-cache", connection: "keep-alive", ...cors }); + const threadId = `project-${ctx.manifest.name}`; + const runId = String(body.runId ?? `run-${Date.now()}`); + const map = createPipelineEventMapper({ threadId, runId }); + try { + await runPipeline({ + contract: contract as Parameters[0]["contract"], + intent, + prompt, + adapter, + maxRepairs: 2, + emitProfile: profile, + onEvent: (event) => { + // The bridge's PipelineEvent is a structural mirror of dspack-gen's + // union (retired once dspack-gen#48 re-exports the type). + for (const agui of map(event as unknown as BridgePipelineEvent)) res.write(encoder.encode(agui as BaseEvent)); + }, + }); + } catch (error) { + res.write(encoder.encode(runErrorEvent(error instanceof Error ? error.message : String(error)))); + } + res.end(); +} + +// --------------------------------------------------------------------------- + +/** + * Dispatch a /project/* route. Returns true when the route was handled. + * `json` mirrors the server's response helper. + */ +export async function handleProjectRoute( + path: string, + body: Record, + res: ServerResponse, + cors: Record, + accept: string | undefined, + json: (res: ServerResponse, status: number, payload: unknown, cors: Record) => void, +): Promise { + if (!path.startsWith("/project/")) return false; + const route = path.slice("/project/".length); + try { + const ctx = openProject(body.path); + switch (route) { + case "connect": + json(res, 200, await connect(ctx), cors); + return true; + case "discover": + json(res, 200, await discover(ctx), cors); + return true; + case "emit": + json(res, 200, emit(ctx), cors); + return true; + case "validate": + json(res, 200, await validate(ctx), cors); + return true; + case "save": + json(res, 200, await save(ctx, body), cors); + return true; + case "run": + await runProject(ctx, body, res, cors, accept); + return true; + default: + json(res, 404, { error: `unknown project route '${route}'` }, cors); + return true; + } + } catch (e) { + if (e instanceof ProjectError) { + json(res, e.status, { error: e.message }, cors); + return true; + } + json(res, 500, { error: e instanceof Error ? e.message : String(e) }, cors); + return true; + } +} diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index b626346..03d46f7 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -25,6 +25,7 @@ import { type PipelineEvent, } from "@dspack-studio/agui-bridge"; import { governedQuestion, governedRun } from "./pipeline.js"; +import { handleProjectRoute } from "./project.js"; import { bookingRespond, bookingStartOps, @@ -218,6 +219,10 @@ const server = createServer(async (req, res) => { return; } + // Composer project routes (connect/discover/emit/validate/save/run) — thin + // orchestration over published packages against a local project directory. + if (await handleProjectRoute(path, body ?? {}, res, CORS, req.headers.accept, json)) return; + // FM-3 deterministic continuation: rebuild the scenario's state from a // fork's event prefix — reset, restore recorded grounding, then replay // the prefix's ACCEPTED actions through the same responders. Nothing is diff --git a/apps/composer/demo-project/.gitignore b/apps/composer/demo-project/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/apps/composer/demo-project/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/apps/composer/demo-project/acme-ui.dspack.json b/apps/composer/demo-project/acme-ui.dspack.json new file mode 100644 index 0000000..1e9f704 --- /dev/null +++ b/apps/composer/demo-project/acme-ui.dspack.json @@ -0,0 +1,405 @@ +{ + "dspack": "0.4", + "name": "Acme UI", + "description": "Small non-canonical React component library used to evidence the composer spike.", + "version": "0.1.0", + "metadata": { + "generatedBy": "@aestheticfunction/dspack-export@0.3.0", + "generatedAt": "2026-08-03T18:19:44.394Z", + "source": ".", + "note": "Generated snapshot. Hand-authored sections (patterns, antiPatterns, whenToUse, accessibility, composition, constraints) are not generated; regeneration refuses to overwrite a document containing human-authored content (see metadata[\"x-bootstrap\"]).", + "x-bootstrap": { + "spec": "0.4", + "generated": { + "tokens": "19a25ccff090fe44ae83e997cafbee2d49a6e9f3d2afec4557c86699340f6f45", + "components": "2f81ce51d4280a90e8532880a0ebb90085ba1c5aec0adf672c65531cb942cc3d", + "frameworkBindings": "1fa677ffda17075341c6b6534cc8c7cf2dbdb005e889fa4afb8155e0174100d4", + "themes": "4254e5c12a38bdee0fd751a638307fefd94088d47f5c01b0a2c0caffd0a39832", + "layout": "84240158ff29a8c25fd428fdf24f3409df36710fabef89086d711704bc0130c2" + }, + "awaitingAuthorship": [ + "categories", + "intents", + "rules", + "examples", + "patterns", + "antiPatterns", + "components.*.whenToUse", + "components.*.accessibility", + "components.*.composition", + "components.*.constraints" + ] + } + }, + "tokens": { + "color": { + "description": "Semantic color tokens extracted from CSS custom properties. Values are the default (light) theme.", + "tier": "semantic", + "values": { + "acme-surface": { + "value": "#ffffff", + "type": "color" + }, + "acme-wash": { + "value": "#f4f4f2", + "type": "color" + }, + "acme-line": { + "value": "#d9d7d2", + "type": "color" + }, + "acme-ink": { + "value": "#1e1d1a", + "type": "color" + }, + "acme-ink-dim": { + "value": "#6d6a63", + "type": "color" + }, + "acme-brand": { + "value": "#2f5d3a", + "type": "color" + }, + "acme-danger": { + "value": "#a3332b", + "type": "color" + }, + "acme-danger-wash": { + "value": "#f9e8e6", + "type": "color" + }, + "acme-ok": { + "value": "#2f5d3a", + "type": "color" + }, + "acme-ok-wash": { + "value": "#e8f1ea", + "type": "color" + }, + "acme-warn": { + "value": "#8a6d1f", + "type": "color" + }, + "acme-warn-wash": { + "value": "#f7f0dc", + "type": "color" + } + } + } + }, + "components": { + "action-button": { + "name": "ActionButton", + "description": "The one button. Tone carries intent; density carries rhythm.", + "x-componentKey": "ui/ActionButton", + "props": { + "tone": { + "type": "enum", + "values": [ + "brand", + "danger", + "quiet", + "outline", + "plain" + ], + "default": "brand", + "propRole": "choice", + "description": "Semantic intent of the action: brand for primary flows, danger for destructive ones, quiet/outline/plain for secondary emphasis." + }, + "density": { + "type": "enum", + "values": [ + "compact", + "cozy", + "comfortable" + ], + "default": "cozy", + "propRole": "dimension" + }, + "label": { + "type": "string", + "description": "Visible label. ActionButton never renders bare icons without one.", + "required": true, + "propRole": "content" + } + }, + "whenToUse": "Any user-initiated action. Choose tone by consequence, not by color preference." + }, + "info-card": { + "name": "InfoCard", + "description": "Structured content container. Compose with InfoCardHeader, InfoCardBody, InfoCardFooter.", + "x-componentKey": "ui/InfoCard", + "composition": { + "subComponents": [ + { + "id": "info-card-header", + "name": "InfoCardHeader", + "description": "Title strip. Exactly one per InfoCard.", + "acceptsChildren": "components" + }, + { + "id": "info-card-title", + "name": "InfoCardTitle", + "description": "Heading text inside the header.", + "acceptsChildren": "text" + }, + { + "id": "info-card-body", + "name": "InfoCardBody", + "description": "Main content region.", + "acceptsChildren": "components" + }, + { + "id": "info-card-footer", + "name": "InfoCardFooter", + "description": "Action strip. Optional; holds ActionButtons.", + "acceptsChildren": "components" + } + ], + "notes": "Compose header (with title), body, and optional footer inside InfoCard." + }, + "whenToUse": "Grouping related status or summary content under one heading with optional actions." + }, + "mini-stepper": { + "name": "MiniStepper", + "description": "Compact progress indicator. Steps are data, not children.", + "x-componentKey": "ui/MiniStepper", + "props": { + "steps": { + "type": "array", + "description": "Ordered steps; each entry is free-form step data (label, done).", + "required": true, + "propRole": "content" + } + } + }, + "note-field": { + "name": "NoteField", + "description": "Multi-line text entry with a mandatory visible label.", + "x-componentKey": "ui/NoteField", + "props": { + "label": { + "type": "string", + "description": "Visible field label rendered above the textarea.", + "required": true, + "propRole": "content" + }, + "resizable": { + "type": "boolean", + "description": "Allow the user to drag-resize vertically.", + "default": false, + "propRole": "flag" + } + } + }, + "tag-pill": { + "name": "TagPill", + "description": "Status marker. Hue is semantic, not decorative.", + "x-componentKey": "ui/TagPill", + "props": { + "hue": { + "type": "enum", + "values": [ + "gray", + "green", + "red", + "amber" + ], + "default": "gray", + "propRole": "choice" + }, + "label": { + "type": "string", + "description": "Short status text; keep under three words.", + "required": true, + "propRole": "content" + } + }, + "whenToUse": "Marking a state (healthy, failed, pending) inline with content." + } + }, + "frameworkBindings": { + "react": { + "name": "React", + "components": { + "action-button": { + "importPath": "./components/ui/action-button", + "exportName": "ActionButton" + }, + "info-card": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCard" + }, + "info-card-header": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardHeader" + }, + "info-card-body": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardBody" + }, + "info-card-footer": { + "importPath": "./components/ui/info-card", + "exportName": "InfoCardFooter" + }, + "mini-stepper": { + "importPath": "./components/ui/mini-stepper", + "exportName": "MiniStepper" + }, + "note-field": { + "importPath": "./components/ui/note-field", + "exportName": "NoteField" + }, + "tag-pill": { + "importPath": "./components/ui/tag-pill", + "exportName": "TagPill" + } + } + } + }, + "themes": { + "dark": { + "name": "Dark", + "description": "Dark theme overrides extracted from the .dark CSS block.", + "overrides": { + "color.acme-surface": "#191815", + "color.acme-wash": "#23221e", + "color.acme-line": "#3a3833", + "color.acme-ink": "#e9e4d9", + "color.acme-ink-dim": "#97927f" + } + } + }, + "layout": { + "breakpoints": { + "sm": { + "minWidth": "640px", + "description": "Small devices and large phones in landscape." + }, + "md": { + "minWidth": "768px", + "description": "Tablets." + }, + "lg": { + "minWidth": "1024px", + "description": "Laptops and small desktops." + }, + "xl": { + "minWidth": "1280px", + "description": "Desktops." + }, + "2xl": { + "minWidth": "1536px", + "description": "Large desktops." + } + }, + "spacingScale": { + "baseUnit": "0.25rem", + "description": "Spacing follows a 0.25rem base unit; use integer multiples of the scale." + } + }, + "intents": [ + { + "id": "status-report", + "name": "Status report", + "description": "Summarize the state of a system or process for review, with at most one acknowledging action." + } + ], + "rules": [ + { + "id": "rule.status-report.info-card-required", + "type": "component-choice", + "severity": "must", + "appliesTo": { + "intents": [ + "status-report" + ] + }, + "require": [ + "info-card" + ], + "rationale": "A status report is a bounded summary. InfoCard is the only Acme container that binds a heading, content, and actions into one reviewable unit.", + "examples": [ + "ex.status-report-basic" + ] + }, + { + "id": "rule.action-button-label", + "type": "required-props", + "severity": "must", + "component": "action-button", + "requiredProps": [ + { + "prop": "label" + } + ], + "rationale": "ActionButton never renders bare icons; an unlabeled action cannot be reviewed, announced, or audited.", + "examples": [ + "ex.status-report-basic" + ] + } + ], + "examples": [ + { + "id": "ex.status-report-basic", + "intent": "status-report", + "name": "Basic deployment status", + "prompt": "Show the current deployment status with room for operator notes and an acknowledge action.", + "description": "InfoCard with a titled header, a status pill and notes field in the body, one acknowledging action.", + "surface": { + "dspackSurface": "0.1", + "system": "Acme UI", + "intent": "status-report", + "root": { + "component": "info-card", + "id": "report", + "children": [ + { + "component": "info-card-header", + "children": [ + { + "component": "info-card-title", + "text": "Deployment status" + } + ] + }, + { + "component": "info-card-body", + "children": [ + { + "component": "tag-pill", + "id": "state", + "props": { + "hue": "green", + "label": "Healthy" + } + }, + { + "component": "note-field", + "id": "notes", + "props": { + "label": "Operator notes", + "resizable": true + } + } + ] + }, + { + "component": "info-card-footer", + "children": [ + { + "component": "action-button", + "id": "ack", + "props": { + "tone": "brand", + "label": "Acknowledge" + } + } + ] + } + ] + } + } + } + ] +} diff --git a/apps/composer/demo-project/acme.profile.json b/apps/composer/demo-project/acme.profile.json new file mode 100644 index 0000000..a3870d5 --- /dev/null +++ b/apps/composer/demo-project/acme.profile.json @@ -0,0 +1,179 @@ +{ + "profileVersion": "1", + "catalogTitle": "Acme UI — A2UI catalog (compiled from dspack)", + "catalogDescription": "A2UI catalog compiled from the Acme UI dspack v0.4 contract. Component shapes and variant enums are projected onto A2UI-conventional names so existing React registries can render them.", + "catalogIdBase": "https://acme.example/catalogs/acme-ui", + "instructions": "For layout, use the Column component to organize other components.", + "primaryColorToken": { "category": "color", "name": "acme-brand" }, + "surfaceSynthesis": { + "textComponent": "Text", + "textProp": "text", + "wrapComponent": "Column", + "wrapChildrenProp": "children" + }, + "components": [ + { + "a2ui": "Button", + "dspackId": "action-button", + "commons": ["ComponentCommon", "Checkable"], + "structural": { + "action": { + "schema": { "$ref": "#/$defs/Action" }, + "description": "The interaction dispatched when the button is activated.", + "synthNote": "A2UI requires a declarative action; Acme expresses this as an onClick handler, which is not representable in a declarative catalog." + } + }, + "propMap": { + "label": { + "a2ui": "label", + "kind": "string", + "description": "Visible label. Acme buttons never render bare icons." + }, + "tone": { + "a2ui": "variant", + "kind": "enum", + "targetEnum": ["primary", "secondary", "ghost", "destructive"], + "valueMap": { + "brand": "primary", + "danger": "destructive", + "outline": "secondary", + "quiet": "ghost", + "plain": "ghost" + }, + "default": "primary", + "description": "Button intent, projected from Acme tones onto the registry-conventional variant vocabulary." + }, + "density": { + "a2ui": "size", + "kind": "enum", + "targetEnum": ["sm", "md", "lg"], + "valueMap": { "compact": "sm", "cozy": "md", "comfortable": "lg" }, + "default": "md", + "description": "Control rhythm, projected one to one from Acme densities." + } + }, + "required": ["label", "action"], + "surfacePlan": { "actionProp": "action" } + }, + { + "a2ui": "Card", + "dspackId": "info-card", + "commons": ["ComponentCommon"], + "structural": { + "child": { + "schema": { "$ref": "#/$defs/ComponentId" }, + "description": "The ID of the single child component. Wrap multiple elements in a Column and pass its ID.", + "synthNote": "A2UI Card takes exactly one child by ID; Acme InfoCard composes via sub-components (header/title/body/footer), which flatten and collapse to a single, possibly Column-wrapped, child slot." + } + }, + "required": ["child"], + "surfacePlan": { + "childProp": "child", + "subFlatten": { + "transparent": ["info-card-header", "info-card-body", "info-card-footer"], + "asText": { "info-card-title": "h3" } + } + }, + "subCoverage": { + "info-card-header": "transparent grouping: children splice inline, in order (subFlatten strategy)", + "info-card-title": "text -> synthesized Text (variant h3)", + "info-card-body": "transparent grouping: children splice inline, in order", + "info-card-footer": "transparent grouping: children splice inline, in order" + } + }, + { + "a2ui": "Badge", + "dspackId": "tag-pill", + "commons": ["ComponentCommon"], + "structural": {}, + "propMap": { + "label": { + "a2ui": "label", + "kind": "string", + "description": "Short status text." + }, + "hue": { + "a2ui": "variant", + "kind": "enum", + "targetEnum": ["secondary", "default", "destructive", "outline"], + "valueMap": { "gray": "secondary", "green": "default", "red": "destructive", "amber": "outline" }, + "default": "secondary", + "description": "Status hue, projected one to one onto the registry-conventional badge variants." + } + }, + "required": ["label"], + "surfacePlan": {} + }, + { + "a2ui": "TextField", + "dspackId": "note-field", + "commons": ["ComponentCommon", "Checkable"], + "structural": { + "value": { + "schema": { "$ref": "#/$defs/DynamicString" }, + "description": "The bound value of the field.", + "synthNote": "A2UI two-way-binds value; Acme NoteField has no value prop in the contract." + } + }, + "propMap": { + "label": { + "a2ui": "label", + "kind": "string", + "description": "Visible field label." + }, + "resizable": { + "a2ui": "resizable", + "kind": "boolean", + "description": "Allow the user to drag-resize vertically." + } + }, + "required": ["label"], + "surfacePlan": {} + } + ], + "synthesized": [ + { + "a2ui": "Text", + "commons": ["ComponentCommon"], + "description": "Displays text content. Synthesized A2UI content primitive (not in the Acme contract).", + "structural": { + "text": { + "schema": { "$ref": "#/$defs/DynamicString" }, + "description": "The text content to display.", + "synthNote": "A2UI content primitive required to render labels/titles in a surface." + } + }, + "propMap": { + "variant": { + "a2ui": "variant", + "kind": "enum", + "targetEnum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"], + "default": "body", + "description": "A hint for the base text style." + } + }, + "required": ["text"] + }, + { + "a2ui": "Column", + "commons": ["ComponentCommon"], + "description": "Arranges children vertically. Synthesized A2UI structural primitive (the Acme contract has no layout component).", + "structural": { + "children": { + "schema": { "$ref": "#/$defs/ChildList" }, + "description": "Child component IDs (or a template).", + "synthNote": "A2UI structural primitive required to compose multiple children." + } + }, + "required": ["children"] + } + ], + "casualtyComponents": [ + { + "dspackId": "mini-stepper", + "attempted": "(none)", + "class": "cannot-represent", + "reason": "steps is an array prop with no declared item vocabulary (label/done are free-form step data); admitting it would let generation invent step shapes the contract does not govern. Same posture as the canonical dropdown-menu casualty." + } + ] +} diff --git a/apps/composer/demo-project/components/ui/action-button.tsx b/apps/composer/demo-project/components/ui/action-button.tsx new file mode 100644 index 0000000..a8aac73 --- /dev/null +++ b/apps/composer/demo-project/components/ui/action-button.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const actionButtonVariants = cva( + 'acme-action-button inline-flex items-center justify-center rounded font-medium transition-colors disabled:opacity-50', + { + variants: { + tone: { + brand: 'bg-[var(--acme-brand)] text-white hover:opacity-90', + danger: 'bg-[var(--acme-danger)] text-white hover:opacity-90', + quiet: 'bg-transparent text-[var(--acme-ink)] hover:bg-[var(--acme-wash)]', + outline: 'border border-[var(--acme-line)] bg-transparent hover:bg-[var(--acme-wash)]', + plain: 'bg-transparent underline-offset-4 hover:underline', + }, + density: { + compact: 'h-7 px-2 text-xs', + cozy: 'h-9 px-4 text-sm', + comfortable: 'h-11 px-6 text-base', + }, + }, + defaultVariants: { + tone: 'brand', + density: 'cozy', + }, + }, +); + +export interface ActionButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** Visible label. ActionButton never renders bare icons without one. */ + label: string; +} + +/** The one button. Tone carries intent; density carries rhythm. */ +const ActionButton = React.forwardRef( + ({ className, tone, density, label, ...props }, ref) => ( + + ), +); +ActionButton.displayName = 'ActionButton'; + +export { ActionButton, actionButtonVariants }; diff --git a/apps/composer/demo-project/components/ui/info-card.tsx b/apps/composer/demo-project/components/ui/info-card.tsx new file mode 100644 index 0000000..f281fd3 --- /dev/null +++ b/apps/composer/demo-project/components/ui/info-card.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +/** Structured content container. Compose with InfoCardHeader, InfoCardBody, InfoCardFooter. */ +const InfoCard = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +InfoCard.displayName = 'InfoCard'; + +/** Title strip. Exactly one per InfoCard. */ +const InfoCardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +InfoCardHeader.displayName = 'InfoCardHeader'; + +/** Main content region. */ +const InfoCardBody = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +InfoCardBody.displayName = 'InfoCardBody'; + +/** Action strip. Optional; holds ActionButtons. */ +const InfoCardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +InfoCardFooter.displayName = 'InfoCardFooter'; + +export { InfoCard, InfoCardHeader, InfoCardBody, InfoCardFooter }; diff --git a/apps/composer/demo-project/components/ui/mini-stepper.tsx b/apps/composer/demo-project/components/ui/mini-stepper.tsx new file mode 100644 index 0000000..d720aef --- /dev/null +++ b/apps/composer/demo-project/components/ui/mini-stepper.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +export interface MiniStepperProps extends React.HTMLAttributes { + /** Ordered steps; each entry is free-form step data. */ + steps: Array<{ label: string; done?: boolean }>; +} + +/** Compact progress indicator. Steps are data, not children. */ +const MiniStepper = React.forwardRef(({ className, steps, ...props }, ref) => ( +
    + {steps.map((step, i) => ( +
  1. + + {step.label} +
  2. + ))} +
+)); +MiniStepper.displayName = 'MiniStepper'; + +export { MiniStepper }; diff --git a/apps/composer/demo-project/components/ui/note-field.tsx b/apps/composer/demo-project/components/ui/note-field.tsx new file mode 100644 index 0000000..321a594 --- /dev/null +++ b/apps/composer/demo-project/components/ui/note-field.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +export interface NoteFieldProps extends React.TextareaHTMLAttributes { + /** Visible field label rendered above the textarea. */ + label: string; + /** Allow the user to drag-resize vertically. */ + resizable?: boolean; +} + +/** Multi-line text entry with a mandatory visible label. */ +const NoteField = React.forwardRef( + ({ className, label, resizable = false, id, ...props }, ref) => { + const fieldId = id ?? React.useId(); + return ( +
+ +