diff --git a/apps/composer/app/catalog-export.test.ts b/apps/composer/app/catalog-export.test.ts new file mode 100644 index 0000000..359c5b6 --- /dev/null +++ b/apps/composer/app/catalog-export.test.ts @@ -0,0 +1,54 @@ +/** + * The Catalog export (Preview's "export catalog"). The beta's experimental + * build adds a per-surface action manifest (docs/blocks/HOST-ACTIONS.md); the + * default build's export must stay byte-identical (docs/blocks/PHASE-2-RELEASE-PLAN.md, + * §12). The oracle below is the export as main wrote it before the action + * manifest (exportBundle in app/views/preview-view.tsx at 3edd589), kept + * verbatim minus the download, and the default export is held to it, byte for + * byte, over real project emits. + */ +import { describe, expect, it } from "vitest"; +import { catalogBundle, catalogJson } from "./catalog-export"; +import { REFERENCES } from "./demo-data"; +import { browserEmit, contractSurfaces } from "./validation"; + +type Emit = { catalog?: Record; surfaces?: Array<{ name: string; messages?: unknown[] }> }; + +/** main's exportBundle at 3edd589, minus the download: the bytes it wrote. */ +function todaysBytes(emit: Emit, exportedAt: string): string { + const bundle = { + a2uiCatalog: emit.catalog, + surfaces: (emit.surfaces ?? []) + .filter((s) => s.messages) + .map((s) => ({ name: s.name, messages: s.messages })), + exportedAt, + }; + return JSON.stringify(bundle, null, 2); +} + +const AT = "2026-10-16T12:00:00.000Z"; + +describe("the default Catalog export is byte-identical to today's", () => { + for (const id of Object.keys(REFERENCES)) { + it(`${id}: the reference's own emit, every surface, exported exactly as before`, () => { + const { contract, profile } = REFERENCES[id]!; + const emit = browserEmit(contract as Record, profile as Record, contractSurfaces(contract as Record)) as Emit; + expect(emit.surfaces?.length).toBeGreaterThan(0); + const bytes = catalogJson(catalogBundle(emit, AT)); + expect(bytes).toBe(todaysBytes(emit, AT)); + expect(Object.keys(JSON.parse(bytes))).toEqual(["a2uiCatalog", "surfaces", "exportedAt"]); + expect(bytes).not.toContain("actionManifest"); + }); + } + + it("a refused surface is left out and a missing catalog is omitted, as before", () => { + const emit: Emit = { surfaces: [{ name: "kept", messages: [{ version: "v0.9" }] }, { name: "refused" }] }; + expect(catalogJson(catalogBundle(emit, AT))).toBe(todaysBytes(emit, AT)); + expect(JSON.parse(catalogJson(catalogBundle(emit, AT)))).toEqual({ surfaces: [{ name: "kept", messages: [{ version: "v0.9" }] }], exportedAt: AT }); + }); + + it("an emit with no surfaces exports an empty list, as before", () => { + const emit: Emit = { catalog: { components: {} } }; + expect(catalogJson(catalogBundle(emit, AT))).toBe(todaysBytes(emit, AT)); + }); +}); diff --git a/apps/composer/app/catalog-export.ts b/apps/composer/app/catalog-export.ts new file mode 100644 index 0000000..46bb6cb --- /dev/null +++ b/apps/composer/app/catalog-export.ts @@ -0,0 +1,53 @@ +/** + * The Catalog export (Preview's "export catalog"): the emitted A2UI catalog, + * the vocabulary an A2UI/AG-UI client consumes, plus every emitted surface's + * messages (worked AG-UI payloads), as one JSON download. A pure browser + * download, in demo mode and agent mode alike, with no filesystem round + * trip, so a hosted user can take the catalog away and use it. + * + * The beta's experimental build adds a per-surface action manifest to the + * bundle (docs/blocks/HOST-ACTIONS.md). Nothing here knows of it, and the + * default build's export stays byte-identical to the one before it + * (catalog-export.test.ts). + */ + +/** What the export reads from an emit. */ +export interface CatalogEmit { + catalog?: unknown; + surfaces?: ReadonlyArray<{ name: string; messages?: unknown[] }>; +} + +export interface CatalogBundle { + a2uiCatalog: unknown; + surfaces: Array<{ name: string; messages: unknown[] }>; + exportedAt: string; +} + +/** The catalog and every surface that emitted; a refused surface has no messages to take away. */ +export function catalogBundle(emit: CatalogEmit, exportedAt: string): CatalogBundle { + return { + a2uiCatalog: emit.catalog, + surfaces: (emit.surfaces ?? []) + .filter((s) => s.messages) + .map((s) => ({ name: s.name, messages: s.messages as unknown[] })), + exportedAt, + }; +} + +/** The bytes the download holds. */ +export function catalogJson(bundle: object): string { + return JSON.stringify(bundle, null, 2); +} + +/** Download a bundle as `-catalog.json`. */ +export function downloadCatalog(bundle: object, name: string): void { + const blob = new Blob([catalogJson(bundle)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "a2ui"}-catalog.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index 3cdf036..398707c 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -242,6 +242,8 @@ export interface ComposerState { importProject: (text: string) => { ok: true; name: string } | { ok: false; error: string }; /** Export a project to a downloadable, portable file. */ exportProject: (id: string) => void; + /** Interface blocks (experimental): a project's block provenance, which its project export and the beta's Catalog export carry. Always empty in default builds. */ + blockProvenanceFor: (projectId: string) => Record; /** Open a packaged reference as a read-only EXAMPLE workspace (ephemeral — * never in "Your projects", pristine on every open, session edits only). */ openExample: (referenceId: string) => void; @@ -640,6 +642,17 @@ export function ComposerProvider({ children }: { children: ReactNode }) { [openProject], ); + /** + * Interface blocks (experimental): a project's block provenance, for its + * project export and the beta's Catalog export (docs/blocks/HOST-ACTIONS.md). + * An Example session keeps its own in memory. Default builds read none. + */ + const blockProvenanceFor = useCallback( + (id: string): Record => + process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" ? (exampleProject && id === exampleProject.id ? sessionProvenance.current : loadBlockProvenance(id)) : {}, + [exampleProject], + ); + /** * Export a project to a downloadable file — its identity plus its governed * vocabulary, nothing machine-specific. The OPEN project exports exactly @@ -684,13 +697,12 @@ export function ComposerProvider({ children }: { children: ReactNode }) { const projectFlows = id === activeProjectId ? flows : loadFlowsStore(id); // Interface blocks (experimental): provenance is project data and rides // along, additive like flows. Default builds export none. - const blockProvenance = - process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" ? (exampleProject && id === exampleProject.id ? sessionProvenance.current : loadBlockProvenance(id)) : {}; + const blockProvenance = blockProvenanceFor(id); downloadProjectExport( buildProjectExport({ name: p.name, description: p.description, vocab, exportedAt: new Date().toISOString(), flows: projectFlows, blockProvenance }), ); }, - [activeProjectId, contract, profile, manifest, flows, exampleProject], + [activeProjectId, contract, profile, manifest, flows, blockProvenanceFor], ); /** Return to the hub without loading a project (ends an example session). */ @@ -1668,6 +1680,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { deleteProject, importProject, exportProject, + blockProvenanceFor, openExample, isExample: exampleProject !== null, duplicateExample, @@ -1703,7 +1716,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { experimental, setExperimental, }), - [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition, experimental, setExperimental], + [mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, blockProvenanceFor, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition, experimental, setExperimental], ); return {children}; diff --git a/apps/composer/app/views/preview-view.tsx b/apps/composer/app/views/preview-view.tsx index 0503d2e..35fd1ac 100644 --- a/apps/composer/app/views/preview-view.tsx +++ b/apps/composer/app/views/preview-view.tsx @@ -18,6 +18,7 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; import { A2uiCanvas, type A2uiClientAction, type Registry } from "@dspack-studio/a2ui-ingest"; +import { catalogBundle, downloadCatalog } from "../catalog-export"; import { useComposer } from "../state"; import { ViewHeader } from "../ui"; import { isPendingStep, mintStepId, missingSurfaceMessage, nextFlowId, pendingStepMessage, type Flow, type FlowStep } from "../flows"; @@ -33,32 +34,6 @@ import { type RegistryId = "wireframe" | PreviewRegistryId; -/** - * Export the emitted artifacts as a downloadable JSON bundle: the A2UI - * catalog (the vocabulary an A2UI/AG-UI client consumes) plus every emitted - * surface's messages (worked AG-UI payloads). Pure browser download — works - * in demo mode and agent mode alike, with no filesystem round-trip, so a - * hosted user can actually take the catalog away and use it. - */ -function exportBundle(emit: NonNullable["emit"]>, name: string): void { - const bundle = { - a2uiCatalog: emit.catalog, - surfaces: (emit.surfaces ?? []) - .filter((s) => s.messages) - .map((s) => ({ name: s.name, messages: s.messages })), - exportedAt: new Date().toISOString(), - }; - const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "a2ui"}-catalog.json`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -} - const field = { fontFamily: "var(--mono)", fontSize: 12, @@ -102,7 +77,7 @@ const draftFrom = (flow: Flow): FlowDraft => ({ }); export function PreviewView({ focus }: { focus?: "flows" } = {}) { - const { contract, emit, manifest, referenceExampleIds, isExample, mode, activeProject, projectPath, flows, saveFlows } = useComposer(); + const { contract, emit, manifest, referenceExampleIds, isExample, mode, activeProject, projectPath, flows, saveFlows, blockProvenanceFor } = useComposer(); // null = "no explicit choice yet" — the project's own design system draws // it. An explicit pick survives until the project changes (B5). const [registryId, setRegistryId] = useState(null); @@ -117,6 +92,27 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) { const catalog = emit?.catalog; const surfaces = (emit?.surfaces ?? []).filter((s) => s.messages); + + /** + * The Catalog export (catalog-export.ts). The beta's experimental build adds + * its per-surface action manifest (docs/blocks/HOST-ACTIONS.md); if that + * cannot be built, today's export goes out instead, which binds nothing. + */ + const exportCatalog = (current: NonNullable, name: string) => { + const bundle = catalogBundle(current, new Date().toISOString()); + if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") { + const provenance = activeProject ? blockProvenanceFor(activeProject.id) : {}; + const examples = (contract?.examples as Array<{ id?: unknown; surface?: unknown }> | undefined) ?? []; + void import("../../experimental/blocks-runtime") + .then((runtime) => runtime.withActionManifest(bundle, { surfaces: current.surfaces ?? [], examples, provenance })) + .then( + (withManifest) => downloadCatalog(withManifest, name), + () => downloadCatalog(bundle, name), + ); + return; + } + downloadCatalog(bundle, name); + }; // A surface a person reads by its TITLE — the goal that produced it, or the // name someone authored — with `ex.chat-1` kept beside it for audit (B7). const entriesById = useMemo(() => surfaceEntriesById(contract?.examples), [contract]); @@ -394,7 +390,7 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) {