Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.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.

Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion apps/agent/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
4 changes: 2 additions & 2 deletions apps/agent/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,8 @@ async function runProject(ctx: ProjectContext, body: Record<string, unknown>, 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
Expand Down
17 changes: 11 additions & 6 deletions apps/composer/app/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down Expand Up @@ -217,11 +221,12 @@ function Shell() {
<div className="af-page" style={{ paddingTop: "clamp(20px,3vw,32px)" }}>
{view === "build" && <BuildView />}
{view === "preview" && <PreviewView />}
{view === "flows" && <PreviewView focus="flows" />}
{view === "inventory" && <InventoryView onOpen={() => setView("component")} />}
{view === "component" && <ComponentView />}
{view === "mapper" && <MapperView />}
{view === "governance" && <GovernanceView />}
{view === "scenarios" && <ScenarioView />}
{view === "surfaces" && <ScenarioView />}
{view === "validate" && <ValidateView />}
{view === "repository" && <RepositoryView onNavigate={(v) => setView(v as View)} />}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/composer/app/hosted-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 40 additions & 1 deletion apps/composer/app/registries.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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");
});
});
27 changes: 27 additions & 0 deletions apps/composer/app/registries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 11 additions & 5 deletions apps/composer/app/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -1295,16 +1298,16 @@ 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,
);
Comment on lines 1299 to 1303
return;
}

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,
});
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 108 additions & 0 deletions apps/composer/app/surface-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading