From a295c0bfe1fd37827146ae970d7835e9acfa17bc Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:01:03 -0400 Subject: [PATCH 1/8] test(F1): pin that an emit-refused surface is not saveable and that a blocked Build names its blocker Phase F of Composer pre-1.0 readiness. Fail-first: every assertion below fails on the code as it stands today. The defect, walked: shadcn project -> Surfaces -> author a root `card` with no children. The gate strip reads "S1 S2 S3 clean" (no authored rule forbids it) while the preview reads "emitter refusal: ... Card#root: required prop 'child' has no value after emission". Save is enabled and succeeds. Afterwards Build reads "Not ready to build yet: gates not green - 1 error finding", naming nothing. Save is gated on S1-S3 only; the emit result the view already computes and displays does not gate it. The agent's /project/save-example has the same hole from the other side: it re-lints S1-S3 server-side and never re-emits. Four new assertions: 1. e2e/composer-surfaces.spec.ts - the refused surface cannot be saved, writes nothing (across a reload), and saves the moment it can render. 2. e2e/composer-surfaces.spec.ts - a project that ARRIVES with a refused surface (import is vocabulary, not a build, so it is not emit-gated) makes Build name the blocker: title, id, the emitter's own reason, and a way to it. 3. apps/agent/src/project.test.ts - save-example refuses an emit-refused surface with the emitter's words and writes nothing. 4. apps/composer/app/surface-identity.test.ts - blockingFindings(), the pure reader that turns unresolved error findings into named rows. FAILING OUTPUT ON MAIN ---------------------- $ npx playwright test --config playwright.composer-smoke.config.ts \ -g "cannot be saved|names the surface that is blocking" 1) e2e/composer-surfaces.spec.ts:165 - a surface the emitter refuses cannot be saved, and saves as soon as it can render Error: expect(locator).toBeDisabled() failed Locator: getByTestId('save-scenario') Expected: disabled Received: enabled Timeout: 5000ms Call log: - waiting for getByTestId('save-scenario') 14 x locator resolved to - unexpected value "enabled" 177 | await expect(page.getByTestId("lint-clean")).toContainText("S1 S2 S3 clean"); 178 | await expect(page.getByTestId("preview-refused")).toContainText("required prop 'child' has no value"); > 179 | await expect(page.getByTestId("save-scenario")).toBeDisabled(); 2) e2e/composer-surfaces.spec.ts:210 - Build blocked by a finding names the surface that is blocking it Error: expect(locator).toContainText(expected) failed Locator: getByTestId('build-blocker-ex.empty-card') Expected substring: "A card with nothing in it" Timeout: 5000ms Error: element(s) not found 229 | const blocker = page.getByTestId("build-blocker-ex.empty-card"); > 230 | await expect(blocker).toContainText("A card with nothing in it"); 2 failed / 1 passed (the second test reaches "gates not green" in build-not-ready before failing - the blocked state is genuinely reachable; only the naming is missing.) $ pnpm --filter agent exec vitest run src/project.test.ts \ -t "rejects a surface the EMITTER refuses" FAIL src/project.test.ts > accepting a build result (/project/save-example, fail-closed) > rejects a surface the EMITTER refuses, even though every S-gate passes AssertionError: expected 200 to be 422 // Object.is equality - Expected 422 + Received 200 471| example: { id: "ex.empty-card", intent: "status-report", ... }, 472| }); > 473| expect(status).toBe(422); Tests 1 failed | 29 skipped (30) $ pnpm --filter composer exec vitest run app/surface-identity.test.ts FAIL app/surface-identity.test.ts > blockingFindings - what is blocking the build, by name (5 tests) TypeError: (0 , blockingFindings) is not a function > 111| const rows = blockingFindings( Tests 5 failed | 10 passed (15) Co-Authored-By: Claude Fable 5 --- apps/agent/src/project.test.ts | 22 +++++ apps/composer/app/surface-identity.test.ts | 73 +++++++++++++- e2e/composer-surfaces.spec.ts | 109 +++++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/apps/agent/src/project.test.ts b/apps/agent/src/project.test.ts index 43b1494..1e35116 100644 --- a/apps/agent/src/project.test.ts +++ b/apps/agent/src/project.test.ts @@ -459,6 +459,28 @@ describe("accepting a build result (/project/save-example, fail-closed)", () => expect(doc.examples.some((e: any) => e.id === "ex.chat-bad")).toBe(false); }); + it("rejects a surface the EMITTER refuses, even though every S-gate passes", async () => { + // A root info-card with no children breaks no authored rule — S1, S2 and + // S3 all pass — and the emitter still refuses it: the mapped Card's + // `child` prop is required and fed by children. The server gate stopped at + // lint, so this saved cleanly and then blocked the project's own emit from + // inside the contract. Refuse it here, in the emitter's own words. + const surface = { ...structuredClone(freshExample().surface), root: { component: "info-card", id: "root" } }; + const { status, payload } = await call("save-example", { + path: root, + example: { id: "ex.empty-card", intent: "status-report", prompt: "a card with nothing in it", surface }, + }); + expect(status).toBe(422); + expect(payload.ok).toBe(false); + const refusal = payload.findings.find((f: any) => f.code === "emit-surface"); + expect(refusal.severity).toBe("error"); + expect(refusal.target).toBe("ex.empty-card"); + expect(refusal.message).toContain("required prop 'child' has no value"); + // Nothing was written. + const doc = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8")); + expect(doc.examples.some((e: any) => e.id === "ex.empty-card")).toBe(false); + }); + it("rejects unknown intents and malformed ids", async () => { const surface = freshExample().surface; expect((await call("save-example", { path: root, example: { id: "ex.x", intent: "not-an-intent", prompt: "p", surface } })).status).toBe(422); diff --git a/apps/composer/app/surface-identity.test.ts b/apps/composer/app/surface-identity.test.ts index cb29fbb..661234a 100644 --- a/apps/composer/app/surface-identity.test.ts +++ b/apps/composer/app/surface-identity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { partitionSurfaces, surfaceEntriesById, surfaceIdentity, surfaceTitle } from "./surface-identity"; +import { finding } from "@dspack-studio/composer-core"; +import { blockingFindings, partitionSurfaces, surfaceEntriesById, surfaceIdentity, surfaceTitle } from "./surface-identity"; /** * Surface identity is a PRODUCT rule, not a rename: `ex.chat-1` stays the @@ -55,6 +56,76 @@ describe("surfaceTitle — the human label, id preserved as metadata", () => { }); }); +/** + * "gates not green — 1 error finding" is true and useless: it names a count, + * not a thing to fix. blockingFindings turns the findings that actually block + * a build into rows a person can act on — the surface's own title, its + * canonical id, and the gate's verbatim reason. + */ +describe("blockingFindings — what is blocking the build, by name", () => { + const examples = [ + { id: "ex.empty-card", name: "A card with nothing in it" }, + { id: "ex.orders-loading", prompt: "show that orders are loading" }, + ]; + + it("resolves a finding's target to the surface's human title and keeps the id", () => { + const rows = blockingFindings([finding("A3", "emit-surface", "error", "ex.empty-card", "refusing to emit: …")], examples); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: "ex.empty-card", + title: "A card with nothing in it", + isSurface: true, + gate: "A3", + code: "emit-surface", + message: "refusing to emit: …", + }); + }); + + it("resolves an S3 target that carries a node path after the surface id", () => { + const rows = blockingFindings([finding("S3", "rule.button-carries-text", "error", "ex.orders-loading $.root.children[0]", "boom")], examples); + expect(rows[0].id).toBe("ex.orders-loading"); + expect(rows[0].title).toBe("show that orders are loading"); + expect(rows[0].isSurface).toBe(true); + }); + + it("keeps non-surface blockers — they still block, they just are not surfaces", () => { + const rows = blockingFindings( + [ + finding("coverage", "unclassified", "error", "badge", "component is neither mapped, adapted, omitted, nor a declared casualty"), + finding("document", "harness", "error", "", "contract is not valid against the schema"), + ], + examples, + ); + expect(rows.map((r) => r.isSurface)).toEqual([false, false]); + expect(rows[0].id).toBe("badge"); + expect(rows[0].title).toBe("badge"); + expect(rows[1].id).toBe(""); + expect(rows[1].title).toBe(""); + }); + + it("reports only unresolved errors: warnings, info, and acknowledged casualties are not blockers", () => { + const acknowledged = { + ...finding("A3", "emit-surface", "error", "ex.docs-article-trail", "refusing to emit: …"), + acknowledged: { componentId: "breadcrumb", class: "cannot-represent", reason: "no A2UI equivalent" }, + }; + const rows = blockingFindings( + [ + finding("S3", "rule.spinner-names-what-is-loading", "warn", "ex.orders-loading", "warned"), + finding("A3", "wrapped", "info", "ex.empty-card", "noted"), + acknowledged, + ], + examples, + ); + expect(rows).toEqual([]); + }); + + it("is empty-safe: no findings, and findings against a project with no examples", () => { + expect(blockingFindings([], examples)).toEqual([]); + const rows = blockingFindings([finding("A3", "emit-surface", "error", "ex.empty-card", "refusing to emit: …")], undefined); + expect(rows[0]).toMatchObject({ id: "ex.empty-card", title: "ex.empty-card", isSurface: false }); + }); +}); + describe("partitionSurfaces — the user's work first, refusals demoted not hidden", () => { const surfaces = [ { name: "ex.delete-account-confirmation" }, diff --git a/e2e/composer-surfaces.spec.ts b/e2e/composer-surfaces.spec.ts index 3127b6a..6caedf1 100644 --- a/e2e/composer-surfaces.spec.ts +++ b/e2e/composer-surfaces.spec.ts @@ -1,6 +1,41 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; import { authorSurface, newProject } from "./support/composer-browser"; +const REFERENCE = fileURLToPath(new URL("../apps/composer/shadcn-v3-project/", import.meta.url)); + +/** + * An exportable project carrying ONE surface the emitter refuses: the shipped + * shadcn vocabulary, plus a root Card with no children. Import is not + * emit-gated (a project file is vocabulary, not a build), so this is how a + * refused surface legitimately arrives in someone's browser — inherited from a + * teammate, or hand-edited — and it is the state Build has to explain. + */ +function unrenderableProject(): Record { + const contract = JSON.parse(readFileSync(`${REFERENCE}shadcn-ui.dspack.json`, "utf8")) as Record; + const profile = JSON.parse(readFileSync(`${REFERENCE}shadcn-v3.profile.json`, "utf8")) as Record; + contract.examples = [ + ...(contract.examples ?? []), + { + id: "ex.empty-card", + intent: "preference-settings", + name: "A card with nothing in it", + prompt: "a card with nothing in it", + surface: { dspackSurface: "0.1", system: contract.name, intent: "preference-settings", root: { component: "card", id: "root" } }, + }, + ]; + return { + composerProjectExport: "0.1", + exportedAt: new Date(0).toISOString(), + name: "Inherited project", + description: "A project that arrived with a surface this design system cannot draw.", + previewRegistry: "shadcn", + contract, + profile, + }; +} + /** * Surface authoring (the "Surfaces" view) — the second thing a new team does * after their first build, and until now covered by nothing. @@ -127,6 +162,80 @@ test("a surface that fails a gate is refused out loud, saves nothing, and unbloc await expect(page.getByTestId("scenario-ex.unnamed-button")).toContainText("ex.unnamed-button"); }); +test("a surface the emitter refuses cannot be saved, and saves as soon as it can render", async ({ page }) => { + await page.goto("/"); + await newProject(page, "shadcn", "Unrenderable surface"); + await page.getByTestId("nav-surfaces").click(); + + // A root Card with no children breaks NO rule the contract authored — every + // S-gate passes — and the emitter still refuses it: Card's `child` prop is + // required and fed by children. The gate strip and the preview disagree, and + // Save has been following the gate strip. It must follow the refusal too: + // a surface the design system cannot render is not project work. + await authorSurface(page, { id: "empty-card", intent: "preference-settings", component: "card" }); + + await expect(page.getByTestId("lint-clean")).toContainText("S1 S2 S3 clean"); + await expect(page.getByTestId("preview-refused")).toContainText("required prop 'child' has no value"); + await expect(page.getByTestId("save-scenario")).toBeDisabled(); + // The reason beside Save is the EMITTER's own words, not a generic apology. + await expect(page.getByTestId("save-blocked-emit")).toContainText("required prop 'child' has no value"); + + // Nothing is written: not on leaving the editor, and not across a reload. + await page.getByRole("button", { name: "← surfaces" }).click(); + await expect(page.getByTestId("scenario-ex.empty-card")).toHaveCount(0); + await expect(page.getByTestId("scenarios-none-yet")).toBeVisible(); + await page.reload(); + await expect(page.getByTestId("build-prompt")).toBeVisible(); + await page.getByTestId("nav-surfaces").click(); + await expect(page.getByTestId("scenario-ex.empty-card")).toHaveCount(0); + + // And Build is not quietly blocked by work that was never saved. + await page.getByTestId("nav-build").click(); + await expect(page.getByTestId("build-not-ready")).toHaveCount(0); + await page.getByTestId("nav-surfaces").click(); + + // Give the Card something to hold and the SAME surface saves — the emitter's + // refusal is live guidance, exactly like the gates. + await authorSurface(page, { id: "empty-card", intent: "preference-settings", component: "card" }); + await page.getByTestId("add-child-0").click(); + await page.getByTestId("node-component-1").selectOption("button"); + await page.getByTestId("node-text-1").fill("Save preferences"); + await expect(page.getByTestId("preview-refused")).toHaveCount(0); + await expect(page.getByTestId("save-blocked-emit")).toHaveCount(0); + await expect(page.getByTestId("save-scenario")).toBeEnabled(); + await page.getByTestId("save-scenario").click(); + await expect(page.getByTestId("scenario-ex.empty-card")).toContainText("ex.empty-card"); +}); + +test("Build blocked by a finding names the surface that is blocking it", async ({ page }) => { + await page.goto("/"); + // A project that arrives with a surface this design system cannot emit — a + // teammate's exported file, a hand-edited contract. Readiness refuses to + // build, and until now said only "gates not green — 1 error finding": true, + // and useless. It must name the surface, by id AND by the title a person + // reads, and offer the way to it. + await page.getByTestId("nav-projects").click(); + await page.getByTestId("import-project-input").setInputFiles({ + name: "inherited.composerproject.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(unrenderableProject()), "utf8"), + }); + await expect(page.getByTestId("project-context")).toContainText("Inherited project"); + + await page.getByTestId("nav-build").click(); + const notReady = page.getByTestId("build-not-ready"); + await expect(notReady).toContainText("gates not green"); + + const blocker = page.getByTestId("build-blocker-ex.empty-card"); + await expect(blocker).toContainText("A card with nothing in it"); + await expect(blocker).toContainText("ex.empty-card"); + await expect(blocker).toContainText("required prop 'child' has no value"); + + // And it is a way out, not a dead end: the offending surface is one click away. + await page.getByTestId("build-blocker-open-ex.empty-card").click(); + await expect(page.getByTestId("scenario-ex.empty-card")).toContainText("A card with nothing in it"); +}); + test("an emitter refusal is stated in the preview panel instead of a blank canvas", async ({ page }) => { await page.goto("/"); await newProject(page, "shadcn", "Emitter refusal"); From 1f84038ec2fa1072181f8095f2f85e8163ef9c4a Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:03:25 -0400 Subject: [PATCH 2/8] fix(F1): refuse to save a surface the emitter refuses, and name what blocks a build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one honest-failure defect, both now asserted (previous commit). SAVE FOLLOWS THE EMITTER, NOT ONLY THE GATES. The Surfaces editor already computes and displays the emit result beside the gate strip; it just did not gate on it. A root Card with no children breaks no authored rule — every S-gate passes — and the emitter still refuses it, so Save wrote a surface the project's own emit then refused from inside the contract. Save is now disabled while the emitter refuses, with the emitter's verbatim reason beside the button (data-testid save-blocked-emit). `should`-level findings are untouched: only refusals and errors block. The agent's /project/save-example had the same hole from the other side — it re-lints S1-S3 server-side and never re-emitted — so accepting a build result could write the same unrenderable surface into a repository contract. It now runs composer-core's projectEmit over the candidate and refuses with 422 and the emitter's own message, writing nothing. Skipped when the project has no profile: nothing to emit against, and discovery is the missing step, not this. A BLOCKED BUILD NAMES ITS BLOCKER. "gates not green — 1 error finding" is true and useless. blockingFindings() (surface-identity.ts) resolves every unresolved error finding's target to the thing it is about — a surface's human title with its canonical id beside it, a component id for coverage, the document otherwise — and Build lists them with the gate's verbatim reason plus a link into Surfaces and Checks. Acknowledged casualties are decisions and never appear, matching gatesSummary. Verification: F1 e2e 2/2 green (were 2/2 red), agent project.test.ts 30 passed (was 29 + 1 red), composer unit 15 passed in surface-identity, composer smoke suite 30 passed. Co-Authored-By: Claude Fable 5 --- apps/agent/src/project.ts | 18 ++++++++ apps/composer/app/composer.tsx | 2 +- apps/composer/app/surface-identity.ts | 53 +++++++++++++++++++++++ apps/composer/app/views/build-view.tsx | 50 +++++++++++++++++++-- apps/composer/app/views/scenario-view.tsx | 21 ++++++++- 5 files changed, 138 insertions(+), 6 deletions(-) diff --git a/apps/agent/src/project.ts b/apps/agent/src/project.ts index 1cad9e8..da10e06 100644 --- a/apps/agent/src/project.ts +++ b/apps/agent/src/project.ts @@ -667,6 +667,24 @@ async function saveExample(ctx: ProjectContext, body: Record) { } if (findings.length > 0) return { status: 422, payload: { ok: false, findings } }; + // The EMIT gate. The lint gate above answers "does this obey the rules the + // owner authored"; it does not answer "can the design system draw it". A + // tree can pass every S-gate and still be refused by the emitter (a Card + // whose required `child` is fed by children it does not have), and accepting + // that writes a surface the project's own /project/emit then refuses from + // inside the contract — the same hole the browser's Surfaces editor had. + // Refuse it here too, in the emitter's own words. Skipped when the project + // has no profile yet: there is nothing to emit against, and discovery is the + // step that is missing, not this one. + if (existsSync(ctx.profilePath)) { + const profileJson = readJson(ctx.profilePath) as Record; + const emitted = projectEmit(contract, profileJson, [{ name: id, surface: raw.surface }]); + const refusal = emitted.surfaces.find((s) => s.name === id)?.error; + if (refusal) { + return { status: 422, payload: { ok: false, findings: [finding("A3", "emit-surface", "error", id, refusal)] } }; + } + } + const entry = { id, intent, diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx index 3aba606..05bbb1c 100644 --- a/apps/composer/app/composer.tsx +++ b/apps/composer/app/composer.tsx @@ -219,7 +219,7 @@ function Shell() { )} {hasProject && (
- {view === "build" && } + {view === "build" && setView(v)} />} {view === "preview" && } {view === "flows" && } {view === "inventory" && setView("component")} />} diff --git a/apps/composer/app/surface-identity.ts b/apps/composer/app/surface-identity.ts index 2b81e62..61fc4ed 100644 --- a/apps/composer/app/surface-identity.ts +++ b/apps/composer/app/surface-identity.ts @@ -74,6 +74,59 @@ export function surfaceEntriesById(examples: unknown): Map return byId; } +/** + * What is actually blocking a build, by name. + * + * Readiness answers with a COUNT ("gates not green — 1 error finding"), which + * is true and useless: a person cannot fix a count. Every unresolved error + * finding already carries the thing it is about in `target` — a surface id for + * emit refusals and S-gate findings, a component id for coverage, "" for + * document-level — so the row a person needs is one resolution away. + * + * Acknowledged casualties are decisions, not blockers, and never appear here + * (the same rule `gatesSummary` counts by); warnings and info never block. + */ +export interface BlockingFinding { + /** The surface/component id the finding is about, or "" for the document. */ + id: string; + /** The surface's human title when the id names one; the id otherwise. */ + title: string; + /** True when the id resolves to one of this project's own surfaces. */ + isSurface: boolean; + gate: string; + code: string; + message: string; +} + +/** + * S3 findings target `" "`; everything else targets a + * bare id. Take the first token either way — a node path never contains a + * space, and a surface id never does. + */ +const targetId = (target: string): string => target.trim().split(/\s+/)[0] ?? ""; + +export function blockingFindings( + findings: ReadonlyArray<{ gate: string; code: string; severity: string; target: string; message: string; acknowledged?: unknown }>, + examples: unknown, +): BlockingFinding[] { + const byId = surfaceEntriesById(examples); + const rows: BlockingFinding[] = []; + for (const f of findings) { + if (f.severity !== "error" || f.acknowledged !== undefined) continue; + const id = targetId(f.target ?? ""); + const entry = byId.get(id); + rows.push({ + id, + title: entry ? surfaceTitle(entry, id, 64) : id, + isSurface: entry !== undefined, + gate: f.gate, + code: f.code, + message: f.message, + }); + } + return rows; +} + /** Anything a picker lists: an emitted surface, or a flow-step candidate. */ export interface OwnedSurface { name: string; diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx index 2ec25e6..8483c8f 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -20,7 +20,7 @@ import { mintStepId, nextFlowId, type StepBinding } from "../flows"; import { planFlow } from "../planning"; import type { BuildTurn } from "../state"; import { useComposer } from "../state"; -import { surfaceEntriesById, surfaceTitle } from "../surface-identity"; +import { blockingFindings, surfaceEntriesById, surfaceTitle } from "../surface-identity"; import { Eyebrow } from "../ui"; import { browserEmit } from "../validation"; @@ -390,8 +390,8 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste ); } -export function BuildView() { - const { mode, agentUp, contract, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows, saveFlows } = +export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "validate") => void } = {}) { + const { mode, agentUp, contract, emit, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows, saveFlows } = useComposer(); const [prompt, setPrompt] = useState(""); // "" = auto: the governed context is INFERRED from the goal. A specific value @@ -509,13 +509,55 @@ export function BuildView() { }; if (!readiness.ready) { + // A count is not a fix. When findings are what is blocking, name them: + // the surface's own title, its canonical id, the gate's verbatim reason, + // and the way to the thing itself. + const blockers = blockingFindings(emit?.findings ?? [], contract?.examples); return (

Build

Not ready to build yet: {readiness.reason}

-

Set up your design system in Catalog and Governance, then build with it.

+ {blockers.length > 0 && ( +
    + {blockers.map((b, i) => ( +
  • + {b.title || "This project’s contract"} + {b.id && {b.id}} + + {b.gate} {b.code} + + {b.isSurface && onNavigate && ( + + )} +
    + {b.message} +
  • + ))} +
+ )} +

+ {blockers.length > 0 ? ( + <> + Fix or remove what’s listed above — {onNavigate ? : "Checks"} runs the same gates over the whole project. + + ) : ( + <>Set up your design system in Catalog and Governance, then build with it. + )} +

); } diff --git a/apps/composer/app/views/scenario-view.tsx b/apps/composer/app/views/scenario-view.tsx index 2785ed0..87dc390 100644 --- a/apps/composer/app/views/scenario-view.tsx +++ b/apps/composer/app/views/scenario-view.tsx @@ -327,10 +327,29 @@ export function ScenarioView() { )} - {lint.some((f) => f.severity === "error") && gates first} + {/* The gates and the EMITTER are two different authorities, and a + surface has to satisfy both to be project work. A tree can break no + authored rule and still be unrenderable (a Card whose required + `child` is fed by children it does not have): saving that writes a + surface the project's own emit then refuses, from inside the + contract, with nothing pointing back here. The emitter's verbatim + reason is already on the right; it gates Save too. `should`-level + findings stay warnings — only refusals and errors block. */} + {preview?.error && ( +

+ Your design system can’t draw this yet, so it isn’t saved: {preview.error} +

+ )} {issue &&

{issue}

} {mode === "demo" &&

Saves to this project in your browser.

}
From 058eddc4f5b394e7e691eec44d26b5169e8fbdd2 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:04:07 -0400 Subject: [PATCH 3/8] test(F2): pin that leaving Build and coming back keeps the in-progress flow plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail-first. `{view === "build" && }` unmounts the view, so buildMode, flowPlan and flowBuild — all local useState in BuildView — are destroyed by any navigation away and back. The round trip is not hypothetical: the product's own pending-step copy sends people on it ("build it from Build and accept into this step"). After it, the plan editor and the per-step rebuild buttons are gone (1 -> 0), per-step rebuild is unreachable, and the only way forward is to re-plan — which mints a SECOND flow beside the one the first plan already created. FAILING OUTPUT ON MAIN ---------------------- $ npx playwright test --config playwright.composer-smoke.config.ts \ -g "leaving Build and coming back" 1) e2e/composer-flows.spec.ts:167 - leaving Build and coming back keeps the in-progress plan - and never mints a second flow Error: expect(locator).toBeVisible() failed Locator: getByTestId('flow-composer') Expected: visible Timeout: 5000ms Error: element(s) not found 182 | // Back exactly as it was left: still in flow mode, the same plan, the same 183 | // frozen drive, and per-step rebuild still reachable. > 184 | await expect(page.getByTestId("flow-composer")).toBeVisible(); 1 failed Co-Authored-By: Claude Fable 5 --- e2e/composer-flows.spec.ts | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/e2e/composer-flows.spec.ts b/e2e/composer-flows.spec.ts index dd7ed1b..7380d4e 100644 --- a/e2e/composer-flows.spec.ts +++ b/e2e/composer-flows.spec.ts @@ -164,6 +164,48 @@ test("a driven step rebuilds in place, and each accepted turn binds to the step await expect(page.getByTestId("finding-flow-pending-step")).toHaveCount(0); }); +test("leaving Build and coming back keeps the in-progress plan — and never mints a second flow", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + await newProject(page, "shadcn", "Flow round trip"); + await twoStepPlan(page); + + // The product's own pending-step copy sends people to Flows and back: + // "build it from Build and accept into this step". That round trip used to + // unmount the Build view and take the plan with it — mode, plan, and + // per-step build state all gone, per-step rebuild unreachable, and the only + // way forward was to re-plan, which mints a SECOND flow beside the first. + await page.getByTestId("nav-flows").click(); + await expect(page.getByTestId("flow-flow.flow-1")).toContainText("Order journey"); + await page.getByTestId("nav-build").click(); + + // Back exactly as it was left: still in flow mode, the same plan, the same + // frozen drive, and per-step rebuild still reachable. + await expect(page.getByTestId("flow-composer")).toBeVisible(); + await expect(page.getByTestId("build-prompt")).toHaveCount(0); + await expect(page.getByTestId("flow-plan-editor")).toBeVisible(); + await expect(page.getByTestId("flow-plan-name")).toHaveValue("Order journey"); + await expect(page.getByTestId("flow-plan-title-0")).toHaveValue("Review the order"); + await expect(page.getByTestId("flow-plan-title-1")).toHaveValue("Delete the account"); + await expect(page.getByTestId("flow-build-step-1")).toBeVisible(); + await expect(page.getByTestId("flow-plan-add")).toHaveCount(0); // still frozen, not a fresh plan + + // And the round trip created nothing: one flow, the one the plan made. + await page.getByTestId("nav-flows").click(); + await expect(page.getByTestId("flow-flow.flow-1")).toBeVisible(); + await expect(page.getByTestId("flow-flow.flow-2")).toHaveCount(0); + + // The surviving plan still DRIVES: rebuild step 2 after the round trip and + // accept it into the step it was built for. + await page.getByTestId("nav-build").click(); + await page.getByTestId("flow-build-step-1").click(); + await expect(page.getByTestId("build-gate-summary-3")).toContainText("Follows your design-system rules", { timeout: 60_000 }); + await page.getByTestId("build-accept-3").click(); + await expect(page.getByTestId("build-accepted-3")).toContainText("Delete the account"); + await page.getByTestId("nav-flows").click(); + await expect(page.getByTestId("flow-flow.flow-2")).toHaveCount(0); +}); + test("the flow editor: cancelling creates nothing, and a saved flow walks to completion on its own emitted action", async ({ page }) => { await page.goto("/"); await newProject(page, "shadcn", "Flow editor"); From e75629233a5062de285e8100ca138f44421e9bad Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:06:58 -0400 Subject: [PATCH 4/8] fix(F2): lift the flow composition above the Build view so navigation cannot destroy it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{view === "build" && }` unmounts the view, and the mode, the goal, the proposed plan and the plan's per-step build state were all local useState inside it. Any navigation away and back destroyed them: the plan editor and the per-step rebuild buttons went 1 -> 0, per-step rebuild became unreachable, and re-planning — the only way forward — minted a SECOND flow beside the one the first plan had already created. The product itself invites that round trip: a pending step reads "build it from Build and accept into this step". Smallest change that survives unmount: exactly that state moves to the existing provider as one `flowComposition` value ({ mode, goal, plan, build }) with a plain setter. The view keeps its own accessors, so every call site inside BuildView reads unchanged; `planBusy` deliberately stays local — it is a transient in-flight flag, and the plan it awaits lands in the provider either way. Deliberately NOT done: no restructuring of state.tsx (one state + one context field), no persistence (this is in-progress working state, not project data), and no change to flow persistence semantics — `saveFlows` still owns what a flow IS. The composition is cleared in `clearBuildThread`, whose five callers are all project transitions (load reference, open imported, connect repository, close project, delete the active project), so a plan can never leak from one project into another. Verification: the F2 e2e passes (was red), composer smoke suite 31 passed, composer typecheck clean. Co-Authored-By: Claude Fable 5 --- apps/composer/app/state.tsx | 62 +++++++++++++++++++++++++- apps/composer/app/views/build-view.tsx | 46 ++++++++++++++----- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index 8151224..19ee523 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -10,7 +10,18 @@ * delta; other edits are session-only, stated plainly per view. * Files (or the delta store) are the source of truth; this state is a view. */ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; import { addTombstone, applyFreshFact, @@ -25,6 +36,7 @@ import { type BuildReadiness, type BuildTurnProgress, type ComposerFinding, + type FlowPlan, type FreshFact, type GoalPlan, type LedgerStatus, @@ -97,6 +109,40 @@ import { export type Mode = "demo" | "agent"; +/** + * "Build a flow" composition state, held HERE rather than in the Build view. + * + * The view unmounts on every navigation (`{view === "build" && }`), + * and the product itself sends people away mid-composition — a pending step + * reads "build it from Build and accept into this step". Held in the view, the + * plan and its drive died on that round trip, per-step rebuild became + * unreachable, and re-planning minted a SECOND flow beside the one already + * created. This is in-progress WORKING state, not project data: it lives for + * the session, is never persisted, and is cleared with the build thread when + * the project changes. + */ +export interface FlowBuildState { + flowId: string; + /** Minted step ids, index-aligned with the frozen plan's steps. */ + stepIds: string[]; + running: boolean; + /** The step the sequential driver is on, or null when it is not driving. */ + at: number | null; +} + +export interface FlowComposition { + /** "surface" = the ordinary single-surface composer; "flow" = plan a flow. */ + mode: "surface" | "flow"; + /** The whole-journey goal the planner decomposes. */ + goal: string; + /** The proposed (and editable) outline, or null before planning. */ + plan: FlowPlan | null; + /** The accepted plan's created flow + drive position, or null before accept. */ + build: FlowBuildState | null; +} + +export const EMPTY_FLOW_COMPOSITION: FlowComposition = { mode: "surface", goal: "", plan: null, build: null }; + /** One chat turn in the Build thread: the ask, its run, and its result. */ export interface BuildTurn { id: number; @@ -240,6 +286,10 @@ export interface ComposerState { * a STALE binding never fails the accept, it is reported in the notice. */ acceptBuildTurn: (turnId: number, exampleId?: string, intoFlowStep?: StepBinding) => Promise; clearBuildThread: () => void; + /** "Build a flow" composition, held above the view so navigating away and + * back does not destroy an in-progress plan (see FlowComposition). */ + flowComposition: FlowComposition; + setFlowComposition: Dispatch>; } const Ctx = createContext(null); @@ -951,6 +1001,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) { /* ---------------- Build (chat-driven creation) ---------------- */ const [buildTurns, setBuildTurns] = useState([]); + // In-progress flow composition, lifted out of BuildView so the view can + // unmount without taking the plan with it (see FlowComposition). + const [flowComposition, setFlowComposition] = useState(EMPTY_FLOW_COMPOSITION); const [buildBusy, setBuildBusy] = useState(false); const [buildModels, setBuildModels] = useState(["scripted"]); // The auto-select correction below must NOT run against the ["scripted"] @@ -1060,6 +1113,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) { buildStream.current = null; setBuildTurns([]); setBuildBusy(false); + // A composition belongs to the project it was planned in — every caller of + // this is a project transition (open, close, connect, import). + setFlowComposition(EMPTY_FLOW_COMPOSITION); }, []); /** @@ -1460,8 +1516,10 @@ export function ComposerProvider({ children }: { children: ReactNode }) { runBuild, acceptBuildTurn, clearBuildThread, + flowComposition, + setFlowComposition, }), - [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread], + [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition], ); return {children}; diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx index 8483c8f..9b3a410 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -18,7 +18,7 @@ import { registryFor, canvasScopeFor } from "../registries"; import { buildFailure, canAcceptTurn, canRefineTurn, intentLabel, type FlowPlan } from "@dspack-studio/composer-core"; import { mintStepId, nextFlowId, type StepBinding } from "../flows"; import { planFlow } from "../planning"; -import type { BuildTurn } from "../state"; +import type { BuildTurn, FlowBuildState } from "../state"; import { useComposer } from "../state"; import { blockingFindings, surfaceEntriesById, surfaceTitle } from "../surface-identity"; import { Eyebrow } from "../ui"; @@ -391,8 +391,24 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste } export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "validate") => void } = {}) { - const { mode, agentUp, contract, emit, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows, saveFlows } = - useComposer(); + const { + mode, + agentUp, + contract, + emit, + readiness, + buildTurns, + buildBusy, + buildModels, + selectableModels, + runBuild, + activeModel, + setActiveModel, + flows, + saveFlows, + flowComposition, + setFlowComposition, + } = useComposer(); const [prompt, setPrompt] = useState(""); // "" = auto: the governed context is INFERRED from the goal. A specific value // is an advanced override for catalog authors — never the normal prerequisite. @@ -402,14 +418,24 @@ export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "va // untouched — this is an ACCEPT-time affordance on the intent-select pattern. const [intoStepKey, setIntoStepKey] = useState(""); /* ---- "Build a flow" (P4 Phase C) — OPT-IN; the default single-surface - path renders and behaves exactly as before until the toggle. ---- */ - const [buildMode, setBuildMode] = useState<"surface" | "flow">("surface"); - const [flowGoal, setFlowGoal] = useState(""); - const [flowPlan, setFlowPlan] = useState(null); + path renders and behaves exactly as before until the toggle. + + The mode, the goal, the plan and the plan's per-step build state live in + the PROVIDER, not here: this view unmounts on every navigation, and the + product's own pending-step copy sends people away mid-composition ("build + it from Build and accept into this step"). Held locally, that round trip + destroyed the plan and re-planning minted a second flow. `planBusy` stays + local on purpose — it is a transient in-flight flag, and the plan it is + waiting on lands in the provider either way. ---- */ + const { mode: buildMode, goal: flowGoal, plan: flowPlan, build: flowBuild } = flowComposition; + const setBuildMode = (mode: "surface" | "flow") => setFlowComposition((c) => ({ ...c, mode })); + const setFlowGoal = (goal: string) => setFlowComposition((c) => ({ ...c, goal })); + const setFlowPlan = (next: FlowPlan | null | ((prev: FlowPlan | null) => FlowPlan | null)) => + setFlowComposition((c) => ({ ...c, plan: typeof next === "function" ? next(c.plan) : next })); + const setFlowBuild = ( + next: FlowBuildState | null | ((prev: FlowBuildState | null) => FlowBuildState | null), + ) => setFlowComposition((c) => ({ ...c, build: typeof next === "function" ? next(c.build) : next })); const [planBusy, setPlanBusy] = useState(false); - // The accepted plan's created flow + its minted step ids (index-aligned - // with the frozen plan), and the sequential driver's position. - const [flowBuild, setFlowBuild] = useState<{ flowId: string; stepIds: string[]; running: boolean; at: number | null } | null>(null); const intents = ((contract?.intents ?? []) as Array<{ id: string }>).map((i) => i.id); const streamStatus = useRef(null); const canRefine = buildTurns.some((t) => canRefineTurn(t.progress)); From 73dafb6211bd0af462fe74a5a1d70bdfb0a3dad2 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:08:26 -0400 Subject: [PATCH 5/8] test(F3): pin one reader and one writer for dspack's two spec-valid enum shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail-first. dspack v0.4 allows an enum prop's `values` to be bare values OR value descriptor objects ({ value, description }); both are spec-valid, and the shipped shadcn contract uses the RICH form throughout. apps/composer/app/views/component-view.tsx:81 renders `p.values.join(", ")`, which stringifies the objects. Demonstrated against the contract we ship: $ node -e "...components.button.props..." variant: [[object Object], [object Object], [object Object], [object Object], [object Object], [object Object]] size: [[object Object], [object Object], [object Object], [object Object]] --- enum props on Button: 2 --- rich-enum props across the shipped contract: 27 The same view's "Add prop" path (line 47) writes FLAT strings, so the catalog ends up holding two shapes for the same idea and the view displays one while authors write the other. New unit suite over the pure reader/writer this needs — enumMembers (the canonical unwrap, mirroring dspack-gen's enumValues(), keeping the description that reader discards), enumLabel, and parseEnumValues. Covers: the flat form, the rich form, a mixed list, non-string values, missing/empty/malformed/ non-enum props, a descriptor with no value, the shipped Button props, and the `join` defect itself. FAILING OUTPUT ON MAIN ---------------------- $ pnpm --filter composer exec vitest run app/contract-enums.test.ts FAIL app/contract-enums.test.ts [ app/contract-enums.test.ts ] Error: Cannot find module './contract-enums' imported from '/.../apps/composer/app/contract-enums.test.ts' 1| import { describe, expect, it } from "vitest"; 2| import contract from "../shadcn-v3-project/shadcn-ui.dspack.json"; 3| import { enumLabel, enumMembers, parseEnumValues } from "./contract-en… | ^ Test Files 1 failed (1) Tests no tests Co-Authored-By: Claude Fable 5 --- apps/composer/app/contract-enums.test.ts | 106 +++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 apps/composer/app/contract-enums.test.ts diff --git a/apps/composer/app/contract-enums.test.ts b/apps/composer/app/contract-enums.test.ts new file mode 100644 index 0000000..b8eda4e --- /dev/null +++ b/apps/composer/app/contract-enums.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import contract from "../shadcn-v3-project/shadcn-ui.dspack.json"; +import { enumLabel, enumMembers, parseEnumValues } from "./contract-enums"; + +/** + * dspack v0.4 allows an enum prop's `values` to be bare values OR value + * descriptor objects (`{ value, description }`), and BOTH are spec-valid. The + * shipped shadcn contract uses the rich form throughout — ten occurrences on + * Button alone — so any reader that assumes strings prints "[object Object]" + * at the user, and any AUTHORING path that writes strings makes the catalog + * hold two shapes for the same idea. + * + * One reader, one writer, both pinned here. This mirrors dspack-gen's + * `enumValues()` (the canonical unwrap) and keeps the description, which that + * reader deliberately discards. + */ +describe("enumMembers — one reader for both spec-valid enum shapes", () => { + it("reads the flat form: bare strings", () => { + expect(enumMembers({ type: "enum", values: ["sm", "md", "lg"] })).toEqual([ + { value: "sm" }, + { value: "md" }, + { value: "lg" }, + ]); + }); + + it("reads the rich form and keeps each value's description", () => { + expect( + enumMembers({ + type: "enum", + values: [ + { value: "default", description: "Standard button for primary page actions." }, + { value: "destructive", description: "For irreversible actions like delete." }, + ], + }), + ).toEqual([ + { value: "default", description: "Standard button for primary page actions." }, + { value: "destructive", description: "For irreversible actions like delete." }, + ]); + }); + + it("reads a mixed list, and non-string values, without stringifying an object", () => { + const members = enumMembers({ type: "enum", values: ["plain", { value: "rich", description: "why" }, 3, true] }); + expect(members.map((m) => m.value)).toEqual(["plain", "rich", "3", "true"]); + expect(members.some((m) => m.value.includes("[object"))).toBe(false); + }); + + it("is empty-safe: missing, empty, malformed, and non-enum props", () => { + expect(enumMembers({ type: "enum" })).toEqual([]); + expect(enumMembers({ type: "enum", values: [] })).toEqual([]); + expect(enumMembers({ type: "string", values: ["a"] })).toEqual([]); + expect(enumMembers({ type: "enum", values: "sm,md" })).toEqual([]); + expect(enumMembers(undefined)).toEqual([]); + expect(enumMembers(null)).toEqual([]); + // A descriptor with no value is not a value — it is skipped, not rendered. + expect(enumMembers({ type: "enum", values: [{ description: "orphan" }, { value: "kept" }] })).toEqual([{ value: "kept" }]); + }); + + it("reads the SHIPPED contract's Button props without producing [object Object]", () => { + const props = (contract as unknown as Record).components.button.props as Record; + const enums = Object.entries(props).filter(([, p]) => p.type === "enum"); + expect(enums.length).toBeGreaterThan(0); + for (const [, p] of enums) { + const members = enumMembers(p); + expect(members.length).toBe(p.values.length); + for (const m of members) { + expect(m.value).not.toContain("[object"); + expect(typeof m.value).toBe("string"); + } + } + const variant = enumMembers(props.variant); + expect(variant.map((m) => m.value)).toContain("destructive"); + expect(variant.find((m) => m.value === "destructive")?.description).toContain("irreversible"); + }); + + it("the defect it replaces: `values.join(', ')` over the shipped contract prints [object Object]", () => { + const values = (contract as unknown as Record).components.button.props.variant.values as unknown[]; + // What component-view.tsx renders today, against the contract we ship. + expect(values.join(", ")).toContain("[object Object]"); + expect(enumMembers({ type: "enum", values }).map((m) => m.value).join(", ")).not.toContain("[object Object]"); + }); + + it("enumLabel reads one member of either shape", () => { + expect(enumLabel("ghost")).toBe("ghost"); + expect(enumLabel({ value: "ghost", description: "Minimal" })).toBe("ghost"); + expect(enumLabel(7)).toBe("7"); + }); +}); + +describe("parseEnumValues — authoring writes the shape the contract already uses", () => { + it("writes value descriptors, not bare strings", () => { + expect(parseEnumValues("sm, md , lg")).toEqual([{ value: "sm" }, { value: "md" }, { value: "lg" }]); + }); + + it("drops blanks and duplicates, so an authored enum is never malformed", () => { + expect(parseEnumValues("sm,,md, ,sm")).toEqual([{ value: "sm" }, { value: "md" }]); + expect(parseEnumValues(" ")).toEqual([]); + expect(parseEnumValues("")).toEqual([]); + }); + + it("round-trips through the reader — what is authored is what is displayed", () => { + expect(enumMembers({ type: "enum", values: parseEnumValues("ghost, link") }).map((m) => m.value)).toEqual([ + "ghost", + "link", + ]); + }); +}); From bf5e5b3dec876eaea1af4d73591acc6984b937b7 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:12:04 -0400 Subject: [PATCH 6/8] fix(F3): one reader and one writer for dspack's two enum shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Catalog's component detail joined `p.values` straight into text. Against the contract this product ships — where every enum is the RICH form — that is what a person saw on the page they open to learn the vocabulary: ---- Catalog > button > props (rendered, before) ---- variant enum [[object Object], [object Object], [object Object], [object Object], [object Object], [object Object]] Visual treatment... size enum [[object Object], [object Object], [object Object], [object Object]] Button size. Use icon for... Both shapes are spec-valid, so this is a reader problem, not a contract problem. `enumMembers` (apps/composer/app/contract-enums.ts) is now the one reader: it mirrors dspack-gen's canonical `enumValues()` unwrap and keeps the per-value description that reader discards, because a UI has a use for it. The detail page renders the values as values and hangs each description off a title, so the rich form finally pays for itself. The same file's "Add prop" wrote FLAT strings, so the view displayed one shape and authors wrote another into the same catalog. `parseEnumValues` writes value descriptors — the shape the shipped contract already uses. Neither the contract nor the schema changes: dspack.v0.4 §valueDescriptor requires only `value`. Two more copies of the same unwrap are now gone: the Mapper's local `enumLabel` and the surface editor's inline `.map(v => v.value)`. The surface editor's enum select also stops requiring `Array.isArray(values)` before rendering, and carries the descriptions as titles too — the vocabulary a person browses and the vocabulary they author with are read by one function. Verification: contract-enums unit suite 10 passed (was a module-not-found failure), composer unit 106 passed, composer smoke suite 32 passed including the new end-to-end assertion that the Catalog page and the surface editor agree, composer typecheck clean. Co-Authored-By: Claude Fable 5 --- apps/composer/app/contract-enums.ts | 69 ++++++++++++++++++++++ apps/composer/app/views/component-view.tsx | 47 +++++++++++---- apps/composer/app/views/mapper-view.tsx | 12 ++-- apps/composer/app/views/scenario-view.tsx | 11 ++-- e2e/composer-surfaces.spec.ts | 29 +++++++++ 5 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 apps/composer/app/contract-enums.ts diff --git a/apps/composer/app/contract-enums.ts b/apps/composer/app/contract-enums.ts new file mode 100644 index 0000000..6279813 --- /dev/null +++ b/apps/composer/app/contract-enums.ts @@ -0,0 +1,69 @@ +/** + * Enum values, read once. + * + * A dspack v0.4 enum prop's `values` may be bare values (`["sm","md"]`) or + * value descriptor objects (`[{ value: "sm", description: "…" }]`). BOTH are + * spec-valid, and the shipped shadcn contract uses the rich form throughout — + * so every reader that assumes strings prints "[object Object]" at the user, + * and every writer that emits strings puts a second shape for the same idea + * into the same catalog. + * + * `enumMembers` is that one reader. It mirrors dspack-gen's `enumValues()` + * (the canonical unwrap: `v.value` when the member is an object, the member + * otherwise) and keeps the per-value description, which that reader + * deliberately discards because generation has no use for it and a UI does. + * + * `parseEnumValues` is the matching writer: authoring produces the descriptor + * form the contract already uses. Neither the contract nor the schema changes + * — value descriptors require only `value` (dspack.v0.4 §valueDescriptor). + */ + +/** One allowed value of an enum prop, in the shape a UI can render. */ +export interface EnumMember { + value: string; + /** When to choose this value, when the contract says. */ + description?: string; +} + +/** One member's text, whichever shape it arrived in. */ +export function enumLabel(member: unknown): string { + if (member && typeof member === "object" && "value" in member) return String((member as { value: unknown }).value); + return String(member); +} + +/** + * The allowed values of a prop descriptor, or [] for anything that is not a + * populated enum (a missing `values`, a non-array, a non-enum prop, a + * descriptor carrying no value). Empty-safe by construction: a catalog page + * renders whatever the contract holds, including a half-authored entry. + */ +export function enumMembers(prop: unknown): EnumMember[] { + if (!prop || typeof prop !== "object") return []; + const { type, values } = prop as { type?: unknown; values?: unknown }; + if (type !== "enum" || !Array.isArray(values)) return []; + const members: EnumMember[] = []; + for (const member of values) { + if (member && typeof member === "object") { + const { value, description } = member as { value?: unknown; description?: unknown }; + if (value === undefined || value === null) continue; // a descriptor with no value is not a value + members.push({ value: String(value), ...(typeof description === "string" && description ? { description } : {}) }); + continue; + } + if (member === undefined || member === null) continue; + members.push({ value: String(member) }); + } + return members; +} + +/** The values authored in the catalog's comma-separated field, as descriptors. */ +export function parseEnumValues(input: string): EnumMember[] { + const seen = new Set(); + const members: EnumMember[] = []; + for (const raw of input.split(",")) { + const value = raw.trim(); + if (!value || seen.has(value)) continue; + seen.add(value); + members.push({ value }); + } + return members; +} diff --git a/apps/composer/app/views/component-view.tsx b/apps/composer/app/views/component-view.tsx index c589b13..ad0efe6 100644 --- a/apps/composer/app/views/component-view.tsx +++ b/apps/composer/app/views/component-view.tsx @@ -8,6 +8,7 @@ * save; in demo mode they stay in memory (stated). */ import { useState } from "react"; +import { enumMembers, parseEnumValues } from "../contract-enums"; import { useComposer } from "../state"; const field = { @@ -44,7 +45,11 @@ export function ComponentView() { c.props ??= {}; c.props[newProp.name] = { type: newProp.type, - ...(newProp.type === "enum" ? { values: newProp.values.split(",").map((v) => v.trim()).filter(Boolean) } : {}), + // Authored values are written as VALUE DESCRIPTORS — the shape this + // contract (and the shipped shadcn one) already uses everywhere. Both + // forms are spec-valid, but a catalog holding two shapes for the same + // idea is a catalog whose readers have to guess. + ...(newProp.type === "enum" ? { values: parseEnumValues(newProp.values) } : {}), ...(newProp.required ? { required: true } : {}), ...(newProp.description ? { description: newProp.description } : {}), }; @@ -73,17 +78,35 @@ export function ComponentView() {

props

- {Object.entries((entry.props ?? {}) as Record).map(([name, p]) => ( - - - - - - ))} + {Object.entries((entry.props ?? {}) as Record).map(([name, p]) => { + // Either spec-valid enum shape reads the same here; a per-value + // description (the rich form's whole point) rides on the title. + const members = enumMembers(p); + return ( + + + + + + ); + })}
{name} - {p.type} - {p.values ? ` [${p.values.join(", ")}]` : ""} - {p.required ? " · required" : ""} - {p.description ?? ""}
{name} + {p.type} + {members.length > 0 && ( + <> + {" ["} + {members.map((m, i) => ( + + {i > 0 ? ", " : ""} + + {m.value} + + + ))} + {"]"} + + )} + {p.required ? " · required" : ""} + {p.description ?? ""}
diff --git a/apps/composer/app/views/mapper-view.tsx b/apps/composer/app/views/mapper-view.tsx index 6cb414d..55279d6 100644 --- a/apps/composer/app/views/mapper-view.tsx +++ b/apps/composer/app/views/mapper-view.tsx @@ -8,16 +8,16 @@ * profile proposes, the emitter judges. */ import { useState } from "react"; -import { useComposer } from "../state"; - /** * A contract enum member is either a plain string (v1) or a * `{ value, description }` object (v3). Everything that treats an enum value - * as text — labels, valueMap keys, React children — must go through this; - * rendering the object directly is React error #31. + * as text — labels, valueMap keys, React children — goes through the ONE + * reader in ../contract-enums; rendering the object directly is React error + * #31, and reading it two different ways is how the catalog page and this one + * disagreed about the same prop. */ -const enumLabel = (v: unknown): string => - typeof v === "string" ? v : v && typeof v === "object" && "value" in v ? String((v as { value: unknown }).value) : String(v); +import { enumLabel } from "../contract-enums"; +import { useComposer } from "../state"; const field = { fontFamily: "var(--mono)", diff --git a/apps/composer/app/views/scenario-view.tsx b/apps/composer/app/views/scenario-view.tsx index 87dc390..a32288e 100644 --- a/apps/composer/app/views/scenario-view.tsx +++ b/apps/composer/app/views/scenario-view.tsx @@ -16,6 +16,7 @@ import { useMemo, useState } from "react"; import { buildVocabulary } from "@aestheticfunction/dspack-spec/lib/validate.mjs"; import { A2uiCanvas } from "@dspack-studio/a2ui-ingest"; import { wireframeRegistryFor } from "@dspack-studio/wireframe-renderers"; +import { enumMembers } from "../contract-enums"; import { useComposer } from "../state"; import { ViewHeader } from "../ui"; import { surfaceTitle } from "../surface-identity"; @@ -91,15 +92,17 @@ function NodeEditor({
{[...descriptors.entries()].map(([name, d]) => { const value = node.props?.[name]; - if (d.type === "enum" && Array.isArray(d.values)) { - const values = d.values.map((v: any) => (v && typeof v === "object" ? v.value : v)); + if (d.type === "enum") { + const members = enumMembers(d); return ( {name}={" "} diff --git a/e2e/composer-surfaces.spec.ts b/e2e/composer-surfaces.spec.ts index 6caedf1..8cd7f58 100644 --- a/e2e/composer-surfaces.spec.ts +++ b/e2e/composer-surfaces.spec.ts @@ -236,6 +236,35 @@ test("Build blocked by a finding names the surface that is blocking it", async ( await expect(page.getByTestId("scenario-ex.empty-card")).toContainText("A card with nothing in it"); }); +test("the Catalog and the surface editor read the contract's enum values the same way", async ({ page }) => { + await page.goto("/"); + await newProject(page, "shadcn", "Enum vocabulary"); + + // The shipped contract declares Button's `variant` as VALUE DESCRIPTORS + // ({ value, description }) — one of dspack's two spec-valid enum shapes, and + // the one this design system uses. The Catalog page joined the raw array, so + // the page a person opens to learn the vocabulary showed [object Object] ten + // times over. + await page.getByTestId("nav-inventory").click(); + await page.getByTestId("inventory-button").click(); + const variant = page.getByTestId("prop-variant"); + await expect(variant).toContainText("destructive"); + await expect(variant).toContainText("ghost"); + await expect(variant).not.toContainText("[object Object]"); + // The per-value description the rich form exists to carry is not thrown away. + await expect(variant.locator('[title*="irreversible"]')).toContainText("destructive"); + + // The surface editor offers the SAME values from the SAME reader — the + // vocabulary a person browses and the vocabulary they author with agree. + await page.getByTestId("nav-surfaces").click(); + await authorSurface(page, { id: "variant-check", intent: "preference-settings", component: "button", text: "Delete workspace" }); + const select = page.getByTestId("node-prop-variant"); + await expect(select.locator("option", { hasText: "destructive" })).toHaveCount(1); + await expect(select).not.toContainText("[object Object]"); + await select.selectOption("destructive"); + await expect(page.getByTestId("save-scenario")).toBeEnabled(); +}); + test("an emitter refusal is stated in the preview panel instead of a blank canvas", async ({ page }) => { await page.goto("/"); await newProject(page, "shadcn", "Emitter refusal"); From 816191eadfd08a072c2bc7e76065a2f7c3a7e59f Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 13:14:23 -0400 Subject: [PATCH 7/8] fix(F4/F5): finish the vocabulary sweep in user-visible copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ratified vocabulary is Surface / Flow / Step / Example (Example = a reference PROJECT only). Four strings a person reads still carried the retired words: - Projects hub, empty state: "pick a governed design system to begin" — the picker label directly above it already reads "Design system". - Checks, lead: "the contract and its worked surfaces" — a hybrid of the retired "worked examples" and the ratified "surfaces". - Build, scripted-mode notice: "replays a representative example for this context" — "example" now means a reference project, and what is replayed is a surface. - Build, scripted-mode refusal (hosted-build.ts): "replays this intent's own worked example" — now word-for-word the AGENT's twin of the same refusal (apps/agent/src/project.ts), which already used the ratified phrasing. The two doors said the same thing differently. Swept and left alone deliberately: every other user-visible "example" refers to a reference project (the Examples section, "duplicate this example into your projects"), which is the ratified meaning; `scenario` survives only in filenames, component names and data-testids, which the earlier sweep kept on purpose (nothing a person reads); the generated emit report's authoring notes in the contracts package are contract content, out of scope. Verification: composer smoke suite 32 passed, composer typecheck clean. Co-Authored-By: Claude Fable 5 --- apps/composer/app/hosted-build.ts | 2 +- apps/composer/app/views/build-view.tsx | 4 ++-- apps/composer/app/views/projects-view.tsx | 4 ++-- apps/composer/app/views/validate-view.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/composer/app/hosted-build.ts b/apps/composer/app/hosted-build.ts index d2d2e17..a353ce1 100644 --- a/apps/composer/app/hosted-build.ts +++ b/apps/composer/app/hosted-build.ts @@ -211,7 +211,7 @@ export function streamHostedBuild( const example = examples.filter((e) => e.intent === intent).at(-1); if (!example) { handlers.onError( - `Scripted mode replays this intent's own worked example, and '${intent || "(none)"}' has none. ` + + `Scripted mode replays this governed context's own surface, and '${intent || "(none)"}' has none yet. ` + "Pick a governed context that already has a surface, or connect the local agent to generate from the contract without few-shot context.", ); handlers.onComplete(); diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx index 9b3a410..0a68094 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -116,8 +116,8 @@ function ContextChip({ turn, onChange }: { turn: BuildTurn; onChange?: () => voi {turn.plan.reason && — {turn.plan.reason}} {turn.modelRef === "scripted" && !turn.refinement && ( - Scripted mode replays a representative example for this context — switch to a hosted or local model to generate - for your exact words. + Scripted mode replays a surface this project already has for this context — switch to a hosted or local model + to generate for your exact words. )} {onChange && ( diff --git a/apps/composer/app/views/projects-view.tsx b/apps/composer/app/views/projects-view.tsx index bb97179..a1950e2 100644 --- a/apps/composer/app/views/projects-view.tsx +++ b/apps/composer/app/views/projects-view.tsx @@ -220,8 +220,8 @@ export function ProjectsView({ onOpen, onConnect }: { onOpen: () => void; onConn

No projects yet

- Name a project above and pick a governed design system to begin. Everything you build is checked against - that system’s rules as you go — or open an example below to see how Composer works first. + Name a project above and pick a design system to begin. Everything you build is checked against that + system’s rules as you go — or open an example below to see how Composer works first.

) : ( diff --git a/apps/composer/app/views/validate-view.tsx b/apps/composer/app/views/validate-view.tsx index 14e4c59..5ea300d 100644 --- a/apps/composer/app/views/validate-view.tsx +++ b/apps/composer/app/views/validate-view.tsx @@ -96,7 +96,7 @@ export function ValidateView() {
)} -
- {b.message} + {group.map((b, i) => ( +

+ + {b.gate} {b.code} + {" "} + {b.message} +

+ ))} ))} + {hidden > 0 && ( +
  • + and {hidden} more — Checks lists every one. +
  • + )} )}