diff --git a/apps/hub/src/artifacts/design-draft.test.ts b/apps/hub/src/artifacts/design-draft.test.ts
index d2986903..e8892b47 100644
--- a/apps/hub/src/artifacts/design-draft.test.ts
+++ b/apps/hub/src/artifacts/design-draft.test.ts
@@ -1,8 +1,56 @@
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,
+ readFile,
+ 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,
+ spliceDesignScreens,
+ syncDesignScreensOnDisk,
+} 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 +58,29 @@ 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"),
+ '',
+ );
+ expect(await readDesignDraft(workspace)).toEqual({
+ "designs/index.html": spliceDesignScreens(
+ STARTER[".builder/designs/index.html"]!,
+ { "list.html": '' },
+ ),
+ "designs/screens/list.html":
+ '',
+ "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
");
@@ -22,7 +92,9 @@ test("the draft serves only regular files inside .builder/designs", async () =>
await symlink(join(outside, "secret.txt"), join(designs, "secret.txt"));
await symlink(outside, join(designs, "home"));
expect(await readDesignDraft(workspace)).toEqual({
- "designs/index.html": "mockup
",
+ "designs/index.html": spliceDesignScreens("mockup
", {
+ "list.html": "list
",
+ }),
"designs/screens/list.html": "list
",
});
@@ -39,3 +111,602 @@ 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"),
+ '',
+ );
+ await writeFile(
+ join(workspace, ".builder", "designs", "screens", "orders.html"),
+ '\n\nShipped
',
+ );
+
+ 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 = [
+ '',
+ ' ',
+ ' ",
+ "
",
+ "",
+ ].join("\n");
+ const ordersScreen = [
+ '',
+ ' ',
+ ' ",
+ ' ',
+ "
",
+ ' ',
+ "
No orders yet
",
+ " ",
+ "",
+ '",
+ '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) {
+ await syncDesignScreensOnDisk(workspace);
+ 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)
+ )
+ await syncDesignScreensOnDisk(workspace);
+ 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:
+ '',
+ },
+ },
+ {
+ type: "tool_use",
+ id: "call-orders",
+ name: "write_file",
+ input: {
+ path: "designs/screens/orders.html",
+ content:
+ '\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 system = body.system;
+ let systemText: string | undefined;
+ if (Array.isArray(system)) {
+ systemText = system
+ .flatMap((part) =>
+ typeof part.text === "string" ? [part.text] : [],
+ )
+ .join("\n");
+ } else if (typeof system === "string") {
+ systemText = system;
+ }
+ if (systemText === undefined) throw new Error("missing_system_prompt");
+ 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/index.html"]).toContain(
+ "",
+ );
+ expect(draft?.["designs/index.html"]).toContain('data-screen="overview"');
+ expect(draft?.["designs/index.html"]).toContain('data-screen="orders"');
+ expect(draft?.["designs/screens/overview.html"]).toContain(
+ 'data-screen="overview"',
+ );
+ expect(draft?.["designs/screens/orders.html"]).toContain(
+ 'data-screen="orders"',
+ );
+
+ const diskIndex = await readFile(
+ join(workspace, ".builder", "designs", "index.html"),
+ "utf8",
+ );
+ expect(diskIndex).toContain("");
+ expect(diskIndex).toContain('data-screen="overview"');
+ expect(diskIndex).toContain('data-screen="orders"');
+ expect(diskIndex).toContain("");
+
+ const parsed = DesignBundle.assert(JSON.parse(filedBody!.content));
+ expect(parsed.files["designs/screens/home.html"]).toBeUndefined();
+ expect(parsed.files["designs/index.html"]).toContain(
+ "",
+ );
+ 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 });
+ }
+});
+
+test("readDesignDraft serves spliced index.html whether synced on disk or packed on read", async () => {
+ const workspace = await mkdtemp(join(tmpdir(), "sb-design-sync-"));
+ const designs = join(workspace, ".builder", "designs");
+ 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 syncDesignScreensOnDisk(workspace);
+ expect(await readDesignDraft(workspace)).toBeUndefined();
+
+ await writeFile(
+ join(designs, "screens", "place.html"),
+ '',
+ );
+ const beforeSync = await readDesignDraft(workspace);
+ await syncDesignScreensOnDisk(workspace);
+ const afterSync = await readDesignDraft(workspace);
+
+ expect(afterSync).toEqual(beforeSync);
+ expect(afterSync?.["designs/screens/home.html"]).toBeUndefined();
+ expect(afterSync?.["designs/index.html"]).toContain(
+ "",
+ );
+ expect(afterSync?.["designs/index.html"]).toContain('data-screen="place"');
+ expect(afterSync?.["designs/index.html"]).not.toContain("Hello, world");
+ } finally {
+ await rm(workspace, { recursive: true, force: true });
+ }
+});
diff --git a/apps/hub/src/artifacts/design-draft.ts b/apps/hub/src/artifacts/design-draft.ts
index e2cba032..93d8a402 100644
--- a/apps/hub/src/artifacts/design-draft.ts
+++ b/apps/hub/src/artifacts/design-draft.ts
@@ -5,12 +5,72 @@ import { type } from "arktype";
import { workflowRunRepoIdForAddress } from "@intx/hub-sessions";
import { hasCode } from "@intx/types";
import { packApp } from "../builds/app-snapshot.ts";
+import {
+ spliceDesignScreens,
+ STARTER_HOME_SCREEN,
+ STARTER_INDEX,
+ STARTER_SPLICED_INDEX,
+} from "../builds/starter.ts";
import { parseJson } from "../lib/json.ts";
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 hasInlineNonHomeScreen = (index: string | undefined) => {
+ if (index === undefined) return false;
+ for (const match of index.matchAll(/data-screen=["']([^"']+)["']/g)) {
+ if (match[1] !== "home") return true;
+ }
+ return false;
+};
+
+// 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 {
+ const screens = Object.fromEntries(
+ Object.entries(files).filter(
+ ([path]) => path.startsWith("designs/screens/") && path.endsWith(".html"),
+ ),
+ );
+ const next =
+ files["designs/index.html"] !== undefined && Object.keys(screens).length > 0
+ ? {
+ ...files,
+ "designs/index.html": spliceDesignScreens(
+ files["designs/index.html"],
+ screens,
+ ),
+ }
+ : files;
+ if (next["designs/screens/home.html"] !== STARTER_HOME_SCREEN) return next;
+ const index = next["designs/index.html"];
+ const hasOtherScreen =
+ Object.entries(next).some(
+ ([path, content]) =>
+ path.startsWith("designs/screens/") &&
+ path.endsWith(".html") &&
+ path !== "designs/screens/home.html" &&
+ content.trim().length > 0,
+ ) || hasInlineNonHomeScreen(index);
+ if (!hasOtherScreen) return next;
+ const { "designs/screens/home.html": _, ...rest } = next;
+ return rest;
+}
+
+const isStarterPages = (files: Readonly>) => {
+ const index = files["designs/index.html"];
+ if (index !== STARTER_INDEX && index !== STARTER_SPLICED_INDEX) return false;
+ const otherPages = Object.keys(files).filter(
+ (path) =>
+ path.endsWith(".html") &&
+ path !== "designs/index.html" &&
+ path !== "designs/screens/home.html",
+ );
+ if (otherPages.length > 0) return false;
+ const home = files["designs/screens/home.html"];
+ return home === undefined || home === STARTER_HOME_SCREEN;
+};
// A warm single-step agent's workspace lives at
// /workflow-step-state//warm//workspace
@@ -46,9 +106,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..0ca2a87b 100644
--- a/apps/hub/src/artifacts/design-tools.ts
+++ b/apps/hub/src/artifacts/design-tools.ts
@@ -5,7 +5,12 @@ import { defineTool, type BaseEnv } from "@intx/agent/authoring";
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 {
+ designCheckFor,
+ registerDesignFiler,
+ type FiledDesignArtifact,
+} 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 +55,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
@@ -73,97 +78,125 @@ export function createDesignTools() {
credentials: [{ handle: HUB_CREDENTIAL_HANDLE }],
requires: ["toolCwd", "capabilities"],
definitions: [{ name: "file_design_option" }],
- factory: (env) => ({
- definitions,
- run: async (call, signal) => {
- if (call.name !== "file_design_option")
- return {
- callId: call.id,
- content: "unknown_design_tool",
- isError: true,
- };
+ factory: (env) => {
+ let lastFiled:
+ | { content: string; artifact: FiledDesignArtifact }
+ | undefined;
+ const fileOption = async (
+ signal: AbortSignal,
+ ): Promise => {
+ const files = await readDesignFolder(env.toolCwd);
+ const doc = files["DESIGN.md"];
+ const styles = files[DESIGN_STYLES];
+ if (doc === undefined || styles === undefined)
+ throw new Error(
+ `invalid_design: .builder needs DESIGN.md and ${DESIGN_STYLES}`,
+ );
try {
- const scope = await resolveWorkflowProjectScope(
- env.capabilities,
- "designer",
- signal,
+ files["DESIGN.md"] = withDesignTokens(doc, styles);
+ } catch (error) {
+ throw new Error(
+ `invalid_design: ${error instanceof Error ? error.message : String(error)}`,
);
- const projectPath =
- "/api/tenants/" +
- encodeURIComponent(scope.tenantId) +
- "/projects/" +
- encodeURIComponent(scope.projectId);
- const hub = (path: string, body?: unknown) =>
- workflowHttpJson({
- capabilities: env.capabilities,
- handle: HUB_CREDENTIAL_HANDLE,
- path: projectPath + path,
- ...(body === undefined ? {} : { body }),
- maxResponseBytes: 16384,
- signal,
- });
- // The person reviews only a mockup that passed check-app as it is filed.
- const check = designCheckFor(await realpath(env.toolCwd));
- if (!check)
- return {
- callId: call.id,
- content:
- "design_unchecked: check-app has not run on the design yet",
- isError: true,
- };
- const checked = await check.current(signal);
- if (!checked.passed)
- return {
- callId: call.id,
- content: `design_check_failed: the design is filed once it passes check-app.\n${checked.checklist}`,
- isError: true,
- };
- const files = await readDesignFolder(env.toolCwd);
- const doc = files["DESIGN.md"];
- const styles = files[DESIGN_STYLES];
- if (doc === undefined || styles === undefined)
+ }
+ const bundle = DesignBundle({ files });
+ if (bundle instanceof type.errors)
+ throw new Error("invalid_design: " + bundle.summary);
+ const content = JSON.stringify(bundle);
+ const bytes = Buffer.byteLength(content);
+ if (bytes > MAX_DESIGN_BYTES)
+ throw new Error(
+ `design_too_large: ${bytes} bytes; the limit is ${MAX_DESIGN_BYTES}`,
+ );
+ if (lastFiled?.content === content) return lastFiled.artifact;
+ const scope = await resolveWorkflowProjectScope(
+ env.capabilities,
+ "designer",
+ signal,
+ );
+ const projectPath =
+ "/api/tenants/" +
+ encodeURIComponent(scope.tenantId) +
+ "/projects/" +
+ encodeURIComponent(scope.projectId);
+ const title = doc.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? "Design";
+ const { artifact } = HubArtifactReceipt.assert(
+ await workflowHttpJson({
+ capabilities: env.capabilities,
+ handle: HUB_CREDENTIAL_HANDLE,
+ path: projectPath + "/artifacts",
+ body: {
+ kind: "design_option",
+ title: title.slice(0, 512),
+ content,
+ },
+ maxResponseBytes: 16384,
+ signal,
+ }),
+ );
+ lastFiled = { content, artifact };
+ return artifact;
+ };
+ registerDesignFiler(env.toolCwd, fileOption);
+ return {
+ definitions,
+ run: async (call, signal) => {
+ if (call.name !== "file_design_option")
return {
callId: call.id,
- content: `invalid_design: .builder needs DESIGN.md and ${DESIGN_STYLES}`,
+ content: "unknown_design_tool",
isError: true,
};
try {
- files["DESIGN.md"] = withDesignTokens(doc, styles);
+ const workspace = await realpath(env.toolCwd);
+ registerDesignFiler(workspace, fileOption);
+ // The person reviews only a mockup that passed check-app as it is filed.
+ const check = designCheckFor(workspace);
+ if (!check)
+ return {
+ callId: call.id,
+ content:
+ "design_unchecked: check-app has not run on the design yet",
+ isError: true,
+ };
+ const checked = await check.current(signal);
+ if (!checked.passed) {
+ if (checked.filingError !== undefined) {
+ if (
+ checked.filingError instanceof Error &&
+ /^(?:invalid_design|design_too_large):/.test(
+ checked.filingError.message,
+ )
+ )
+ return {
+ callId: call.id,
+ content: checked.filingError.message,
+ isError: true,
+ };
+ return failure(call.id, checked.filingError);
+ }
+ return {
+ callId: call.id,
+ content: `design_check_failed: the design is filed once it passes check-app.\n${checked.checklist}`,
+ isError: true,
+ };
+ }
+ const artifact = checked.artifact ?? (await fileOption(signal));
+ return { callId: call.id, content: JSON.stringify(artifact) };
} catch (error) {
- return {
- callId: call.id,
- content: `invalid_design: ${error instanceof Error ? error.message : String(error)}`,
- isError: true,
- };
+ if (
+ error instanceof Error &&
+ /^(?:invalid_design|design_too_large):/.test(error.message)
+ )
+ return {
+ callId: call.id,
+ content: error.message,
+ isError: true,
+ };
+ return failure(call.id, error);
}
- const bundle = DesignBundle({ files });
- if (bundle instanceof type.errors)
- return {
- callId: call.id,
- content: "invalid_design: " + bundle.summary,
- isError: true,
- };
- const content = JSON.stringify(bundle);
- const bytes = Buffer.byteLength(content);
- if (bytes > MAX_DESIGN_BYTES)
- return {
- callId: call.id,
- content: `design_too_large: ${bytes} bytes; the limit is ${MAX_DESIGN_BYTES}`,
- isError: true,
- };
- const title = doc.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? "Design";
- const { artifact } = HubArtifactReceipt.assert(
- await hub("/artifacts", {
- kind: "design_option",
- title: title.slice(0, 512),
- content,
- }),
- );
- return { callId: call.id, content: JSON.stringify(artifact) };
- } catch (error) {
- return failure(call.id, error);
- }
- },
- }),
+ },
+ };
+ },
});
}
diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts
index 08cff9d2..11dfe165 100644
--- a/apps/hub/src/artifacts/service.ts
+++ b/apps/hub/src/artifacts/service.ts
@@ -981,15 +981,20 @@ export function createArtifactService(
concept?: string,
): Promise {
await requireProject(actor, projectId, "read");
- for (const workspace of await liveWorkspaces(actor, "designer")) {
- if ((workspace.concept ?? undefined) !== concept) continue;
- const files = await readDesignDraft(workspace.dir);
- if (files === undefined) continue;
- const draft = DesignDraft.assert({ files });
- if (Buffer.byteLength(JSON.stringify(draft)) > MAX_DESIGN_BYTES)
- throw new ProjectError(409, "design_draft_too_large");
- return draft;
- }
+ for (const ended of [false, true])
+ for (const workspace of await liveWorkspaces(
+ actor,
+ "designer",
+ ended,
+ )) {
+ if ((workspace.concept ?? undefined) !== concept) continue;
+ const files = await readDesignDraft(workspace.dir);
+ if (files === undefined) continue;
+ const draft = DesignDraft.assert({ files });
+ if (Buffer.byteLength(JSON.stringify(draft)) > MAX_DESIGN_BYTES)
+ throw new ProjectError(409, "design_draft_too_large");
+ return draft;
+ }
throw new ProjectError(404, "design_draft_not_found");
},
diff --git a/apps/hub/src/builds/app-snapshot.ts b/apps/hub/src/builds/app-snapshot.ts
index 6398fb0e..c60cb79e 100644
--- a/apps/hub/src/builds/app-snapshot.ts
+++ b/apps/hub/src/builds/app-snapshot.ts
@@ -14,6 +14,7 @@ import {
} from "@corbits-builder/schemas/artifact";
import { WorkflowHttpError, workflowHttpJson } from "../workflows/http-json.ts";
import { INPUTS_DIRECTORY } from "../config/inputs.ts";
+import { spliceDesignScreens } from "./starter.ts";
// Installs and builds are rebuilt by setup and check-app, and .builder and the inputs come from
// filed documents.
@@ -95,6 +96,19 @@ export async function packApp(
}
}
}
+ if (root === undefined && files["designs/index.html"] !== undefined) {
+ const screens = Object.fromEntries(
+ Object.entries(files).filter(
+ ([path]) =>
+ path.startsWith("designs/screens/") && path.endsWith(".html"),
+ ),
+ );
+ if (Object.keys(screens).length > 0)
+ files["designs/index.html"] = spliceDesignScreens(
+ files["designs/index.html"],
+ screens,
+ );
+ }
return files;
}
diff --git a/apps/hub/src/builds/design-check.ts b/apps/hub/src/builds/design-check.ts
index 1cc2f8a2..21a27e8a 100644
--- a/apps/hub/src/builds/design-check.ts
+++ b/apps/hub/src/builds/design-check.ts
@@ -1,30 +1,116 @@
import { createHash } from "node:crypto";
-import { readFile } from "node:fs/promises";
+import { readFile, realpath } from "node:fs/promises";
import { join, relative, resolve, sep } from "node:path";
+import { withDesignTokens } from "../artifacts/design-tokens.ts";
+import { DESIGN_STYLES } from "../artifacts/design-tweaks.ts";
export const DESIGNS = join(".builder", "designs");
-// A designer writes a screen in several calls; the check starts once they pause.
-const SETTLE_MS = 2_000;
-type Result = { fingerprint: string; passed: boolean; checklist: string };
+export interface FiledDesignArtifact {
+ artifactId: string;
+ kind: string;
+ version: number;
+ sha256: string;
+}
+
+type Result = {
+ fingerprint: string;
+ passed: boolean;
+ checklist: string;
+ artifact?: FiledDesignArtifact;
+ filingError?: unknown;
+};
type Run = (
signal: AbortSignal,
) => Promise<{ exitCode: number | null; output: string }>;
+type DesignFiler = (signal: AbortSignal) => Promise;
export type DesignCheck = ReturnType;
// By workspace. The designer's coding tools run the check and file_design_option waits on it; both
// tool packages load into the one workflow child of the designer's bundle
// (unified-execution-host-design.md, "The tool-execution locus"; build-packages.ts bundles a role).
const checks = new Map();
+const filers = new Map();
export const designCheckFor = (workspace: string) => checks.get(workspace);
+export function registerDesignFiler(workspace: string, filer: DesignFiler) {
+ filers.set(resolve(workspace), filer);
+}
+
+async function findDesignFiler(
+ workspace: string,
+): Promise {
+ const direct = filers.get(workspace) ?? filers.get(resolve(workspace));
+ if (direct) return direct;
+ for (const [candidate, filer] of filers) {
+ try {
+ if ((await realpath(candidate)) === workspace) {
+ filers.set(workspace, filer);
+ return filer;
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ }
+ }
+ return undefined;
+}
// check-app also reads the design document beside the mockup.
const DESIGN_DOC = join(".builder", "DESIGN.md");
+const DESIGN_STYLES_PATH = join(".builder", DESIGN_STYLES);
+const DESIGN_INDEX_PATH = join(".builder", "designs", "index.html");
+// Prose in angle brackets, like ; HTML tags carry attributes and code is stripped first.
+const PLACEHOLDER = /<[A-Za-z][^<>=`"]*\s[^<>=`"]*>/g;
+
+async function readTextIfPresent(path: string): Promise {
+ try {
+ return await readFile(path, "utf8");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
+ throw error;
+ }
+}
+
+export async function validateDesignWorkspace(
+ workspace: string,
+): Promise {
+ const doc = await readTextIfPresent(join(workspace, DESIGN_DOC));
+ const styles = await readTextIfPresent(join(workspace, DESIGN_STYLES_PATH));
+ const index = await readTextIfPresent(join(workspace, DESIGN_INDEX_PATH));
+ if (doc === undefined || styles === undefined || index === undefined)
+ return `invalid_design: .builder needs DESIGN.md, designs/index.html and ${DESIGN_STYLES}`;
+ const prose = doc.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
+ const placeholders = [
+ ...new Set(
+ Array.from(prose.matchAll(PLACEHOLDER), ([match]) =>
+ match.replace(/\s+/g, " ").slice(0, 40),
+ ),
+ ),
+ ];
+ if (placeholders.length > 0)
+ return `invalid_design: DESIGN.md still has ${placeholders.length} template placeholder(s): ${placeholders.slice(0, 5).join(", ")}`;
+ try {
+ withDesignTokens(doc, styles);
+ } catch (error) {
+ return `invalid_design: ${error instanceof Error ? error.message : String(error)}`;
+ }
+ return undefined;
+}
+
+function withValidationIssue(output: string, issue: string): string {
+ const trimmed = output.trim();
+ const detail = issue.replace(/^invalid_design:\s*/, "");
+ const rewritten = trimmed.replace(
+ /^check passed\b[^\n]*/,
+ `check failed: ${issue}`,
+ );
+ if (trimmed.includes(detail)) return rewritten;
+ return `${rewritten}\nā ${issue}`;
+}
export const CHECK_DESIGN_TOOL = "check_design";
export const checkDesignDefinition = {
name: CHECK_DESIGN_TOOL,
- description: `Runs check-app on the design as it is now: it serves ${DESIGNS} in a headless browser, opens its screens at desktop and phone widths, checks that ${DESIGN_DOC} is filled in, and that the look is moved off the kit's starting point. The harness also runs it after each design write and shows the result with a later write, so call this to see the checklist for the design as it is now; the design is ready to file once it reads "check passed".`,
+ description: `Runs check-app on the design as it is now: it serves ${DESIGNS} in a headless browser, opens its screens at desktop and phone widths, checks that ${DESIGN_DOC} is filled in, and that the look is moved off the kit's starting point. Call this once the screens and ${DESIGN_DOC} are written; when it reads "check passed", it also files the .builder folder as your design concept's option.`,
inputSchema: { type: "object", properties: {}, additionalProperties: false },
};
@@ -34,12 +120,15 @@ export const touchesDesigns = (workspace: string, path: string) => {
};
/**
- * check-app on a designer workspace's mockup, run by the harness: after design writes settle,
- * restarted when more arrive, and on demand for filing. A result belongs to the exact files it saw.
+ * check-app on a designer workspace's mockup, run on demand for checking and filing. A result
+ * belongs to the exact files it saw.
*/
export function createDesignCheck(workspace: string, run: Run) {
let latest: Result | undefined;
- let heard: Result | undefined;
+ let filed: { fingerprint: string; artifact: FiledDesignArtifact } | undefined;
+ let filing:
+ | { fingerprint: string; done: Promise }
+ | undefined;
let running:
| {
fingerprint: string;
@@ -47,7 +136,6 @@ export function createDesignCheck(workspace: string, run: Run) {
done: Promise;
}
| undefined;
- let timer: ReturnType | undefined;
const fingerprint = async () => {
const hash = createHash("sha256");
@@ -65,67 +153,100 @@ export function createDesignCheck(workspace: string, run: Run) {
return hash.digest("hex");
};
+ const runCheck = async (
+ print: string,
+ controller: AbortController,
+ ): Promise => {
+ try {
+ let ran: { exitCode: number | null; output: string };
+ try {
+ ran = await run(controller.signal);
+ } catch (error) {
+ ran = {
+ exitCode: 1,
+ output: `check-app did not run: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+ const issue = await validateDesignWorkspace(workspace);
+ if (controller.signal.aborted) return undefined;
+ const result: Result = issue
+ ? {
+ fingerprint: print,
+ passed: false,
+ checklist: withValidationIssue(ran.output, issue),
+ }
+ : {
+ fingerprint: print,
+ passed: ran.exitCode === 0,
+ checklist: ran.output.trim(),
+ };
+ latest = result;
+ return result;
+ } finally {
+ if (running?.controller === controller) running = undefined;
+ }
+ };
+
// The result for the files as they are now: the latest one if they have not changed, else a run.
// undefined when a newer change superseded the run.
const check = async (): Promise => {
- timer = undefined;
const print = await fingerprint();
if (latest?.fingerprint === print) return latest;
if (running?.fingerprint === print) return running.done;
running?.controller.abort();
const controller = new AbortController();
- const done = run(controller.signal)
- .then(
- ({ exitCode, output }) => ({
- fingerprint: print,
- passed: exitCode === 0,
- checklist: output.trim(),
- }),
- (error: unknown) => ({
- fingerprint: print,
- passed: false,
- checklist: `check-app did not run: ${error instanceof Error ? error.message : String(error)}`,
- }),
- )
- .then((result) => {
- if (controller.signal.aborted) return undefined;
- latest = result;
- return result;
- })
- .finally(() => {
- if (running?.controller === controller) running = undefined;
- });
+ const done = runCheck(print, controller);
running = { fingerprint: print, controller, done };
return done;
};
const designCheck = {
- /** A design file changed: drop the run in progress and check again once writes settle. */
- changed() {
- clearTimeout(timer);
- running?.controller.abort();
- timer = setTimeout(() => void check(), SETTLE_MS);
- },
- /** The checklist of the latest run the agent has not been shown, with what design it saw. */
- async unheard() {
- if (!latest || latest === heard) return "";
- heard = latest;
- const current = latest.fingerprint === (await fingerprint());
- return `check-app ${DESIGNS}, run by the harness on ${current ? "the current design" : "the design before the latest changes, which are checked next"}:\n${latest.checklist}`;
- },
/** The result for the design as it is now, waiting for a run when there is none. */
async current(signal: AbortSignal): Promise {
- clearTimeout(timer);
for (;;) {
signal.throwIfAborted();
const result = await check();
- if (result) return (heard = result);
+ if (!result) continue;
+ if (!result.passed) return result;
+ if (filed?.fingerprint === result.fingerprint) {
+ return {
+ ...result,
+ artifact: filed.artifact,
+ checklist: `${result.checklist}\nFiled design_option: ${JSON.stringify(filed.artifact)}`,
+ };
+ }
+ const filer = await findDesignFiler(workspace);
+ if (!filer) return result;
+ try {
+ if (filing?.fingerprint !== result.fingerprint)
+ filing = {
+ fingerprint: result.fingerprint,
+ done: filer(signal),
+ };
+ const artifact = await filing.done;
+ filed = { fingerprint: result.fingerprint, artifact };
+ return {
+ ...result,
+ artifact,
+ checklist: `${result.checklist}\nFiled design_option: ${JSON.stringify(artifact)}`,
+ };
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ return {
+ fingerprint: result.fingerprint,
+ passed: false,
+ checklist: withValidationIssue(result.checklist, reason),
+ filingError: error,
+ };
+ } finally {
+ filing = undefined;
+ }
}
},
dispose() {
- clearTimeout(timer);
running?.controller.abort();
checks.delete(workspace);
+ filers.delete(workspace);
},
};
checks.set(workspace, designCheck);
diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts
index fef7123c..eec0291a 100644
--- a/apps/hub/src/builds/sidecar-tools.ts
+++ b/apps/hub/src/builds/sidecar-tools.ts
@@ -22,6 +22,7 @@ import {
WATCH_PAGE,
prepareWorkspace,
sharedBrowser,
+ syncDesignScreensOnDisk,
} from "./starter.ts";
import {
fileApp,
@@ -123,6 +124,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,11 +519,23 @@ 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);
if (!designCheck) throw new Error("design_check_missing");
+ await syncDesignScreensOnDisk(await realpath(env.toolCwd));
const { checklist } = await designCheck.current(signal);
return { callId: call.id, content: checklist };
}
@@ -545,24 +569,16 @@ export function createSidecarCodingTools(policy: {
const path = call.arguments["path"];
const executor = await sandbox;
if (WRITES.has(call.name) && typeof path === "string") {
+ const workspace = await realpath(env.toolCwd);
if (policy.packageInstall)
notes.push(await installImports(call, path, signal));
// After the import scan, so a package it installed resolves.
if (policy.workspace === "app" && executor)
notes.push(
- await typeProbe(
- executor,
- await realpath(env.toolCwd),
- path,
- call.id,
- signal,
- ),
+ await typeProbe(executor, workspace, path, call.id, signal),
);
- if (designCheck) {
- notes.push(await designCheck.unheard());
- if (touchesDesigns(await realpath(env.toolCwd), path))
- designCheck.changed();
- }
+ if (touchesDesigns(workspace, path))
+ await syncDesignScreensOnDisk(workspace);
}
if (call.name === PACKAGE_INSTALL_TOOL && succeeded(result))
notes.push(await restartForPackages());
diff --git a/apps/hub/src/builds/starter.test.ts b/apps/hub/src/builds/starter.test.ts
new file mode 100644
index 00000000..3bc9e56b
--- /dev/null
+++ b/apps/hub/src/builds/starter.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, test } from "bun:test";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { designStarterFiles } from "@corbits-builder/designer/skills" with { type: "macro" };
+import {
+ spliceDesignScreens,
+ STARTER_HOME_SCREEN,
+ STARTER_INDEX,
+ syncDesignScreensOnDisk,
+} from "./starter.ts";
+
+const STARTER = designStarterFiles();
+
+describe("spliceDesignScreens", () => {
+ test("is idempotent across repeated splices and replaces the marked block in place", () => {
+ const first = spliceDesignScreens(STARTER_INDEX, {
+ "place.html": '',
+ });
+ expect(first).toContain("");
+ expect(first).toContain(
+ '',
+ );
+ expect(first).toContain("");
+
+ const repeated = spliceDesignScreens(first, {
+ "place.html": '',
+ });
+ expect(repeated).toBe(first);
+
+ const updated = spliceDesignScreens(first, {
+ "place.html": '',
+ "search.html": '',
+ });
+ expect(updated).not.toContain("Places v1");
+ expect(updated).toContain(
+ '',
+ );
+ expect(updated).toContain(
+ '',
+ );
+ expect(updated.match(//g)?.length).toBe(1);
+ expect(updated.match(//g)?.length).toBe(1);
+
+ const cleared = spliceDesignScreens(updated, { "empty.html": " \n " });
+ expect(cleared).not.toContain("");
+ expect(cleared).toContain("");
+
+ const restored = spliceDesignScreens(cleared, {
+ "place.html": '',
+ });
+ expect(restored).toBe(first);
+ });
+
+ test("ignores empty screen files and skips unmodified STARTER_HOME_SCREEN when custom screens exist", () => {
+ const onlyStarterHome = spliceDesignScreens(STARTER_INDEX, {
+ "home.html": STARTER_HOME_SCREEN,
+ "empty.html": "",
+ "whitespace.html": " \n\t ",
+ });
+ expect(onlyStarterHome).toContain('data-screen="home"');
+ expect(onlyStarterHome).toContain("Hello, world");
+ expect(onlyStarterHome).not.toContain("empty");
+ expect(onlyStarterHome).not.toContain("whitespace");
+
+ const withCustomScreens = spliceDesignScreens(onlyStarterHome, {
+ "home.html": STARTER_HOME_SCREEN,
+ "place.html": '',
+ "search.html": '',
+ "blank.html": " ",
+ });
+ expect(withCustomScreens).not.toContain("Hello, world");
+ expect(withCustomScreens).not.toContain('data-screen="home"');
+ expect(withCustomScreens).toContain('data-screen="place"');
+ expect(withCustomScreens).toContain('data-screen="search"');
+
+ const withModifiedHome = spliceDesignScreens(withCustomScreens, {
+ "home.html": '',
+ "place.html": '',
+ });
+ expect(withModifiedHome).toContain('data-screen="home"');
+ expect(withModifiedHome).toContain("Custom Home");
+ expect(withModifiedHome).toContain('data-screen="place"');
+ });
+});
+
+describe("syncDesignScreensOnDisk", () => {
+ test("updates .builder/designs/index.html on disk and removes untouched starter home.html when custom screens are written", async () => {
+ const workspace = await mkdtemp(join(tmpdir(), "sb-starter-sync-"));
+ const designs = join(workspace, ".builder", "designs");
+ 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 syncDesignScreensOnDisk(workspace);
+ const initialIndex = await readFile(join(designs, "index.html"), "utf8");
+ expect(initialIndex).toContain("");
+ expect(initialIndex).toContain("Hello, world");
+
+ await writeFile(
+ join(designs, "screens", "place.html"),
+ '',
+ );
+ await syncDesignScreensOnDisk(workspace);
+
+ const afterFirstCustom = await readFile(
+ join(designs, "index.html"),
+ "utf8",
+ );
+ expect(afterFirstCustom).toContain("");
+ expect(afterFirstCustom).toContain('data-screen="place"');
+ expect(afterFirstCustom).not.toContain("Hello, world");
+ expect(
+ await Bun.file(join(designs, "screens", "home.html")).exists(),
+ ).toBe(false);
+
+ await writeFile(
+ join(designs, "screens", "search.html"),
+ '',
+ );
+ await syncDesignScreensOnDisk(workspace);
+
+ const afterSecondCustom = await readFile(
+ join(designs, "index.html"),
+ "utf8",
+ );
+ expect(afterSecondCustom).toContain('data-screen="place"');
+ expect(afterSecondCustom).toContain('data-screen="search"');
+ expect(
+ afterSecondCustom.match(//g)?.length,
+ ).toBe(1);
+ } finally {
+ await rm(workspace, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/hub/src/builds/starter.ts b/apps/hub/src/builds/starter.ts
index 74b52f31..302db569 100644
--- a/apps/hub/src/builds/starter.ts
+++ b/apps/hub/src/builds/starter.ts
@@ -2,13 +2,16 @@ import {
access,
mkdir,
mkdtemp,
+ readdir,
+ readFile,
realpath,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
-import { dirname, join } from "node:path";
+import { basename, dirname, join } from "node:path";
+import { hasCode } from "@intx/types";
import {
AppBundle,
type ArtifactContent,
@@ -34,9 +37,137 @@ import { designStarterFiles } from "@corbits-builder/designer/skills" with { typ
const STARTER = starterFiles();
const DESIGN_STARTER = designStarterFiles();
+export const STARTER_INDEX = DESIGN_STARTER[".builder/designs/index.html"]!;
+export const STARTER_HOME_SCREEN =
+ DESIGN_STARTER[".builder/designs/screens/home.html"]!;
const KIT = kitDigest();
export const CHECK_APP = checkScript();
export const WATCH_PAGE = watchScript();
+
+const SCREENS_PLACEHOLDER = "";
+const SCREENS_START = "";
+const SCREENS_END = "";
+const SPLICED_BLOCK =
+ /[\s\S]*?/;
+
+function cleanScreenBody(html: string): string {
+ const body = html.match(/]*>([\s\S]*?)<\/body>/i)?.[1] ?? html;
+ return body.replace(/