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/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/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", + ]); + }); +}); 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/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/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/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/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..6c4f4e2 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -18,9 +18,9 @@ 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 { surfaceEntriesById, surfaceTitle } from "../surface-identity"; +import { blockingFindings, surfaceEntriesById, surfaceTitle } from "../surface-identity"; import { Eyebrow } from "../ui"; import { browserEmit } from "../validation"; @@ -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 && ( @@ -390,9 +390,25 @@ 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 } = - useComposer(); +export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "validate") => void } = {}) { + 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() { // 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)); @@ -509,13 +535,69 @@ 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. Grouped by the thing they are about — + // one surface with three findings is one problem, not three — and capped, + // because Checks is where the exhaustive list belongs. + const blockers = blockingFindings(emit?.findings ?? [], contract?.examples); + const byTarget = new Map(); + for (const b of blockers) byTarget.set(b.id, [...(byTarget.get(b.id) ?? []), b]); + const shown = [...byTarget.entries()].slice(0, 6); + const hidden = byTarget.size - shown.length; return (

Build

Not ready to build yet: {readiness.reason}

-

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

+ {shown.length > 0 && ( +
    + {shown.map(([id, group]) => ( +
  • + {group[0].title || "This project’s contract"} + {id && {id}} + {group[0].isSurface && onNavigate && ( + + )} + {group.map((b, i) => ( +

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

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

+ {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/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/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/scenario-view.tsx b/apps/composer/app/views/scenario-view.tsx index 2785ed0..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}={" "} @@ -327,10 +330,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.

}
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() {