diff --git a/.claude/launch.json b/.claude/launch.json index 0fbdacb..6b83e56 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,14 +4,30 @@ { "name": "studio-static", "runtimeExecutable": "node", - "runtimeArgs": ["e2e/serve-static.mjs"], + "runtimeArgs": [ + "e2e/serve-static.mjs" + ], "port": 3311 }, { "name": "agent", "runtimeExecutable": "pnpm", - "runtimeArgs": ["--filter", "agent", "dev"], + "runtimeArgs": [ + "--filter", + "agent", + "dev" + ], "port": 8787 + }, + { + "name": "composer", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "--filter", + "composer", + "dev" + ], + "port": 3001 } ] -} +} \ No newline at end of file diff --git a/apps/agent/package.json b/apps/agent/package.json index 9603331..3237962 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -13,10 +13,14 @@ "record:catch": "tsx src/record-catch.ts" }, "dependencies": { - "@aestheticfunction/dspack-gen": "^0.1.2", + "@aestheticfunction/dspack-gen": "^0.1.3", "@dspack-studio/agui-bridge": "workspace:*", "@dspack-studio/contracts": "workspace:*", - "@dspack-studio/replay": "workspace:*" + "@dspack-studio/replay": "workspace:*", + "@aestheticfunction/dspack-emit": "^0.4.0", + "@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/.gitignore b/apps/composer/.gitignore new file mode 100644 index 0000000..ca300cc --- /dev/null +++ b/apps/composer/.gitignore @@ -0,0 +1,4 @@ +app/demo/generated/ +.next/ +out/ +next-env.d.ts diff --git a/apps/composer/app/agent-client.ts b/apps/composer/app/agent-client.ts new file mode 100644 index 0000000..3068a98 --- /dev/null +++ b/apps/composer/app/agent-client.ts @@ -0,0 +1,69 @@ +/** + * Thin client for the local agent's /project routes. Degradation is honest: + * every helper resolves to a typed error the UI states plainly ("requires the + * local agent") instead of simulating results. + */ +import type { ComposerFinding, LedgerStatus, ProjectManifest } from "@dspack-studio/composer-core"; + +export interface EmitPayload { + ok: boolean; + catalog?: Record; + report?: unknown; + surfaces?: Array<{ name: string; messages?: unknown[]; warnings: Array<{ code: string; message: string }>; error?: string }>; + findings: ComposerFinding[]; +} + +export interface ValidatePayload { + ok: boolean; + findings: ComposerFinding[]; +} + +export interface ConnectPayload { + manifest: ProjectManifest; + contract: Record | null; + ledger: LedgerStatus | null; + profile: Record | null; + profileIssue: string | null; + surfaces: string[]; +} + +export type AgentResult = { ok: true; value: T } | { ok: false; error: string }; + +const DEFAULT_AGENT = process.env.NEXT_PUBLIC_AGENT_URL ?? "http://localhost:8787"; + +export function agentUrl(): string { + return DEFAULT_AGENT; +} + +async function post(route: string, body: unknown): Promise> { + try { + const res = await fetch(`${agentUrl()}${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(180_000), + }); + const payload = await res.json(); + if (!res.ok) return { ok: false, error: String(payload.error ?? `agent replied ${res.status}`) }; + return { ok: true, value: payload as T }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +export async function probeAgent(): Promise { + try { + const res = await fetch(agentUrl(), { signal: AbortSignal.timeout(1500) }); + const body = (await res.json()) as { ok?: boolean }; + return body.ok === true; + } catch { + return false; + } +} + +export const agentConnect = (path: string) => post("/project/connect", { path }); +export const agentDiscover = (path: string) => post<{ ok: boolean; log: string; contract: Record; ledger: LedgerStatus }>("/project/discover", { path }); +export const agentEmit = (path: string) => post("/project/emit", { path }); +export const agentValidate = (path: string) => post("/project/validate", { path }); +export const agentSave = (path: string, kind: "contract" | "profile", document: unknown) => + post<{ ok: boolean; findings: Array<{ path?: string; target?: string; message: string }>; ledger?: LedgerStatus }>("/project/save", { path, kind, document }); diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx new file mode 100644 index 0000000..a512452 --- /dev/null +++ b/apps/composer/app/composer.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { ComposerProvider, useComposer } from "./state"; +import { ProjectView } from "./views/project-view"; +import { InventoryView } from "./views/inventory-view"; +import { ComponentView } from "./views/component-view"; +import { MapperView } from "./views/mapper-view"; +import { PreviewView } from "./views/preview-view"; +import { ValidateView } from "./views/validate-view"; + +type View = "project" | "inventory" | "component" | "mapper" | "preview" | "validate"; + +const VIEWS: Array<{ id: View; label: string }> = [ + { id: "project", label: "Project" }, + { id: "inventory", label: "Inventory" }, + { id: "component", label: "Component" }, + { id: "mapper", label: "Mapper" }, + { id: "preview", label: "Preview" }, + { id: "validate", label: "Validate" }, +]; + +function Shell() { + const [view, setView] = useState("project"); + const state = useComposer(); + + return ( +
+
+

+ Catalog Composer +

+ + {state.manifest ? state.manifest.name : "no project"} + {" · "} + {state.mode === "demo" ? "demo project" : state.projectPath} + + + agent: {state.agentUp ? "connected" : "not running"} + +
+ + + + {state.notice && ( +

+ {state.notice} +

+ )} + + {view === "project" && } + {view === "inventory" && setView("component")} />} + {view === "component" && } + {view === "mapper" && } + {view === "preview" && } + {view === "validate" && } +
+ ); +} + +export function Composer() { + return ( + + + + ); +} diff --git a/apps/composer/app/demo-data.ts b/apps/composer/app/demo-data.ts new file mode 100644 index 0000000..b83836f --- /dev/null +++ b/apps/composer/app/demo-data.ts @@ -0,0 +1,13 @@ +/** + * The shipped demo project (honest magic: real files, pre-emitted at build + * time by scripts/demo-assets.mjs — the same published APIs the agent runs). + */ +import demoContract from "../demo-project/acme-ui.dspack.json"; +import demoProfile from "../demo-project/acme.profile.json"; +import demoManifest from "../demo-project/project.json"; +import demoEmit from "./demo/generated/emit.json"; + +export const DEMO_CONTRACT = demoContract as unknown as Record; +export const DEMO_PROFILE = demoProfile as unknown as Record; +export const DEMO_MANIFEST = demoManifest as unknown as Record; +export const DEMO_EMIT = demoEmit as unknown as Record; diff --git a/apps/composer/app/fonts.ts b/apps/composer/app/fonts.ts new file mode 100644 index 0000000..524a79c --- /dev/null +++ b/apps/composer/app/fonts.ts @@ -0,0 +1,43 @@ +/** + * The Aesthetic Function type set (matches af-site/assets/af.css): Oswald + * for headlines, IBM Plex Sans for body, IBM Plex Mono for labels and + * buttons, Jost for the wordmark. next/font downloads at build time and + * self-hosts under _next/static/media — no runtime request to Google, so + * the production smoke suite's clean-network check is unaffected. + */ +import { IBM_Plex_Mono, IBM_Plex_Sans, Jost, Oswald } from "next/font/google"; + +export const oswald = Oswald({ + subsets: ["latin"], + weight: ["600"], + variable: "--font-oswald", + display: "swap", +}); + +export const plexSans = IBM_Plex_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600"], + variable: "--font-plex-sans", + display: "swap", +}); + +export const plexMono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["400", "500", "600"], + variable: "--font-plex-mono", + display: "swap", +}); + +export const jost = Jost({ + subsets: ["latin"], + weight: ["400"], + variable: "--font-jost", + display: "swap", +}); + +export const fontVariables = [ + oswald.variable, + plexSans.variable, + plexMono.variable, + jost.variable, +].join(" "); diff --git a/apps/composer/app/globals.css b/apps/composer/app/globals.css new file mode 100644 index 0000000..945badc --- /dev/null +++ b/apps/composer/app/globals.css @@ -0,0 +1,241 @@ +/* + * Aesthetic Function brand layer for the studio chrome. + * Tokens transcribed from af-site/assets/af.css (the authoritative design + * system). This file styles the studio's own UI only; the rendered A2UI + * canvas keeps its Astryx tokens. Deliberately does NOT set `color-scheme` + * or `data-theme` anywhere global: Astryx base tokens resolve via + * light-dark() from the inherited color-scheme, and a global dark scheme + * would silently retint the canvas surfaces. + */ + +:root { + /* Surfaces (warm near-black, never pure black) */ + --bg: #131310; + --bg-1: #181814; + --bg-2: #1e1e19; + --line: #2b2b24; + --line-soft: #21211b; + + /* Ink (warm cream scale) */ + --fg: #e7dfd2; + --fg-body: #c4bdb0; + --fg-dim: #8f8a7c; + --fg-faint: #6a665b; + + /* Accent (sage / pine, used sparingly) */ + --green: #7e9652; + --green-bright: #97b063; + --green-deep: #62763f; + --green-glow: rgba(126, 150, 82, 0.16); + + /* Semantic tokens tuned for the dark chrome (all text uses >= 4.5:1 + on --bg / --bg-1 / --bg-2) */ + --err: #f87171; + --err-line: #8a3a3a; + --err-soft: rgba(248, 113, 113, 0.09); + --ok: #97b063; + --warn: #d9a05b; + --violet: #a78bfa; + --info: #7dd3fc; + --link: var(--green-bright); + + /* Type (next/font variables with system fallbacks) */ + --hl: var(--font-oswald), system-ui, sans-serif; + --sans: var(--font-plex-sans), system-ui, sans-serif; + --mono: var(--font-plex-mono), ui-monospace, monospace; + --geo: var(--font-jost), system-ui, sans-serif; + + /* Motion */ + --ease: cubic-bezier(0.4, 0.5, 0.15, 1); +} + +body { + background: var(--bg); + color: var(--fg-body); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; +} + +::selection { + background: var(--green); + color: var(--bg); +} + +:focus-visible { + outline: 2px solid var(--green-bright); + outline-offset: 2px; +} + +code, +pre, +kbd { + font-family: var(--mono); +} + +input[type="range"] { + accent-color: var(--green); +} + +/* AF button language: mono, uppercase, near-square. Class-based so hover + and focus states exist (inline styles cannot express pseudo-classes). */ +.st-btn { + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: 2px; + background: transparent; + color: var(--fg-body); + cursor: pointer; + transition: + border-color 0.18s var(--ease), + color 0.18s var(--ease), + background 0.18s var(--ease); +} +.st-btn:hover { + border-color: var(--green); + color: var(--green-bright); +} +.st-btn--active { + background: var(--green); + border-color: var(--green); + color: #10120c; + font-weight: 600; +} +.st-btn--active:hover { + background: var(--green-bright); + border-color: var(--green-bright); + color: #10120c; +} +.st-btn--dashed { + border-style: dashed; +} +.st-btn[disabled] { + opacity: 0.5; + cursor: not-allowed; +} +.st-btn[disabled]:hover { + border-color: var(--line); + color: var(--fg-body); +} + +/* Quiet text links (AF .tlink adapted to in-app link-buttons). */ +.st-link { + font: inherit; + border: none; + background: none; + padding: 0; + color: var(--link); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.16s var(--ease); +} +.st-link:hover { + color: var(--fg); +} + +/* Layout utilities: the app is otherwise inline-styled; width-sensitive + layout lives here so it can respond to the viewport. */ +.st-main { + padding: 40px 24px 48px; +} +.st-cols-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +/* The pipeline diagram: AF's .dgm idiom (node cards over a green bus) + transcribed from af-site. Static, no animation. */ +.dgm { + margin: 14px 0 4px; +} +.dgm-grid { + display: grid; + grid-template-columns: repeat(6, minmax(96px, 1fr)); + gap: 8px; +} +.dgm-node { + border: 1px solid var(--line); + border-radius: 4px; + background: var(--bg-1); + padding: 10px 10px 9px; + min-width: 0; +} +.dgm-node--contract { + border-left: 2px solid var(--green); +} +.dgm-node__k { + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--fg-dim); + margin: 0 0 4px; +} +.dgm-node__t { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--fg); + margin: 0; +} +.dgm-node__s { + font-family: var(--mono); + font-size: 11px; + color: var(--fg-dim); + margin: 4px 0 0; + line-height: 1.45; +} +.dgm-bus { + display: block; + width: 100%; + height: 40px; + margin-top: 2px; +} +.dgm-caption { + font-size: 12px; + color: var(--fg-dim); + margin: 8px 0 0; + line-height: 1.6; +} + +@media (max-width: 640px) { + .st-main { + padding: 32px 16px 40px; + } + .st-cols-2 { + grid-template-columns: 1fr; + } + .dgm-grid { + grid-template-columns: 1fr 1fr; + } + .dgm-bus { + display: none; + } +} + +/* Astryx integration fix, scoped to the rendered canvas. Astryx's table + scroll wrapper carries a -16px margin on every side: a full-bleed + affordance so a lone table spans a padded Card edge to edge. The + horizontal bleed is wanted; the vertical bleed is not, because two + tables stacked in a Column (VStack gap 12px) then compute 12 - 16 - 16 + = -20px and overlap (the recipe's ingredients and instructions tables). + This zeroes only the vertical bleed, keeping the horizontal full-bleed. + globals.css is loaded unlayered so it wins over Astryx's layered sheets. */ +[data-canvas] .astryx-table-scroll-wrapper { + margin-top: 0; + margin-bottom: 0; +} + +@media (prefers-reduced-motion: reduce) { + .st-btn, + .st-link { + transition: none; + } +} diff --git a/apps/composer/app/layout.tsx b/apps/composer/app/layout.tsx new file mode 100644 index 0000000..3338450 --- /dev/null +++ b/apps/composer/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +// shadcn canvas styles are Tailwind-compiled and scoped under +// [data-design-system="shadcn"]; the AF brand layer (globals.css, shared +// transcription with apps/web) styles the composer chrome only. +import "@dspack-studio/shadcn-renderers/styles.css"; +import "./globals.css"; +import { fontVariables } from "./fonts"; + +export const metadata: Metadata = { + title: "Catalog Composer · Aesthetic Function Studio", + description: + "Create and maintain a project-specific A2UI component catalog: connect a project, enrich its discovered contract, map it through a data profile, preview the emitted catalog, and validate the governed artifacts.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/composer/app/page.tsx b/apps/composer/app/page.tsx new file mode 100644 index 0000000..43ac369 --- /dev/null +++ b/apps/composer/app/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// The composer is a browser construct end to end (WebCrypto ledger hashing, +// A2UI surface model, canvas registries) — client-only, like the studio. +const Composer = dynamic(() => import("./composer").then((m) => m.Composer), { ssr: false }); + +export default function Page() { + return ; +} diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx new file mode 100644 index 0000000..20c602e --- /dev/null +++ b/apps/composer/app/state.tsx @@ -0,0 +1,286 @@ +"use client"; + +/** + * Composer state: one context holding the project documents plus the latest + * emit/validate results. Two modes, stated plainly in the UI: + * - "agent": a local project directory via the agent; saves persist. + * - "demo": the shipped Acme UI demo project (pre-emitted at build time); + * edits live in memory only. + * Files are the source of truth; this state is a view of them. + */ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { ledgerStatus, type ComposerFinding, type LedgerStatus, type ProjectManifest } from "@dspack-studio/composer-core"; +import { + agentConnect, + agentDiscover, + agentEmit, + agentSave, + agentValidate, + probeAgent, + type EmitPayload, + type ValidatePayload, +} from "./agent-client"; +import { DEMO_CONTRACT, DEMO_EMIT, DEMO_MANIFEST, DEMO_PROFILE } from "./demo-data"; + +export type Mode = "demo" | "agent"; + +export interface ComposerState { + mode: Mode; + agentUp: boolean; + projectPath: string; + manifest: ProjectManifest | null; + contract: Record | null; + profile: Record | null; + ledger: LedgerStatus | null; + emit: EmitPayload | null; + validate: ValidatePayload | null; + busy: string | null; + notice: string | null; + selected: string | null; // contract component id + setSelected: (id: string | null) => void; + connect: (path: string) => Promise; + loadDemo: () => void; + discover: () => Promise; + saveContract: (doc: Record) => Promise; + saveProfile: (doc: Record) => Promise; + runEmit: () => Promise; + runValidate: () => Promise; +} + +const Ctx = createContext(null); +export const useComposer = (): ComposerState => { + const state = useContext(Ctx); + if (!state) throw new Error("useComposer outside provider"); + return state; +}; + +export function ComposerProvider({ children }: { children: ReactNode }) { + const [mode, setMode] = useState("demo"); + const [agentUp, setAgentUp] = useState(false); + const [projectPath, setProjectPath] = useState(""); + const [manifest, setManifest] = useState(null); + const [contract, setContract] = useState | null>(null); + const [profile, setProfile] = useState | null>(null); + const [ledger, setLedger] = useState(null); + const [emit, setEmit] = useState(null); + const [validate, setValidate] = useState(null); + const [busy, setBusy] = useState(null); + const [notice, setNotice] = useState(null); + const [selected, setSelected] = useState(null); + + useEffect(() => { + void probeAgent().then(setAgentUp); + }, []); + + const refreshLedger = useCallback(async (doc: Record | null) => { + setLedger(doc ? await ledgerStatus(doc) : null); + }, []); + + const loadDemo = useCallback(() => { + setMode("demo"); + setProjectPath(""); + setManifest(DEMO_MANIFEST as ProjectManifest); + setContract(structuredClone(DEMO_CONTRACT)); + setProfile(structuredClone(DEMO_PROFILE)); + setEmit(structuredClone(DEMO_EMIT) as EmitPayload); + setValidate(null); + setSelected(null); + setNotice("Demo project loaded (pre-emitted at build time). Edits stay in memory; run the local agent to work on real files."); + void refreshLedger(DEMO_CONTRACT); + }, [refreshLedger]); + + useEffect(() => { + loadDemo(); + }, [loadDemo]); + + const connect = useCallback( + async (path: string) => { + setBusy("connecting"); + const result = await agentConnect(path); + setBusy(null); + if (!result.ok) { + setNotice(`Connect failed: ${result.error}`); + return; + } + const v = result.value; + setMode("agent"); + setProjectPath(path); + setManifest(v.manifest); + setContract((v.contract as Record) ?? null); + setProfile((v.profile as Record) ?? null); + setLedger(v.ledger); + setEmit(null); + setValidate(null); + setSelected(null); + setNotice(v.profileIssue ? `Connected. Profile issue: ${v.profileIssue}` : `Connected to ${path}.`); + }, + [], + ); + + const discover = useCallback(async () => { + if (mode !== "agent") { + setNotice("Discovery runs dspack-export on your machine — connect a project through the local agent first."); + return; + } + setBusy("discovering"); + const result = await agentDiscover(projectPath); + setBusy(null); + if (!result.ok) { + // dspack-export's refusal table speaks verbatim (e.g. human-owned sections). + setNotice(`Discovery refused: ${result.error}`); + return; + } + setContract(result.value.contract as Record); + setLedger(result.value.ledger); + setNotice(`Discovery complete: ${result.value.log}`); + }, [mode, projectPath]); + + const saveContract = useCallback( + async (doc: Record) => { + setContract(doc); + void refreshLedger(doc); + if (mode !== "agent") return { savedInMemory: true as const }; + const result = await agentSave(projectPath, "contract", doc); + if (!result.ok) { + setNotice(`Save failed: ${result.error}`); + return []; + } + if (!result.value.ok) { + return result.value.findings.map((f) => ({ + gate: "document" as const, + code: "save", + severity: "error" as const, + target: f.target ?? f.path ?? "", + message: f.message, + })); + } + if (result.value.ledger) setLedger(result.value.ledger); + setNotice("Contract saved."); + return []; + }, + [mode, projectPath, refreshLedger], + ); + + const saveProfile = useCallback( + async (doc: Record) => { + setProfile(doc); + if (mode !== "agent") return { savedInMemory: true as const }; + const result = await agentSave(projectPath, "profile", doc); + if (!result.ok) { + setNotice(`Save failed: ${result.error}`); + return []; + } + if (!result.value.ok) { + return result.value.findings.map((f) => ({ + gate: "profile" as const, + code: "save", + severity: "error" as const, + target: f.target ?? f.path ?? "", + message: f.message, + })); + } + setNotice("Profile saved."); + return []; + }, + [mode, projectPath], + ); + + const runEmit = useCallback(async () => { + if (mode !== "agent") { + setNotice("Live re-emission runs dspack-emit on your files — the demo shows the build-time emit. Connect through the local agent to re-emit."); + return; + } + setBusy("emitting"); + const result = await agentEmit(projectPath); + setBusy(null); + if (!result.ok) { + setNotice(`Emit failed: ${result.error}`); + return; + } + setEmit(result.value); + setNotice(result.value.ok ? "Emitted: catalog gates green." : "Emitted with failures — see Validate."); + }, [mode, projectPath]); + + const runValidate = useCallback(async () => { + if (mode === "agent") { + setBusy("validating"); + const result = await agentValidate(projectPath); + setBusy(null); + if (!result.ok) { + setNotice(`Validate failed: ${result.error}`); + return; + } + setValidate(result.value); + return; + } + // Demo mode: S1–S3 run IN the browser (dspack-gen/core is pure); the + // contract harness needs the local agent and is listed as such. + setBusy("validating"); + try { + const { lintSurface } = await import("@aestheticfunction/dspack-gen/core"); + const findings: ComposerFinding[] = [ + { + gate: "document", + code: "requires-agent", + severity: "info", + target: "", + message: "The dspack-validate harness runs on your machine; connect through the local agent to include it.", + }, + ]; + const surfaces: Array<{ name: string; surface: unknown }> = []; + for (const example of contract?.examples ?? []) { + if (example.surface) surfaces.push({ name: example.id ?? "example", surface: example.surface }); + } + for (const { name, surface } of surfaces) { + const report = lintSurface(surface, contract as never); + for (const gate of report.gates) { + if (gate.status === "FAIL") { + for (const error of gate.errors ?? []) { + findings.push({ gate: gate.gate as ComposerFinding["gate"], code: "lint", severity: "error", target: name, message: error }); + } + } + } + for (const f of report.findings) { + findings.push({ + gate: "S3", + code: f.ruleId, + severity: f.level === "error" ? "error" : "warn", + target: `${name} ${f.location.path}`, + message: `${f.message} — ${f.rationale}`, + }); + } + } + setValidate({ ok: findings.every((f) => f.severity !== "error"), findings }); + } finally { + setBusy(null); + } + }, [mode, projectPath, contract]); + + const value = useMemo( + () => ({ + mode, + agentUp, + projectPath, + manifest, + contract, + profile, + ledger, + emit, + validate, + busy, + notice, + selected, + setSelected, + connect, + loadDemo, + discover, + saveContract, + saveProfile, + runEmit, + runValidate, + }), + [mode, agentUp, projectPath, manifest, contract, profile, ledger, emit, validate, busy, notice, selected, connect, loadDemo, discover, saveContract, saveProfile, runEmit, runValidate], + ); + + return {children}; +} diff --git a/apps/composer/app/views/component-view.tsx b/apps/composer/app/views/component-view.tsx new file mode 100644 index 0000000..4c0f5bf --- /dev/null +++ b/apps/composer/app/views/component-view.tsx @@ -0,0 +1,114 @@ +"use client"; + +/** + * Component detail: the enrichment step. Prose (description / whenToUse / + * whenNotToUse) AND props — the spike proved discovery is variant-centric + * (plain interface props like `label` are not extracted), so adding props is + * a required capability, not polish. Edits go through the ledger-honoring + * save; in demo mode they stay in memory (stated). + */ +import { useState } from "react"; +import { useComposer } from "../state"; + +const field = { + width: "100%", + fontFamily: "var(--sans)", + fontSize: 13, + background: "var(--bg-1)", + border: "1px solid var(--line)", + color: "var(--fg)", + padding: "6px 8px", + borderRadius: 2, +} as const; +const label = { fontFamily: "var(--mono)", fontSize: 11, textTransform: "uppercase", color: "var(--fg-dim)" } as const; + +export function ComponentView() { + const { contract, selected, saveContract, mode } = useComposer(); + const [newProp, setNewProp] = useState({ name: "", type: "string", values: "", required: false, description: "" }); + const [saved, setSaved] = useState(null); + + if (!contract || !selected || !contract.components?.[selected]) { + return

Pick a component in the Inventory.

; + } + const entry = contract.components[selected]; + + const update = async (mutate: (draft: any) => void) => { + const draft = structuredClone(contract); + mutate(draft.components[selected]); + const result = await saveContract(draft); + setSaved(Array.isArray(result) && result.length > 0 ? result[0].message : mode === "demo" ? "kept in memory (demo)" : "saved"); + }; + + const addProp = () => + void update((c) => { + c.props ??= {}; + c.props[newProp.name] = { + type: newProp.type, + ...(newProp.type === "enum" ? { values: newProp.values.split(",").map((v) => v.trim()).filter(Boolean) } : {}), + ...(newProp.required ? { required: true } : {}), + ...(newProp.description ? { description: newProp.description } : {}), + }; + }); + + return ( +
+

+ {selected} · {entry.name} +

+ + {(["description", "whenToUse", "whenNotToUse"] as const).map((key) => ( +
+ {key} +