From 175933c68fee1f1d8507753399006209227cd494 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 17:05:37 -0800 Subject: [PATCH 1/7] feat(designer): split mockup screens into designs/screens/*.html --- apps/hub/src/artifacts/design-draft.test.ts | 612 +++++++++++++++++- apps/hub/src/artifacts/design-draft.ts | 45 +- apps/hub/src/artifacts/design-tools.ts | 3 +- apps/hub/src/builds/sidecar-tools.ts | 24 +- .../src/pages/workspace/design-preview.tsx | 67 +- .../workspace/preview-interactions.test.tsx | 33 + workflows/coder/check/check-app.ts | 100 ++- workflows/designer/README.md | 5 +- workflows/designer/skills/react-ui/SKILL.md | 5 +- workflows/designer/src/prompt.ts | 7 +- .../starter/.builder/designs/index.html | 12 +- .../.builder/designs/screens/home.html | 5 + .../starter/.builder/designs/styles.css | 4 +- workflows/designer/starter/README.md | 2 +- .../themes/liquid-glass/designs/styles.css | 4 +- .../themes/modern-sleek/designs/styles.css | 4 +- .../designer/themes/retro/designs/styles.css | 4 +- 17 files changed, 891 insertions(+), 45 deletions(-) create mode 100644 workflows/designer/starter/.builder/designs/screens/home.html diff --git a/apps/hub/src/artifacts/design-draft.test.ts b/apps/hub/src/artifacts/design-draft.test.ts index d2986903..7df51aff 100644 --- a/apps/hub/src/artifacts/design-draft.test.ts +++ b/apps/hub/src/artifacts/design-draft.test.ts @@ -1,8 +1,51 @@ import { expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { readDesignDraft } from "./design-draft.ts"; +import { + mkdir, + mkdtemp, + realpath, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + createAgent, + createDirectorRegistry, + createStaticCredentialResolver, +} from "@intx/agent"; +import { defineTool } from "@intx/agent/authoring"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { createDefaultScheduler } from "@intx/inference"; +import { createBuiltinRegistry } from "@intx/inference/providers"; +import { createIsogitStore } from "@intx/storage-isogit/node"; +import { posix } from "@intx/tools-posix/sidecar-bundle"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { createDesignerAgent } from "@corbits-builder/designer/agent"; +import { + designStarterFiles, + designThemes, +} from "@corbits-builder/designer/skills" with { type: "macro" }; +import { DesignBundle } from "@corbits-builder/schemas/artifact"; +import { + CHECK_DESIGN_TOOL, + checkDesignDefinition, + createDesignCheck, + touchesDesigns, +} from "../builds/design-check.ts"; +import { designPath, type CodingSidecarEnv } from "../builds/sidecar-tools.ts"; +import { prepareWorkspace } from "../builds/starter.ts"; +import { + compactingDirector, + dropDegenerateTurn, + trimHistory, +} from "../workflows/compaction.ts"; +import { readDesignBundle, readDesignDraft } from "./design-draft.ts"; +import { createDesignTools } from "./design-tools.ts"; + +const STARTER = designStarterFiles(); +const MODERN_SLEEK = designThemes()["modern-sleek"]!; test("the draft serves only regular files inside .builder/designs", async () => { const root = await mkdtemp(join(tmpdir(), "sb-design-draft-")); @@ -10,7 +53,26 @@ test("the draft serves only regular files inside .builder/designs", async () => const designs = join(workspace, ".builder", "designs"); const outside = join(root, "home"); try { - await mkdir(join(designs, "screens"), { recursive: true }); + for (const [path, text] of Object.entries(STARTER)) { + const target = join(workspace, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, text); + } + expect(await readDesignDraft(workspace)).toBeUndefined(); + + await writeFile( + join(designs, "screens", "list.html"), + '

List

', + ); + expect(await readDesignDraft(workspace)).toEqual({ + "designs/index.html": STARTER[".builder/designs/index.html"]!, + "designs/screens/list.html": + '

List

', + "designs/styles.css": STARTER[".builder/designs/styles.css"]!, + }); + + await rm(join(designs, "screens", "home.html")); + await rm(join(designs, "styles.css")); await mkdir(outside); await writeFile(join(outside, "secret.txt"), "secret\n"); await writeFile(join(outside, "index.html"), "

outside

"); @@ -39,3 +101,543 @@ test("the draft serves only regular files inside .builder/designs", async () => await rm(root, { recursive: true, force: true }); } }); + +test("file_design_option packs per-screen files and drops the untouched starter home screen", async () => { + const workspace = await realpath( + await mkdtemp(join(tmpdir(), "sb-design-file-")), + ); + const check = createDesignCheck(workspace, async () => ({ + exitCode: 0, + output: + "check passed: served .builder/designs and opened /, /#/overview, /#/orders at 1280 and 390 px with no errors", + })); + try { + for (const [path, text] of Object.entries(STARTER)) { + const target = join(workspace, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, text); + } + await writeFile( + join(workspace, ".builder", "DESIGN.md"), + MODERN_SLEEK["DESIGN.md"]!, + ); + await writeFile( + join(workspace, ".builder", "designs", "styles.css"), + MODERN_SLEEK["designs/styles.css"]!, + ); + await writeFile( + join(workspace, ".builder", "designs", "screens", "overview.html"), + '

Overview

', + ); + await writeFile( + join(workspace, ".builder", "designs", "screens", "orders.html"), + '

Orders

\n\n
Shipped
', + ); + + const bundle = await readDesignBundle(workspace); + expect(bundle?.["designs/screens/home.html"]).toBeUndefined(); + expect(bundle?.["designs/screens/overview.html"]).toContain( + 'data-screen="overview"', + ); + expect(bundle?.["designs/screens/orders.html"]).toContain( + 'data-screen="orders"', + ); + + const deployment = { + source: { + kind: "asset" as const, + assetId: "asset", + package: { format: "tarball" as const }, + }, + entry: "entry", + pin: "@corbits-builder/designer@1.0.0", + sourceOfferingIds: ["offering"], + defaultSourceOfferingId: "offering", + }; + let filedBody: { kind: string; title: string; content: string } | undefined; + const fetch = async (path: string, init: RequestInit) => { + if (path === "/api/workflow-project-scope") + return Response.json({ + tenantId: "tenant", + projectId: "project", + domain: "example.invalid", + role: "designer", + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + }, + openNetwork: false, + build: { + assetDirectory: "/assets", + browserCli: "/browser", + childPath: "/child", + dependencyRoots: ["/deps"], + ripgrepPath: "/rg", + runtimeVersion: "1.0.0", + }, + }); + if (path === "/api/tenants/tenant/projects/project/artifacts") { + filedBody = JSON.parse(String(init.body)) as { + kind: string; + title: string; + content: string; + }; + return Response.json({ + artifact: { + artifactId: "design-1", + kind: "design_option", + title: filedBody.title, + version: 1, + sha256: "a".repeat(64), + }, + }); + } + return Response.json({ code: "unexpected_route" }, { status: 404 }); + }; + const capabilities = { + resolve: () => ({ resolve: async () => ({ kind: "http", fetch }) }), + } as unknown as RuntimeCapabilities; + const tools = createDesignTools()({ + toolCwd: workspace, + capabilities, + } as never); + const result = await tools.run( + { id: "call-1", name: "file_design_option", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBeUndefined(); + expect(filedBody?.kind).toBe("design_option"); + const parsed = DesignBundle.assert(JSON.parse(filedBody!.content)); + expect(parsed.files["designs/screens/home.html"]).toBeUndefined(); + expect(parsed.files["designs/screens/overview.html"]).toContain( + 'data-screen="overview"', + ); + expect(parsed.files["designs/screens/orders.html"]).toContain( + 'id="new-order-dialog"', + ); + expect(parsed.files["DESIGN.md"]).toContain("colors:"); + } finally { + check.dispose(); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("check-app serves a per-screen mockup in the headless browser and checks every screen", async () => { + const browserCache = join( + homedir(), + ".cache", + "corbits-builder", + "browsers", + "156.0.8078.4", + ); + let hasBrowser = false; + try { + hasBrowser = (await stat(browserCache)).isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (!hasBrowser) return; + + const root = await realpath(await mkdtemp(join(tmpdir(), "sb-check-app-"))); + const workspace = join(root, "workspace"); + const home = join(root, "home"); + const checkApp = resolve( + import.meta.dir, + "../../../../workflows/coder/check/check-app.ts", + ); + try { + await mkdir(home, { recursive: true }); + await symlink(browserCache, join(home, "browsers")); + for (const [path, text] of Object.entries(STARTER)) { + const target = join(workspace, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, text); + } + const shell = STARTER[".builder/designs/index.html"]!.replaceAll( + "#/home", + "#/overview", + ).replace( + 'Home', + 'Overview\n Orders', + ); + const overviewScreen = [ + '
', + '
', + '
', + '

Good morning, Ines

', + ' Review orders', + "
", + "
", + "
", + ].join("\n"); + const ordersScreen = [ + '
', + '
', + '
', + '

Orders

', + ' ', + "
", + ' ', + "
", + '
', + "

No orders yet

", + "
", + "
", + '', + '
', + "

New order

", + ' ', + ' ', + "
", + "
", + '
Order marked as shipped.
', + ].join("\n"); + + await writeFile( + join(workspace, ".builder", "designs", "index.html"), + shell, + ); + await writeFile( + join(workspace, ".builder", "designs", "styles.css"), + MODERN_SLEEK["designs/styles.css"]!, + ); + await writeFile( + join(workspace, ".builder", "designs", "screens", "overview.html"), + overviewScreen, + ); + await writeFile( + join(workspace, ".builder", "designs", "screens", "orders.html"), + ordersScreen, + ); + await writeFile( + join(workspace, ".builder", "DESIGN.md"), + `${MODERN_SLEEK["DESIGN.md"]!}\n- Requirements: IR-1, IR-2\n`, + ); + await mkdir(join(workspace, ".inputs"), { recursive: true }); + await writeFile( + join(workspace, ".inputs", "requirements.md"), + "## Interface requirements\n\n- IR-1: Overview screen\n- IR-2: Orders screen\n", + ); + + const run = Bun.spawn( + [process.execPath, checkApp, join(workspace, ".builder", "designs")], + { + cwd: workspace, + env: { ...process.env, HOME: home }, + stdout: "pipe", + stderr: "pipe", + }, + ); + const output = + (await new Response(run.stdout).text()) + + (await new Response(run.stderr).text()); + expect(output).toContain("check passed:"); + expect(output).toContain("/#/overview"); + expect(output).toContain("/#/orders"); + expect(await run.exited).toBe(0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 30_000); + +test("designer agent writes per-screen files, is held by checkBeforeDone until checked, and files the bundle", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "sb-designer-e2e-")), + ); + const workspace = join(root, "workspace"); + const workdir = join(root, "agent"); + await mkdir(workspace, { recursive: true }); + await mkdir(workdir, { recursive: true }); + await prepareWorkspace("design", workspace, async (kind) => + kind === "requirements" + ? { + artifactId: "req-1", + kind: "requirements", + title: "Requirements", + version: 1, + sha256: "b".repeat(64), + filedAt: "2026-11-01T00:00:00.000Z", + content: + "## Interface requirements\n\n- IR-1: Overview screen\n- IR-2: Orders screen\n", + } + : undefined, + ); + const check = createDesignCheck(workspace, async () => ({ + exitCode: 0, + output: + "check passed: served .builder/designs and opened /, /#/overview, /#/orders at 1280 and 390 px with no errors", + })); + const designCodingTools = defineTool({ + id: "@corbits-builder/coding/test-design", + requires: ["toolCwd"], + definitions: [ + { name: "read_file" }, + { name: "write_file" }, + { name: "edit_file" }, + { name: "grep" }, + { name: CHECK_DESIGN_TOOL }, + ], + factory: (env) => { + const native = posix(env); + return { + definitions: [ + ...native.definitions.filter((tool) => + ["read_file", "write_file", "edit_file", "grep"].includes( + tool.name, + ), + ), + checkDesignDefinition, + ], + run: async (rawCall, signal) => { + const rawPath = rawCall.arguments?.["path"]; + const call = + typeof rawPath === "string" + ? { + ...rawCall, + arguments: { + ...rawCall.arguments, + path: designPath(rawPath), + }, + } + : rawCall; + if (call.name === CHECK_DESIGN_TOOL) { + const { checklist } = await check.current(signal); + return { callId: call.id, content: checklist }; + } + const result = await native.run(call, signal); + const path = call.arguments?.["path"]; + if ( + !result.isError && + typeof path === "string" && + touchesDesigns(workspace, path) + ) + check.changed(); + return result; + }, + dispose: async () => { + await native.dispose?.(); + }, + }; + }, + }); + + const deployment = { + source: { + kind: "asset" as const, + assetId: "asset", + package: { format: "tarball" as const }, + }, + entry: "entry", + pin: "@corbits-builder/designer@1.0.0", + sourceOfferingIds: ["offering"], + defaultSourceOfferingId: "offering", + }; + let filedBody: { kind: string; title: string; content: string } | undefined; + const hubFetch = async (path: string, init: RequestInit) => { + if (path === "/api/workflow-project-scope") + return Response.json({ + tenantId: "tenant", + projectId: "project", + domain: "example.invalid", + role: "designer", + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + }, + openNetwork: false, + build: { + assetDirectory: "/assets", + browserCli: "/browser", + childPath: "/child", + dependencyRoots: ["/deps"], + ripgrepPath: "/rg", + runtimeVersion: "1.0.0", + }, + }); + if (path === "/api/tenants/tenant/projects/project/artifacts") { + filedBody = JSON.parse(String(init.body)) as { + kind: string; + title: string; + content: string; + }; + return Response.json({ + artifact: { + artifactId: "design-1", + kind: "design_option", + title: filedBody.title, + version: 1, + sha256: "a".repeat(64), + }, + }); + } + return Response.json({ code: "unexpected_route" }, { status: 404 }); + }; + const capabilities = { + resolve: () => ({ + resolve: async () => ({ kind: "http", fetch: hubFetch }), + }), + } as unknown as RuntimeCapabilities; + + const compacting = compactingDirector("@corbits-builder/designer-test"); + const historyCompactor = trimHistory(160_000); + const definition = createDesignerAgent({ + id: "designer", + director: compacting.build({ + contextWindow: 200_000, + compactAt: 0.6, + checkBeforeDone: true, + }), + standards: [], + inference: { provider: "anthropic", model: "claude-sonnet-4-6" }, + tools: [createDesignTools(), designCodingTools], + }); + + const shell = STARTER[".builder/designs/index.html"]!.replaceAll( + "#/home", + "#/overview", + ); + const systemPrompts: string[] = []; + const scriptedTurns = [ + [ + { + type: "tool_use", + id: "call-shell", + name: "write_file", + input: { path: "designs/index.html", content: shell }, + }, + { + type: "tool_use", + id: "call-overview", + name: "write_file", + input: { + path: "designs/screens/overview.html", + content: + '

Overview

', + }, + }, + { + type: "tool_use", + id: "call-orders", + name: "write_file", + input: { + path: "designs/screens/orders.html", + content: + '

Orders

\n', + }, + }, + { + type: "tool_use", + id: "call-doc", + name: "write_file", + input: { path: "DESIGN.md", content: MODERN_SLEEK["DESIGN.md"]! }, + }, + ], + [{ type: "text", text: "Premature reply before checking or filing." }], + [ + { + type: "tool_use", + id: "call-check", + name: CHECK_DESIGN_TOOL, + input: {}, + }, + { + type: "tool_use", + id: "call-file", + name: "file_design_option", + input: {}, + }, + ], + [{ type: "text", text: "Filed the two-screen order desk design." }], + ]; + let turnIndex = 0; + const storage = await createIsogitStore(workdir); + const agent = await createAgent(definition, { + sources: [ + { + id: "anthropic:claude-sonnet-4-6", + provider: "anthropic", + baseURL: "https://api.anthropic.invalid", + credentialId: "test-key", + model: "claude-sonnet-4-6", + }, + ], + defaultSource: "anthropic:claude-sonnet-4-6", + storage, + workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDirectorRegistry({ + factories: [compacting.factory], + defaultId: compacting.factory.id, + }), + compactors: { + [dropDegenerateTurn.name]: dropDegenerateTurn, + [historyCompactor.name]: historyCompactor, + }, + readCurrentMaterial: createStaticCredentialResolver({ + "test-key": "sk-test", + }), + deps: { + scheduler: createDefaultScheduler(), + adapters: createBuiltinRegistry(), + fetch: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + system?: string | { text?: string }[]; + }; + const systemText = Array.isArray(body.system) + ? body.system.map((part) => part.text ?? "").join("\n") + : String(body.system ?? ""); + systemPrompts.push(systemText); + const content = scriptedTurns[turnIndex++]; + if (!content) throw new Error("unexpected_inference_turn"); + return Response.json({ + type: "message", + content, + usage: { input_tokens: 120, output_tokens: 60 }, + }); + }, + }, + toolCwd: workspace, + capabilities, + }); + + try { + const sent = await agent.send("Design the order desk mockup."); + expect(sent).toMatchObject({ + type: "reply", + reply: "Filed the two-screen order desk design.", + }); + expect(turnIndex).toBe(4); + expect(systemPrompts[0]).toContain("designs/screens/.html"); + expect(systemPrompts[2]).toContain( + "Files changed after the last passing check-app, so the work is not done yet.", + ); + + const draft = await readDesignDraft(workspace); + expect(draft?.["designs/screens/home.html"]).toBeUndefined(); + expect(draft?.["designs/screens/overview.html"]).toContain( + 'data-screen="overview"', + ); + expect(draft?.["designs/screens/orders.html"]).toContain( + 'data-screen="orders"', + ); + + const parsed = DesignBundle.assert(JSON.parse(filedBody!.content)); + expect(parsed.files["designs/screens/home.html"]).toBeUndefined(); + expect(parsed.files["designs/screens/overview.html"]).toContain( + 'data-screen="overview"', + ); + expect(parsed.files["designs/screens/orders.html"]).toContain( + 'id="new-order-dialog"', + ); + expect(parsed.files["DESIGN.md"]).toContain("colors:"); + } finally { + await agent.close(); + check.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/hub/src/artifacts/design-draft.ts b/apps/hub/src/artifacts/design-draft.ts index e2cba032..62ac4d28 100644 --- a/apps/hub/src/artifacts/design-draft.ts +++ b/apps/hub/src/artifacts/design-draft.ts @@ -10,7 +10,45 @@ import { ProjectError } from "../projects/errors.ts"; import { DESIGN_STYLES } from "./design-tweaks.ts"; import { designStarterFiles } from "@corbits-builder/designer/skills" with { type: "macro" }; -const STARTER_INDEX = designStarterFiles()[".builder/designs/index.html"]; +const STARTER = designStarterFiles(); +const STARTER_HOME = STARTER[".builder/designs/screens/home.html"]; +const STARTER_PAGES = Object.entries(STARTER) + .filter( + ([path]) => path.startsWith(".builder/designs/") && path.endsWith(".html"), + ) + .map(([path, text]) => [path.slice(".builder/".length), text] as const); + +// The starter's home.html stays on disk when a designer builds screens with other names; drop it +// once another screen exists while home.html is still the starter's. +export function withoutUnusedStarterHome( + files: Record, +): Record { + if ( + STARTER_HOME === undefined || + files["designs/screens/home.html"] !== STARTER_HOME + ) + return files; + const index = files["designs/index.html"]; + const hasOtherScreen = + Object.keys(files).some( + (path) => + path.startsWith("designs/screens/") && + path.endsWith(".html") && + path !== "designs/screens/home.html", + ) || + (index !== undefined && /data-screen=/.test(index)); + if (!hasOtherScreen) return files; + const { "designs/screens/home.html": _, ...rest } = files; + return rest; +} + +const isStarterPages = (files: Readonly>) => { + const pages = Object.keys(files).filter((path) => path.endsWith(".html")); + return ( + pages.length === STARTER_PAGES.length && + STARTER_PAGES.every(([path, text]) => files[path] === text) + ); +}; // A warm single-step agent's workspace lives at // /workflow-step-state//warm//workspace @@ -46,9 +84,10 @@ export async function readDesignDraft(workspace: string) { if (actual !== join(await realpath(workspace), ".builder", "designs")) return undefined; const files = await packApp(builder, designs); - const index = files["designs/index.html"]; // The starter mockup is the designer's scaffold, not its draft; the draft begins with its first change. - return index === undefined || index === STARTER_INDEX ? undefined : files; + if (files["designs/index.html"] === undefined || isStarterPages(files)) + return undefined; + return withoutUnusedStarterHome(files); } const MAX_DESIGN_DOC_BYTES = 256 * 1024; diff --git a/apps/hub/src/artifacts/design-tools.ts b/apps/hub/src/artifacts/design-tools.ts index 91af0408..366d71bd 100644 --- a/apps/hub/src/artifacts/design-tools.ts +++ b/apps/hub/src/artifacts/design-tools.ts @@ -6,6 +6,7 @@ import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; import { DesignBundle } from "@corbits-builder/schemas/artifact"; import { designCheckFor } from "../builds/design-check.ts"; +import { withoutUnusedStarterHome } from "./design-draft.ts"; import { withDesignTokens } from "./design-tokens.ts"; import { DESIGN_STYLES } from "./design-tweaks.ts"; import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; @@ -50,7 +51,7 @@ async function readDesignFolder(workspace: string) { cwd: join(workspace, ".builder"), })) files[path] = await readFile(join(workspace, ".builder", path), "utf8"); - return files; + return withoutUnusedStarterHome(files); } // Files the designer's .builder folder as its design concept's option. The person decides on the diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index fef7123c..5185a5ff 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -123,6 +123,17 @@ const DESIGNER_TOOLS = new Set([ "edit_file", "grep", ]); +// The designer's prompt names files under .builder (DESIGN.md, designs/...) without repeating the +// folder prefix on every bullet; map bare design paths into .builder so writes never land outside it. +export const designPath = (raw: string) => { + const clean = raw.replace(/^\.\/+/, ""); + return clean === "DESIGN.md" || + clean === "designs" || + clean.startsWith("designs/") + ? `.builder/${clean}` + : raw; +}; + // The calls after which the agent hears what its running app complained of since the last one. const REPORTING = new Set([...WRITES, PACKAGE_INSTALL_TOOL]); // run_shell reports a failed command as its output, headed by its exit code. @@ -507,7 +518,18 @@ export function createSidecarCodingTools(policy: { ...(design ? [checkDesignDefinition] : []), ...(policy.browser?.tools ?? []), ], - run: async (call, signal) => { + run: async (rawCall, signal) => { + const rawPath = rawCall.arguments?.["path"]; + const call = + design && typeof rawPath === "string" + ? { + ...rawCall, + arguments: { + ...rawCall.arguments, + path: designPath(rawPath), + }, + } + : rawCall; if (browserNames.has(call.name)) return runBrowser(call, signal); if (design && call.name === CHECK_DESIGN_TOOL) { await open(signal); diff --git a/apps/web/src/pages/workspace/design-preview.tsx b/apps/web/src/pages/workspace/design-preview.tsx index 67179e2a..ce07f8b6 100644 --- a/apps/web/src/pages/workspace/design-preview.tsx +++ b/apps/web/src/pages/workspace/design-preview.tsx @@ -97,8 +97,66 @@ export function parseBundle(content: string): Record | null { } } +const SCREEN_PREFIX = "designs/screens/"; +const STARTER_HOME_MARK = '

Hello, world

'; + +function screenNameOf(path: string, html: string): string { + const match = html.match(/data-screen=["']([^"']+)["']/); + if (match?.[1] !== undefined) return match[1]; + return path.slice(SCREEN_PREFIX.length, -".html".length); +} + +function normalizeScreenHtml(path: string, html: string): string { + const body = html.match(/]*>([\s\S]*?)<\/body>/i)?.[1] ?? html; + const withoutScripts = body + .replace(//gi, "") + .trim(); + if (/data-screen=/.test(withoutScripts)) return withoutScripts; + const name = screenNameOf(path, withoutScripts); + return `
\n${withoutScripts}\n
`; +} + +function screenFragments(files: DesignFiles, page: string): string { + const inline = new Set( + [...page.matchAll(/data-screen=["']([^"']+)["']/g)].flatMap((m) => + m[1] === undefined ? [] : [m[1]], + ), + ); + const raw = Object.entries(files).filter( + ([path]) => path.startsWith(SCREEN_PREFIX) && path.endsWith(".html"), + ); + const filtered = raw.filter(([path, html]) => { + if ( + path === "designs/screens/home.html" && + html.includes(STARTER_HOME_MARK) && + (raw.length > 1 || inline.size > 0) + ) + return false; + return !inline.has(screenNameOf(path, html)); + }); + const order = [...page.matchAll(/href=["']#\/([^/"'\s]+)/g)].flatMap((m) => + m[1] === undefined ? [] : [m[1]], + ); + const rank = (entry: readonly [string, string]) => { + const index = order.indexOf(screenNameOf(entry[0], entry[1])); + return index === -1 ? order.length : index; + }; + return filtered + .sort((a, b) => rank(a) - rank(b) || a[0].localeCompare(b[0])) + .map(([path, html]) => normalizeScreenHtml(path, html)) + .join("\n"); +} + +function spliceScreens(page: string, fragments: string): string { + if (fragments.length === 0) return page; + if (/<\/main>/i.test(page)) + return page.replace(/<\/main>/i, (tag) => `${fragments}\n${tag}`); + return `${page}\n${fragments}`; +} + function mockupPage(files: DesignFiles, page: string, head: string): string { - const inlined = page.replace( + const withScreens = spliceScreens(page, screenFragments(files, page)); + const inlined = withScreens.replace( //g, (link, href: string) => { const css = files[`designs/${href}`]; @@ -114,7 +172,12 @@ function mockupPage(files: DesignFiles, page: string, head: string): string { function bundlePages(files: DesignFiles): string[] { return Object.keys(files) - .filter((path) => path.startsWith("designs/") && path.endsWith(".html")) + .filter( + (path) => + path.startsWith("designs/") && + !path.startsWith(SCREEN_PREFIX) && + path.endsWith(".html"), + ) .map((path) => path.slice("designs/".length)); } diff --git a/apps/web/src/pages/workspace/preview-interactions.test.tsx b/apps/web/src/pages/workspace/preview-interactions.test.tsx index a96f912d..a9df5a21 100644 --- a/apps/web/src/pages/workspace/preview-interactions.test.tsx +++ b/apps/web/src/pages/workspace/preview-interactions.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; +import { firstPage } from "./design-preview.tsx"; import { predecessorOf } from "./document-versions.ts"; import { DevicePreview } from "./device-preview.tsx"; @@ -24,4 +25,36 @@ describe("device preview", () => { expect(html).toContain('sandbox=""'); expect(html).not.toContain("allow-scripts"); }); + + test("splices per-screen files into index.html in navigation order", () => { + const rendered = firstPage({ + "designs/index.html": + '
', + "designs/screens/home.html": + '

Hello, world

', + "designs/screens/orders.html": + '

Orders

', + "designs/screens/overview.html": + '

Overview

', + }); + expect(rendered).not.toContain("Hello, world"); + expect(rendered.indexOf('data-screen="overview"')).toBeLessThan( + rendered.indexOf('data-screen="orders"'), + ); + expect(rendered.indexOf('data-screen="orders"')).toBeLessThan( + rendered.indexOf(""), + ); + }); + + test("wraps a screen file that omits data-screen or includes a body tag", () => { + const rendered = firstPage({ + "designs/index.html": + '
', + "designs/screens/orders.html": + "

Orders

", + }); + expect(rendered).toContain('
'); + expect(rendered).toContain("

Orders

"); + expect(rendered).not.toContain("alert(1)"); + }); }); diff --git a/workflows/coder/check/check-app.ts b/workflows/coder/check/check-app.ts index b3d57881..3ec7f523 100644 --- a/workflows/coder/check/check-app.ts +++ b/workflows/coder/check/check-app.ts @@ -364,8 +364,77 @@ const EMOJI = /\p{Emoji_Presentation}|\p{Extended_Pictographic}️/gu; // Prose in angle brackets, like ; HTML tags carry attributes and code is stripped first. const PLACEHOLDER = /<[A-Za-z][^<>=`"]*\s[^<>=`"]*>/g; +const STARTER_HOME_MARK = '

Hello, world

'; + +function screenNameOf(path: string, html: string): string { + const match = html.match(/data-screen=["']([^"']+)["']/); + if (match?.[1] !== undefined) return match[1]; + return basename(path, ".html"); +} + +function normalizeScreenHtml(path: string, html: string): string { + const body = html.match(/]*>([\s\S]*?)<\/body>/i)?.[1] ?? html; + const withoutScripts = body + .replace(//gi, "") + .trim(); + if (/data-screen=/.test(withoutScripts)) return withoutScripts; + const name = screenNameOf(path, withoutScripts); + return `
\n${withoutScripts}\n
`; +} + +function spliceScreens(index: string, entries: [string, string][]): string { + const inline = new Set( + [...index.matchAll(/data-screen=["']([^"']+)["']/g)].flatMap((m) => + m[1] === undefined ? [] : [m[1]], + ), + ); + const filtered = entries.filter(([path, html]) => { + if ( + path === "screens/home.html" && + html.includes(STARTER_HOME_MARK) && + (entries.length > 1 || inline.size > 0) + ) + return false; + return !inline.has(screenNameOf(path, html)); + }); + if (!filtered.length) return index; + const order = [...index.matchAll(/href=["']#\/([^/"'\s]+)/g)].flatMap((m) => + m[1] === undefined ? [] : [m[1]], + ); + const rank = (entry: [string, string]) => { + const pos = order.indexOf(screenNameOf(entry[0], entry[1])); + return pos === -1 ? order.length : pos; + }; + const fragments = filtered + .sort((a, b) => rank(a) - rank(b) || a[0].localeCompare(b[0])) + .map(([path, html]) => normalizeScreenHtml(path, html)) + .join("\n"); + if (/<\/main>/i.test(index)) + return index.replace(/<\/main>/i, (tag) => `${fragments}\n${tag}`); + return `${index}\n${fragments}`; +} + +async function readMockupIndex(root: string): Promise { + const index = Bun.file(join(root, "index.html")); + if (!(await index.exists())) return undefined; + const paths = await Array.fromAsync( + new Bun.Glob("screens/*.html").scan({ cwd: root }), + ); + const entries = await Promise.all( + paths.map(async (path): Promise<[string, string]> => [ + path, + await Bun.file(join(root, path)).text(), + ]), + ); + return spliceScreens(await index.text(), entries); +} + // The workspace's documents sit in .inputs beside .builder. -async function checkCoverage(root: string, design: string) { +async function checkCoverage( + root: string, + design: string, + mockup: string | undefined, +) { const requirements = Bun.file( join(root, "..", "..", ".inputs", "requirements.md"), ); @@ -386,12 +455,11 @@ async function checkCoverage(root: string, design: string) { kind: "coverage", detail: `DESIGN.md names no screen or data source for ${unnamed.join(", ")} from the requirements`, }); - const mockup = Bun.file(join(root, "index.html")); const screens = new Set( [ - ...((await mockup.exists()) ? await mockup.text() : "").matchAll( - /data-screen=["']([^"']+)["']/g, - ), + ...(mockup === undefined + ? [] + : mockup.matchAll(/data-screen=["']([^"']+)["']/g)), ].map((m) => m[1]), ); const missing = [ @@ -408,7 +476,7 @@ async function checkCoverage(root: string, design: string) { }); } -async function checkDesign(root: string) { +async function checkDesign(root: string, mockup: string | undefined) { const doc = Bun.file(join(root, "..", "DESIGN.md")); const design = (await doc.exists()) ? await doc.text() : undefined; // The person may ask for emoji; the direction records it on its Icons line. @@ -431,7 +499,7 @@ async function checkDesign(root: string) { kind: "design doc", detail: `DESIGN.md still has ${left.length} template placeholder(s): ${left.slice(0, 5).join(", ")}`, }); - await checkCoverage(root, design); + await checkCoverage(root, design, mockup); } const files = await Array.fromAsync( new Bun.Glob("**/*.{html,css,js}").scan({ cwd: root }), @@ -473,15 +541,22 @@ let origin: string; let stop: () => void; if (staticRoot) { const root = resolve(staticRoot); - await checkDesign(root); + const indexHtml = await readMockupIndex(root); + await checkDesign(root, indexHtml); const files = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: async (request) => { const path = decodeURIComponent(new URL(request.url).pathname); - const file = Bun.file( - join(root, path.endsWith("/") ? `${path}index.html` : path), + const target = join( + root, + path.endsWith("/") ? `${path}index.html` : path, ); + if (target === join(root, "index.html") && indexHtml !== undefined) + return new Response(indexHtml, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + const file = Bun.file(target); return (await file.exists()) && resolve(file.name ?? "").startsWith(root + sep) ? new Response(file) @@ -587,13 +662,18 @@ if (!chrome) { const DEAD_CONTROLS_SCRIPT = `(() => { const name = (el) => el.dataset.testid || el.textContent.trim().replace(/\\s+/g, " ").slice(0, 40); const dead = []; + const linked = new Set(); for (const a of document.querySelectorAll('a[href^="#/"]')) { const [screen, state] = a.getAttribute("href").slice(2).split("/"); + linked.add(screen); const target = [...document.querySelectorAll("[data-screen]")].find((s) => s.dataset.screen === screen); if (!target) dead.push(name(a) + " links to #/" + screen + ", which has no data-screen"); else if (state && !target.querySelector(":scope > [data-state='" + state + "']")) dead.push(name(a) + " links to state " + state + " of " + screen + ", which has no such data-state"); } + for (const s of document.querySelectorAll("[data-screen]")) + if (s.dataset.screen && !linked.has(s.dataset.screen)) + dead.push("screen " + s.dataset.screen + " has no #/" + s.dataset.screen + " link to reach it"); for (const el of document.querySelectorAll("[data-open]:not([data-open=true]):not([data-open=false])")) if (document.getElementById(el.dataset.open)?.tagName !== "DIALOG") dead.push(name(el) + " opens #" + el.dataset.open + ", which is not a dialog"); diff --git a/workflows/designer/README.md b/workflows/designer/README.md index 7d7a4e25..497be64d 100644 --- a/workflows/designer/README.md +++ b/workflows/designer/README.md @@ -24,8 +24,9 @@ and skills through its macros. step on a posix sidecar; each message is another request on the same warm workspace. - `starter/`: the workspace every design begins from: `.builder/DESIGN.md`, - `.builder/designs/index.html` and `styles.css`, and a `README.md` for the - designer that is not filed with the design. + `.builder/designs/index.html`, `.builder/designs/screens/home.html` and + `styles.css`, and a `README.md` for the designer that is not filed with the + design. - `themes/`: the design themes a person can preview and pick, each a sample `DESIGN.md` and `designs/` bundle. - `skills/`: skill bundles; the coder reads `react-ui` and `stack-docs`. diff --git a/workflows/designer/skills/react-ui/SKILL.md b/workflows/designer/skills/react-ui/SKILL.md index 997a7144..007d02a8 100644 --- a/workflows/designer/skills/react-ui/SKILL.md +++ b/workflows/designer/skills/react-ui/SKILL.md @@ -33,8 +33,9 @@ kit's documentation, props included. ## In a design mockup -The mockup is three files: `.builder/DESIGN.md`, `.builder/designs/index.html` -and `.builder/designs/styles.css`. It is plain HTML and CSS: pick the kit +The mockup lives in `.builder`: `DESIGN.md`, `designs/styles.css`, +`designs/index.html` (the app shell and navigation) and one +`designs/screens/.html` file per screen. It is plain HTML and CSS: pick the kit component for each part from these references, write its markup by hand with the tokens and base component classes in `styles.css`, and name the component in the `DESIGN.md` components table so the coder builds it from the kit. The diff --git a/workflows/designer/src/prompt.ts b/workflows/designer/src/prompt.ts index a102f77f..a7c6cd46 100644 --- a/workflows/designer/src/prompt.ts +++ b/workflows/designer/src/prompt.ts @@ -2,16 +2,17 @@ export const designerPrompt = `## Role You are the product designer. You turn the project's documents into a clickable HTML and CSS mockup of the app's interface, before any code exists. The documents are in .inputs: the problem brief, the constraints, the chosen approach and the product requirements. Later tasks change the same design in the same workspace. ## The job -The workspace holds the design's three files in .builder: +The workspace holds the design in .builder: - designs/styles.css: the theme. It has the tokens in :root and .dark and a class for every @corbits/react-ui component plus a small layout layer. Use it as it is when the task names a theme. When the task or the person describes their own look, change the stylesheet as far as that look needs, keeping its token names so the coder can map it. -- designs/index.html: the mockup. Write the whole app here: the shell with its navigation, and one
per screen with a
(and empty, loading or error states where they apply), reached by . Keep the starter's script at the end of the file; it runs the navigation, dialogs and the theme toggle. Put a data-testid on each control and figure a person reads. +- designs/index.html: the app shell. It has the header with its navigation ( for each screen),
, and the starter's script at the end of the file, which runs the navigation, dialogs, toasts and the theme toggle. Keep the script at the end of the file. +- designs/screens/.html: one file per screen, spliced into
when the mockup runs. Write one
with a
(and empty, loading or error states where they apply), followed by any or
its buttons open. Put a data-testid on each control and figure a person reads. - DESIGN.md: for the coder, holding what only you know. Fill in the template: an Overview of who the app is for and how it feels, a few Do's and Don'ts, each screen's route, Requirements line, states and the behaviour the mockup cannot show, and the Data the screens use. The tokens are added from styles.css when the design is filed. Design a real product, the way a senior designer hands one to an engineer: every requirement on screen with realistic content, one clear title and primary action per screen, sensible spacing, and a layout that works from a phone to a wide desktop. The product's name and copy come from the documents. ## Done The interface requirements (IR-n) in requirements.md are the checklist: each is a screen to build or a data source to show. The design is done when: -- every screen they name is a data-screen in index.html with its populated state, and the empty, loading and error states the requirements describe; +- every screen they name is a designs/screens/.html file with its data-screen, its populated state, and the empty, loading and error states the requirements describe, linked from the navigation in designs/index.html; - every link goes to a screen or state that exists, and every button does something: opens or closes a dialog (data-open, data-close), shows a toast (data-toast), toggles (aria-pressed, role="tab") or is a link; - DESIGN.md names every IR-n: each screen's Requirements line, and the Data section for services and device features. Build the screens one at a time, in the order the requirements list them. diff --git a/workflows/designer/starter/.builder/designs/index.html b/workflows/designer/starter/.builder/designs/index.html index 49e27bce..21019b29 100644 --- a/workflows/designer/starter/.builder/designs/index.html +++ b/workflows/designer/starter/.builder/designs/index.html @@ -47,17 +47,15 @@ -
-
-
-

Hello, world

-
-
-
+