diff --git a/README.md b/README.md index f890543..3ceaf55 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Both can be reachable at once. When the local agent is connected, Hosted AI stay ## Projects - **Your projects** holds only what you created or imported. **Examples** (on the same hub) are read-only reference projects — the design systems' own worked examples, scenarios, and governance — for learning: *Open example* to explore (changes are not kept), *Create copy* to start your own. -- A project's source is a packaged **reference** (shadcn/ui or Astryx), an **imported** file, or a **connected repository** (through the agent). The canonical references are immutable; **your project owns its accepted surfaces** — a per-project authored delta layered over the base vocabulary. What you build and accept shows up in Preview and Scenarios as *yours*, seeds future generation, and survives reload. +- A project's source is a packaged **reference** (shadcn/ui or Astryx), an **imported** file, or a **connected repository** (through the agent). The canonical references are immutable; **your project owns its accepted surfaces** — a per-project authored delta layered over the base vocabulary. What you build and accept shows up in Preview and Surfaces as *yours*, seeds future generation, and survives reload. - Full lifecycle: create, rename, duplicate, remove, switch; the last-opened project reopens on return. - **Portability:** *Export* (on the project card, and always in the top bar while a project is open) downloads a `.composerproject.json` — the project's name, description, design system, contract, and profile, including your accepted surfaces. No machine paths, ids, or credentials ever travel in it. *Import a project…* on the hub restores it, ready to keep building. @@ -115,10 +115,11 @@ The Composer UX and AI pipeline are shared; adapters for further design systems | View | What it is | |---|---| | **Build** | Goal-first conversational authoring: describe → inferred governed context → generate → gates → render → refine → *Add to project* | -| **Preview** | The project's own accepted surfaces (default), with the reference examples clearly separated; catalog export | +| **Preview** | The project's own surfaces, drawn in its own design system by default, with the reference surfaces clearly separated; catalog export | | **Catalog** | The governed component vocabulary available to this project — what Composer is allowed to use, and how each maps to A2UI | | **Governance** | The intents, rules, and constraints governing generation and validation — why certain things are allowed or refused | -| **Scenarios** | Project worked examples (yours), plus the clearly labeled reference corpus; hand-authoring with live gates | +| **Surfaces** | Every screen this project has built or authored (yours), plus the clearly labeled reference corpus; hand-authoring with live gates | +| **Flows** | Your surfaces composed into a walkable workflow — Preview, opened on flow mode | | **Checks** | The validation dashboard: contract + surface gates, findings, evidence | | **Settings** | Providers (Hosted / Local / Scripted) and appearance | | **Repository** | Repository-backed tools: discovery, rediscovery, the ownership ledger (agent projects only) | diff --git a/apps/agent/src/project.test.ts b/apps/agent/src/project.test.ts index 1e58495..43b1494 100644 --- a/apps/agent/src/project.test.ts +++ b/apps/agent/src/project.test.ts @@ -564,7 +564,7 @@ describe("safe worked-example persistence (#42) and honest scripted absence (#43 const { status, payload } = await call("run", { path: root, prompt: "an onboarding screen", intent: "onboarding", modelRef: "scripted" }); expect(status).toBe(400); expect(String(payload.error)).toMatch(/onboarding/); - expect(String(payload.error)).toMatch(/worked example/i); + expect(String(payload.error)).toMatch(/own surface/i); }); }); diff --git a/apps/agent/src/project.ts b/apps/agent/src/project.ts index 5f36027..1cad9e8 100644 --- a/apps/agent/src/project.ts +++ b/apps/agent/src/project.ts @@ -494,8 +494,8 @@ async function runProject(ctx: ProjectContext, body: Record, re if (modelRef === "scripted" && !example) { throw new ProjectError( 400, - `scripted mode replays this intent's own worked example, and '${intent}' has none yet. ` + - `Author one in Scenarios, or run with a model — generation works from the scoped contract without few-shot context.`, + `scripted mode replays this governed context's own surface, and '${intent}' has none yet. ` + + `Author one in Surfaces, or run with a model — generation works from the scoped contract without few-shot context.`, ); } const adapter = provider diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx index 181c295..3aba606 100644 --- a/apps/composer/app/composer.tsx +++ b/apps/composer/app/composer.tsx @@ -25,22 +25,26 @@ export type View = | "component" | "mapper" | "governance" - | "scenarios" + | "surfaces" + | "flows" | "validate" | "settings" | "connect" | "repository"; /** The working views, shown in the nav only when a project is open. Their order - * is the product's, not the pipeline's: build, look, then the vocabulary and - * rules behind it. Catalog is the inventory; Components/Mapper drill in from it; - * Scenarios and Checks hang off Governance and Build. */ + * is the product's, not the pipeline's: make something, look at it, compose it + * into a workflow — then the vocabulary and rules behind it. Catalog is the + * inventory; Components/Mapper drill in from it; Checks hangs off Governance + * and Build. Flows opens Preview on flow mode: one canvas, one implementation, + * one click from anywhere. */ const WORK_NAV: Array<{ id: View; label: string }> = [ { id: "build", label: "Build" }, { id: "preview", label: "Preview" }, + { id: "surfaces", label: "Surfaces" }, + { id: "flows", label: "Flows" }, { id: "inventory", label: "Catalog" }, { id: "governance", label: "Governance" }, - { id: "scenarios", label: "Scenarios" }, { id: "validate", label: "Checks" }, ]; @@ -217,11 +221,12 @@ function Shell() {
{view === "build" && } {view === "preview" && } + {view === "flows" && } {view === "inventory" && setView("component")} />} {view === "component" && } {view === "mapper" && } {view === "governance" && } - {view === "scenarios" && } + {view === "surfaces" && } {view === "validate" && } {view === "repository" && setView(v as View)} />}
diff --git a/apps/composer/app/hosted-build.ts b/apps/composer/app/hosted-build.ts index ecb68d2..d2d2e17 100644 --- a/apps/composer/app/hosted-build.ts +++ b/apps/composer/app/hosted-build.ts @@ -212,7 +212,7 @@ export function streamHostedBuild( if (!example) { handlers.onError( `Scripted mode replays this intent's own worked example, and '${intent || "(none)"}' has none. ` + - "Pick an intent that already has a worked scenario, or connect the local agent to generate from the contract without few-shot context.", + "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(); return; diff --git a/apps/composer/app/registries.test.ts b/apps/composer/app/registries.test.ts index 65a363f..aa88176 100644 --- a/apps/composer/app/registries.test.ts +++ b/apps/composer/app/registries.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { planRegistry } from "@dspack-studio/a2ui-ingest"; -import { registryFor, nativeRegistryFor, wireframeFallbackNames } from "./registries"; +import { defaultRegistryId, registryFor, nativeRegistryFor, resolveRegistryId, wireframeFallbackNames } from "./registries"; import shadcnEmit from "./demo/generated/emit.shadcn.json"; import astryxEmit from "./demo/generated/emit.astryx.json"; @@ -52,3 +52,42 @@ describe("registryFor — wireframe fallback composition", () => { } }); }); + +/** + * B5 — Preview opens on the project's OWN design system. Wireframe is the + * inspection mode and the fallback for a project that has no native one; it is + * no longer what every project meets first. + */ +describe("defaultRegistryId — a project previews as itself", () => { + it("defaults to the project's native registry when it has one", () => { + expect(defaultRegistryId("shadcn")).toBe("shadcn"); + expect(defaultRegistryId("astryx")).toBe("astryx"); + }); + + it("falls back to wireframe only when there is no native registry", () => { + expect(defaultRegistryId("wireframe")).toBe("wireframe"); + expect(defaultRegistryId(undefined)).toBe("wireframe"); + expect(defaultRegistryId("")).toBe("wireframe"); + expect(defaultRegistryId("vue-someday")).toBe("wireframe"); + }); +}); + +describe("resolveRegistryId — a stale selection clamps safely", () => { + it("honours an explicit choice this project can render", () => { + expect(resolveRegistryId("wireframe", "shadcn")).toBe("wireframe"); // inspection mode stays available + expect(resolveRegistryId("shadcn", "shadcn")).toBe("shadcn"); + }); + + it("clamps a selection carried over from ANOTHER project back to this one's default", () => { + expect(resolveRegistryId("astryx", "shadcn")).toBe("shadcn"); + expect(resolveRegistryId("shadcn", "astryx")).toBe("astryx"); + expect(resolveRegistryId("shadcn", undefined)).toBe("wireframe"); + }); + + it("no selection, or an unknown one, is the project's default", () => { + expect(resolveRegistryId(null, "astryx")).toBe("astryx"); + expect(resolveRegistryId(undefined, "shadcn")).toBe("shadcn"); + expect(resolveRegistryId("not-a-registry", "shadcn")).toBe("shadcn"); + expect(resolveRegistryId("not-a-registry", undefined)).toBe("wireframe"); + }); +}); diff --git a/apps/composer/app/registries.ts b/apps/composer/app/registries.ts index a155289..c4b7e88 100644 --- a/apps/composer/app/registries.ts +++ b/apps/composer/app/registries.ts @@ -97,3 +97,30 @@ export function canvasScopeFor( export function isNativeRegistry(id: string | undefined): id is PreviewRegistryId { return id === "shadcn" || id === "astryx"; } + +/** + * The registry a project's Preview OPENS on: its own design system. + * + * A project that has a native registry should look like itself the moment you + * open it — the wireframe is the universal fallback and the explicit + * inspection mode, not the thing every project meets first. Falls back to + * wireframe only when the project has no native registry at all (a repository + * whose target has no renderers yet, an unrecognised value from an older + * export). + */ +export function defaultRegistryId(previewRegistry: string | undefined): PreviewRegistryId { + return isNativeRegistry(previewRegistry) ? previewRegistry : "wireframe"; +} + +/** + * Resolve a possibly-stale selection against the project that is open now. + * "wireframe" is always honoured (every catalog renders through it); the + * project's own native id is honoured; anything else — no choice yet, a + * registry belonging to a DIFFERENT project, a value from a future version — + * clamps to this project's default rather than rendering the wrong system. + */ +export function resolveRegistryId(selected: string | null | undefined, previewRegistry: string | undefined): PreviewRegistryId { + const fallback = defaultRegistryId(previewRegistry); + if (selected === "wireframe") return "wireframe"; + return selected && selected === previewRegistry && isNativeRegistry(selected) ? selected : fallback; +} diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index 0082213..8151224 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -1277,10 +1277,13 @@ export function ComposerProvider({ children }: { children: ReactNode }) { setBuildTurns((prev) => prev.map((t) => (t.id === turnId ? { ...t, acceptFindings: findings } : t))); return; } + // The surface's TITLE is the goal that produced it — what a person + // reads in Preview, Surfaces, and flow pickers. Provenance is not + // lost: the minted `ex.chat-N` id carries it (B7). const entry: ExampleEntry = { id, intent: turn.intent, - name: `Chat: ${chain[0].slice(0, 60)}`, + name: chain[0].slice(0, 80), prompt, surface: turn.progress.surface, }; @@ -1295,8 +1298,8 @@ export function ComposerProvider({ children }: { children: ReactNode }) { ); setNotice( (activeProjectId - ? `Accepted as '${id}' — saved to this project in your browser; it now seeds generation for '${turn.intent}'.` - : `Accepted as '${id}' for this session — duplicate this example into your projects to keep it.`) + bound.note, + ? `Saved “${entry.name}” (${id}) to this project — it is one of your surfaces now, and context the next '${turn.intent}' build learns from.` + : `Saved “${entry.name}” (${id}) for this session — duplicate this example into your projects to keep it.`) + bound.note, ); return; } @@ -1304,7 +1307,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { const result = await agentSaveExample(projectPath, { ...(exampleId ? { id: exampleId } : {}), // omitted ⇒ the agent mints a collision-free id intent: turn.intent, - name: `Chat: ${chain[0].slice(0, 60)}`, + name: chain[0].slice(0, 80), // the goal IS the surface's title (B7) prompt, surface: turn.progress.surface, }); @@ -1338,7 +1341,10 @@ export function ComposerProvider({ children }: { children: ReactNode }) { t.id === turnId ? { ...t, accepted: savedId, acceptFindings: undefined, ...(bound.stepTitle ? { acceptedIntoStep: bound.stepTitle } : {}) } : t, ), ); - setNotice(`Accepted as worked example '${savedId}' — it now seeds generation for '${turn.intent}'.` + bound.note); + setNotice( + `Saved “${(result.value.example as { name?: string } | undefined)?.name ?? savedId}” (${savedId}) to your repository — it is one of your surfaces now, and context the next '${turn.intent}' build learns from.` + + bound.note, + ); } finally { decisionLock.current = false; setBusy(null); diff --git a/apps/composer/app/surface-identity.test.ts b/apps/composer/app/surface-identity.test.ts new file mode 100644 index 0000000..cb29fbb --- /dev/null +++ b/apps/composer/app/surface-identity.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { partitionSurfaces, surfaceEntriesById, surfaceIdentity, surfaceTitle } from "./surface-identity"; + +/** + * Surface identity is a PRODUCT rule, not a rename: `ex.chat-1` stays the + * canonical id everywhere (audit, export, flow bindings), and the thing a + * person reads is the title that produced it. These pin the resolution order, + * the empty-safety, and the ownership partition every picker orders by. + */ +describe("surfaceTitle — the human label, id preserved as metadata", () => { + it("prefers an authored name over the prompt and the description", () => { + expect( + surfaceTitle({ id: "ex.a", name: "Delete account confirmation", prompt: "a screen to delete my account", description: "Card with…" }, "ex.a"), + ).toBe("Delete account confirmation"); + }); + + it("falls back to the goal that produced it, then to the description", () => { + expect(surfaceTitle({ id: "ex.chat-1", prompt: "let people permanently delete their account" }, "ex.chat-1")).toBe( + "let people permanently delete their account", + ); + expect(surfaceTitle({ id: "ex.b", description: "A semantic data table" }, "ex.b")).toBe("A semantic data table"); + }); + + it("is empty-safe: blank, whitespace-only, and missing entries fall back to the id", () => { + expect(surfaceTitle({ id: "ex.c", name: " ", prompt: "" }, "ex.c")).toBe("ex.c"); + expect(surfaceTitle(undefined, "ex.d")).toBe("ex.d"); + expect(surfaceTitle(null, "ex.e")).toBe("ex.e"); + expect(surfaceTitle({ id: "ex.f" }, "ex.f")).toBe("ex.f"); + }); + + it("truncates sensibly and collapses whitespace, never mid-ellipsis noise", () => { + const long = "a settings panel for notification preferences with digest frequency and channel toggles"; + const title = surfaceTitle({ id: "ex.g", prompt: long }, "ex.g"); + expect(title.length).toBeLessThanOrEqual(48); + expect(title.endsWith("…")).toBe(true); + expect(long.startsWith(title.slice(0, -1).trim())).toBe(true); + expect(surfaceTitle({ id: "ex.h", name: " two spaces " }, "ex.h")).toBe("two spaces"); + }); + + it("surfaceIdentity keeps the canonical id beside the title (audit stays visible)", () => { + expect(surfaceIdentity({ id: "ex.chat-1", prompt: "a table of orders" }, "ex.chat-1")).toEqual({ + title: "a table of orders", + id: "ex.chat-1", + }); + // A title-less surface must not render the id twice. + expect(surfaceIdentity(undefined, "ex.chat-2")).toEqual({ title: "ex.chat-2", id: "ex.chat-2" }); + }); + + it("indexes contract.examples by id, tolerating junk entries", () => { + const byId = surfaceEntriesById([{ id: "ex.a", name: "A" }, null, { name: "no id" }, { id: "ex.b", prompt: "B" }]); + expect(byId.get("ex.a")?.name).toBe("A"); + expect(byId.get("ex.b")?.prompt).toBe("B"); + expect(byId.size).toBe(2); + expect(surfaceEntriesById(undefined).size).toBe(0); + }); +}); + +describe("partitionSurfaces — the user's work first, refusals demoted not hidden", () => { + const surfaces = [ + { name: "ex.delete-account-confirmation" }, + { name: "ex.broken-reference", error: "emit refused: unknown component" }, + { name: "ex.chat-1" }, + { name: "ex.notification-preferences" }, + { name: "ex.chat-2", error: "emit refused: S2" }, + ]; + const referenceIds = new Set(["ex.delete-account-confirmation", "ex.broken-reference", "ex.notification-preferences"]); + + it("puts the project's own surfaces first and the reference corpus second", () => { + const groups = partitionSurfaces(surfaces, { referenceIds }); + expect(groups.yours.map((s) => s.name)).toEqual(["ex.chat-1"]); + expect(groups.reference.map((s) => s.name)).toEqual(["ex.delete-account-confirmation", "ex.notification-preferences"]); + // Ordered: everything the user owns comes before anything that teaches. + expect(groups.ordered.map((s) => s.name)).toEqual([ + "ex.chat-1", + "ex.chat-2", + "ex.delete-account-confirmation", + "ex.notification-preferences", + "ex.broken-reference", + ]); + }); + + it("separates refusals by OWNER: the user's stay visible, the reference's collapse", () => { + const groups = partitionSurfaces(surfaces, { referenceIds }); + expect(groups.yoursRefused.map((s) => s.name)).toEqual(["ex.chat-2"]); + expect(groups.referenceRefused.map((s) => s.name)).toEqual(["ex.broken-reference"]); + // Nothing is dropped — every surface lands in exactly one group. + const all = [...groups.yours, ...groups.yoursRefused, ...groups.reference, ...groups.referenceRefused]; + expect(all).toHaveLength(surfaces.length); + }); + + it("an EXAMPLE workspace owns everything it shows — the reference corpus IS the content", () => { + const groups = partitionSurfaces(surfaces, { referenceIds, isExample: true }); + expect(groups.reference).toEqual([]); + expect(groups.referenceRefused).toEqual([]); + expect(groups.yours.map((s) => s.name)).toEqual([ + "ex.delete-account-confirmation", + "ex.chat-1", + "ex.notification-preferences", + ]); + }); + + it("a project with no reference corpus (imported, repository) owns every surface", () => { + const groups = partitionSurfaces(surfaces, { referenceIds: null }); + expect(groups.reference).toEqual([]); + expect(groups.yours).toHaveLength(3); + expect(groups.yoursRefused).toHaveLength(2); + }); +}); diff --git a/apps/composer/app/surface-identity.ts b/apps/composer/app/surface-identity.ts new file mode 100644 index 0000000..2b81e62 --- /dev/null +++ b/apps/composer/app/surface-identity.ts @@ -0,0 +1,119 @@ +/** + * Surface identity: what a person READS, and what the product AUDITS. + * + * A surface is stored under a canonical id (`ex.chat-1`, `ex.support-ticket- + * queue`) — that id is the contract's, the export's, and every flow step's + * reference, and none of that changes here. What changes is which of the two + * leads: a person meets their own work by the words that produced it, with + * the id kept beside it, small, for audit. `ex.chat-1` is a filename, not a + * title. + * + * Ownership is the other half. Every picker in the product orders the same + * way: the project's own surfaces first and primary, the design system's + * reference surfaces second and quieter, and refusals grouped by OWNER — + * yours stay in front of you, the reference's collapse behind one honest + * disclosure. Nothing is ever hidden; the hierarchy is what changes. + */ + +/** The fields a `contract.examples` entry can carry a human title in. */ +export interface SurfaceEntry { + id?: string; + name?: string; + prompt?: string; + description?: string; +} + +/** Title, then id — what every surface label renders. */ +export interface SurfaceIdentity { + title: string; + id: string; +} + +const DEFAULT_MAX = 48; + +/** Trim, collapse internal whitespace, and clamp on a whole-word boundary + * where one is close enough to look deliberate. */ +function tidy(value: string, max: number): string { + const text = value.replace(/\s+/g, " ").trim(); + if (text.length <= max) return text; + const cut = text.slice(0, max - 1); + const lastSpace = cut.lastIndexOf(" "); + return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`; +} + +/** + * The human title for one surface: an authored name first (someone chose + * it), then the goal that produced it, then the design system's own + * description. Empty-safe by construction — a surface with nothing readable + * falls back to its id, so a label is never blank. + */ +export function surfaceTitle(entry: SurfaceEntry | null | undefined, id: string, max = DEFAULT_MAX): string { + for (const candidate of [entry?.name, entry?.prompt, entry?.description]) { + if (typeof candidate !== "string") continue; + const text = tidy(candidate, max); + if (text) return text; + } + return tidy(id, max) || id; +} + +/** The pair every label renders: the title leads, the id stays for audit. */ +export function surfaceIdentity(entry: SurfaceEntry | null | undefined, id: string, max = DEFAULT_MAX): SurfaceIdentity { + return { title: surfaceTitle(entry, id, max), id }; +} + +/** Index a contract's `examples` by id for O(1) title lookup; junk entries + * (a malformed delta, a hand-edited file) are skipped, never thrown on. */ +export function surfaceEntriesById(examples: unknown): Map { + const byId = new Map(); + if (!Array.isArray(examples)) return byId; + for (const entry of examples) { + if (!entry || typeof entry !== "object") continue; + const id = (entry as SurfaceEntry).id; + if (typeof id === "string" && id) byId.set(id, entry as SurfaceEntry); + } + return byId; +} + +/** Anything a picker lists: an emitted surface, or a flow-step candidate. */ +export interface OwnedSurface { + name: string; + error?: string; +} + +export interface SurfaceGroups { + /** The project's own surfaces that render — visually primary. */ + yours: T[]; + /** The project's own surfaces the emitter refused — the user's problem to + * see, kept in front of them. */ + yoursRefused: T[]; + /** The design system's reference surfaces that render — clearly secondary. */ + reference: T[]; + /** Reference surfaces the emitter refused — demoted behind a disclosure. */ + referenceRefused: T[]; + /** Every surface in picker order: yours (rendering, then refused), then the + * reference corpus (rendering, then refused). */ + ordered: T[]; +} + +/** + * Partition a project's emitted surfaces by OWNERSHIP first, then by whether + * they render. `referenceIds` is null when every surface is the project's own + * (an imported bundle, a repository); an EXAMPLE workspace owns everything it + * shows, because the reference corpus IS the content on display there. + */ +export function partitionSurfaces( + surfaces: readonly T[], + { referenceIds, isExample = false }: { referenceIds: Set | null; isExample?: boolean }, +): SurfaceGroups { + const yours: T[] = []; + const yoursRefused: T[] = []; + const reference: T[] = []; + const referenceRefused: T[] = []; + for (const surface of surfaces) { + const isReference = !isExample && referenceIds !== null && referenceIds.has(surface.name); + const refused = Boolean(surface.error); + if (isReference) (refused ? referenceRefused : reference).push(surface); + else (refused ? yoursRefused : yours).push(surface); + } + return { yours, yoursRefused, reference, referenceRefused, ordered: [...yours, ...yoursRefused, ...reference, ...referenceRefused] }; +} diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx index d123384..2ec25e6 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -20,6 +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 { Eyebrow } from "../ui"; import { browserEmit } from "../validation"; @@ -193,7 +194,12 @@ function TurnCanvas({ turn }: { turn: BuildTurn }) { } function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: StepBinding }) { - const { acceptBuildTurn, buildBusy, busy, mode, isExample } = useComposer(); + const { acceptBuildTurn, buildBusy, busy, mode, isExample, contract } = useComposer(); + // What the saved surface is CALLED — read back from the project's own + // record, so the confirmation says exactly what Preview and Surfaces say. + const savedTitle = turn.accepted + ? surfaceTitle(surfaceEntriesById(contract?.examples).get(turn.accepted), turn.accepted, 64) + : ""; // Accept targeting: the header "flow step" select (an explicit choice) // wins; otherwise a "Build a flow" turn is PRE-TARGETED to the step it was // built for (Phase C driver hint). Plain accepts stay plain. @@ -314,7 +320,7 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste {turn.acceptFindings && turn.acceptFindings.length > 0 && (

- The agent refused to save this surface as a worked example. + This surface was refused — it is not saved to your project.

    {turn.acceptFindings.map((f, i) => ( @@ -346,8 +352,8 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste setExampleId(e.target.value)} - placeholder="(a free id is minted)" - aria-label={`Example id for turn ${turn.id} — leave blank to mint a collision-free id`} + placeholder="(an id is minted for you)" + aria-label={`Surface id for turn ${turn.id} — leave blank to mint a collision-free id`} style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", border: "1px solid var(--line)", color: "var(--fg)", padding: "5px 8px", borderRadius: 2 }} data-testid={`build-example-id-${turn.id}`} /> @@ -363,10 +369,10 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste

{mode === "agent" - ? "Saves into your repository's contract on disk as a worked example." + ? "Saves this surface into your repository's contract on disk." : isExample ? "Kept for this session only — duplicate this example into your projects to keep what you build." - : "Saves to this project in your browser as a worked example — it appears in Preview and Scenarios, and seeds future generation."} + : "Saves this surface to your project in your browser — it appears in Preview, Surfaces, and any flow you compose, and it becomes context the next build learns from."} {!intoFlowStep && turn.flowStepHint && ( <> Accepting also points flow step “{turn.flowStepHint.title}” at this surface. )} @@ -375,9 +381,9 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste )} {turn.accepted && (

- Accepted as {turn.accepted} - {turn.acceptedIntoStep && <> — flow step “{turn.acceptedIntoStep}” now shows this surface} — now part of this - intent's few-shot corpus. + Saved as “{savedTitle}” {turn.accepted} + {turn.acceptedIntoStep && <> — flow step “{turn.acceptedIntoStep}” now shows this surface} — it is one of + your project’s surfaces, and context the next build learns from.

)} diff --git a/apps/composer/app/views/governance-view.tsx b/apps/composer/app/views/governance-view.tsx index 9707d9d..2fbbd07 100644 --- a/apps/composer/app/views/governance-view.tsx +++ b/apps/composer/app/views/governance-view.tsx @@ -361,7 +361,7 @@ export function GovernanceView() { {exampleIds.length > 0 && ( <> - worked examples this rule cites + surfaces this rule cites setDraft({ ...draft, examples: v })} /> )} @@ -378,7 +378,7 @@ export function GovernanceView() {