From de85b568fe502f6287dc90a791b9efadc68f1413 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 11:32:09 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test(pre-1.0):=20fail-first=20for=20the=20f?= =?UTF-8?q?irst=20impression=20=E2=80=94=20B5/B6/B7/B8=20+=20C9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertions for Phases B and C, written against the CURRENT product and run red before a line of implementation exists. - registries.test.ts: defaultRegistryId / resolveRegistryId — a project previews as ITSELF (native when it has one, wireframe only as fallback), and a selection carried over from another project clamps to this one's default instead of silently rendering the wrong system. - surface-identity.test.ts (new): the human title (name -> prompt -> description -> id), empty-safety, truncation, and the ownership partition every picker orders by — the user's work first, refusals demoted by OWNER rather than hidden. - wireframe.test.tsx: the registry must draw low-fidelity STRUCTURE, not a props dump. Asserted on an inline collection catalog the package has never seen, so the rule stays generic and data-driven. - composer-prod-smoke.spec.ts: a first-run journey — one scripted build, one accept, then Preview through the NATIVE registry, the user's surface FIRST with a human label, refused reference surfaces behind one honest disclosure, and Flows one click from the nav. Existing specs updated deliberately in this commit (old copy pinned by them is being retired): nav-scenarios -> nav-surfaces (composer-build.spec, composer-prod-smoke.spec), "Reference examples" -> "Reference surfaces", and the wireframe test's `label=Acknowledge` / `variant=primary` props-dump assertions, which pinned exactly the debugger view B6 removes. --- pnpm --filter composer test --------------------------------------- ❯ app/registries.test.ts (10 tests | 5 failed) 5ms × defaultRegistryId — a project previews as itself > defaults to the project's native registry when it has one → (0 , defaultRegistryId) is not a function × defaultRegistryId — a project previews as itself > falls back to wireframe only when there is no native registry → (0 , defaultRegistryId) is not a function × resolveRegistryId — a stale selection clamps safely > honours an explicit choice this project can render → (0 , resolveRegistryId) is not a function × resolveRegistryId — a stale selection clamps safely > clamps a selection carried over from ANOTHER project back to this one's default → (0 , resolveRegistryId) is not a function × resolveRegistryId — a stale selection clamps safely > no selection, or an unknown one, is the project's default → (0 , resolveRegistryId) is not a function ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL app/surface-identity.test.ts [ app/surface-identity.test.ts ] Error: Cannot find module './surface-identity' imported from '/Users/ryandombrowski/Desktop/dspack-studio/apps/composer/app/surface-identity.test.ts' Test Files 2 failed | 8 passed (10) Tests 5 failed | 76 passed (81) --- pnpm --filter @dspack-studio/wireframe-renderers test -------------- ❯ src/wireframe.test.tsx (11 tests | 9 failed) 15ms × classifies a value prop's SHAPE from the catalog (enum, boolean, text, structure) → expected undefined to be 'text' // Object.is equality × renders a labeled wireframe with real text, a state chip, and an inert action → expected ' never serializes props into the visual → expected ' draws the component name and a structural sketch → expected ' a header band uses the REAL column labels and count → expected ' row bands use the REAL row count and show the row's own text → expected [] to have a length of 3 but got +0 × the wireframe is a structural sketch… > a long collection stays a sketch: bands are capped and the remainder is stated → expected 0 to be greater than 0 × the wireframe is a structural sketch… > textual props read as text lines — the honest 'no native visual here', never an error → expected '
Table
caption=Recent invoices columns=["Invoice","Status","Amount"] rows=[{"cells":["INV-001","Paid","$120.00"]},…]
--- playwright --config playwright.composer-smoke.config.ts ------------ -g "first run: the project previews as ITSELF" 1) first run: the project previews as ITSELF, the user's work leads with a human label, and Flows is one click away Error: expect(locator).toHaveClass(expected) failed Locator: getByTestId('registry-shadcn') Expected pattern: /st-btn--active/ Received string: "st-btn" 14 × locator resolved to 1 failed Co-Authored-By: Claude Fable 5 --- apps/composer/app/registries.test.ts | 41 +++++- apps/composer/app/surface-identity.test.ts | 108 +++++++++++++++ e2e/composer-build.spec.ts | 2 +- e2e/composer-prod-smoke.spec.ts | 63 +++++++-- .../src/wireframe.test.tsx | 127 +++++++++++++++++- 5 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 apps/composer/app/surface-identity.test.ts 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/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/e2e/composer-build.spec.ts b/e2e/composer-build.spec.ts index aade568..c6a89bd 100644 --- a/e2e/composer-build.spec.ts +++ b/e2e/composer-build.spec.ts @@ -122,7 +122,7 @@ test("Accept persists the worked example, survives reload, and the next run rece // Survives a full reload + reconnect. await page.reload(); await connect(page, project.root); - await page.getByTestId("nav-scenarios").click(); + await page.getByTestId("nav-surfaces").click(); await expect(page.locator("body")).toContainText(saved.id); // Few-shot round-trip, user-visible: a fresh scripted run now converges on diff --git a/e2e/composer-prod-smoke.spec.ts b/e2e/composer-prod-smoke.spec.ts index 8862f2d..5aff3f6 100644 --- a/e2e/composer-prod-smoke.spec.ts +++ b/e2e/composer-prod-smoke.spec.ts @@ -108,7 +108,7 @@ test("a project exports to a portable file and imports back, ready to keep build await expect(page.getByTestId("projects-grid")).toContainText("Imported"); }); -test("a built surface becomes first-class project content: accept → Preview default → Scenarios → reload → export", async ({ page }) => { +test("a built surface becomes first-class project content: accept → Preview default → Surfaces → reload → export", async ({ page }) => { await page.goto("/"); await newProject(page, "shadcn", "Ownership"); await scriptedBuild(page, "destructive-action", "let people permanently delete their account"); @@ -122,22 +122,24 @@ test("a built surface becomes first-class project content: accept → Preview de await page.getByTestId("nav-preview").click(); await expect(page.getByTestId("surface-ex.chat-1")).toBeVisible(); await expect(page.getByTestId("surface-ex.chat-1")).toHaveClass(/st-btn--active/); // the default - await expect(page.getByTestId("preview-reference-surfaces")).toContainText("Reference examples"); + await expect(page.getByTestId("preview-reference-surfaces")).toContainText("Reference surfaces"); await expect(page.getByTestId("preview-no-project-surfaces")).toHaveCount(0); // Honest wireframe fallback: a reference surface using Switch/Separator // renders through wireframe stand-ins under the native registry — never - // raw [unimplemented:] text — and the caption says so plainly. + // raw [unimplemented:] text, and never a serialized props dump — and the + // caption says so plainly. await page.getByTestId("registry-shadcn").click(); await page.getByTestId("surface-ex.notification-preferences").click(); await expect(page.getByTestId("registry-coverage")).toContainText("render as wireframe"); await expect(page.locator('[data-project-canvas] [data-wireframe]').first()).toBeVisible(); await expect(page.locator('[data-project-canvas]')).not.toContainText("[unimplemented:"); + await expect(page.locator("[data-project-canvas]")).not.toContainText('columns=["'); - // Scenarios: project-owned content separate from the reference corpus. - await page.getByTestId("nav-scenarios").click(); + // Surfaces: project-owned content separate from the reference corpus. + await page.getByTestId("nav-surfaces").click(); await expect(page.getByTestId("scenario-ex.chat-1")).toBeVisible(); - await expect(page.getByTestId("scenarios-reference")).toContainText("Reference examples"); + await expect(page.getByTestId("scenarios-reference")).toContainText("Reference surfaces"); // The authored surface SURVIVES a reload (the persisted delta). await page.reload(); @@ -158,12 +160,55 @@ test("a fresh project's Preview is honest: no project surfaces yet, references l await newProject(page, "shadcn", "Fresh"); await page.getByTestId("nav-preview").click(); await expect(page.getByTestId("preview-no-project-surfaces")).toBeVisible(); - await expect(page.getByTestId("preview-reference-surfaces")).toContainText("Reference examples"); - // Inspecting a reference example states what it is. + await expect(page.getByTestId("preview-reference-surfaces")).toContainText("Reference surfaces"); + // Inspecting a reference surface states what it is. await page.getByTestId("surface-ex.delete-account-confirmation").click(); await expect(page.locator("body")).toContainText("teaching material, not part of your project"); }); +/** + * B5/B7/B8/C9 — the FIRST IMPRESSION, end to end. One scripted build and one + * accept is the whole journey a newcomer takes; everything asserted here is + * what they see immediately afterwards. + */ +test("first run: the project previews as ITSELF, the user's work leads with a human label, and Flows is one click away", async ({ page }) => { + await page.goto("/"); + await newProject(page, "shadcn", "First impression"); + await scriptedBuild(page, "destructive-action", "let people permanently delete their account"); + await page.getByTestId("build-accept-1").click(); + await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/); + + await page.getByTestId("nav-preview").click(); + + // B5 — Preview opens on the project's OWN design system, not the wireframe. + await expect(page.getByTestId("registry-shadcn")).toHaveClass(/st-btn--active/); + await expect(page.getByTestId("registry-wireframe")).not.toHaveClass(/st-btn--active/); + await expect(page.locator('[data-project-canvas][data-design-system="shadcn"]')).toBeVisible(); + + // B8 — the user's own surface is the FIRST surface in the picker. + const own = page.getByTestId("surface-ex.chat-1"); + await expect(page.locator('[data-testid^="surface-ex."]').first()).toHaveAttribute("data-testid", "surface-ex.chat-1"); + await expect(own).toHaveClass(/st-btn--active/); + + // B7 — it reads as what the person asked for; the canonical id stays beside + // it as metadata, never as the headline. + await expect(own).toContainText("let people permanently delete their account"); + await expect(own).toContainText("ex.chat-1"); + await expect(page.getByTestId("preview-your-surfaces")).toContainText("Your surfaces"); + + // B8 — the three reference surfaces the emitter refuses are DEMOTED, not + // hidden: one honest disclosure instead of three red rows at first contact. + await expect(page.getByTestId("preview-reference-refused")).toContainText("3 reference surfaces"); + await expect(page.getByTestId("surface-refused-ex.docs-article-trail")).toBeHidden(); + await page.getByTestId("preview-reference-refused").click(); + await expect(page.getByTestId("surface-refused-ex.docs-article-trail")).toBeVisible(); + + // C9 — Flows is in the primary navigation, one click from anywhere. + await page.getByTestId("nav-flows").click(); + await expect(page.getByTestId("preview-flows")).toBeVisible(); + await expect(page.getByTestId("flows-empty")).toBeVisible(); +}); + test("Examples are teaching material: separate section, ephemeral read-only workspace, duplicate to keep", async ({ page }) => { await page.goto("/"); // The hub separates Examples from Your projects. @@ -275,7 +320,7 @@ test("flows: create, walk, advance, persist, and round-trip through export/impor await expect(page.getByTestId("build-accepted-2")).toContainText("ex.chat-2"); // Author a flow over the two surfaces, in Preview ("Your flows" — a - // first-class concept, distinct from Scenarios). + // first-class concept, distinct from a single Surface). await page.getByTestId("nav-preview").click(); await expect(page.getByTestId("preview-flows")).toBeVisible(); await page.getByTestId("new-flow").click(); diff --git a/packages/wireframe-renderers/src/wireframe.test.tsx b/packages/wireframe-renderers/src/wireframe.test.tsx index 942bd9e..2f7fee3 100644 --- a/packages/wireframe-renderers/src/wireframe.test.tsx +++ b/packages/wireframe-renderers/src/wireframe.test.tsx @@ -6,7 +6,9 @@ * Verified properties: every catalog name gets a visual (planRegistry * reports zero unimplemented); child/children/action props are classified * from the catalog's $refs; a wireframe renders without executing any - * user code (static markup contains the component name and scalar props). + * user code; and — the point of the whole registry — what it draws is a + * LOW-FIDELITY STRUCTURE (name, bands, text lines), never a serialized + * props dump. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -24,6 +26,47 @@ const catalog = JSON.parse( readFileSync(fileURLToPath(new URL("../fixtures/acme.catalog.v0_9_1.json", import.meta.url)), "utf8"), ); +/** + * A collection component in the emitted shape ANY catalog uses for tabular + * data (array-of-string headers, array-of-record rows). Declared inline + * rather than hand-written per design system: the registry is generated to + * cover any conformant catalog, so its structural rendering is proven on a + * catalog it has never seen. + */ +const collectionCatalog = { + components: { + Table: { + type: "object", + allOf: [ + { + type: "object", + properties: { + component: { const: "Table" }, + caption: { $ref: "#/$defs/DynamicString" }, + columns: { type: "array", items: { type: "string" } }, + rows: { type: "array", items: { type: "object" } }, + }, + }, + ], + }, + }, +}; + +const tableProps = { + caption: "Recent invoices", + columns: ["Invoice", "Status", "Amount"], + rows: [ + { cells: ["INV-001", "Paid", "$120.00"] }, + { cells: ["INV-002", "Overdue", "$80.00"] }, + { cells: ["INV-003", "Draft", "$12.00"] }, + ], +}; + +const renderTable = () => + renderToStaticMarkup( + createElement(wireframeRegistryFor(collectionCatalog).custom.Table, { props: tableProps, buildChild: () => null }), + ); + describe("wireframeRegistryFor", () => { it("covers every catalog name (zero unimplemented)", () => { const registry = wireframeRegistryFor(catalog); @@ -43,7 +86,17 @@ describe("wireframeRegistryFor", () => { expect(column.children).toBe("children"); }); - it("renders a labeled wireframe with scalar props and an inert action", () => { + it("classifies a value prop's SHAPE from the catalog (enum, boolean, text, structure)", () => { + const button = Object.fromEntries(classifyProps(catalog, "Button").map((p) => [p.name, p.shape])); + expect(button.label).toBe("text"); // free-form string: real content + expect(button.variant).toBe("enum"); // a token, not content + const table = Object.fromEntries(classifyProps(collectionCatalog, "Table").map((p) => [p.name, p.shape])); + expect(table.caption).toBe("text"); + expect(table.columns).toBe("list"); + expect(table.rows).toBe("list"); + }); + + it("renders a labeled wireframe with real text, a state chip, and an inert action", () => { const registry = wireframeRegistryFor(catalog); const html = renderToStaticMarkup( createElement(registry.custom.Button, { @@ -51,10 +104,12 @@ describe("wireframeRegistryFor", () => { buildChild: () => null, }), ); - expect(html).toContain("Button"); - expect(html).toContain("label=Acknowledge"); - expect(html).toContain("variant=primary"); - expect(html).toContain("action: action"); + expect(html).toContain("Button"); // the component's own name + expect(html).toContain("Acknowledge"); // genuinely textual content stays visible + expect(html).toContain("primary"); // the token reads as a state chip… + expect(html).not.toContain("label=Acknowledge"); // …never as a props dump + expect(html).not.toContain("variant=primary"); + expect(html).toContain('data-wire="state"'); expect(html).toContain("disabled"); expect(html).toContain("data-a2ui-component"); // provenance wrapper intact }); @@ -68,5 +123,65 @@ describe("wireframeRegistryFor", () => { }), ); expect(html).toContain("child:inner"); + expect(html).toContain('data-wire="block"'); // a bordered structural block, not a bare div + }); +}); + +/** + * B6 — a wireframe must look like a wireframe. The product's most important + * moment renders low-fidelity STRUCTURE derived from the data's own shape: + * generic, data-driven, and identical for any catalog. + */ +describe("the wireframe is a structural sketch, never a props dump", () => { + it("never serializes props into the visual", () => { + const html = renderTable(); + for (const leak of ['rows=[{', 'columns=["', "rows=", "columns=", "caption=", "cells", "[{", '"']) { + expect(html).not.toContain(leak); + } + expect(html).not.toContain(JSON.stringify(tableProps.rows)); + expect(html).not.toContain(JSON.stringify(tableProps.columns)); + }); + + it("draws the component name and a structural sketch", () => { + const html = renderTable(); + expect(html).toContain('data-wireframe="Table"'); + expect(html).toContain("Table"); + expect(html).toContain("data-wire-sketch"); + }); + + it("a header band uses the REAL column labels and count", () => { + const html = renderTable(); + expect(html).toContain('data-wire-band="header"'); + for (const column of tableProps.columns) expect(html).toContain(column); + const headerBand = html.slice(html.indexOf('data-wire-band="header"'), html.indexOf('data-wire-band="row"')); + expect(headerBand.match(/data-wire="cell"/g) ?? []).toHaveLength(tableProps.columns.length); + }); + + it("row bands use the REAL row count and show the row's own text", () => { + const html = renderTable(); + expect(html.match(/data-wire-band="row"/g) ?? []).toHaveLength(tableProps.rows.length); + expect(html).toContain("INV-001"); + expect(html).toContain("Overdue"); + }); + + it("a long collection stays a sketch: bands are capped and the remainder is stated", () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ cells: [`INV-${i}`, "Paid", "$1.00"] })); + const html = renderToStaticMarkup( + createElement(wireframeRegistryFor(collectionCatalog).custom.Table, { + props: { ...tableProps, rows }, + buildChild: () => null, + }), + ); + const bands = html.match(/data-wire-band="row"/g) ?? []; + expect(bands.length).toBeGreaterThan(0); + expect(bands.length).toBeLessThanOrEqual(6); + expect(html).toContain(`${rows.length - bands.length} more`); + }); + + it("textual props read as text lines — the honest 'no native visual here', never an error", () => { + const html = renderTable(); + expect(html).toContain('data-wire="text"'); + expect(html).toContain("Recent invoices"); + expect(html).not.toMatch(/error|unimplemented|undefined/i); }); }); From 7565496cf689182171e3ac085459de5903e5ac16 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 11:54:07 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(pre-1.0):=20the=20first=20impression?= =?UTF-8?q?=20=E2=80=94=20Phases=20B=20and=20C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newcomer picked shadcn, typed a goal, watched four gates go green, and then met a debugger: a greybox printing `rows=[{"cells":…}]`, their own work labelled `ex.chat-1`, fourteen reference chips and three red refusals in front of it, and no way to reach Flows. Every one of those is fixed here; nothing about contracts, emit, generation, A2UI, intents, or governance moved. B5 — Preview opens on the project's own design system. `defaultRegistryId` / `resolveRegistryId` (registries.ts) own the rule: native when the project has one, wireframe only as fallback, and a selection carried over from another project clamps to THIS project's default instead of drawing the wrong catalog. Wireframe stays a first-class inspection mode with its honest coverage caption; it is now the mode you switch INTO, not the one every project meets first. Identical for single-surface and flow mode, because both read one resolved id. B6 — a wireframe looks like a wireframe. New `sketch.tsx` draws low-fidelity structure from the data's own shape: text lines for genuinely textual values, state chips for enums and flags, a header band sized by the real column count, one row band per real record with the record's own cell text, bordered blocks for nesting, and a stated remainder past the cap. `classifyProps` now also reports a prop's declared SHAPE (DynamicString/enum/boolean/number/array/object), so every decision comes from the catalog plus the value — no component names, no design system, no hand-written visuals. A catalog the package has never seen sketches as well as the ones it ships with; nothing is ever serialized. B7 — surfaces have human titles. New `surface-identity.ts` resolves name → prompt → description → id, empty safe, whitespace-collapsed, word-boundary truncated. Applied in Preview's surface picker, the flow step picker, the flow editor's step rows, the flow navigator tooltips, the Surfaces listing, and Build's accept confirmation and notices. The canonical id is never dropped — it sits beside the title, small, so an audit is one glance away. Accepted builds now mint the goal itself as the title (the `ex.chat-N` id still carries the provenance). B8 — the user's work is visually primary. `partitionSurfaces` groups by OWNER first, then by whether the surface renders: yours, yours-refused, reference, reference-refused. Every picker orders by it. The three reference surfaces the emitter refuses now sit behind one honest disclosure ("3 reference surfaces can't be emitted — why?") that opens to the verbatim reasons; the project's OWN refusals stay in front of the person whose work they are. The flow editor's step dropdown is optgrouped the same way instead of a flat list with the user's surface last. C9 — Flows in the primary navigation. `Build · Preview · Surfaces · Flows · Catalog · Governance · Checks`. Flows renders ``: the same canvas, the same flow code, opened on flow mode with a flows-specific header and a real empty state. No duplicated preview logic. C10 / C10b — the hub tells the workflow story, and states the boundary. The hub leads with what you get — describe a screen, build it from your design system's approved components, compose those screens into a walkable workflow — and follows with the honest position: Composer composes and governs interface REPRESENTATIONS; live data, running workflows, and state between steps are where it goes next. Preview carries the same sentence where a person is actually walking one. C11 — the ratified vocabulary, everywhere a person reads. Surface / Flow / Step / Example. "Scenarios" is gone from the UI; inside a project "reference examples" are "reference surfaces", so "example" only ever means a reference PROJECT on the hub. Internal names are untouched by design: `contract.examples`, `ex.*` ids, `packages/scenarios`, `scenario-view.tsx`, the `scenario-*` data-testids, AG-UI STEP_* events. Specs updated deliberately (they pinned copy this retires): - e2e/composer-prod-smoke.spec.ts, e2e/composer-build.spec.ts — `nav-scenarios` -> `nav-surfaces` (the only data-testid renamed); "Reference examples" -> "Reference surfaces"; the agent-side scripted refusal now says "own surface". - packages/composer-core/src/composer-core.test.ts — the readiness reason no longer says "worked example ... save one scenario first". - apps/agent/src/project.test.ts — same message, agent side. - packages/wireframe-renderers/src/wireframe.test.tsx — the `label=Acknowledge` / `variant=primary` assertions pinned the props dump itself. Green: pnpm test (11 packages), pnpm -r typecheck (12 projects), composer smoke 14/14, composer-agent 44/44. Co-Authored-By: Claude Fable 5 --- README.md | 7 +- apps/agent/src/project.test.ts | 2 +- apps/agent/src/project.ts | 4 +- apps/composer/app/composer.tsx | 17 +- apps/composer/app/hosted-build.ts | 2 +- apps/composer/app/registries.ts | 27 ++ apps/composer/app/state.tsx | 16 +- apps/composer/app/surface-identity.ts | 119 +++++++++ apps/composer/app/views/build-view.tsx | 24 +- apps/composer/app/views/governance-view.tsx | 4 +- apps/composer/app/views/preview-view.tsx | 250 ++++++++++++------ apps/composer/app/views/projects-view.tsx | 19 +- apps/composer/app/views/repository-view.tsx | 2 +- apps/composer/app/views/scenario-view.tsx | 66 +++-- apps/composer/app/views/settings-view.tsx | 4 +- docs/COMPOSER.md | 4 +- e2e/composer-build.spec.ts | 4 +- packages/composer-core/src/build.ts | 2 +- .../composer-core/src/composer-core.test.ts | 2 +- .../wireframe-renderers/src/classify-props.ts | 30 ++- packages/wireframe-renderers/src/index.ts | 2 +- packages/wireframe-renderers/src/registry.tsx | 57 ++-- packages/wireframe-renderers/src/sketch.tsx | 184 +++++++++++++ 23 files changed, 670 insertions(+), 178 deletions(-) create mode 100644 apps/composer/app/surface-identity.ts create mode 100644 packages/wireframe-renderers/src/sketch.tsx 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.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.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() {