From 876848f5a8e55d9c516e093a75064df2b9e968f3 Mon Sep 17 00:00:00 2001 From: Ben Jacobson Date: Tue, 13 Oct 2026 23:50:23 +0000 Subject: [PATCH] Add ViewSpec playground and typed UI interaction intents --- .changeset/tender-toys-shout.md | 5 + apps/demo/README.md | 16 +- apps/demo/e2e/viewspec.scenarios.ts | 205 +++++++++++++ apps/demo/e2e/workflow.e2e.test.ts | 2 + apps/demo/src/app/message.ts | 2 + apps/demo/src/app/model.ts | 3 + apps/demo/src/app/route.ts | 7 + apps/demo/src/app/update.ts | 9 + apps/demo/src/app/view.ts | 74 +++-- apps/demo/src/home/view.ts | 3 +- apps/demo/src/viewspec/cassettes.ts | 219 ++++++++++++++ apps/demo/src/viewspec/catalog.ts | 33 +++ apps/demo/src/viewspec/document.ts | 111 +++++++ apps/demo/src/viewspec/generation.test.ts | 151 ++++++++++ apps/demo/src/viewspec/generation.ts | 64 +++++ apps/demo/src/viewspec/message.ts | 22 ++ apps/demo/src/viewspec/model.ts | 42 +++ apps/demo/src/viewspec/update.test.ts | 88 ++++++ apps/demo/src/viewspec/update.ts | 170 +++++++++++ apps/demo/src/viewspec/view.ts | 270 ++++++++++++++++++ docs/viewspec.md | 126 ++++++++ docs/worldvm-devtools.md | 3 + packages/generative-ui/README.md | 81 +++++- packages/generative-ui/src/catalog.ts | 31 +- packages/generative-ui/src/foldworks.ts | 33 ++- .../generative-ui/src/generative-ui.test.ts | 165 ++++++++++- packages/generative-ui/src/model.ts | 1 + packages/generative-ui/src/renderer.ts | 6 +- packages/generative-ui/src/validate.ts | 32 ++- 29 files changed, 1934 insertions(+), 40 deletions(-) create mode 100644 .changeset/tender-toys-shout.md create mode 100644 apps/demo/e2e/viewspec.scenarios.ts create mode 100644 apps/demo/src/viewspec/cassettes.ts create mode 100644 apps/demo/src/viewspec/catalog.ts create mode 100644 apps/demo/src/viewspec/document.ts create mode 100644 apps/demo/src/viewspec/generation.test.ts create mode 100644 apps/demo/src/viewspec/generation.ts create mode 100644 apps/demo/src/viewspec/message.ts create mode 100644 apps/demo/src/viewspec/model.ts create mode 100644 apps/demo/src/viewspec/update.test.ts create mode 100644 apps/demo/src/viewspec/update.ts create mode 100644 apps/demo/src/viewspec/view.ts create mode 100644 docs/viewspec.md diff --git a/.changeset/tender-toys-shout.md b/.changeset/tender-toys-shout.md new file mode 100644 index 0000000..7699716 --- /dev/null +++ b/.changeset/tender-toys-shout.md @@ -0,0 +1,5 @@ +--- +"@foldworks/generative-ui": minor +--- + +Add controlled TextInput documents and schema-derived typed host action intents with separate user event data. diff --git a/apps/demo/README.md b/apps/demo/README.md index 7f36a5f..da9e27c 100644 --- a/apps/demo/README.md +++ b/apps/demo/README.md @@ -9,7 +9,7 @@ diff review prototype at `/diff-viewer`. Foldkit's bidirectional router owns navigational state. The project overview lives at `/`; demos live at `/ui-kit`, `/editor`, `/codebase`, `/data-table`, `/data-grid`, `/query-builder`, `/form-builder`, `/workflow`, `/pdf-annotator`, `/agent`, `/outliner`, `/code-editor`, -`/workbench`, and `/statechart`. Workflow orientation and the +`/workbench`, `/statechart`, and `/viewspec`. Workflow orientation and the form's example and Editor/Preview mode live in query parameters so those states can be linked, reloaded, and traversed with browser history. Transient selections, drag state, column widths, and in-progress document edits remain in the @@ -172,3 +172,17 @@ The `/ui-kit` Desktop workspace demonstrates shared keyboard commands, measured breadcrumb/action rows, inline editing, token entry, a nested inspector, context menus, modal variants and a controlled date input. See [the integration guide](../../packages/ui/docs/desktop.md). + +## ViewSpec playground + +The `/viewspec` route uses `@foldworks/generative-ui` to render a validated +fixture crew directory and assignment draft. It demonstrates controlled text +edits, host-owned typed intents, and local acceptance/refusal without an +ontology connection. The sidebar and generative-ui package card link to it. +Its mock generation panel accepts a prompt and replays one of seven response +cassettes through the real generative-ui generation API and validation. Try +**Mission brief** for an accepted generated action, **Off-duty request** for a +refused one, or an invalid/error cassette to see failure and retry. The cassette +determines the fixed response; the mock does not interpret the prompt. Add +responses in `src/viewspec/cassettes.ts`. No AI credentials are needed. +See [the ownership map](../../docs/viewspec.md) for the adopted slice and gaps. diff --git a/apps/demo/e2e/viewspec.scenarios.ts b/apps/demo/e2e/viewspec.scenarios.ts new file mode 100644 index 0000000..78c2563 --- /dev/null +++ b/apps/demo/e2e/viewspec.scenarios.ts @@ -0,0 +1,205 @@ +import type { Page } from "playwright"; +import { expect, it } from "vitest"; + +export const viewspecScenarios = ( + getPage: () => Page, + appUrl: string, + screenshot: (name: string) => Promise, +): void => { + it("replays ViewSpec prompts into interactive generated documents", async () => { + const page = getPage(); + await page.goto(`${appUrl}/viewspec`, { waitUntil: "networkidle" }); + const playground = page.locator('[data-viewspec-playground="true"]'); + const generate = playground.getByRole("button", { name: "Generate mock UI", exact: true }); + await playground.getByLabel("UI prompt", { exact: true }).fill(" "); + await expect.poll(() => generate.isDisabled()).toBe(true); + await playground.getByLabel("Response cassette", { exact: true }).selectOption("mission-brief"); + const prompt = "Build a landing party briefing for our next expedition."; + await playground.getByLabel("UI prompt", { exact: true }).fill(prompt); + await generate.click(); + await expect + .poll(() => + playground.getByRole("button", { name: "Generating mock UI…", exact: true }).isDisabled(), + ) + .toBe(true); + const generated = playground.getByRole("region", { name: "Generated UI", exact: true }); + await expect + .poll(() => + generated.getByRole("heading", { name: "Landing party briefing", exact: true }).isVisible(), + ) + .toBe(true); + await expect.poll(() => generated.textContent()).toContain(prompt); + await playground + .getByRole("heading", { name: "Mock UI generation", exact: true }) + .scrollIntoViewIfNeeded(); + await screenshot("viewspec-mock-generation"); + await generated.getByRole("button", { name: "Assign Ford to the survey", exact: true }).click(); + await expect + .poll(() => + playground.getByRole("status", { name: "Host receipt", exact: true }).textContent(), + ) + .toContain("accepted the assignment for Ford Prefect"); + + await playground.getByLabel("Response cassette", { exact: true }).selectOption("crew-cards"); + await generate.click(); + await expect + .poll(() => + generated.getByRole("button", { name: "Choose Trillian", exact: true }).isVisible(), + ) + .toBe(true); + await generated.getByRole("button", { name: "Choose Trillian", exact: true }).click(); + await expect + .poll(() => playground.getByRole("heading", { name: "Trillian", exact: true }).count()) + .toBe(2); + + await playground.getByLabel("Response cassette", { exact: true }).selectOption("off-duty"); + await generate.click(); + const request = generated.getByRole("button", { + name: "Request Marvin's systems review", + exact: true, + }); + await expect.poll(() => request.isVisible()).toBe(true); + await request.click(); + await expect + .poll(() => + playground.getByRole("status", { name: "Host receipt", exact: true }).textContent(), + ) + .toContain("crew member is off duty"); + await playground.getByRole("button", { name: "Inspect document", exact: true }).click(); + await expect + .poll(() => + playground + .getByRole("region", { name: "Generated version-1 document", exact: true }) + .isVisible(), + ) + .toBe(true); + }); + + it("shows ViewSpec cassette failures, preserves valid output, and allows retry", async () => { + const page = getPage(); + await page.goto(`${appUrl}/viewspec`, { waitUntil: "networkidle" }); + const generate = page.getByRole("button", { name: "Generate mock UI", exact: true }); + const select = page.getByLabel("Response cassette", { exact: true }); + await select.selectOption("mission-brief"); + await generate.click(); + const generated = page.getByRole("region", { name: "Generated UI", exact: true }); + await expect + .poll(() => + generated.getByRole("heading", { name: "Landing party briefing", exact: true }).isVisible(), + ) + .toBe(true); + for (const [cassette, error] of [ + ["invalid-component", "schema/JSON decoding"], + ["broken-reference", "missing-child"], + ["malformed-json", "schema/JSON decoding"], + ["provider-error", "provider failure"], + ] as const) { + await select.selectOption(cassette); + await generate.click(); + await expect.poll(() => page.getByRole("alert").textContent()).toContain(error); + await expect + .poll(() => + generated + .getByRole("heading", { name: "Landing party briefing", exact: true }) + .isVisible(), + ) + .toBe(true); + } + await select.selectOption("crew-cards"); + await generate.click(); + await expect + .poll(() => + generated.getByRole("heading", { name: "Meet the crew", exact: true }).isVisible(), + ) + .toBe(true); + await expect.poll(() => page.getByRole("alert").count()).toBe(0); + await page.getByRole("button", { name: "Reset playground", exact: true }).click(); + await expect.poll(() => generated.count()).toBe(0); + }); + + it("completes the ViewSpec fixture interaction and leaves execution with the host", async () => { + const page = getPage(); + await page.goto(`${appUrl}/viewspec`, { waitUntil: "networkidle" }); + const playground = page.locator('[data-viewspec-playground="true"]'); + const submit = playground.getByRole("button", { name: "Request assignment", exact: true }); + await expect.poll(() => submit.isDisabled()).toBe(true); + await playground.getByLabel("Filter crew", { exact: true }).fill("Ford"); + await expect.poll(() => playground.getByRole("button", { name: /^Select / }).count()).toBe(1); + await playground.getByRole("button", { name: "Select Ford Prefect", exact: true }).click(); + await playground.getByLabel("Assignment note", { exact: true }).fill("Survey the landing site"); + await expect.poll(() => submit.isEnabled()).toBe(true); + await submit.click(); + await expect + .poll(() => + playground.getByRole("status", { name: "Host receipt", exact: true }).textContent(), + ) + .toBe("Fixture host accepted the assignment for Ford Prefect."); + const intent = playground.getByRole("region", { name: "Last submitted intent", exact: true }); + await expect.poll(() => intent.textContent()).toContain('"crewId": "ford"'); + await expect.poll(() => intent.textContent()).toContain('"note": "Survey the landing site"'); + await screenshot("viewspec-accepted"); + + await playground.getByLabel("Filter crew", { exact: true }).fill("no such crew"); + await expect + .poll(() => playground.getByText("No crew matches", { exact: true }).isVisible()) + .toBe(true); + await playground.getByLabel("Filter crew", { exact: true }).fill("Marvin"); + await playground.getByRole("button", { name: "Select Marvin", exact: true }).click(); + await expect + .poll(() => playground.getByLabel("Assignment note", { exact: true }).inputValue()) + .toBe(""); + await playground.getByLabel("Assignment note", { exact: true }).fill("Review systems"); + await submit.click(); + await expect + .poll(() => + playground.getByRole("status", { name: "Host receipt", exact: true }).textContent(), + ) + .toContain("crew member is off duty"); + await playground.getByRole("button", { name: "Inspect document", exact: true }).click(); + await expect + .poll(() => + playground + .getByRole("region", { name: "Validated version-1 document", exact: true }) + .isVisible(), + ) + .toBe(true); + await playground.getByRole("button", { name: "Reset playground", exact: true }).click(); + await expect + .poll(() => playground.getByLabel("Filter crew", { exact: true }).inputValue()) + .toBe(""); + await expect.poll(() => submit.isDisabled()).toBe(true); + }); + + it("keeps the ViewSpec draft usable by keyboard on a narrow dark viewport", async () => { + const page = getPage(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${appUrl}/viewspec`, { waitUntil: "networkidle" }); + await page.getByLabel("Appearance", { exact: true }).selectOption("Dark"); + const filter = page.getByLabel("Filter crew", { exact: true }); + await filter.focus(); + await page.keyboard.type("Trillian"); + await expect.poll(() => filter.inputValue()).toBe("Trillian"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.getByRole("heading", { name: "Trillian", exact: true }).count()) + .toBe(1); + await expect + .poll(() => page.evaluate(() => document.activeElement?.textContent)) + .toContain("Select Trillian"); + await page.keyboard.press("Tab"); + await page.keyboard.type("Plot the route"); + await expect + .poll(() => page.getByLabel("Assignment note", { exact: true }).inputValue()) + .toBe("Plot the route"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.getByRole("status", { name: "Host receipt", exact: true }).textContent()) + .toContain("accepted the assignment for Trillian"); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await screenshot("viewspec-mobile-dark"); + }); +}; diff --git a/apps/demo/e2e/workflow.e2e.test.ts b/apps/demo/e2e/workflow.e2e.test.ts index f12ba13..a7b8ad4 100644 --- a/apps/demo/e2e/workflow.e2e.test.ts +++ b/apps/demo/e2e/workflow.e2e.test.ts @@ -21,6 +21,7 @@ import { pdfViewerScenarios } from "./pdf-viewer.scenarios"; import { statechartScenarios } from "./statechart.scenarios"; import { outlinerScenarios } from "./outliner.scenarios"; import { orchestratorScenarios } from "./orchestrator.scenarios"; +import { viewspecScenarios } from "./viewspec.scenarios"; const appRoot = resolve(import.meta.dirname, ".."); const screenshotDirectory = resolve(appRoot, "../../.context/demo-screenshots"); @@ -1652,4 +1653,5 @@ describe.sequential("structured workflow builder", () => { outlinerScenarios(() => page, appUrl, screenshot); orchestratorScenarios(() => page, appUrl, screenshot); packageDemoScreenshotScenarios(() => page, appUrl, screenshot); + viewspecScenarios(() => page, appUrl, screenshot); }); diff --git a/apps/demo/src/app/message.ts b/apps/demo/src/app/message.ts index 2b60166..aba85c5 100644 --- a/apps/demo/src/app/message.ts +++ b/apps/demo/src/app/message.ts @@ -21,6 +21,7 @@ import { Message as PdfViewerDemoMessage } from "../pdf-viewer/message"; import { Message as QueryBuilderDemoMessage } from "../query-builder/message"; import { ThemeName, ThemePreference } from "../theme"; import { Message as StatechartMessage } from "../statechart/message"; +import { Message as ViewSpecMessage } from "../viewspec/message"; import { Message as ThemeBuilderMessage } from "../theme-builder/message"; import { Message as UiKitMessage } from "../ui-kit/message"; import { Message as WorkflowEditorMessage } from "../workflow/message"; @@ -41,6 +42,7 @@ export const Message = defineMessageUnion({ GotWorkflowEditorMessage: { message: WorkflowEditorMessage }, GotFormEditorMessage: { message: FormEditorMessage }, GotStatechartMessage: { message: StatechartMessage }, + GotViewSpecMessage: { message: ViewSpecMessage }, GotCodeEditorMessage: { message: CodeEditorMessage }, GotWorkbenchMessage: { message: WorkbenchMessage }, GotDataGridDemoMessage: { message: DataGridMessage }, diff --git a/apps/demo/src/app/model.ts b/apps/demo/src/app/model.ts index 460e954..27e0904 100644 --- a/apps/demo/src/app/model.ts +++ b/apps/demo/src/app/model.ts @@ -36,6 +36,7 @@ import { } from "../theme-builder/model"; import { Model as UiKitModel, initialModel as initialUiKit } from "../ui-kit/model"; import { Model as StatechartModel, initialModel as initialStatechart } from "../statechart/model"; +import { Model as ViewSpecModel, init as initViewSpec } from "../viewspec/model"; import { Model as WorkflowEditorModel, init as initWorkflowEditor } from "../workflow/model"; import { AppRoute, @@ -54,6 +55,7 @@ export const Model = S.Struct({ workbench: WorkbenchModel, workflowEditor: WorkflowEditorModel, statechart: StatechartModel, + viewspec: ViewSpecModel, formEditor: FormEditorModel, dataGridDemo: DataGridModel, dataTableDemo: DataTableModel, @@ -95,6 +97,7 @@ export const init = ( workbench: initWorkbench(workers?.snapshot, workers?.error), workflowEditor: initWorkflowEditor(persisted?.workflow, workflowOrientationFromRoute(route)), statechart: initialStatechart, + viewspec: initViewSpec(), formEditor: initFormEditor(persisted?.forms ?? exampleForms, exampleId, mode), dataGridDemo: setDataGridExample(initialDataGrid, dataGridExampleFromRoute(route)), dataTableDemo: initDataTable(dataTablePersonFromRoute(route)), diff --git a/apps/demo/src/app/route.ts b/apps/demo/src/app/route.ts index a44070c..73a1ec2 100644 --- a/apps/demo/src/app/route.ts +++ b/apps/demo/src/app/route.ts @@ -17,6 +17,7 @@ export type Demo = | "Workbench" | "Workflow" | "Statechart" + | "ViewSpec" | "DataTable" | "DataGrid" | "FormBuilder" @@ -42,6 +43,7 @@ export const AppRoute = defineRouteUnion({ Workbench: {}, Workflow: { orientation: S.Option(WorkflowOrientation) }, Statechart: {}, + ViewSpec: {}, DataTable: { person: S.Option(S.String) }, DataGrid: { example: S.Option(DataGridExample) }, FormBuilder: { @@ -80,6 +82,8 @@ export const homeRouter = pipe(Route.root, Route.mapTo(AppRoute.Home)); export const statechartRouter = pipe(Route.literal("statechart"), Route.mapTo(AppRoute.Statechart)); +export const viewspecRouter = pipe(Route.literal("viewspec"), Route.mapTo(AppRoute.ViewSpec)); + export const codeEditorRouter = pipe( Route.literal("code-editor"), Route.mapTo(AppRoute.CodeEditor), @@ -149,6 +153,7 @@ const routeParser = Route.oneOf( editorRouter, workflowRouter, statechartRouter, + viewspecRouter, agentRouter, orchestratorRouter, workbenchRouter, @@ -211,6 +216,8 @@ export const demoFromRoute = (route: AppRoute): Demo => { return "Workflow"; case "Statechart": return "Statechart"; + case "ViewSpec": + return "ViewSpec"; case "NotFound": return "Home"; } diff --git a/apps/demo/src/app/update.ts b/apps/demo/src/app/update.ts index b82c9d5..5035aa5 100644 --- a/apps/demo/src/app/update.ts +++ b/apps/demo/src/app/update.ts @@ -29,6 +29,7 @@ import { update as updateOutliner } from "../outliner/update"; import { applyTheme, ThemeName } from "../theme"; import { Message as StatechartMessage } from "../statechart/message"; import { update as updateStatechart } from "../statechart/update"; +import { update as updateViewSpec } from "../viewspec/update"; import { update as updateThemeBuilder } from "../theme-builder/update"; import { Message as ThemeBuilderMessage } from "../theme-builder/message"; import { update as updateUiKit } from "../ui-kit/update"; @@ -152,6 +153,13 @@ const foldStatechart = Update.foldChild({ toParentMessage: (message) => Message.GotStatechartMessage({ message }), }); +const foldViewSpec = Update.foldChild({ + update: updateViewSpec, + read: (model: Model) => Option.some(model.viewspec), + write: (model, viewspec) => ({ ...model, viewspec }), + toParentMessage: (message) => Message.GotViewSpecMessage({ message }), +}); + const foldThemeBuilder = Update.foldChild({ update: updateThemeBuilder, read: (model: Model) => Option.some(model.themeBuilder), @@ -402,6 +410,7 @@ export const update = (model: Model, message: Message): UpdateReturn => GotWorkflowEditorMessage: ({ message: childMessage }) => foldWorkflow(model, childMessage), GotFormEditorMessage: ({ message: childMessage }) => foldForm(model, childMessage), GotStatechartMessage: ({ message: childMessage }) => foldStatechart(model, childMessage), + GotViewSpecMessage: ({ message }) => foldViewSpec(model, message), GotCodeEditorMessage: ({ message }) => foldCodeEditor(model, message), GotWorkbenchMessage: ({ message }) => foldWorkbench(model, message), GotDataGridDemoMessage: ({ message: childMessage }) => foldDataGrid(model, childMessage), diff --git a/apps/demo/src/app/view.ts b/apps/demo/src/app/view.ts index a2a1890..c7b0db2 100644 --- a/apps/demo/src/app/view.ts +++ b/apps/demo/src/app/view.ts @@ -48,6 +48,7 @@ import { view as outlinerView } from "../outliner/view"; import { view as pdfViewerView } from "../pdf-viewer/view"; import { view as queryBuilderView } from "../query-builder/view"; import { statechartSummary, view as statechartView } from "../statechart/view"; +import { view as viewspecView } from "../viewspec/view"; import { view as themeBuilderView } from "../theme-builder/view"; import { view as uiKitView } from "../ui-kit/view"; import { allNodes } from "../workflow/graph"; @@ -69,6 +70,7 @@ import { pdfViewerRouter, queryBuilderRouter, statechartRouter, + viewspecRouter, uiKitRouter, themeRouter, workflowPath, @@ -229,6 +231,13 @@ const navigationGroups = (model: Model): ReadonlyArray // where one exists, otherwise the package reference for headless behavior. const itemsByLayer: Readonly>> = { Workbenches: [ + { + id: "viewspec", + label: "ViewSpec playground", + href: viewspecRouter(), + icon: Blocks, + isActive: demo === "ViewSpec", + }, { id: "orchestrator", label: "Outline workspace", @@ -492,7 +501,9 @@ const toolbar = (model: Model, h: HtmlBuilder): Html => { ? "Onboarding packet review" : demo === "Home" ? "Foldworks" - : "@foldworks/ui"; + : demo === "ViewSpec" + ? "ViewSpec playground" + : "@foldworks/ui"; const description = demo === "Docs" ? docsDescription(model) @@ -532,7 +543,9 @@ const toolbar = (model: Model, h: HtmlBuilder): Html => { ? "Read-only pages · overlay hotspots · crop boxes and rotation" : demo === "Home" ? "The UI standard library for Foldkit and StyleX" - : "61 components · Foldkit behavior · StyleX"; + : demo === "ViewSpec" + ? "Validated UI documents · local drafts · host intents" + : "61 components · Foldkit behavior · StyleX"; return Toolbar.view( { title, @@ -623,7 +636,7 @@ const toolbar = (model: Model, h: HtmlBuilder): Html => { h, ), ] - : demo === "Home" || demo === "Docs" + : demo === "Home" || demo === "Docs" || demo === "ViewSpec" ? [] : [ toolbarBadge( @@ -699,6 +712,13 @@ const content = (model: Model, h: HtmlBuilder): Html => { toParentMessage: (message) => Message.GotThemeBuilderMessage({ message }), }); if (demo === "Home") return homeView(h); + if (demo === "ViewSpec") + return h.submodel({ + slotId: "viewspec-content", + model: model.viewspec, + view: viewspecView, + toParentMessage: (message) => Message.GotViewSpecMessage({ message }), + }); if (demo === "Editor") return h.submodel({ slotId: "document-editor", @@ -827,28 +847,32 @@ const content = (model: Model, h: HtmlBuilder): Html => { const documentTitle = (demo: Demo): string => demo === "Theme" ? "Theme builder · Foldworks" - : Match.value(demo).pipe( - Match.when("Docs", () => "Documentation · Foldworks"), - Match.when("Editor", () => "Document editor · Foldworks"), - Match.when("Orchestrator", () => "Outline workspace · Foldworks"), - Match.when("Agent", () => "Interactive agent · Foldworks"), - Match.when("CodeEditor", () => "Code editor · Foldworks"), - Match.when("Codebase", () => "Codebase workbench · Foldworks"), - Match.when("DiffViewer", () => "Code review · Foldworks"), - Match.when("Workbench", () => "Workers workbench · Foldworks"), - Match.when("Home", () => "Foldworks · The UI standard library for Foldkit and StyleX"), - Match.when("DataTable", () => "Data table · Foldworks"), - Match.when("DataGrid", () => "Data grid · Foldworks"), - Match.when("FormBuilder", () => "Form builder · Foldworks"), - Match.when("QueryBuilder", () => "Query builder · Foldworks"), - Match.when("Outliner", () => "Outliner · Foldworks"), - Match.when("PdfAnnotator", () => "PDF annotator · Foldworks"), - Match.when("PdfViewer", () => "PDF viewer · Foldworks"), - Match.when("UiKit", () => "UI components · Foldworks"), - Match.when("Workflow", () => "Workflow · Foldworks"), - Match.when("Statechart", () => "Statechart · Foldworks"), - Match.exhaustive, - ); + : Match.value(demo) + .pipe( + Match.when("Docs", () => "Documentation · Foldworks"), + Match.when("Editor", () => "Document editor · Foldworks"), + Match.when("Orchestrator", () => "Outline workspace · Foldworks"), + Match.when("Agent", () => "Interactive agent · Foldworks"), + Match.when("CodeEditor", () => "Code editor · Foldworks"), + Match.when("Codebase", () => "Codebase workbench · Foldworks"), + Match.when("DiffViewer", () => "Code review · Foldworks"), + Match.when("Workbench", () => "Workers workbench · Foldworks"), + Match.when("Home", () => "Foldworks · The UI standard library for Foldkit and StyleX"), + Match.when("DataTable", () => "Data table · Foldworks"), + Match.when("DataGrid", () => "Data grid · Foldworks"), + Match.when("FormBuilder", () => "Form builder · Foldworks"), + Match.when("QueryBuilder", () => "Query builder · Foldworks"), + Match.when("Outliner", () => "Outliner · Foldworks"), + Match.when("PdfAnnotator", () => "PDF annotator · Foldworks"), + Match.when("PdfViewer", () => "PDF viewer · Foldworks"), + Match.when("UiKit", () => "UI components · Foldworks"), + Match.when("Workflow", () => "Workflow · Foldworks"), + ) + .pipe( + Match.when("Statechart", () => "Statechart · Foldworks"), + Match.when("ViewSpec", () => "ViewSpec playground · Foldworks"), + Match.exhaustive, + ); export const view = (model: Model, h: HtmlBuilder): Document => { const demo = demoFromRoute(model.route); diff --git a/apps/demo/src/home/view.ts b/apps/demo/src/home/view.ts index d71f2fd..3228b71 100644 --- a/apps/demo/src/home/view.ts +++ b/apps/demo/src/home/view.ts @@ -40,6 +40,7 @@ import { pdfViewerRouter, queryBuilderRouter, statechartRouter, + viewspecRouter, uiKitRouter, workbenchRouter, workflowPath, @@ -157,7 +158,7 @@ const packages: Readonly> = { }, "generative-ui": { description: "Schema-checked agent interfaces rendered through a closed component catalog.", - href: "#generative-ui", + href: viewspecRouter(), icon: Sparkles, }, codebase: { diff --git a/apps/demo/src/viewspec/cassettes.ts b/apps/demo/src/viewspec/cassettes.ts new file mode 100644 index 0000000..f06a0e3 --- /dev/null +++ b/apps/demo/src/viewspec/cassettes.ts @@ -0,0 +1,219 @@ +import type { Spec } from "@foldworks/generative-ui/core"; +import { Schema as S } from "effect"; + +import { crew } from "./catalog"; + +export const CassetteId = S.Literals([ + "crew-cards", + "mission-brief", + "off-duty", + "invalid-component", + "broken-reference", + "malformed-json", + "provider-error", +]); +export type CassetteId = typeof CassetteId.Type; + +type Cassette = Readonly<{ + label: string; + description: string; + examplePrompt: string; +}> & + ({ readonly response: string } | { readonly error: string }); + +const crewCards: Spec = { + version: "1", + root: "mock-crew", + elements: { + "mock-crew": { type: "Stack", props: { gap: "md" }, children: ["mock-title", "mock-cards"] }, + "mock-title": { type: "Heading", props: { text: "Meet the crew", level: 2 }, children: [] }, + "mock-cards": { + type: "Grid", + props: { columns: 2, minColumnWidth: "14rem", gap: "md" }, + children: crew.map((person) => `mock-card-${person.id}`), + }, + ...Object.fromEntries( + crew.flatMap((person) => [ + [ + `mock-card-${person.id}`, + { + type: "Card", + props: { title: person.name, description: person.role }, + children: [`mock-content-${person.id}`], + }, + ], + [ + `mock-content-${person.id}`, + { + type: "Stack", + props: { gap: "sm" }, + children: [`mock-status-${person.id}`, `mock-select-${person.id}`], + }, + ], + [ + `mock-status-${person.id}`, + { + type: "Badge", + props: { + label: person.available ? "Available" : "Off duty", + tone: person.available ? "success" : "warning", + }, + children: [], + }, + ], + [ + `mock-select-${person.id}`, + { + type: "Button", + props: { label: `Choose ${person.name}`, variant: "outline" }, + children: [], + on: { press: { action: "select_crew", params: { crewId: person.id } } }, + }, + ], + ]), + ), + }, +}; + +const missionBrief: Spec = { + version: "1", + root: "mock-brief", + elements: { + "mock-brief": { + type: "Card", + props: { + title: "Landing party briefing", + description: "A compact mission summary with a host-owned action.", + }, + children: ["mock-brief-content"], + }, + "mock-brief-content": { + type: "Stack", + props: { gap: "md" }, + children: ["mock-summary", "mock-table", "mock-request"], + }, + "mock-summary": { + type: "Text", + props: { text: "Survey the landing site, plot a safe route, and report to the crew." }, + children: [], + }, + "mock-table": { + type: "Table", + props: { + caption: "Suggested assignments", + columns: ["Crew member", "Assignment"], + rows: [ + ["Ford Prefect", "Survey the landing site"], + ["Trillian", "Plot the route"], + ], + }, + children: [], + }, + "mock-request": { + type: "Button", + props: { label: "Assign Ford to the survey" }, + children: [], + on: { + press: { + action: "request_assignment", + params: { crewId: "ford", note: "Survey the landing site" }, + }, + }, + }, + }, +}; + +const offDuty: Spec = { + version: "1", + root: "mock-off-duty", + elements: { + "mock-off-duty": { + type: "Card", + props: { + title: "Systems review", + description: "A valid UI request can still be refused by the host.", + }, + children: ["mock-off-duty-content"], + }, + "mock-off-duty-content": { + type: "Stack", + props: { gap: "md" }, + children: ["mock-warning", "mock-marvin-request"], + }, + "mock-warning": { + type: "Alert", + props: { + title: "Marvin is off duty", + description: + "Request a systems review to see the fixture host enforce its availability policy.", + }, + children: [], + }, + "mock-marvin-request": { + type: "Button", + props: { label: "Request Marvin's systems review" }, + children: [], + on: { + press: { + action: "request_assignment", + params: { crewId: "marvin", note: "Review systems" }, + }, + }, + }, + }, +}; + +/** Fixed mock responses, not recordings from a live provider. */ +export const cassettes: Readonly> = { + "crew-cards": { + label: "Crew cards", + description: "A card directory with actions that select records in the fixture host.", + examplePrompt: "Show the crew as cards with their roles, availability, and a choose button.", + response: JSON.stringify(crewCards), + }, + "mission-brief": { + label: "Mission brief", + description: "A summary, a table, and a request the host can accept.", + examplePrompt: + "Create a landing party briefing and let me assign Ford to survey the landing site.", + response: JSON.stringify(missionBrief), + }, + "off-duty": { + label: "Off-duty request", + description: "A valid document whose action the host refuses.", + examplePrompt: "Ask Marvin to review the ship's systems.", + response: JSON.stringify(offDuty), + }, + "invalid-component": { + label: "Invalid component", + description: "Catalog schema decoding rejects a component outside the allowlist.", + examplePrompt: "Render a component that is not in the catalog.", + response: JSON.stringify({ + version: "1", + root: "mock-script", + elements: { "mock-script": { type: "Script", props: {}, children: [] } }, + }), + }, + "broken-reference": { + label: "Broken reference", + description: "Graph validation rejects a missing child after schema decoding succeeds.", + examplePrompt: "Show a stack containing a missing component reference.", + response: JSON.stringify({ + version: "1", + root: "mock-broken", + elements: { "mock-broken": { type: "Stack", props: {}, children: ["missing-child"] } }, + }), + }, + "malformed-json": { + label: "Malformed JSON", + description: "Structured output decoding rejects incomplete JSON.", + examplePrompt: "Simulate an incomplete model response.", + response: '{"version":"1","root":', + }, + "provider-error": { + label: "Provider error", + description: "Simulate a provider failure, then choose another cassette to retry.", + examplePrompt: "Simulate a provider error while generating the interface.", + error: "Simulated provider failure. Choose another cassette and retry.", + }, +}; diff --git a/apps/demo/src/viewspec/catalog.ts b/apps/demo/src/viewspec/catalog.ts new file mode 100644 index 0000000..62826f0 --- /dev/null +++ b/apps/demo/src/viewspec/catalog.ts @@ -0,0 +1,33 @@ +import { createFoldworksCatalog } from "@foldworks/generative-ui/foldworks"; +import { Schema as S } from "effect"; + +export const CrewId = S.Literals(["arthur", "ford", "trillian", "marvin"]); +export type CrewId = typeof CrewId.Type; + +export const catalog = createFoldworksCatalog({ + edit_filter: { + description: "Update the local crew filter.", + params: S.Struct({}), + data: S.Struct({ value: S.String }), + }, + select_crew: { + description: "Select a fixture crew record.", + params: S.Struct({ crewId: CrewId }), + }, + edit_note: { + description: "Update the local request note.", + params: S.Struct({}), + data: S.Struct({ value: S.String }), + }, + request_assignment: { + description: "Ask the fixture host to assign a crew member. The host may refuse.", + params: S.Struct({ crewId: CrewId, note: S.String }), + }, +}); + +export const crew = [ + { id: "arthur", name: "Arthur Dent", role: "Observer", available: true }, + { id: "ford", name: "Ford Prefect", role: "Field researcher", available: true }, + { id: "trillian", name: "Trillian", role: "Navigator", available: true }, + { id: "marvin", name: "Marvin", role: "Systems analyst", available: false }, +] as const; diff --git a/apps/demo/src/viewspec/document.ts b/apps/demo/src/viewspec/document.ts new file mode 100644 index 0000000..3cdda35 --- /dev/null +++ b/apps/demo/src/viewspec/document.ts @@ -0,0 +1,111 @@ +import type { Element, Spec } from "@foldworks/generative-ui/core"; + +import { crew } from "./catalog"; +import type { Model } from "./model"; + +/** Local presentation adapter: fixture data and Foldkit state become concrete props. */ +export const document = (model: Model): Spec => { + const matches = crew.filter((person) => + `${person.name} ${person.role}`.toLowerCase().includes(model.filter.trim().toLowerCase()), + ); + const selected = crew.find((person) => person.id === model.selected)!; + const elements: Record = { + root: { + type: "Grid", + props: { columns: 2, minColumnWidth: "18rem", gap: "lg" }, + children: ["directory", "request"], + }, + directory: { + type: "Card", + props: { title: "Crew directory", description: "Local fixtures · search and select" }, + children: ["directory-content"], + }, + "directory-content": { + type: "Stack", + props: { gap: "md" }, + children: [ + "crew-filter", + "match-count", + ...matches.map((person) => `crew-${person.id}`), + ...(matches.length === 0 ? ["empty"] : []), + ], + }, + "crew-filter": { + type: "TextInput", + props: { label: "Filter crew", value: model.filter, placeholder: "Name or role" }, + children: [], + on: { change: { action: "edit_filter", params: {} } }, + }, + "match-count": { + type: "Text", + props: { + text: `${matches.length} crew ${matches.length === 1 ? "member" : "members"}`, + tone: "muted", + size: "sm", + }, + children: [], + }, + request: { + type: "Card", + props: { + title: "Assignment request", + description: "The document emits an intent; the host decides.", + }, + children: ["request-content"], + }, + "request-content": { + type: "Stack", + props: { gap: "md" }, + children: ["selected-name", "selected-role", "availability", "request-note", "submit"], + }, + "selected-name": { type: "Heading", props: { text: selected.name, level: 3 }, children: [] }, + "selected-role": { type: "Text", props: { text: selected.role, tone: "muted" }, children: [] }, + availability: { + type: "Badge", + props: { + label: selected.available ? "Available" : "Off duty", + tone: selected.available ? "success" : "warning", + }, + children: [], + }, + "request-note": { + type: "TextInput", + props: { + label: "Assignment note", + value: model.note, + description: "Explain the assignment before sending a request.", + }, + children: [], + on: { change: { action: "edit_note", params: {} } }, + }, + submit: { + type: "Button", + props: { label: "Request assignment", disabled: model.note.trim().length === 0 }, + children: [], + on: { + press: { + action: "request_assignment", + params: { crewId: model.selected, note: model.note }, + }, + }, + }, + }; + for (const person of matches) { + elements[`crew-${person.id}`] = { + type: "Button", + props: { + label: `Select ${person.name}`, + variant: person.id === model.selected ? "secondary" : "outline", + }, + children: [], + on: { press: { action: "select_crew", params: { crewId: person.id } } }, + }; + } + if (matches.length === 0) + elements.empty = { + type: "Alert", + props: { title: "No crew matches", description: "Try a different name or role." }, + children: [], + }; + return { version: "1", root: "root", elements }; +}; diff --git a/apps/demo/src/viewspec/generation.test.ts b/apps/demo/src/viewspec/generation.test.ts new file mode 100644 index 0000000..e4cb449 --- /dev/null +++ b/apps/demo/src/viewspec/generation.test.ts @@ -0,0 +1,151 @@ +import { generate } from "@foldworks/generative-ui/ai"; +import { Effect, Option } from "effect"; +import { LanguageModel } from "effect/unstable/ai"; +import { describe, expect, it } from "vitest"; + +import { catalog } from "./catalog"; +import type { CassetteId } from "./cassettes"; +import { cassetteModel, replayCassette } from "./generation"; +import { Message } from "./message"; +import { init, type Model } from "./model"; +import { update } from "./update"; + +describe("ViewSpec mock generation", () => { + it("passes the user prompt and real catalog through the generation API", async () => { + let request: LanguageModel.ProviderOptions | undefined; + const prompt = "Give me a crew view for my expedition."; + const spec = await Effect.runPromise( + Effect.gen(function* () { + const model = yield* cassetteModel("crew-cards", (options) => { + request = options; + }); + return yield* generate(catalog, { prompt }).pipe( + Effect.provideService(LanguageModel.LanguageModel, model), + ); + }), + ); + expect(spec.root).toBe("mock-crew"); + expect(JSON.stringify(request?.prompt)).toContain(prompt); + expect(JSON.stringify(request?.prompt)).toContain("User request:"); + expect(JSON.stringify(request?.prompt)).toContain("request_assignment"); + expect(request?.responseFormat.type).toBe("json"); + if (request?.responseFormat.type !== "json") throw new Error("Expected structured output"); + expect(request.responseFormat.schema).toBe(catalog.schema); + }); + + it.each(["crew-cards", "mission-brief", "off-duty"] as const)( + "decodes and validates the %s cassette", + async (cassette) => { + const result = await Effect.runPromise(replayCassette(cassette, "Test prompt")); + expect(result._tag).toBe("Ready"); + }, + ); + + it.each([ + ["invalid-component", "schema/JSON decoding"], + ["malformed-json", "schema/JSON decoding"], + ["broken-reference", "graph validation"], + ["provider-error", "provider failure"], + ] as const)("rejects the %s cassette", async (cassette, error) => { + const result = await Effect.runPromise(replayCassette(cassette, "Test prompt")); + if (result._tag !== "Error") throw new Error("Expected failed generation"); + expect(result.error).toContain(error); + }); + + it("ignores empty submissions and duplicate in-flight generation requests", () => { + expect(update({ ...init(), prompt: " " }, Message.RequestedGeneration())).toEqual({ + model: { ...init(), prompt: " " }, + }); + const started = update(init(), Message.RequestedGeneration()); + expect(started.commands).toHaveLength(1); + expect(started.model.generationState).toBe("Loading"); + expect(update(started.model, Message.RequestedGeneration())).toEqual({ model: started.model }); + }); + + const complete = async (model: Model, cassetteId: CassetteId) => + update( + model, + Message.CompletedGeneration({ + requestId: model.requestId, + prompt: model.prompt, + cassetteId, + result: await Effect.runPromise(replayCassette(cassetteId, model.prompt)), + }), + ).model; + + it("keeps the last valid document when a subsequent response fails", async () => { + const ready = await complete( + update(init(), Message.RequestedGeneration()).model, + "mission-brief", + ); + const next = update(ready, Message.SelectedCassette({ cassetteId: "broken-reference" })).model; + const failed = await complete( + update(next, Message.RequestedGeneration()).model, + "broken-reference", + ); + expect(failed.generationState).toBe("Error"); + expect(failed.generatedSpec).toEqual(ready.generatedSpec); + expect(failed.generatedCassette).toBe("mission-brief"); + expect(failed.generatedPrompt).toBe(ready.generatedPrompt); + expect(failed.generationError).toContain("missing-child"); + }); + + it("ignores obsolete completions after changing cassettes or resetting", async () => { + const started = update(init(), Message.RequestedGeneration()).model; + const old = Message.CompletedGeneration({ + requestId: started.requestId, + cassetteId: "crew-cards", + prompt: started.prompt, + result: await Effect.runPromise(replayCassette("crew-cards", started.prompt)), + }); + const selected = update( + started, + Message.SelectedCassette({ cassetteId: "mission-brief" }), + ).model; + expect(update(selected, old).model).toEqual(selected); + const reset = update(started, Message.Reset()).model; + const restarted = update(reset, Message.RequestedGeneration()).model; + expect(update(restarted, old).model).toEqual(restarted); + expect(Option.isNone(restarted.generatedSpec)).toBe(true); + }); + + it("handles generated actions through host policy and checks current bindings", async () => { + const ready = await complete( + update(init(), Message.RequestedGeneration()).model, + "mission-brief", + ); + const intent = { + action: "request_assignment", + elementId: "mock-request", + event: "press", + params: { crewId: "ford", note: "Survey the landing site" }, + }; + expect(update(ready, Message.ReceivedGeneratedIntent({ intent })).model.outcome).toBe( + "Accepted", + ); + expect( + update( + ready, + Message.ReceivedGeneratedIntent({ + intent: { ...intent, params: { crewId: "trillian", note: "Changed request" } }, + }), + ).model.outcome, + ).toBe("Invalid"); + expect(update(init(), Message.ReceivedGeneratedIntent({ intent })).model.outcome).toBe( + "Invalid", + ); + const offDuty = await complete(update(init(), Message.RequestedGeneration()).model, "off-duty"); + expect( + update( + offDuty, + Message.ReceivedGeneratedIntent({ + intent: { + ...intent, + elementId: "mock-marvin-request", + params: { crewId: "marvin", note: "Review systems" }, + }, + }), + ).model.outcome, + ).toBe("Refused"); + }); +}); diff --git a/apps/demo/src/viewspec/generation.ts b/apps/demo/src/viewspec/generation.ts new file mode 100644 index 0000000..ff3eb11 --- /dev/null +++ b/apps/demo/src/viewspec/generation.ts @@ -0,0 +1,64 @@ +import { generate } from "@foldworks/generative-ui/ai"; +import { Spec, validateSpec } from "@foldworks/generative-ui/core"; +import { Effect, Result, Schema as S, Stream } from "effect"; +import { AiError, LanguageModel } from "effect/unstable/ai"; + +import { catalog } from "./catalog"; +import { cassettes, type CassetteId } from "./cassettes"; + +export const GenerationResult = S.Union([ + S.TaggedStruct("Ready", { spec: Spec }), + S.TaggedStruct("Error", { error: S.String }), +]); +export type GenerationResult = typeof GenerationResult.Type; + +/** Substitute only the provider response; structured generation still uses the real API. */ +export const cassetteModel = ( + cassetteId: CassetteId, + observeRequest?: (options: LanguageModel.ProviderOptions) => void, +): Effect.Effect => + LanguageModel.make({ + generateText: (options) => + Effect.gen(function* () { + observeRequest?.(options); + const cassette = cassettes[cassetteId]; + if ("error" in cassette) + return yield* Effect.fail( + AiError.make({ + module: "ViewSpecMock", + method: "generateText", + reason: new AiError.UnknownError({ description: cassette.error }), + }), + ); + return [{ type: "text" as const, text: cassette.response }]; + }), + streamText: () => Stream.empty, + }); + +export const replayCassette = ( + cassetteId: CassetteId, + prompt: string, +): Effect.Effect => + Effect.gen(function* () { + const model = yield* cassetteModel(cassetteId); + const spec = yield* generate(catalog, { prompt }).pipe( + Effect.provideService(LanguageModel.LanguageModel, model), + ); + const validated = validateSpec(catalog, spec); + return Result.isSuccess(validated) + ? { _tag: "Ready" as const, spec: validated.success } + : { + _tag: "Error" as const, + error: `Mock response failed graph validation:\n${validated.failure.map((issue) => `${issue.path ?? "spec"}: ${issue.message}`).join("\n")}`, + }; + }).pipe( + Effect.catch((error) => + Effect.succeed({ + _tag: "Error" as const, + error: + cassetteId === "provider-error" + ? cassettes["provider-error"].description + " Choose another cassette and retry." + : `Mock response failed schema/JSON decoding (${error.reason._tag}). ${error.message.slice(0, 1_000)}`, + }), + ), + ); diff --git a/apps/demo/src/viewspec/message.ts b/apps/demo/src/viewspec/message.ts new file mode 100644 index 0000000..7a8767c --- /dev/null +++ b/apps/demo/src/viewspec/message.ts @@ -0,0 +1,22 @@ +import { ActionIntent } from "@foldworks/generative-ui/core"; +import { defineMessageUnion } from "foldkit/message"; +import { Schema as S } from "effect"; +import { CassetteId } from "./cassettes"; +import { GenerationResult } from "./generation"; + +export const Message = defineMessageUnion({ + ReceivedIntent: { intent: ActionIntent }, + ReceivedGeneratedIntent: { intent: ActionIntent }, + ChangedPrompt: { value: S.String }, + SelectedCassette: { cassetteId: CassetteId }, + RequestedGeneration: {}, + CompletedGeneration: { + requestId: S.Int, + prompt: S.String, + cassetteId: CassetteId, + result: GenerationResult, + }, + ToggledDocument: {}, + Reset: {}, +}); +export type Message = typeof Message.Type; diff --git a/apps/demo/src/viewspec/model.ts b/apps/demo/src/viewspec/model.ts new file mode 100644 index 0000000..d8eabd6 --- /dev/null +++ b/apps/demo/src/viewspec/model.ts @@ -0,0 +1,42 @@ +import { Option, Schema as S } from "effect"; +import { Spec } from "@foldworks/generative-ui/core"; + +import { CrewId } from "./catalog"; +import { CassetteId, cassettes } from "./cassettes"; + +export const Model = S.Struct({ + filter: S.String, + selected: CrewId, + note: S.String, + outcome: S.Literals(["Idle", "Accepted", "Refused", "Invalid"]), + receipt: S.String, + lastIntent: S.String, + showDocument: S.Boolean, + prompt: S.String, + cassetteId: CassetteId, + generationState: S.Literals(["Idle", "Loading", "Ready", "Error"]), + generationError: S.String, + requestId: S.Int, + generatedSpec: S.Option(Spec), + generatedPrompt: S.String, + generatedCassette: CassetteId, +}); +export type Model = typeof Model.Type; + +export const init = (): Model => ({ + filter: "", + selected: "arthur", + note: "", + outcome: "Idle", + receipt: "Choose a crew member and add a note. The fixture host reviews each request.", + lastIntent: "", + showDocument: false, + prompt: cassettes["crew-cards"].examplePrompt, + cassetteId: "crew-cards", + generationState: "Idle", + generationError: "", + requestId: 0, + generatedSpec: Option.none(), + generatedPrompt: "", + generatedCassette: "crew-cards", +}); diff --git a/apps/demo/src/viewspec/update.test.ts b/apps/demo/src/viewspec/update.test.ts new file mode 100644 index 0000000..ec188e8 --- /dev/null +++ b/apps/demo/src/viewspec/update.test.ts @@ -0,0 +1,88 @@ +import { validateSpec, type ActionIntent } from "@foldworks/generative-ui/core"; +import { Result } from "effect"; +import { Scene } from "foldkit/test"; +import { describe, expect, it } from "vitest"; + +import { catalog } from "./catalog"; +import { document } from "./document"; +import { Message } from "./message"; +import { init, type Model } from "./model"; +import { update } from "./update"; +import { view } from "./view"; + +const send = (model: Model, intent: ActionIntent): Model => + update(model, Message.ReceivedIntent({ intent })).model; + +describe("ViewSpec fixture host", () => { + it("validates complete documents through filter, empty, selection, and draft states", () => { + for (const model of [ + init(), + { ...init(), filter: "zzz" }, + { ...init(), selected: "marvin" as const, note: "Review systems" }, + ]) { + expect(Result.isSuccess(validateSpec(catalog, document(model)))).toBe(true); + } + }); + + it("connects rendered field edits and buttons to local state and host review", () => { + Scene.scene( + { view, update }, + Scene.given(init()), + Scene.type(Scene.role("textbox", { name: "Filter crew" }), "Ford"), + Scene.expect(Scene.role("button", { name: "Select Ford Prefect" })).toExist(), + Scene.click(Scene.role("button", { name: "Select Ford Prefect" })), + Scene.type(Scene.role("textbox", { name: "Assignment note" }), "Survey the landing site"), + Scene.click(Scene.role("button", { name: "Request assignment" })), + Scene.expect(Scene.role("status")).toHaveText( + "Fixture host accepted the assignment for Ford Prefect.", + ), + ); + }); + + it("refuses off-duty records and requests that do not match the current draft", () => { + const model = { ...init(), selected: "marvin" as const, note: "Review systems" }; + const intent = { + action: "request_assignment", + elementId: "submit", + event: "press", + params: { crewId: "marvin", note: model.note }, + }; + const refused = send(model, intent); + expect(refused.outcome).toBe("Refused"); + expect(refused.receipt).toContain("off duty"); + expect(JSON.parse(refused.lastIntent)).toEqual(intent); + expect( + send(init(), { ...intent, params: { crewId: "ford", note: "Forged draft" } }).outcome, + ).toBe("Refused"); + }); + + it("rejects malformed events, clears notes when changing records, and resets locally", () => { + expect( + send(init(), { + action: "edit_note", + elementId: "request-note", + event: "change", + params: {}, + data: { value: 42 }, + }).outcome, + ).toBe("Invalid"); + const edited = send(init(), { + action: "edit_note", + elementId: "request-note", + event: "change", + params: {}, + data: { value: "Draft" }, + }); + const selected = send(edited, { + action: "select_crew", + elementId: "crew-ford", + event: "press", + params: { crewId: "ford" }, + }); + expect(selected.note).toBe(""); + expect(update(selected, Message.Reset()).model).toEqual({ + ...init(), + requestId: selected.requestId + 1, + }); + }); +}); diff --git a/apps/demo/src/viewspec/update.ts b/apps/demo/src/viewspec/update.ts new file mode 100644 index 0000000..9422b4e --- /dev/null +++ b/apps/demo/src/viewspec/update.ts @@ -0,0 +1,170 @@ +import { validateActionIntent } from "@foldworks/generative-ui/core"; +import { Effect, Option, Result, Schema as S } from "effect"; +import { Command, type Update } from "foldkit"; + +import { catalog, crew } from "./catalog"; +import { CassetteId, cassettes } from "./cassettes"; +import { replayCassette } from "./generation"; +import { Message } from "./message"; +import { init, type Model } from "./model"; + +export const GenerateMockUi = Command.define("GenerateMockUi", { + args: { requestId: S.Int, prompt: S.String, cassetteId: CassetteId }, + messages: [Message.CompletedGeneration], + execute: ({ requestId, prompt, cassetteId }) => + replayCassette(cassetteId, prompt).pipe( + Effect.delay("450 millis"), + Effect.map((result) => + Message.CompletedGeneration({ requestId, prompt, cassetteId, result }), + ), + ), +}); + +/** This reducer is the demo host, including its local fixture policy. */ +export const update = (model: Model, message: Message): Update.Return => { + switch (message._tag) { + case "Reset": + return { model: { ...init(), requestId: model.requestId + 1 } }; + case "ToggledDocument": + return { model: { ...model, showDocument: !model.showDocument } }; + case "ChangedPrompt": + return { model: { ...model, prompt: message.value } }; + case "SelectedCassette": + return { + model: { + ...model, + cassetteId: message.cassetteId, + prompt: cassettes[message.cassetteId].examplePrompt, + generationState: "Idle", + generationError: "", + requestId: model.requestId + 1, + }, + }; + case "RequestedGeneration": { + if (model.generationState === "Loading" || model.prompt.trim() === "") return { model }; + const requestId = model.requestId + 1; + return { + model: { ...model, generationState: "Loading", generationError: "", requestId }, + commands: [ + GenerateMockUi({ requestId, prompt: model.prompt.trim(), cassetteId: model.cassetteId }), + ], + }; + } + case "CompletedGeneration": { + if (model.generationState !== "Loading" || message.requestId !== model.requestId) + return { model }; + return message.result._tag === "Error" + ? { model: { ...model, generationState: "Error", generationError: message.result.error } } + : { + model: { + ...model, + generationState: "Ready", + generatedSpec: Option.some(message.result.spec), + generatedPrompt: message.prompt, + generatedCassette: message.cassetteId, + }, + }; + } + case "ReceivedGeneratedIntent": { + const decoded = validateActionIntent(catalog, message.intent); + const spec = Option.getOrUndefined(model.generatedSpec); + const binding = spec?.elements[message.intent.elementId]?.on?.[message.intent.event]; + if ( + Result.isFailure(decoded) || + binding === undefined || + binding.action !== decoded.success.action + ) + return { + model: { + ...model, + outcome: "Invalid", + receipt: "Host refused an invalid generated UI intent.", + }, + }; + const intent = decoded.success; + // These mock documents use static selection and assignment requests. The host + // checks the current document binding as well as its own fixture policy. + if (intent.action === "select_crew" && binding.params?.crewId === intent.params.crewId) + return update(model, Message.ReceivedIntent({ intent: message.intent })); + if ( + intent.action === "request_assignment" && + binding.params?.crewId === intent.params.crewId && + binding.params?.note === intent.params.note + ) { + const person = crew.find((entry) => entry.id === intent.params.crewId)!; + const accepted = person.available && intent.params.note.trim().length > 0; + return { + model: { + ...model, + lastIntent: JSON.stringify(intent, null, 2), + outcome: accepted ? "Accepted" : "Refused", + receipt: accepted + ? `Fixture host accepted the assignment for ${person.name}.` + : `Fixture host refused the assignment for ${person.name}: crew member is off duty.`, + }, + }; + } + return { + model: { + ...model, + outcome: "Invalid", + receipt: "Host refused an unsupported or changed generated UI intent.", + }, + }; + } + case "ReceivedIntent": { + const decoded = validateActionIntent(catalog, message.intent); + if (Result.isFailure(decoded)) + return { + model: { + ...model, + outcome: "Invalid", + receipt: "Host refused an invalid action intent.", + }, + }; + const intent = decoded.success; + switch (intent.action) { + case "edit_filter": + return { model: { ...model, filter: intent.data.value } }; + case "edit_note": + return { + model: { + ...model, + note: intent.data.value, + outcome: "Idle", + receipt: "Draft updated. Send a request for host review.", + }, + }; + case "select_crew": + return { + model: { + ...model, + selected: intent.params.crewId, + note: "", + outcome: "Idle", + receipt: "Selection changed. Add a note for this crew member.", + }, + }; + case "request_assignment": { + const person = crew.find((entry) => entry.id === intent.params.crewId)!; + // Schema validation is not authorization. The host checks the current draft and its policy. + const accepted = + person.available && + intent.params.note.trim().length > 0 && + intent.params.crewId === model.selected && + intent.params.note === model.note; + return { + model: { + ...model, + lastIntent: JSON.stringify(intent, null, 2), + outcome: accepted ? "Accepted" : "Refused", + receipt: accepted + ? `Fixture host accepted the assignment for ${person.name}.` + : `Fixture host refused the assignment for ${person.name}: ${!person.available ? "crew member is off duty" : "request does not match a complete current draft"}.`, + }, + }; + } + } + } + } +}; diff --git a/apps/demo/src/viewspec/view.ts b/apps/demo/src/viewspec/view.ts new file mode 100644 index 0000000..3e093cf --- /dev/null +++ b/apps/demo/src/viewspec/view.ts @@ -0,0 +1,270 @@ +import { render, validateSpec } from "@foldworks/generative-ui/core"; +import { foldworksRegistry } from "@foldworks/generative-ui/foldworks"; +import { Alert, Badge, Button, Card, CodeBlock, Field, Heading, Layout, Text } from "@foldworks/ui"; +import * as stylex from "@stylexjs/stylex"; +import { Option, Result } from "effect"; +import type { Html, HtmlBuilder } from "foldkit/html"; +import { defineView } from "foldkit/submodel"; + +import { catalog } from "./catalog"; +import { CassetteId, cassettes } from "./cassettes"; +import { document } from "./document"; +import { Message } from "./message"; +import type { Model } from "./model"; + +const styles = stylex.create({ + root: { padding: "24px", width: "100%", maxWidth: "1100px", marginInline: "auto", minWidth: 0 }, +}); + +const generationPanel = (model: Model, h: HtmlBuilder): Html => + Card.view( + { + title: "Mock UI generation", + description: + "Enter a prompt and choose a fixed response cassette. This runs the generation API with a mock provider; the cassette controls the output.", + children: [ + Layout.Stack.view( + { + gap: "md", + children: [ + Field.select( + { + id: "viewspec-cassette", + label: "Response cassette", + value: model.cassetteId, + description: cassettes[model.cassetteId].description, + options: CassetteId.literals.map((id) => ({ + value: id, + label: cassettes[id].label, + })), + onChange: (value) => + Message.SelectedCassette({ cassetteId: value as CassetteId }), + }, + h, + ), + Field.textarea( + { + id: "viewspec-prompt", + label: "UI prompt", + value: model.prompt, + minRows: 3, + onInput: (value) => Message.ChangedPrompt({ value }), + }, + h, + ), + Button.view( + { + label: + model.generationState === "Loading" + ? "Generating mock UI…" + : "Generate mock UI", + isDisabled: model.generationState === "Loading" || model.prompt.trim() === "", + onClick: Message.RequestedGeneration(), + }, + h, + ), + h.div( + [h.AriaLive("polite"), h.DataAttribute("generation-state", model.generationState)], + [ + model.generationState === "Loading" + ? "Loading mock response…" + : model.generationState === "Ready" + ? "Mock response validated and rendered." + : model.generationState === "Error" + ? "Generation failed. The last valid response is preserved." + : "Ready to replay a mock response.", + ], + ), + model.generationState === "Error" + ? Alert.view( + { + title: "Mock generation failed", + description: model.generationError, + tone: "danger", + }, + h, + ) + : h.empty, + ], + }, + h, + ), + ], + }, + h, + ); + +const generatedPanel = (model: Model, h: HtmlBuilder): Html => { + const spec = Option.getOrUndefined(model.generatedSpec); + if (spec === undefined) return h.empty; + return h.section( + [h.AriaLabel("Generated UI"), h.DataAttribute("generated-ui", "true")], + [ + Layout.Stack.view( + { + gap: "md", + children: [ + Heading.view({ children: ["Generated UI"], level: 2 }, h), + Text.view( + { + children: [ + `${cassettes[model.generatedCassette].label} · Prompt: ${model.generatedPrompt}`, + ], + size: "sm", + tone: "muted", + }, + h, + ), + render( + { + spec, + registry: foldworksRegistry(), + toMessage: (intent) => Message.ReceivedGeneratedIntent({ intent }), + }, + h, + ), + model.showDocument + ? CodeBlock.view( + { + code: JSON.stringify(spec, null, 2), + title: "Generated version-1 document", + language: "json", + maxHeight: "300px", + wrap: true, + }, + h, + ) + : h.empty, + ], + }, + h, + ), + ], + ); +}; + +export const view = defineView((model, h) => { + const spec = validateSpec(catalog, document(model)); + return h.div( + [ + h.Class(stylex.props(styles.root).className ?? ""), + h.DataAttribute("viewspec-playground", "true"), + ], + [ + Layout.Stack.view( + { + gap: "lg", + children: [ + Layout.Row.view( + { + justify: "between", + wrap: true, + gap: "sm", + children: [ + Heading.view({ children: ["ViewSpec playground"], level: 1, size: "xl" }, h), + Badge.view({ label: "Mock provider · Local fixtures", tone: "info" }, h), + ], + }, + h, + ), + Text.view( + { + children: [ + "Filter the crew, select a record, and request an assignment. Foldkit keeps the draft locally; the fixture host checks each intent.", + ], + tone: "muted", + }, + h, + ), + generationPanel(model, h), + generatedPanel(model, h), + Heading.view({ children: ["Fixture host"], level: 2 }, h), + Result.isSuccess(spec) + ? render( + { + spec: spec.success, + registry: foldworksRegistry(), + toMessage: (intent) => Message.ReceivedIntent({ intent }), + }, + h, + ) + : Alert.view( + { + title: "Invalid UI document", + description: spec.failure.map((issue) => issue.message).join("\n"), + tone: "danger", + }, + h, + ), + Card.view( + { + title: "Host receipt", + children: [ + Layout.Stack.view( + { + gap: "sm", + children: [ + h.div( + [h.Role("status"), h.AriaLabel("Host receipt"), h.AriaLive("polite")], + [model.receipt], + ), + model.lastIntent === "" + ? h.empty + : CodeBlock.view( + { + code: model.lastIntent, + title: "Last submitted intent", + language: "json", + maxHeight: "220px", + wrap: true, + }, + h, + ), + ], + }, + h, + ), + ], + }, + h, + ), + Layout.Row.view( + { + gap: "sm", + wrap: true, + children: [ + Button.view( + { + label: model.showDocument ? "Hide document" : "Inspect document", + variant: "outline", + onClick: Message.ToggledDocument(), + }, + h, + ), + Button.view( + { label: "Reset playground", variant: "ghost", onClick: Message.Reset() }, + h, + ), + ], + }, + h, + ), + model.showDocument + ? CodeBlock.view( + { + code: JSON.stringify(document(model), null, 2), + title: "Validated version-1 document", + language: "json", + maxHeight: "400px", + wrap: true, + }, + h, + ) + : h.empty, + ], + }, + h, + ), + ], + ); +}); diff --git a/docs/viewspec.md b/docs/viewspec.md new file mode 100644 index 0000000..429f936 --- /dev/null +++ b/docs/viewspec.md @@ -0,0 +1,126 @@ +# ViewSpec in Foldworks + +This carries forward a selective slice of the Open Ontology ViewSpec exploration +using `@foldworks/generative-ui`. It is not a compatibility implementation of the +legacy protocol. The source reference is +[open-ontology-legacy at 7c2ff8a](https://github.com/bjacobso/open-ontology-legacy/tree/7c2ff8a614b9f06a6e530aa80d0c7311dc81a323), +inspected from the sibling checkout at that exact commit without changing it. + +The inspection covered `specs/compiler/ui/viewspec.md`, the `view-protocol` +implementation and structural/conformance tests, `preludes/ui.lisp`, +`viewspec.lisp`, `viewspec-protocol.lisp`, `viewspec-compiler.lisp`, the web +`ViewRenderer.tsx`, `component-registry.ts`, `view-expressions.ts`, and the +hardcoded `routes/viewspec.tsx` playground. The playground is the feature +inventory, not evidence that every legacy feature was safe, portable, or +complete. In particular, its renderer combines generic presentation with +ontology reads, domain commands, and asynchronous action chains. + +## Feature and ownership map + +| Legacy feature | Decision | Foldworks / host boundary | +| -------------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reusable JSON UI documents | Adopt existing Foldworks contract | Keep the version-1 flat keyed graph, static props, and child references. No second envelope or nested legacy node union. | +| Component catalog and validation | Adopt and extend | Effect Schema remains the source for component props, actions, generation, and decoding. Keep strict excess-property, graph, event, and budget checks. Do not port legacy normalization that silently repairs invalid input. | +| React/Tailwind registry and theme/style props | Adapt | Allowlisted Foldkit views backed by Foldworks/StyleX primitives and semantic themes. Host code can define a catalog/registry; documents cannot register code or set arbitrary styles. | +| Local inputs and interaction | Adapt; first slice implemented | Controlled `TextInput` emits `{ value }` event data. Foldkit host models store drafts and rebuild concrete documents. Typed intent decoding is generic Foldworks functionality. | +| Input, row, item, index, state scopes; pipes and operators | Defer | A future bounded binding design belongs in Foldworks. For now, pure host presentation adapters filter data, format values, and materialize rows. No expression evaluator or property-path mutation. | +| `for-each`, conditions, empty/loading states | Adapt partly; defer protocol control flow | Hosts compose concrete graphs and existing Alert/Badge/Text primitives. This demo exercises filtering and empty results. Dynamic loops and conditions are not document semantics. | +| `defs/use`, runtime slots, nested views | Defer reusable document API | Existing graph composition and host functions suffice for this slice. Parameterized fragments, instance IDs, scope isolation, host view resolution, and runtime replacement need a separate design. | +| Form widgets, tabs, dialogs, popovers, charts, trees, interactive tables | Defer catalog coverage | Foldworks already has many underlying primitives. They need deliberate schema/event/state adapters; their availability as primitives does not imply support in generative-ui. | +| `setState` / `patchState`, navigation, toast, submit | Adapt to typed host messages | No action lists, lifecycle handlers, callback chains, or general workflow executor. Hosts handle local reducers, navigation, feedback, and commands. | +| `fetch`, tool calls, query dependencies, refresh, `onSuccess/onError/onFinally` | Leave execution with host | Transport, scheduling, errors, authorization, and domain execution stay outside the renderer. Future UI loading/error props can be generic. | +| Entity browser/picker/forms, query console, entity/action refs, namespace-backed views | Leave in WorldVM | WorldVM owns view definitions, entity/query adapters, authorized reads, domain policies, and command dispatch. Foldworks receives presentation data and emits inert requests. | +| Raw HTML/CSS/JS | Omit from this contract | No renderer escape hatch for document-supplied executable content. Trusted custom views belong in the host registry. | +| Lisp compiler hooks and generated protocol | Defer authoring adapters | A future Forma lowering adapter may target the Foldworks catalog. It must produce validated documents and preserve host boundaries; it is not required to render this slice. | +| Shareable fixture playground | Adopt | `/viewspec` opens directly with deterministic local fixtures, no ontology preload or runtime connection. Draft edits are local to the session, not encoded into the URL. | + +This follows [the WorldVM developer-tools boundary](./worldvm-devtools.md): +generic UI documents, catalogs, validation, interaction primitives, composition, +and rendering belong in Foldworks. World-specific view definitions and adapters +belong in WorldVM. Neither the package nor the demo imports WorldVM, Triplex, or +the legacy runtime. + +## Implemented interaction + +Run `pnpm dev` and open `/viewspec` (also linked from the sidebar and generative-ui +package card). Filter crew by name or role, select a person, enter an assignment +note, and request an assignment. The local fixture host accepts an available +person and refuses Marvin, who is off duty. Changing records clears the draft; +empty results do not discard the selected record. Reset restores the fixtures. + +The document is a normal generative-ui spec. A presentation function composes +fixture rows and model values into concrete elements and action params. The +view validates it before rendering. Text changes carry user data separately +from the binding's static params; the host decodes each intent into a +schema-derived action union. The request button is disabled for an empty note, +and the host independently checks availability and that the request matches +the current draft. The displayed receipt and JSON expose the application +boundary. This is a local fixture demonstration, not a production integration. + +The package README documents the event-data API, compatibility, and limits. +Tests cover strict field/binding/intent validation, typed payloads, controlled +input updates, disabled/unbound fields, complete-spec MCP decoding, filtered +and empty graphs, host refusal, and reset. Browser scenarios exercise the +complete interaction and keyboard navigation on a narrow dark viewport. + +## Mock prompt generation + +The same `/viewspec` route now accepts a user prompt and a response cassette. +Click **Generate mock UI** to see loading, validation, and the resulting UI. +The prompt goes through the package's actual `generate(catalog, { prompt })` +function. A local Effect `LanguageModel` service supplies a fixed text response; +Effect's structured output decoder applies the catalog schema, and +`validateSpec` checks the graph before the result replaces the generated UI. +No provider credentials or network connection are needed. + +These are authored mock responses, not recordings from a real model. The chosen +cassette determines the output; changing the prompt does not change that fixed +response. The UI shows the submitted prompt and the cassette that produced the +last successful document. Changing cassettes also loads an example prompt. + +| Cassette | Behavior | +| ----------------- | --------------------------------------------------------------- | +| Crew cards | A card grid; buttons select records in the fixture host. | +| Mission brief | A summary, table, and assignment request the host accepts. | +| Off-duty request | A valid UI whose request the host refuses. | +| Invalid component | Catalog schema decoding fails. | +| Broken reference | Schema decoding succeeds; graph validation fails. | +| Malformed JSON | Structured output decoding fails. | +| Provider error | A simulated provider failure; choose another cassette to retry. | + +Add or edit cassettes in `apps/demo/src/viewspec/cassettes.ts`: each entry has a +label, description, example prompt, and raw response text or simulated error. +The mock provider adapter lives in `generation.ts`. The generated documents use +concrete props and static button params; controlled drafts remain in the fixture +host. Generated buttons emit typed intents, and the host checks the current +document binding and its own availability policy before accepting a request. + +Failed generation preserves the last valid document. Request IDs prevent +obsolete responses from replacing it after cassette changes or reset. Unit +tests cover the real generation API with the mock service, every cassette, +failed-output preservation, stale completions, and generated-action policy. +Browser tests exercise prompting, cassette switching, generated interactions, +failure/retry, and reset alongside the original fixture interactions. + +## Remaining gaps + +There is no document-declared state, persistence, field-level error model, +submit grouping, number/select/checkbox adapters, binding language, repeat +scope, reusable fragment API, or host view-loader contract. The current demo +materializes complete graphs on every edit; it does not add patching or +streaming. There is no live AI provider, real MCP host draft round trip, remote +query, or Forma compiler. Mock prompt generation exercises the API boundary; +it does not evaluate model quality or demonstrate prompt-dependent output. + +Intent validation checks action params and event-data shape, not element/event +identity, authorization, freshness, or execution. Component event payloads and +action data schemas are checked when an intent is received, not statically +matched during document validation. Existing graph/string budgets remain; +large event payloads, shared-graph expansion, and streaming will need further +resource budgeting. The browser coverage is a first accessibility check, not a +full assistive-technology audit. + +The next coherent extension should be chosen from an actual adapter use case: +more controlled fields, or bounded document-local bindings and fragment +composition. Keep effectful operations as host commands and use Effect Schema +at every new boundary. Avoid importing the legacy action execution language. diff --git a/docs/worldvm-devtools.md b/docs/worldvm-devtools.md index f5bffcb..3b06ebb 100644 --- a/docs/worldvm-devtools.md +++ b/docs/worldvm-devtools.md @@ -6,6 +6,9 @@ software that models the world, reasons about it, and acts on it. Its planned thread inspector, ontology browser, event log, webhook inspector, and admin UI. Foldworks is the UI layer those tools are planned to use. +The [ViewSpec feature and ownership map](./viewspec.md) records a first generic +UI-document slice and the legacy features that remain host or WorldVM concerns. + This document records which Foldworks packages would build each surface and where WorldVM-specific code should live. It is a proposal, not a commitment. WorldVM's packages are not published yet, and nothing in this repository diff --git a/packages/generative-ui/README.md b/packages/generative-ui/README.md index ad349e2..337ab8b 100644 --- a/packages/generative-ui/README.md +++ b/packages/generative-ui/README.md @@ -5,9 +5,9 @@ data, not code: an Effect Schema validates a flat component graph, and a registry maps the allowed component names onto real Foldworks views. This is an intentionally small first slice. It includes layout, content, -status, tables, and buttons. It does not yet include agent-defined HTML/CSS, -remote scripts, arbitrary URLs, mutable form state, repeat expressions, or -streaming patches. +status, tables, buttons, and controlled text fields. It does not yet include +agent-defined HTML/CSS, remote scripts, arbitrary URLs, document-declared state, +repeat expressions, or streaming patches. Use `@foldworks/generative-ui/core`, `/ai`, and `/mcp` in server code. The `/foldworks` entry point includes browser UI components, and `/mcp-app` contains @@ -52,6 +52,78 @@ Validation is strict and checks catalog membership, component props, action params, supported events, references, reachability, cycles, depth, node count, fan-out, and string sizes. +## Controlled fields and typed intents + +`TextInput` takes `label` and `value`, with optional `description`, `placeholder`, +and `disabled` props. Its `change` event fires on every edit with +`data: { value: string }`. The document's static `params` stay separate from +user event data. Declare the receiving action's `data` schema in the catalog: + +```ts +import { validateActionIntent } from "@foldworks/generative-ui/core"; + +const catalog = createFoldworksCatalog({ + edit_note: { + description: "Edit the local note draft.", + params: Schema.Struct({}), + data: Schema.Struct({ value: Schema.String }), + }, +}); + +// An element in a normal version-1 document: +const field = { + type: "TextInput", + props: { label: "Note", value: "Current draft" }, + children: [], + on: { change: { action: "edit_note", params: {} } }, +}; + +// In the host's Foldkit reducer: +const decoded = validateActionIntent(catalog, receivedIntent); +if (Result.isSuccess(decoded)) { + const intent = decoded.success; + // action, params, and data are inferred from this catalog. + return { model: { ...model, note: intent.data.value } }; +} +``` + +The host stores the new value and supplies it in the next document. Use a pure +presentation adapter to compose fixture or application data into concrete +props and static action params, then call `validateSpec` before rendering. +`toMessage` constructs a Foldkit message; domain work belongs in the host's +update/command handling. The renderer has no expression evaluator, state store, +network access, or action executor. An unbound text field is read-only; a +disabled field has no edit handler. + +`catalog.intentSchema` and the exported `IntentFor` +derive a discriminated action union from the same schemas. Actions declaring +`data` require it in received intents; actions without a data schema reject it. +`validateActionIntent` rejects excess properties and applies the default +4,000-character string limit, adjustable with `{ maxStringLength }`. +Bindings cannot supply event data. `validateSpec` validates static params and +event names; it does not prove that an action's data schema accepts a +component's emitted payload. Decode received intents to check that boundary. + +Intent decoding validates shape, not authorization, element identity, event +origin, freshness, or whether a control was enabled. Hosts must check their own +policy and current domain state before executing requests. Keep all schemas +JSON-compatible when carrying documents and intents through transports. + +Existing version-1 documents and button intents retain their shape. Adding a +text field requires a renderer/catalog that supports it. The MCP Apps bridge +can carry fields in complete specs and includes event data when sending an +intent; it does not maintain drafts or apply host responses automatically. + +The demo's `/viewspec` route exercises search, selection, local drafts, empty +results, and host acceptance/refusal using hardcoded crew fixtures. It also +accepts prompts through `generate` with a mock Effect `LanguageModel` service. +Select a fixed response cassette to try valid layouts, generated host actions, +schema/graph failures, malformed JSON, and a provider error. These are local +mock responses; no live model interprets the prompt. Inspect the generated and +fixture documents and the last submitted intent in the playground. See the +[ViewSpec feature and ownership map](../../docs/viewspec.md) for the legacy +reference, adoption choices, and remaining gaps. + ## Generate with Effect AI `generate` uses the provider-neutral `LanguageModel` service from @@ -111,6 +183,7 @@ useful inside the existing agent chat, over MCP, or over another transport. permission flow. - MCP is a transport and embedding boundary, not the UI domain model. -The next useful step is patch streaming. It should follow after the +Further work includes additional controlled fields, bounded document-local +state and composition, and patch streaming. Streaming should follow after the complete-spec path has agent-generation evals, accessibility checks, and resource-budget telemetry. diff --git a/packages/generative-ui/src/catalog.ts b/packages/generative-ui/src/catalog.ts index d0881cb..23e1345 100644 --- a/packages/generative-ui/src/catalog.ts +++ b/packages/generative-ui/src/catalog.ts @@ -14,11 +14,26 @@ export type ComponentDefinition = Rea export type ActionDefinition = Readonly<{ description: string; params: Params; + /** User event data, separate from the document's static parameters. */ + data?: ObjectSchema; }>; export type ComponentDefinitions = Readonly>; export type ActionDefinitions = Readonly>; +/** A discriminated union inferred from the host's action schemas. */ +export type IntentFor = { + [Name in keyof Actions & string]: Readonly<{ + action: Name; + elementId: string; + event: string; + params: Actions[Name]["params"]["Type"]; + }> & + (Actions[Name] extends { readonly data: infer Data extends ObjectSchema } + ? Readonly<{ data: Data["Type"] }> + : {}); +}[keyof Actions & string]; + export type Catalog< Components extends ComponentDefinitions = ComponentDefinitions, Actions extends ActionDefinitions = ActionDefinitions, @@ -28,6 +43,7 @@ export type Catalog< components: Components; actions: Actions; schema: S.Codec; + intentSchema: S.Codec, IntentFor, never, never>; }>; const union = (members: ReadonlyArray): S.Top => { @@ -76,7 +92,18 @@ export const defineCatalog = < root: S.String, elements: S.Record(S.String, elementSchema(config.components, config.actions)), }) as unknown as S.Codec; - return { ...config, schema }; + const intentSchema = union( + Object.entries(config.actions).map(([name, definition]) => + S.Struct({ + action: S.Literal(name), + elementId: S.String, + event: S.String, + params: definition.params, + ...(definition.data === undefined ? {} : { data: definition.data }), + }), + ), + ) as S.Codec, IntentFor, never, never>; + return { ...config, schema, intentSchema }; }; const jsonSchemaFor = (schema: S.Top): unknown => { @@ -105,6 +132,7 @@ export const catalogPrompt = (catalog: Catalog): string => { name, description: definition.description, params: jsonSchemaFor(definition.params), + ...(definition.data === undefined ? {} : { data: jsonSchemaFor(definition.data) }), }), ) .join("\n"); @@ -115,6 +143,7 @@ export const catalogPrompt = (catalog: Catalog): string => { "The root and every child reference must name an element in the elements map.", "The element graph must be connected and acyclic. Leaf elements use an empty children array.", "Bind interactions through an element's on map. Never emit code, HTML, CSS, URLs, or event-handler source.", + "Action params are static document data. User event data is supplied by the renderer, never by a binding.", "Components (one JSON object per line):", components, "Actions (one JSON object per line):", diff --git a/packages/generative-ui/src/foldworks.ts b/packages/generative-ui/src/foldworks.ts index cb51d72..5b7e28e 100644 --- a/packages/generative-ui/src/foldworks.ts +++ b/packages/generative-ui/src/foldworks.ts @@ -1,4 +1,4 @@ -import { Alert, Badge, Button, Card, Heading, Layout, Table, Text } from "@foldworks/ui"; +import { Alert, Badge, Button, Card, Field, Heading, Layout, Table, Text } from "@foldworks/ui"; import { Schema as S } from "effect"; import type { ActionDefinitions, Catalog } from "./catalog"; @@ -93,6 +93,19 @@ export const foldworksComponents = { children: "none", events: ["press"], }, + TextInput: { + description: + "Controlled text field. The change event sends data { value: string } on every edit. The host updates the value prop.", + props: S.Struct({ + label: S.String, + value: S.String, + description: S.optional(S.String), + placeholder: S.optional(S.String), + disabled: S.optional(S.Boolean), + }), + children: "none", + events: ["change"], + }, Table: { description: "Small read-only data table. Prefer fewer than ten rows.", props: S.Struct({ @@ -258,4 +271,22 @@ export const foldworksRegistry = (): Registry => ({ h, ); }, + TextInput: (context, h) => { + const value = props("TextInput", context); + return Field.input( + { + id: context.id, + attributes: [h.Key(context.id)], + label: value.label, + value: value.value, + ...optional("description", value.description), + ...optional("placeholder", value.placeholder), + ...optional("isDisabled", value.disabled), + ...(context.element.on?.change === undefined + ? { isReadOnly: true } + : { onInput: (next: string) => context.emit("change", { value: next }) as Message }), + }, + h, + ); + }, }); diff --git a/packages/generative-ui/src/generative-ui.test.ts b/packages/generative-ui/src/generative-ui.test.ts index e5bb605..16e625a 100644 --- a/packages/generative-ui/src/generative-ui.test.ts +++ b/packages/generative-ui/src/generative-ui.test.ts @@ -2,7 +2,7 @@ import { Context, Option, Result, Schema as S } from "effect"; import { Tool } from "effect/unstable/ai"; import { inertHtml as h, type Html } from "foldkit/html"; import { Scene } from "foldkit/test"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { catalogPrompt, @@ -12,6 +12,9 @@ import { makeMcpRenderToolkit, render, validateSpec, + validateActionIntent, + type ActionIntent, + type Spec, } from "./index"; const catalog = createFoldworksCatalog({ @@ -21,6 +24,166 @@ const catalog = createFoldworksCatalog({ }, }); +describe("controlled text input", () => { + const editableCatalog = createFoldworksCatalog({ + edit: { + description: "Edit a local draft.", + params: S.Struct({ field: S.Literal("note") }), + data: S.Struct({ value: S.String }), + }, + save: { description: "Request a save.", params: S.Struct({ note: S.String }) }, + }); + const inputSpec = (value: string): Spec => ({ + version: "1", + root: "note", + elements: { + note: { + type: "TextInput", + props: { label: "Note", value }, + children: [], + on: { change: { action: "edit", params: { field: "note" } } }, + }, + }, + }); + const editIntent = { + action: "edit", + elementId: "note", + event: "change", + params: { field: "note" }, + data: { value: "next" }, + }; + + it("keeps bindings static and validates field props and events", () => { + expect(Result.isSuccess(validateSpec(editableCatalog, inputSpec("before")))).toBe(true); + for (const mutation of [ + { props: { label: "Note", value: 42 } }, + { props: { label: "Note", value: "", html: "