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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-toys-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@foldworks/generative-ui": minor
---

Add controlled TextInput documents and schema-derived typed host action intents with separate user event data.
5 changes: 5 additions & 0 deletions .changeset/violet-kings-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@foldworks/diagram": patch
---

Update graph subscriptions to use the read API after the Foldkit upgrade.
16 changes: 15 additions & 1 deletion apps/demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
205 changes: 205 additions & 0 deletions apps/demo/e2e/viewspec.scenarios.ts
Original file line number Diff line number Diff line change
@@ -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>,
): 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");
});
};
2 changes: 2 additions & 0 deletions apps/demo/e2e/workflow.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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");
Expand Down Expand Up @@ -1656,4 +1657,5 @@ describe("structured workflow builder", { concurrent: false }, () => {
outlinerScenarios(() => page, appUrl, screenshot);
orchestratorScenarios(() => page, appUrl, screenshot);
packageDemoScreenshotScenarios(() => page, appUrl, screenshot);
viewspecScenarios(() => page, appUrl, screenshot);
});
2 changes: 2 additions & 0 deletions apps/demo/src/app/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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";
Expand All @@ -42,6 +43,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 },
Expand Down
3 changes: 3 additions & 0 deletions apps/demo/src/app/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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,
Expand All @@ -58,6 +59,7 @@ export const Model = S.Struct({
workbench: WorkbenchModel,
workflowEditor: WorkflowEditorModel,
statechart: StatechartModel,
viewspec: ViewSpecModel,
formEditor: FormEditorModel,
dataGridDemo: DataGridModel,
dataTableDemo: DataTableModel,
Expand Down Expand Up @@ -100,6 +102,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)),
Expand Down
7 changes: 7 additions & 0 deletions apps/demo/src/app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type Demo =
| "Workbench"
| "Workflow"
| "Statechart"
| "ViewSpec"
| "DataTable"
| "DataGrid"
| "FormBuilder"
Expand All @@ -43,6 +44,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: {
Expand Down Expand Up @@ -82,6 +84,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),
Expand Down Expand Up @@ -156,6 +160,7 @@ const routeParser = Route.oneOf(
editorRouter,
workflowRouter,
statechartRouter,
viewspecRouter,
agentRouter,
orchestratorRouter,
workbenchRouter,
Expand Down Expand Up @@ -221,6 +226,8 @@ export const demoFromRoute = (route: AppRoute): Demo => {
return "Workflow";
case "Statechart":
return "Statechart";
case "ViewSpec":
return "ViewSpec";
case "NotFound":
return "Home";
}
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/app/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { subscriptions as agentSubscriptions } from "../agent/subscriptions";
import { subscriptions as workbenchSubscriptions } from "../workbench/subscriptions";
import { subscriptions as dataGridSubscriptions } from "../data-grid/subscriptions";
import { Message as DataGridMessage } from "../data-grid/message";

Check warning on line 10 in apps/demo/src/app/subscriptions.ts

View workflow job for this annotation

GitHub Actions / Validate packages and demo

eslint(no-unused-vars)

apps/demo/src/app/subscriptions.ts:10:21: Identifier 'DataGridMessage' is imported but never used.
import { subscriptions as formSubscriptions } from "../form-builder/subscriptions";
import { Message as FormMessage } from "../form-builder/message";
import { subscriptions as queryBuilderSubscriptions } from "../query-builder/subscriptions";
Expand Down Expand Up @@ -132,7 +132,7 @@

const documentAnalysis = Subscription.lift(documentAnalysisSubscriptions)<Model, Message>({
when: (model) => model.route._tag === "DocumentAnalysis",
toChildModel: (model) => model.documentAnalysis,
read: (model) => Option.some(model.documentAnalysis),
toParentMessage: (message) => Message.GotDocumentAnalysisMessage({ message }),
});

Expand Down
9 changes: 9 additions & 0 deletions apps/demo/src/app/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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";
Expand Down Expand Up @@ -154,6 +155,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),
Expand Down Expand Up @@ -416,6 +424,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),
Expand Down
Loading
Loading