diff --git a/.gitignore b/.gitignore index ae0da7bb51..d4006d0976 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # GSD workflow state .planning/ +# Conductor / Claude workspace state +.agents/ + # Logs logs *.log diff --git a/AGENTS.md b/AGENTS.md index 0e73c78c40..b29d1e953f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,36 @@ This file provides guidance when working with code in this repository, including Studio is an open-source control plane for Model Context Protocol (MCP) traffic. It provides a unified layer for authentication, routing, and observability between MCP clients (Cursor, Claude, VS Code) and MCP servers. The system is built as a monorepo using Bun workspaces with TypeScript, Hono (API), and React 19 (UI). +## Working on a feature (the harness contract) + +This repo ships a **feature catalog** at `features//`. Each catalogued feature has: + +- `feature.md` — the user-facing story, the happy path, why the feature matters, and a prompt for AI agents extending it. +- `happy-path.test.ts` — the executable contract, runnable end-to-end. + +**Before touching code that participates in a documented feature:** + +1. `bun run features:list` — find the feature(s) your change touches. +2. `bun run features:test ` — verify the harness passes on the current code. If it doesn't, that's your first task; fix the divergence (or fix the test if the contract genuinely changed) before doing anything else. +3. Read `features//feature.md` end-to-end. The "Prompt for AI agents" section at the bottom is written for you. +4. **Write or extend `happy-path.test.ts` to cover the new behavior FIRST.** RED. Then code until GREEN. +5. Loop on test ↔ code ↔ `feature.md` until the harness cohesively expresses the experience you intend. +6. **Only then ask a human to verify.** If they reject the result, update `feature.md` to capture the new expectation, fail the test against it, and loop. + +We do not ship features verified only by humans. The harness is the contract; coding agents that skip it are out of contract. + +If your change adds a NEW major feature (something that would degrade the product story if removed, not a refactor), create a feature folder following `features/page-editor/` as the template. The pattern is: + +``` +features// + feature.md # story, value, happy path, prompt for AI agents + happy-path.test.ts # the executable contract +``` + +See `features/README.md` for the catalog invariants. + +**For richer browser-driven verification beyond the deterministic Playwright spec**, install [Webwright](https://github.com/microsoft/Webwright) as a Claude Code skill (`/plugin install webwright@webwright`) and feed it the happy path from `feature.md`. It writes a re-runnable Playwright script + screenshots + a self-verification log against the critical points you described. Treat the output as evidence for a human pass — not a substitute for the deterministic contract that lives in `happy-path.test.ts` + `*.browser.spec.ts`. + ## Commands ### Development @@ -324,6 +354,8 @@ See [`TESTING.md`](./TESTING.md) for the testing philosophy and rules. If a test needs `vi.mock`, `mock.module`, a stubbed `MeshContext`, or a fake `fetch` — it's not a unit test. Move it to e2e. +**For cross-cutting features** (Page Editor, Brand Manager, Studio Pack install, etc.), the canonical executable contract is `features//happy-path.test.ts` plus an optional `apps/mesh/e2e/tests/features/.browser.spec.ts`. See **Working on a feature** above — the contract MUST be extended before the implementation, not after. + ## Working with Tools When creating new MCP tools: diff --git a/apps/mesh/e2e/tests/features/page-editor.browser.spec.ts b/apps/mesh/e2e/tests/features/page-editor.browser.spec.ts new file mode 100644 index 0000000000..543db5c0bd --- /dev/null +++ b/apps/mesh/e2e/tests/features/page-editor.browser.spec.ts @@ -0,0 +1,108 @@ +/** + * Page Editor browser leg of the feature harness. + * + * The data-path contract (Studio Pack install, MCP tool sequence, + * storage state, multi-tenant isolation, export bundle) lives in + * features/page-editor/happy-path.test.ts. This spec is the + * browser-visible half: it sits between Better Auth signup and the + * user actually seeing the iframe with the right contract wired in. + * + * What it proves: + * - Fresh signup auto-installs the Studio Pack, including the Page + * Editor agent. + * - The Page Editor agent surfaces in the org's agents-list UI. + * - Clicking it opens a chat with the Page-Preview tab routed + * automatically — i.e. the `defaultMainView: { type: "page-preview" }` + * metadata threads end-to-end into the panel-tab resolver. + * - The iframe at `/api//page-preview/host` loads and runs its + * preact bootstrap (the welcome quiz mounts). + * + * Run: `PW=1 bun run features:test page-editor` + * (the harness CLI shells out to playwright when PW=1) + * + * Or directly via Playwright: + * bun run --cwd=apps/mesh exec playwright test \ + * e2e/tests/features/page-editor.browser.spec.ts + * + * If this spec breaks, follow the Loop in features/page-editor/feature.md + * before patching. The browser leg is part of the contract; don't + * loosen the test to make a green build. + */ + +import { expect, test } from "@playwright/test"; +import { signUp } from "../../fixtures/auth"; + +// Studio Pack installation runs via a DBOS workflow on org.afterCreate. +// It's idempotent and fast (<1s in practice) but async — so the agent +// may not appear in the agents list on the first navigation. Polling +// the list with a generous timeout absorbs that race. +const STUDIO_PACK_INSTALL_TIMEOUT = 30_000; + +test.describe("Page Editor — browser leg", () => { + test("Studio Pack installs the agent and the preview iframe boots", async ({ + page, + }) => { + // 1. Fresh user + auto-created org. signUp lands somewhere under + // //...; agents-section route is reliable. + await signUp(page); + await page.waitForURL( + (url) => { + const slug = url.pathname.split("/")[1]; + return !!slug && slug !== "login" && slug !== "api"; + }, + { timeout: 15_000 }, + ); + const orgSlug = new URL(page.url()).pathname.split("/")[1]!; + + // 2. Navigate to the agents list — Studio Pack agents (including + // Page Editor) live alongside any user-created ones. + await page.goto(`/${orgSlug}/agents`); + + // 3. Wait for Page Editor to appear. The DBOS install workflow + // races signup; refreshing once after a brief delay catches the + // case where the page rendered the agents list before the install + // finished. + const pageEditorCard = page + .locator('[data-testid="project-card"]') + .filter({ hasText: "Page Editor" }) + .or(page.getByRole("link", { name: /Page Editor/i })) + .or(page.getByRole("button", { name: /Page Editor/i })) + .first(); + + await expect(pageEditorCard).toBeVisible({ + timeout: STUDIO_PACK_INSTALL_TIMEOUT, + }); + + // 4. Click into the Page Editor agent. The card navigates to + // //?virtualmcpid=studio-page-editor_. + await pageEditorCard.click(); + await page.waitForURL(/[?&]virtualmcpid=studio-page-editor_/, { + timeout: 15_000, + }); + + // 5. The page-preview tab must be the default main view. We don't + // pin to the tab DOM (it can vary across UI revisions); we pin to + // the iframe it owns — that's the only thing that has to exist + // exactly as named for the feature to work. + const previewIframe = page.frameLocator('iframe[title="Page preview"]'); + + // 6. The iframe is mounted and pointing at the host route. Wait + // for ANY element inside — the welcome quiz root, the empty stage, + // anything that proves the preact bundle executed. Catch most boot + // failures (network, syntax errors in host-html.ts, CSP block). + await expect(previewIframe.locator("body")).toBeVisible({ + timeout: 20_000, + }); + + // 7. The host runtime renders the welcome quiz on first load (no + // active page yet). It shows the "Build a beautiful page" headline + // or similar marketing copy; pin to one stable string from the + // welcome template. If you renamed the welcome quiz, update both + // the template AND this assertion. + const welcomeHeadline = previewIframe + .locator("body") + .getByText(/build|create|start|page/i) + .first(); + await expect(welcomeHeadline).toBeVisible({ timeout: 20_000 }); + }); +}); diff --git a/apps/mesh/index.css b/apps/mesh/index.css index 2b7c63262a..9e13032795 100644 --- a/apps/mesh/index.css +++ b/apps/mesh/index.css @@ -183,4 +183,27 @@ transform: translateY(0); } } + + @keyframes mise-breathe { + 0%, + 100% { + transform: scale(1) translate3d(0, 0, 0); + opacity: 0.8; + } + 50% { + transform: scale(1.04) translate3d(0, -10px, 0); + opacity: 1; + } + } + + @keyframes mise-panel-in { + 0% { + opacity: 0; + transform: translateY(18px) scale(0.985); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } + } } diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 2772ed746e..05d4033e3e 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -63,6 +63,7 @@ "ai-sdk-provider-claude-code": "^3.4.4", "ai-sdk-provider-codex-cli": "^1.1.0", "embedded-postgres": "^18.3.0-beta.16", + "fflate": "^0.8.0", "ink": "^6.8.0", "kysely": "^0.28.12", "nats": "^2.29.3", diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index fd02e70058..45fd19cb24 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -1091,10 +1091,18 @@ export async function createApp(options: CreateAppOptions = {}) { app.use("*", async (c, next) => { await next(); // Org-scoped /files/* serves user content (HTML pages written by the - // web-developer agent, uploaded images, etc.) that we deliberately - // iframe back into the app. Same-origin only — auth middleware still - // gates access — and consumers are expected to sandbox the iframe. + // web-developer agent, uploaded images, page-preview blocks, etc.) + // that we deliberately iframe back into the app. Same-origin only — + // auth middleware still gates access — and consumers are expected + // to sandbox the iframe. Page-preview's /host endpoint is also + // framable (it serves the host shell that loads /files/* into the + // preview iframe). if (c.req.path.includes("/files/")) return; + if (/^\/api\/[^/]+\/page-preview\/host(\/|$)/.test(c.req.path)) { + c.header("X-Frame-Options", "SAMEORIGIN"); + c.header("Content-Security-Policy", "frame-ancestors 'self'"); + return; + } c.header("X-Frame-Options", "DENY"); c.header("Content-Security-Policy", "frame-ancestors 'none'"); }); diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index 24247aee0f..03fc9101ea 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -16,6 +16,7 @@ import { createDownstreamTokenRoutes } from "./downstream-token"; import { createFileUploadRoutes } from "./file-uploads"; import { createKVRoutes } from "./kv"; import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy"; +import { createPagePreviewRoutes } from "./page-preview"; import { createSsoRoutes } from "./org-sso"; import { createProxyRoutes } from "./proxy"; import { createSelfRoutes } from "./self"; @@ -83,6 +84,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/sandbox", createSandboxRoutes()); // /api/:org/sandbox/:virtualMcpId/:branch/* app.route("/", createHomeNextActionsRoutes()); app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites + app.route("/page-preview", createPagePreviewRoutes()); // /api/:org/page-preview app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso) app.route( "/", diff --git a/apps/mesh/src/api/routes/page-preview.ts b/apps/mesh/src/api/routes/page-preview.ts new file mode 100644 index 0000000000..6f011df765 --- /dev/null +++ b/apps/mesh/src/api/routes/page-preview.ts @@ -0,0 +1,172 @@ +import { createHash } from "node:crypto"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { zip } from "fflate"; +import type { MeshContext } from "@/core/mesh-context"; +import { + buildDesignSystemExportBundle, + buildPageExportBundle, + getPagePreviewStatus, +} from "@/page-preview/service"; +import { PAGE_PREVIEW_HOST_HTML } from "@/page-preview/host-html"; +import { getSettings } from "@/settings"; + +type Variables = { meshContext: MeshContext }; + +function routeBaseUrl(reqUrl: string): string { + const url = new URL(reqUrl); + return `${url.protocol}//${url.host}`; +} + +/** + * Stable hash of the current iframe source so the in-iframe dev poller + * can detect an edit-save cycle and reload itself. bun --hot reloads + * the host-html module on every save, so the export changes too — + * which means the hash changes — which means dev clients reload. + * + * Cached on first read because `createHash` is non-trivial and the + * host source doesn't change within a process lifetime (bun --hot + * forks the process; new process = new cache). + */ +let cachedHostHash: string | null = null; +function getHostHash(): string { + if (cachedHostHash != null) return cachedHostHash; + const h = createHash("sha1"); + h.update(PAGE_PREVIEW_HOST_HTML); + cachedHostHash = h.digest("hex").slice(0, 12); + return cachedHostHash; +} + +/** + * Inject a tiny polling script into the iframe in dev mode so the user + * sees host-html.ts edits without a manual refresh. Studio's tab.tsx + * is the source of truth for what the iframe should display; on reload + * the existing host:hello / host:set-page handshake re-dispatches + * whatever intent was active. State recovers in <1s. + * + * Production gets the raw HTML — no poller, no extra requests. + */ +function injectDevAutoReload(html: string, orgSlug: string): string { + if (getSettings().nodeEnv === "production") return html; + const hash = getHostHash(); + const versionUrl = `/api/${encodeURIComponent(orgSlug)}/page-preview/host-version`; + const snippet = + ``; + return html.replace("", `${snippet}\n`); +} + +export function createPagePreviewRoutes() { + const app = new Hono<{ Variables: Variables }>(); + + // The Studio-controlled host iframe. The preview pane loads this once + // and drives transitions via postMessage; the host dynamically imports + // the page's chunks from /files/... to render in place. In dev mode + // we inject a tiny poller that reloads the iframe when host-html.ts + // changes — Studio re-dispatches intent on reload so state recovers. + app.get("/host", (c) => { + const orgSlug = c.req.param("org") ?? ""; + const body = injectDevAutoReload(PAGE_PREVIEW_HOST_HTML, orgSlug); + return new Response(body, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + }); + + // Sibling endpoint the dev-mode poller hits to detect changes. + // Returns just the host-content hash; cheap, no auth, no I/O. + app.get("/host-version", () => { + return new Response(getHostHash(), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + }); + + app.get("/state", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id || !ctx.objectStorage) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + return c.json( + await getPagePreviewStatus({ + orgId: org.id, + objectStorage: ctx.objectStorage, + orgSlug: org.slug ?? c.req.param("org"), + baseUrl: routeBaseUrl(c.req.url), + }), + ); + }); + + // NB: there is no /files/* route — page-preview assets are served via + // Studio's canonical /api/{org}/files/{key} redirect on object storage. + // Persistence and serving share the same key namespace (page-preview/...). + + app.get("/export", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id || !ctx.objectStorage) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + const kind = c.req.query("kind"); + const slug = c.req.query("slug"); + if (kind !== "page" && kind !== "design-system") { + throw new HTTPException(400, { + message: "kind must be 'page' or 'design-system'", + }); + } + if (!slug) { + throw new HTTPException(400, { message: "slug query param required" }); + } + + let bundle: Awaited>; + try { + const args = { + orgId: org.id, + objectStorage: ctx.objectStorage, + slug, + }; + bundle = + kind === "page" + ? await buildPageExportBundle(args) + : await buildDesignSystemExportBundle(args); + } catch (err) { + throw new HTTPException(404, { message: (err as Error).message }); + } + const { bundleName, files } = bundle; + const zipInput: Record = {}; + for (const file of files) { + zipInput[`${bundleName}/${file.relativePath}`] = file.data; + } + + const archive = await new Promise((resolve, reject) => { + zip(zipInput, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + return new Response(archive as BodyInit, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${bundleName}.zip"`, + "Content-Length": archive.byteLength.toString(), + "Cache-Control": "no-store", + }, + }); + }); + + return app; +} diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 57d5534016..4eb2539af6 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -18,8 +18,10 @@ import { Context, Hono } from "hono"; import { endTime, startTime } from "hono/timing"; import type { MeshContext } from "../../core/mesh-context"; import { managementMCP } from "../../tools"; +import { usesLocalObjectStorage } from "../../tools/connection/dev-assets"; import { guardResponseStream } from "../utils/stream-guard"; import { handleAuthError } from "./oauth-proxy"; +import { handleDevAssetsMcpRequest } from "./dev-assets-mcp"; import { handleVirtualMcpRequest } from "./virtual-mcp"; export { toServerClient, type MCPProxyClient } from "./mcp-proxy-factory"; @@ -88,6 +90,27 @@ export const createProxyRoutes = () => { return guardResponseStream(selfResponse, `mcp:self:${connectionId}`); } + // Dev-assets pseudo-connection ({orgId}_dev-assets) — only active when + // object storage is the local filesystem fallback. Mirrors the + // unscoped /mcp/{connectionId}_dev-assets route registered in dev-only.ts + // so frontend code using the canonical /api/:org/mcp/ URL still + // reaches the dev-assets MCP server in dev mode. + if (connectionId.endsWith("_dev-assets")) { + const devOrgId = connectionId.slice(0, -"_dev-assets".length); + if (!ctx.organization || ctx.organization.id !== devOrgId) { + return c.json({ error: "Connection not found" }, 404); + } + if (!usesLocalObjectStorage()) { + return c.json( + { error: "dev-assets is only available in local mode" }, + 404, + ); + } + const url = new URL(c.req.url); + const baseUrl = `${url.protocol}//${url.host}`; + return handleDevAssetsMcpRequest(c.req.raw, ctx, baseUrl); + } + try { try { // Organization context is required — without it the ownership diff --git a/apps/mesh/src/harnesses/claude-code/index.ts b/apps/mesh/src/harnesses/claude-code/index.ts index 9f73af37a9..355fc9f1c5 100644 --- a/apps/mesh/src/harnesses/claude-code/index.ts +++ b/apps/mesh/src/harnesses/claude-code/index.ts @@ -80,6 +80,41 @@ async function resolveClaudeCodeCwd( return await resolveCwd(); } +/** + * Per-agent extra disallowed tools. + * + * Some agent templates (currently: page-editor) have no legitimate use for + * the filesystem / shell / search tools — calling Read on a 25k-token + * source file burns 10–30 s of dead air per failed attempt and starts the + * agent down a rabbit hole that derails the entire run. The system prompt + * forbids these tools but agents under-follow textual rules; hard-disabling + * them at the SDK level is the only reliable fix. + * + * The selected_tools allowlist on the MCP connection only restricts MCP + * tools — built-in Read/Write/Edit/Bash/Grep/Glob/ToolSearch/NotebookEdit + * are always available unless explicitly disallowed here. + */ +function extraDisallowedToolsForAgent( + virtualMcp: HarnessStreamInput["virtualMcp"], +): string[] { + const metadata = (virtualMcp.metadata ?? {}) as { type?: unknown }; + if (metadata.type === "page-editor") { + return [ + "Read", + "Write", + "Edit", + "Bash", + "Grep", + "Glob", + "ToolSearch", + "NotebookEdit", + "WebFetch", + "WebSearch", + ]; + } + return []; +} + export const claudeCodeHarnessFactory: HarnessFactory = { id: "claude-code", create(_ctx: HarnessContext): Harness { @@ -116,6 +151,7 @@ export const claudeCodeHarnessFactory: HarnessFactory = { isPlanMode: input.mode === "plan", resume: input.resumeSessionRef, cwd, + extraDisallowedTools: extraDisallowedToolsForAgent(input.virtualMcp), }); // 4. Convert UIMessages to ModelMessages. The AI SDK's diff --git a/apps/mesh/src/harnesses/claude-code/model/index.ts b/apps/mesh/src/harnesses/claude-code/model/index.ts index a40fe6abea..31ca288825 100644 --- a/apps/mesh/src/harnesses/claude-code/model/index.ts +++ b/apps/mesh/src/harnesses/claude-code/model/index.ts @@ -24,6 +24,11 @@ export function createClaudeCodeModel( resume?: string; /** Working directory for Claude Code's subprocess. Defaults to mesh's cwd. */ cwd?: string; + /** Extra tool names to disallow on top of the headless baseline. Used by + * task-specific agents (e.g. page-editor) that must not touch the + * filesystem or shell — agents under-follow textual prohibitions, so + * we hard-disable the tools at the SDK level. */ + extraDisallowedTools?: string[]; }, ): LanguageModelV3 { // Tools that require a TTY, manage local state, or are not useful in headless mode @@ -45,6 +50,8 @@ export function createClaudeCodeModel( const restrictWrites = options?.isPlanMode || options?.toolApprovalLevel === "readonly"; + const extra = options?.extraDisallowedTools ?? []; + if (restrictWrites) { settings.permissionMode = "bypassPermissions"; settings.disallowedTools = [ @@ -53,10 +60,11 @@ export function createClaudeCodeModel( "Edit", "Bash", "NotebookEdit", + ...extra, ]; } else { settings.permissionMode = "bypassPermissions"; - settings.disallowedTools = [...HEADLESS_DISALLOWED_TOOLS]; + settings.disallowedTools = [...HEADLESS_DISALLOWED_TOOLS, ...extra]; } if (options?.resume) { diff --git a/apps/mesh/src/mcp-clients/client.ts b/apps/mesh/src/mcp-clients/client.ts index 1007f6ca5e..e498b0417f 100644 --- a/apps/mesh/src/mcp-clients/client.ts +++ b/apps/mesh/src/mcp-clients/client.ts @@ -8,13 +8,35 @@ import type { MeshContext } from "@/core/mesh-context"; import type { ConnectionEntity } from "@/tools/connection/schema"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { managementMCP } from "@/tools"; import { createOutboundClient } from "./outbound"; import { createVirtualClient } from "./virtual-mcp"; +/** + * Build an in-process MCP client backed by an MCP Server (already + * registered with tools). Avoids an HTTP roundtrip for pseudo-connections + * that are hosted in this same process — notably the SELF management MCP, + * whose stored URL points at the configured BASE_URL (e.g. a `*.localhost` + * proxy hostname in dev) that Bun's fetch on macOS cannot resolve. + */ +async function connectInProcess( + server: Awaited>, + name: string, +): Promise { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name, version: "1.0.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + /** * Create an MCP client from a connection entity * * Routes to the appropriate factory based on connection type: + * - SELF pseudo-connections (`_self`): In-process management MCP * - VIRTUAL: Creates a virtual MCP aggregator client * - STDIO, HTTP, Websocket, SSE: Creates an outbound client * @@ -28,6 +50,9 @@ export async function clientFromConnection( ctx: MeshContext, superUser = false, ): Promise { + if (connection.id.endsWith("_self")) { + return connectInProcess(await managementMCP(ctx), "self-in-process"); + } if (connection.connection_type === "VIRTUAL") { return createVirtualClient(connection, ctx, superUser); } diff --git a/apps/mesh/src/mcp-clients/lazy-client.ts b/apps/mesh/src/mcp-clients/lazy-client.ts index 8af44efa4b..a7920df1a4 100644 --- a/apps/mesh/src/mcp-clients/lazy-client.ts +++ b/apps/mesh/src/mcp-clients/lazy-client.ts @@ -77,8 +77,17 @@ export function createLazyClient( const shouldBypassCache = (params?: unknown, options?: unknown) => params !== undefined || options !== undefined; + // In-process MCP servers (dev-assets, SELF management MCP) have tool + // lists determined at compile time. There's no latency benefit to + // caching them, and the cache keeps biting when the tool list evolves + // (e.g. PAGE_PREVIEW_* added to management MCP but virtual MCPs still + // see the stale cached list and filter them out of selected_tools). + const isInProcessConn = + connection.id.endsWith("_dev-assets") || connection.id.endsWith("_self"); + // SWR helper: delegates to fetchWithCache for cache-hit/miss logic. - // VIRTUAL connections and paginated requests bypass the cache entirely. + // VIRTUAL connections, in-process servers, and paginated requests + // bypass the cache entirely. const swrList = ( type: "tools" | "resources" | "prompts", listFn: ( @@ -90,9 +99,11 @@ export function createLazyClient( buildCachedResult: (cached: unknown[]) => T, ) => { return async (params?: unknown, options?: RequestOptions): Promise => { - // Bypass cache for VIRTUAL connections or paginated requests + // Bypass cache for VIRTUAL connections, in-process MCP servers, or + // paginated requests if ( connection.connection_type === "VIRTUAL" || + isInProcessConn || !cache || shouldBypassCache(params, options) ) { diff --git a/apps/mesh/src/page-preview/contrast.test.ts b/apps/mesh/src/page-preview/contrast.test.ts new file mode 100644 index 0000000000..949be06517 --- /dev/null +++ b/apps/mesh/src/page-preview/contrast.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { + contrastRatio, + enforceContrast, + ensureSurfaceDistinct, + parseHex, +} from "./contrast"; + +const hex = (h: string) => parseHex(h)!; + +describe("contrast utilities", () => { + test("WCAG ratios match known anchors", () => { + // Black on white = 21:1 + expect(contrastRatio(hex("#000000"), hex("#FFFFFF"))).toBeCloseTo(21, 0); + // Same color = 1:1 + expect(contrastRatio(hex("#777777"), hex("#777777"))).toBeCloseTo(1, 5); + }); + + test("enforceContrast leaves passing colors alone", () => { + const fg = "#0A0A0A"; + expect(enforceContrast(fg, "#FFFFFF", { minRatio: 4.5 })).toBe(fg); + }); + + test("enforceContrast pulls a too-light muted toward fg on a light bg", () => { + // muted is pastel pink ~ #FFB6C1; bg cream ~ #FFFAF0 → low contrast + const muted = "#FFB6C1"; + const bg = "#FFFAF0"; + const corrected = enforceContrast(muted, bg, { + minRatio: 4.5, + toward: "#1A1A1A", + }); + expect(contrastRatio(hex(corrected), hex(bg))).toBeGreaterThanOrEqual(4.5); + // Should not be a totally different color — luminance has moved, but + // it shouldn't be pure black. + expect(corrected).not.toBe("#000000"); + }); + + test("enforceContrast pulls a too-dark muted toward fg on a dark bg", () => { + // muted ~ slightly darker than bg + const muted = "#1A1A22"; + const bg = "#0B0B12"; + const corrected = enforceContrast(muted, bg, { + minRatio: 4.5, + toward: "#F6F6F8", + }); + expect(contrastRatio(hex(corrected), hex(bg))).toBeGreaterThanOrEqual(4.5); + }); + + test("border threshold is lower (1.5:1) — keeps subtle dividers", () => { + const border = "#2A2A36"; + const bg = "#0B0B12"; + const corrected = enforceContrast(border, bg, { minRatio: 1.5 }); + expect(contrastRatio(hex(corrected), hex(bg))).toBeGreaterThanOrEqual(1.5); + }); + + test("ensureSurfaceDistinct nudges identical surface/bg", () => { + const bg = "#0B0B12"; + const fixed = ensureSurfaceDistinct(bg, bg); + expect(fixed).not.toBe(bg); + // The nudge should be small — not radically different. + const dist = contrastRatio(hex(fixed), hex(bg)); + expect(dist).toBeGreaterThan(1); + expect(dist).toBeLessThan(2); + }); + + test("parses 3-char, 6-char, and 8-char hex", () => { + expect(parseHex("#F00")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseHex("#FF0000")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseHex("#FF0000AA")).toEqual({ r: 255, g: 0, b: 0 }); + expect(parseHex("nope")).toBeNull(); + }); +}); diff --git a/apps/mesh/src/page-preview/contrast.ts b/apps/mesh/src/page-preview/contrast.ts new file mode 100644 index 0000000000..63656f3a2c --- /dev/null +++ b/apps/mesh/src/page-preview/contrast.ts @@ -0,0 +1,174 @@ +/** + * Color contrast utilities. The Page Editor agent regularly produces brand + * palettes where `muted` (used for secondary body text) or `fg` ends up at + * < 2:1 contrast against `bg` — illegible. Rather than hoping the model + * follows the system-prompt rules, we normalize tokens before writing them + * so the produced design system always reads. + * + * Implementation: WCAG 2.x relative-luminance contrast. When a token fails + * its minimum ratio against the background, we mix it toward the + * page foreground color (or pure black / pure white if no fg is available) + * until it meets the threshold — preserving hue as much as possible. + */ + +export type Rgb = { r: number; g: number; b: number }; + +const HEX_RE = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +export function parseHex(input: string): Rgb | null { + const m = input.trim().match(HEX_RE); + if (!m) return null; + let hex = m[1]!; + if (hex.length === 3) { + hex = hex + .split("") + .map((c) => c + c) + .join(""); + } + // 8-char hex carries alpha — drop the last 2 chars. + if (hex.length === 8) hex = hex.slice(0, 6); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null; + return { r, g, b }; +} + +function toHex(c: Rgb): string { + const toC = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${toC(c.r)}${toC(c.g)}${toC(c.b)}`; +} + +function channelLinear(n: number): number { + const v = n / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(c: Rgb): number { + return ( + 0.2126 * channelLinear(c.r) + + 0.7152 * channelLinear(c.g) + + 0.0722 * channelLinear(c.b) + ); +} + +export function contrastRatio(a: Rgb, b: Rgb): number { + const la = relativeLuminance(a) + 0.05; + const lb = relativeLuminance(b) + 0.05; + return la > lb ? la / lb : lb / la; +} + +function isLight(c: Rgb): boolean { + return relativeLuminance(c) > 0.5; +} + +function mix(a: Rgb, b: Rgb, t: number): Rgb { + return { + r: a.r + (b.r - a.r) * t, + g: a.g + (b.g - a.g) * t, + b: a.b + (b.b - a.b) * t, + }; +} + +/** + * Push `from` toward `toward` (a high-contrast anchor) until the result + * meets `minRatio` against `bg`. Returns the smallest blended color that + * passes — preserves hue better than snapping straight to fg. + */ +export function enforceContrast( + fromHex: string, + bgHex: string, + options: { minRatio: number; toward?: string }, +): string { + const from = parseHex(fromHex); + const bg = parseHex(bgHex); + if (!from || !bg) return fromHex; + if (contrastRatio(from, bg) >= options.minRatio) return fromHex; + + const towardHex = options.toward + ? (parseHex(options.toward) ?? + (isLight(bg) ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 })) + : isLight(bg) + ? { r: 0, g: 0, b: 0 } + : { r: 255, g: 255, b: 255 }; + + // Binary search the blend factor t for the smallest t whose mix passes. + let lo = 0; + let hi = 1; + let best = towardHex; + for (let i = 0; i < 18; i++) { + const t = (lo + hi) / 2; + const candidate = mix(from, towardHex, t); + if (contrastRatio(candidate, bg) >= options.minRatio) { + best = candidate; + hi = t; + } else { + lo = t; + } + } + // Rounding to int channels can knock the contrast back below the + // threshold; nudge t up in 8-bit-channel-sized steps until the rounded + // hex actually passes. + let result = toHex(best); + let t = hi; + for (let i = 0; i < 32 && t <= 1; i++) { + const rounded = parseHex(result)!; + if (contrastRatio(rounded, bg) >= options.minRatio) return result; + t = Math.min(1, t + 1 / 255); + result = toHex(mix(from, towardHex, t)); + } + return result; +} + +/** + * Pick a readable text color to lay on top of `bgHex`. Tries the candidates + * in order and returns the first one that hits `minRatio`; falls back to + * pure white or pure black (whichever wins) if every candidate fails. + * + * Used to derive the `onPrimary` / `onSecondary` / `onAccent` tokens at + * design-system-create time so that text rendered on colored backgrounds + * (highlighted pricing card, primary button, accent badge) is always + * legible no matter which hue the agent chose for the brand color. + */ +export function pickReadableText( + bgHex: string, + options: { candidates: string[]; minRatio?: number }, +): string { + const minRatio = options.minRatio ?? 4.5; + const bg = parseHex(bgHex); + if (!bg) return "#FFFFFF"; + for (const candidate of options.candidates) { + const c = parseHex(candidate); + if (!c) continue; + if (contrastRatio(c, bg) >= minRatio) return toHex(c); + } + // No candidate passed — fall back to the higher-contrast pure choice. + const blackRatio = contrastRatio({ r: 0, g: 0, b: 0 }, bg); + const whiteRatio = contrastRatio({ r: 255, g: 255, b: 255 }, bg); + return blackRatio >= whiteRatio ? "#0A0A0F" : "#FFFFFF"; +} + +/** + * Ensure `surface` is *visually distinct* from `bg` without going so far + * it competes with content. Aims for a small but perceptible lightness + * difference (about 6% in linear luminance terms). + */ +export function ensureSurfaceDistinct( + surfaceHex: string, + bgHex: string, +): string { + const surface = parseHex(surfaceHex); + const bg = parseHex(bgHex); + if (!surface || !bg) return surfaceHex; + if (contrastRatio(surface, bg) >= 1.1) return surfaceHex; + // Nudge toward the opposite end of the luminance scale. + const anchor: Rgb = isLight(bg) + ? { r: 0, g: 0, b: 0 } + : { r: 255, g: 255, b: 255 }; + // ~5% toward anchor. + return toHex(mix(surface, anchor, 0.06)); +} diff --git a/apps/mesh/src/page-preview/default-themes.ts b/apps/mesh/src/page-preview/default-themes.ts new file mode 100644 index 0000000000..76d2b1e2a3 --- /dev/null +++ b/apps/mesh/src/page-preview/default-themes.ts @@ -0,0 +1,238 @@ +/** + * Curated default themes for the Page Editor. + * + * Audit source: `.context/page-editor-audit.md` (2026-05-20). We picked 10 + * themes out of ~36 generated in active demo usage by: + * + * 1. Aesthetic identity — distinct design languages, not 6 variants of + * dark-neon. + * 2. Contrast quality — fg/bg ≥ 7:1, muted/bg ≥ 5.5:1, primary-as-button- + * background passes WCAG AA against either white or black text. + * All themes here have been verified by `contrastRatio()` in + * `./contrast.ts`. The DS-create pipeline runs `normalizeBrandContrast` + * on every load anyway — so even if a theme's source values drift, + * the renderer is safe. + * 3. Memorability — short, recognizable slug + display name + 6-word vibe + * hint. The agent picks by slug; the welcome quiz shows display name + * and vibe. + * + * Two consumers: + * - The welcome quiz's "Visual anchor" question — these 10 replace the + * six hand-rolled anchors (Minimal mono, Dark neon, etc.) with names + * the agent can name back as a `template` argument to + * `DESIGN_SYSTEM_CREATE` or extend with brand-specific tweaks. + * - The `DESIGN_SYSTEM_CREATE` tool itself, which accepts a `template` + * slug to seed the brand tokens. The agent may then override individual + * fields (typically `primary` + `name`) for the specific brand. + */ + +import type { BrandTokens } from "./templates"; + +export interface DefaultTheme { + slug: string; + displayName: string; + vibe: string; + /** Full BrandTokens minus the auto-derived on-X fields (computed at DS-create). */ + brand: Omit; +} + +export const DEFAULT_THEMES: readonly DefaultTheme[] = [ + { + slug: "dark-violet", + displayName: "Dark Violet", + vibe: "Cinematic violet for AI products", + brand: { + name: "Dark Violet", + primary: "#8B5CF6", + secondary: "#EC4899", + accent: "#10B981", + bg: "#07050F", + surface: "#130E22", + fg: "#EDE9FE", + muted: "#9080C0", + border: "#231940", + headingFont: "Inter", + bodyFont: "Inter", + radius: "10px", + }, + }, + { + slug: "cyber-lime", + displayName: "Cyber Lime", + vibe: "Lime on graphite, hacker meets finance", + brand: { + name: "Cyber Lime", + primary: "#D0EC1A", + secondary: "#A595FF", + accent: "#FFC116", + bg: "#1C1917", + surface: "#282524", + fg: "#FAFAF9", + muted: "#94908C", + border: "#44403C", + headingFont: "Inter", + bodyFont: "Inter", + radius: "12px", + }, + }, + { + slug: "editorial-serif", + displayName: "Editorial Serif", + vibe: "Quiet luxury, magazine-grade typography", + brand: { + name: "Editorial Serif", + primary: "#1C1917", + secondary: "#44403C", + accent: "#0D9488", + bg: "#FAFAF9", + surface: "#F0F0F0", + fg: "#1C1917", + muted: "#6B6560", + border: "#D0CECD", + headingFont: "DM Serif Display", + bodyFont: "Inter", + radius: "4px", + }, + }, + { + slug: "pastel-peach", + displayName: "Pastel Peach", + vibe: "Warm terracotta for friendly B2B", + brand: { + name: "Pastel Peach", + primary: "#B5694A", + secondary: "#8A7265", + accent: "#6E9B82", + bg: "#FFF3EE", + surface: "#FFE5D9", + fg: "#2A160E", + muted: "#775D53", + border: "#E1C4B7", + headingFont: "Plus Jakarta Sans", + bodyFont: "Inter", + radius: "20px", + }, + }, + { + slug: "neon-retro", + displayName: "Neon Retro 80s", + vibe: "Arcade-flyer yellow, pink, electric cyan", + brand: { + name: "Neon Retro 80s", + primary: "#FFE500", + secondary: "#FF2D78", + accent: "#00E8FF", + bg: "#0D0D0F", + surface: "#1A1A22", + fg: "#F0F0FF", + muted: "#8888AA", + border: "#303045", + headingFont: "Impact", + bodyFont: "Arial", + radius: "0px", + }, + }, + { + slug: "brutalist-mono", + displayName: "Brutalist Mono", + vibe: "Black borders, monospace, single hot accent", + brand: { + name: "Brutalist Mono", + primary: "#E8320A", + secondary: "#0A0A0A", + accent: "#E8320A", + bg: "#F5F3EE", + surface: "#EAE7DF", + fg: "#0A0A0A", + muted: "#555550", + border: "#0A0A0A", + headingFont: "Space Mono", + bodyFont: "Inter", + radius: "0px", + }, + }, + { + slug: "sage-minimal", + displayName: "Sage Minimal", + vibe: "Hushed olive on warm paper", + brand: { + name: "Sage Minimal", + primary: "#7BA17B", + secondary: "#3A3733", + accent: "#5C875C", + bg: "#F6F5F1", + surface: "#DFDEDC", + fg: "#1C1917", + muted: "#67625D", + border: "#CCC9C3", + headingFont: "Inter", + bodyFont: "Inter", + radius: "4px", + }, + }, + { + slug: "glass-deep-sea", + displayName: "Glass Deep Sea", + vibe: "Teal glow over midnight navy", + brand: { + name: "Glass Deep Sea", + primary: "#00D4C8", + secondary: "#7B6FFF", + accent: "#F5A623", + bg: "#05091A", + surface: "#0D1630", + fg: "#E8F0FF", + muted: "#7387B3", + border: "#1F3052", + headingFont: "Sora", + bodyFont: "Inter", + radius: "20px", + }, + }, + { + slug: "electric-indigo", + displayName: "Electric Indigo", + vibe: "Crisp indigo on white, modern SaaS", + brand: { + name: "Electric Indigo", + primary: "#2E5AFF", + secondary: "#7C5CFF", + accent: "#2E5AFF", + bg: "#FFFFFF", + surface: "#E7E9EF", + fg: "#0A1022", + muted: "#5B647C", + border: "#CCD3E2", + headingFont: "Plus Jakarta Sans", + bodyFont: "Public Sans", + radius: "18px", + }, + }, + { + slug: "confetti-magenta", + displayName: "Confetti Magenta", + vibe: "Hot pink, violet, yellow — party energy", + brand: { + name: "Confetti Magenta", + primary: "#FF2D6B", + secondary: "#5B3FFF", + accent: "#FFE500", + bg: "#FAFAF7", + surface: "#F0ECF9", + fg: "#0F0A1E", + muted: "#6B6085", + border: "#D1CAE9", + headingFont: "Bricolage Grotesque", + bodyFont: "Inter", + radius: "18px", + }, + }, +]; + +/** + * Look up a default theme by slug. Used by `DESIGN_SYSTEM_CREATE` when + * the agent passes `template: ""` to seed the brand tokens. + */ +export function getDefaultTheme(slug: string): DefaultTheme | null { + return DEFAULT_THEMES.find((t) => t.slug === slug) ?? null; +} diff --git a/apps/mesh/src/page-preview/host-html.ts b/apps/mesh/src/page-preview/host-html.ts new file mode 100644 index 0000000000..bc631d1b49 --- /dev/null +++ b/apps/mesh/src/page-preview/host-html.ts @@ -0,0 +1,2523 @@ +/** + * The Studio-controlled "host" iframe. + * + * Instead of loading each page's `index.html` directly, the preview pane + * loads this host once. The host runs a preact render loop, dynamically + * imports the page's tokens.js / sections.js / page.js from the + * `/api//page-preview/files/...` file server, and exposes a + * postMessage bridge for Studio to drive transitions in-place: + * + * host:welcome show the welcome quiz + * host:set-page load and render a page (slug + ds slug) + * host:show-design-system render the design-system gallery inline + * host:show-design-system-grid render a card grid of all design systems + * host:retheme apply a different DS's brand to current page + * host:refresh re-fetch + re-render current view + * host:set-page-progress set or clear the status overlay (synced + * with the Studio-side overlay) + * + * The host emits back: + * host:ready initial handshake + * page-editor:prompt user clicked a welcome card or generate + * page-editor:runtime-error captured window.error / unhandledrejection + * page-editor:host-select-ds user clicked a DS card in the grid + */ +const PAGE_PREVIEW_HOST_MARKER = "DECO_PAGE_EDITOR_HOST_V1"; + +export const PAGE_PREVIEW_HOST_HTML = ` + + + + + + Page Editor preview + + + + + + + + + + +
+ + + +`; diff --git a/apps/mesh/src/page-preview/service.test.ts b/apps/mesh/src/page-preview/service.test.ts new file mode 100644 index 0000000000..0f77380d1a --- /dev/null +++ b/apps/mesh/src/page-preview/service.test.ts @@ -0,0 +1,770 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { DevObjectStorage } from "@/object-storage/dev-object-storage"; +import { getSettings } from "@/settings"; +import { + appendBlock, + buildDesignSystemExportBundle, + buildPageExportBundle, + cleanupPageEditorStorage, + createDesignSystem, + createPage, + DEFAULT_BRAND, + discoverHtmlPages, + getBlocks, + getPagePreviewStatus, + refreshPagePreview, + removeBlock, + resetBlocks, + sanitizeBlockProps, + setActiveDesignSystem, + setPagePreviewActive, + updateBlock, +} from "./service"; +import { contrastRatio, parseHex } from "./contrast"; + +const ORG_ID = "org-test"; +const ORG_SLUG = "acme"; + +// DevObjectStorage writes under ./data/assets// relative to cwd. We +// can't redirect it via a constructor arg (the path is baked in), so we cd +// into a temp dir per test to isolate the on-disk footprint. The encryption +// key isn't relevant for the methods we exercise. +let originalCwd: string; +let cwdDir: string; + +beforeEach(async () => { + originalCwd = process.cwd(); + cwdDir = await mkdtemp(join(tmpdir(), "page-preview-")); + process.chdir(cwdDir); + // DevObjectStorage signs presigned URLs against settings.encryptionKey; we + // don't generate any URLs in these tests so the default is fine. + void getSettings(); +}); + +afterEach(async () => { + process.chdir(originalCwd); + await rm(cwdDir, { recursive: true, force: true }); +}); + +function makeStorage(orgId = ORG_ID): DevObjectStorage { + return new DevObjectStorage(orgId); +} + +async function writePage( + storage: DevObjectStorage, + slug: string, + body = "", +) { + await storage.put(`page-preview/pages/${slug}/index.html`, body, { + contentType: "text/html; charset=utf-8", + }); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("sanitizeBlockProps", () => { + test("collapses javascript: hrefs to '#'", () => { + const sanitized = sanitizeBlockProps({ + ctaLabel: "Click", + ctaHref: "javascript:alert('xss')", + ctaPrimaryHref: " JAVASCRIPT:alert(1)", + links: [ + { label: "Docs", href: "https://example.com" }, + { label: "Bad", href: "javascript:void(0)" }, + ], + }); + expect(sanitized.ctaHref).toBe("#"); + expect(sanitized.ctaPrimaryHref).toBe("#"); + const links = sanitized.links as { href: string }[]; + expect(links[0]!.href).toBe("https://example.com"); + expect(links[1]!.href).toBe("#"); + }); + + test("preserves http/https/mailto/tel and relative URLs", () => { + const ok = sanitizeBlockProps({ + a: { href: "https://example.com" }, + b: { href: "http://example.com" }, + c: { href: "mailto:hi@example.com" }, + d: { href: "tel:+15551234567" }, + e: { href: "#anchor" }, + f: { href: "/relative" }, + g: { href: "without-scheme/path" }, + }); + expect((ok.a as { href: string }).href).toBe("https://example.com"); + expect((ok.b as { href: string }).href).toBe("http://example.com"); + expect((ok.c as { href: string }).href).toBe("mailto:hi@example.com"); + expect((ok.d as { href: string }).href).toBe("tel:+15551234567"); + expect((ok.e as { href: string }).href).toBe("#anchor"); + expect((ok.f as { href: string }).href).toBe("/relative"); + expect((ok.g as { href: string }).href).toBe("without-scheme/path"); + }); + + test("rejects data:text/html and vbscript:", () => { + const sanitized = sanitizeBlockProps({ + img: { src: "data:text/html;base64,PHNjcmlwdD4=" }, + x: { src: "vbscript:msgbox(1)" }, + }); + expect((sanitized.img as { src: string }).src).toBe("#"); + expect((sanitized.x as { src: string }).src).toBe("#"); + }); + + test("leaves non-URL keys untouched", () => { + const sanitized = sanitizeBlockProps({ + title: "javascript: this is not a URL", + body: "Some text", + }); + expect(sanitized.title).toBe("javascript: this is not a URL"); + expect(sanitized.body).toBe("Some text"); + }); +}); + +describe("page preview service — multi-tenant isolation", () => { + test("org A's bindings cannot see org B's pages", async () => { + const storageA = makeStorage("org-A"); + const storageB = makeStorage("org-B"); + + await writePage(storageA, "alpha", "org A page"); + await writePage(storageB, "beta", "org B page"); + + const pagesA = await discoverHtmlPages({ + orgId: "org-A", + objectStorage: storageA, + orgSlug: "org-a", + baseUrl: "http://localhost:3000", + }); + const pagesB = await discoverHtmlPages({ + orgId: "org-B", + objectStorage: storageB, + orgSlug: "org-b", + baseUrl: "http://localhost:3000", + }); + + expect(pagesA.map((p) => p.slug).sort()).toEqual(["alpha"]); + expect(pagesB.map((p) => p.slug).sort()).toEqual(["beta"]); + }); +}); + +describe("page preview service", () => { + let storage: DevObjectStorage; + beforeEach(() => { + storage = makeStorage(); + }); + + test("normalizes page slug to index.html and sets active preview", async () => { + await writePage(storage, "pricing"); + + const status = await setPagePreviewActive({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + path: "pricing", + }); + + expect(status.activeRelativePath).toBe("pages/pricing/index.html"); + expect(status.activeKind).toBe("page"); + expect(status.refreshVersion).toBe(1); + expect(status.activeUrl).toBe( + "http://localhost:3000/api/acme/files/page-preview/pages/pricing/index.html?v=1", + ); + }); + + test("accepts relative paths under pages/", async () => { + await writePage(storage, "relative"); + + const status = await setPagePreviewActive({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + path: "pages/relative/index.html", + }); + + expect(status.activeRelativePath).toBe("pages/relative/index.html"); + }); + + test("rejects paths that don't resolve to a stored page", async () => { + await expect( + setPagePreviewActive({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + path: "nope/index.html", + }), + ).rejects.toThrow(); + }); + + test("rejects non-HTML paths", async () => { + await storage.put("page-preview/pages/img/logo.png", "fake-png", { + contentType: "image/png", + }); + + await expect( + setPagePreviewActive({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + path: "pages/img/logo.png", + }), + ).rejects.toThrow(); + }); + + test("discovers HTML pages under the pages prefix", async () => { + await writePage(storage, "landing"); + await writePage(storage, "pricing"); + // Sibling non-index file should be ignored by discovery. + await storage.put( + "page-preview/pages/pricing/app.js", + "console.log('ignored')", + { contentType: "application/javascript" }, + ); + + const pages = await discoverHtmlPages({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + }); + + expect(pages.map((p) => p.slug).sort()).toEqual(["landing", "pricing"]); + expect(pages.every((p) => p.relativePath.endsWith("/index.html"))).toBe( + true, + ); + }); + + test("refresh increments version and preserves the active page", async () => { + await writePage(storage, "launch"); + await setPagePreviewActive({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + path: "launch", + }); + + const refreshed = await refreshPagePreview({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + }); + + expect(refreshed.activeRelativePath).toBe("pages/launch/index.html"); + expect(refreshed.refreshVersion).toBe(2); + expect(refreshed.activeUrl).toContain("?v=2"); + }); + + test("status falls back to the newest discovered page when no active page is set", async () => { + await writePage(storage, "fallback"); + + const status = await getPagePreviewStatus({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + }); + + expect(status.activeRelativePath).toBe("pages/fallback/index.html"); + expect(status.activeKind).toBe("page"); + expect(status.refreshVersion).toBe(0); + }); + + test("status switches to a newer page written after the active page was set", async () => { + await writePage(storage, "first"); + await setPagePreviewActive({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + path: "first", + }); + + await sleep(10); + await writePage(storage, "second"); + + const status = await getPagePreviewStatus({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + }); + + expect(status.activeRelativePath).toBe("pages/second/index.html"); + }); +}); + +describe("design system scaffolding", () => { + let storage: DevObjectStorage; + beforeEach(() => { + storage = makeStorage(); + }); + + test("creates a design system with tokens, demo and meta", async () => { + const brand = { ...DEFAULT_BRAND, primary: "#FF00AA" }; + const { slug, status } = await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "Pristine!", + name: "Pristine", + brand, + }); + + expect(slug).toBe("pristine"); + expect(status.activeKind).toBe("design-system"); + expect(status.activeDesignSystem).toBe("pristine"); + expect(status.designSystems).toHaveLength(1); + expect(status.designSystems[0]?.brand.primary).toBe("#FF00AA"); + + const tokensCssRes = await storage.get( + "page-preview/design-systems/pristine/tokens.css", + ); + if ("error" in tokensCssRes) throw new Error("tokens.css too large"); + expect(tokensCssRes.content).toContain("--brand-primary: #FF00AA"); + + const metaRes = await storage.get( + "page-preview/design-systems/pristine/meta.json", + ); + if ("error" in metaRes) throw new Error("meta.json too large"); + const meta = JSON.parse(metaRes.content); + expect(meta.brand.primary).toBe("#FF00AA"); + }); + + test("setActiveDesignSystem requires the design system to exist", async () => { + await expect( + setActiveDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "ghost", + }), + ).rejects.toThrow(); + }); + + test("progress label is set by setPageProgress and cleared by scaffold/refresh", async () => { + const { setPageProgress } = await import("./service"); + const set = await setPageProgress({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + label: "Picking a design system…", + }); + expect(set.progressLabel).toBe("Picking a design system…"); + + const created = await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "pristine", + brand: DEFAULT_BRAND, + }); + expect(created.status.progressLabel).toBeNull(); + + const set2 = await setPageProgress({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + label: "Building the hero", + }); + expect(set2.progressLabel).toBe("Building the hero"); + + const refreshed = await refreshPagePreview({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + }); + expect(refreshed.progressLabel).toBeNull(); + }); + + test("auto-corrects illegible muted/border on a light bg", async () => { + const { status } = await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "lavender", + brand: { + ...DEFAULT_BRAND, + bg: "#F3EBFF", + surface: "#FFFFFF", + fg: "#1A1A1A", + muted: "#E5DDF3", + border: "#FFE600", + }, + }); + const ds = status.designSystems.find((d) => d.slug === "lavender")!; + const bg = parseHex(ds.brand.bg)!; + const muted = parseHex(ds.brand.muted)!; + expect(contrastRatio(muted, bg)).toBeGreaterThanOrEqual(5.5); + const border = parseHex(ds.brand.border)!; + expect(contrastRatio(border, bg)).toBeGreaterThanOrEqual(1.5); + const fg = parseHex(ds.brand.fg)!; + expect(contrastRatio(fg, bg)).toBeGreaterThanOrEqual(7); + }); + + test("auto-corrects illegible muted on a dark bg", async () => { + const { status } = await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "deepnight", + brand: { + ...DEFAULT_BRAND, + bg: "#0B0B12", + surface: "#15151F", + fg: "#F6F6F8", + muted: "#1A1A22", + border: "#181820", + }, + }); + const ds = status.designSystems.find((d) => d.slug === "deepnight")!; + const bg = parseHex(ds.brand.bg)!; + const muted = parseHex(ds.brand.muted)!; + expect(contrastRatio(muted, bg)).toBeGreaterThanOrEqual(5.5); + }); +}); + +describe("page scaffolding", () => { + let storage: DevObjectStorage; + beforeEach(() => { + storage = makeStorage(); + }); + + test("creates a page bound to an existing design system", async () => { + await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "pristine", + brand: DEFAULT_BRAND, + }); + + const { slug, status } = await createPage({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "Landing!", + designSystem: "pristine", + title: "Landing", + description: "A new landing page", + }); + + expect(slug).toBe("landing"); + expect(status.activeKind).toBe("design-system"); + expect(status.activeDesignSystem).toBe("pristine"); + + const indexRes = await storage.get("page-preview/pages/landing/index.html"); + if ("error" in indexRes) throw new Error("index.html too large"); + expect(indexRes.content).toContain("Landing"); + expect(indexRes.content).toContain( + "../../design-systems/pristine/tokens.css", + ); + + const metaRes = await storage.get("page-preview/pages/landing/meta.json"); + if ("error" in metaRes) throw new Error("meta.json too large"); + const meta = JSON.parse(metaRes.content); + expect(meta.designSystem).toBe("pristine"); + + const pageJsRes = await storage.get("page-preview/pages/landing/page.js"); + if ("error" in pageJsRes) throw new Error("page.js too large"); + expect(pageJsRes.content).toMatch(/export const PAGE = \[\];?\s*$/); + }); + + test("createPage with activate=true switches the preview immediately", async () => { + await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "pristine", + brand: DEFAULT_BRAND, + }); + const { status } = await createPage({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + designSystem: "pristine", + activate: true, + }); + expect(status.activeKind).toBe("page"); + expect(status.activeRelativePath).toBe("pages/landing/index.html"); + }); + + test("rejects page creation when the design system does not exist", async () => { + await expect( + createPage({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + designSystem: "ghost", + }), + ).rejects.toThrow(); + }); +}); + +describe("block tools (appendBlock / updateBlock / removeBlock)", () => { + let storage: DevObjectStorage; + beforeEach(async () => { + storage = makeStorage(); + await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "pristine", + brand: DEFAULT_BRAND, + }); + await createPage({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + designSystem: "pristine", + }); + // The in-memory live-blocks map persists across tests since it's a + // module-level Map. Reset for the slug under test. + resetBlocks({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + }); + }); + + test("appendBlock validates the section name", async () => { + await expect( + appendBlock({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + block: { section: "Whatever", props: {} }, + }), + ).rejects.toThrow(/Unknown section/); + }); + + test("appendBlock rejects duplicates and post-Footer additions", async () => { + const args = { + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + }; + await appendBlock({ + ...args, + block: { section: "Hero", props: { title: "Hi" } }, + }); + await expect( + appendBlock({ + ...args, + block: { section: "Hero", props: { title: "Twice" } }, + }), + ).rejects.toThrow(/already shipped/); + await appendBlock({ ...args, block: { section: "Footer", props: {} } }); + await expect( + appendBlock({ + ...args, + block: { section: "CTASection", props: {} }, + }), + ).rejects.toThrow(/Footer has already been shipped/); + }); + + test("appendBlock sanitizes javascript: hrefs before storing", async () => { + const args = { + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + }; + await appendBlock({ + ...args, + block: { + section: "Hero", + props: { + title: "Welcome", + ctaPrimaryHref: "javascript:alert(1)", + }, + }, + }); + const blocks = getBlocks(args); + expect(blocks[0]!.props.ctaPrimaryHref).toBe("#"); + }); + + test("updateBlock and removeBlock manage the live block list", async () => { + const args = { + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + }; + await appendBlock({ + ...args, + block: { section: "Hero", props: { title: "Original" } }, + }); + await updateBlock({ + ...args, + index: 0, + propsPatch: { title: "Patched" }, + }); + expect(getBlocks(args)[0]!.props.title).toBe("Patched"); + + await removeBlock({ ...args, index: 0 }); + expect(getBlocks(args).length).toBe(0); + }); +}); + +describe("cleanupPageEditorStorage", () => { + let storage: DevObjectStorage; + beforeEach(() => { + storage = makeStorage(); + }); + + test("deletes every page-preview/* object", async () => { + await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "pristine", + brand: DEFAULT_BRAND, + }); + await createPage({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + designSystem: "pristine", + }); + // Sanity: there's content in the bucket. + const beforeList = await storage.list({ prefix: "page-preview/" }); + expect(beforeList.objects.length).toBeGreaterThan(0); + + await cleanupPageEditorStorage(storage); + + const afterList = await storage.list({ prefix: "page-preview/" }); + expect(afterList.objects.length).toBe(0); + }); +}); + +describe("export", () => { + let storage: DevObjectStorage; + beforeEach(() => { + storage = makeStorage(); + }); + + test("page export bundles a self-contained index.html plus raw src/", async () => { + await createDesignSystem({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "pristine", + brand: DEFAULT_BRAND, + }); + await createPage({ + orgId: ORG_ID, + objectStorage: storage, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + slug: "landing", + designSystem: "pristine", + }); + + const { bundleName, files } = await buildPageExportBundle({ + orgId: ORG_ID, + objectStorage: storage, + slug: "landing", + }); + expect(bundleName).toBe("page-landing"); + + const names = files.map((f) => f.relativePath).sort(); + expect(names).toContain("index.html"); + expect(names).toContain("README.txt"); + expect(names).toContain("src/index.html"); + expect(names).toContain("src/app.js"); + expect(names).toContain("src/tokens.css"); + expect(names).toContain("src/tokens.js"); + + const dec = new TextDecoder(); + const indexHtml = dec.decode( + files.find((f) => f.relativePath === "index.html")!.data, + ); + expect(indexHtml).not.toContain("../../design-systems"); + expect(indexHtml).toContain("`, + ); + html = html.replace( + /]*?src=["']\.\/app\.js["'][^>]*?>\s*<\/script>/g, + ``, + ); + + // GEO artifacts — JSON-LD @graph in (server-rendered, AI + // crawlers don't execute JS) + sibling llms.txt + robots.txt. + const brand = parseTokensJsBrand(tokensJs); + const blocks = parsePageJsBlocks(pageJs); + const pageName = pageMeta.name ?? slug; + const brandName = brand?.name ?? pageName; + const jsonLd = buildJsonLdGraph({ pageName, brandName, blocks }); + const jsonLdScript = ``; + // Inject right before . The placeholder index.html always has + // a ; if a future template change drops it, we'll catch this + // in tests because the JSON-LD won't end up in the served HTML. + html = html.replace(/<\/head>/i, `${jsonLdScript}\n`); + + const llmsTxt = buildLlmsTxt({ pageName, brandName, blocks }); + const robotsTxt = buildRobotsTxt(); + + const enc = new TextEncoder(); + const files: Array<{ relativePath: string; data: Uint8Array }> = [ + { relativePath: "index.html", data: enc.encode(html) }, + { relativePath: "llms.txt", data: enc.encode(llmsTxt) }, + { relativePath: "robots.txt", data: enc.encode(robotsTxt) }, + { + relativePath: "README.txt", + data: enc.encode( + `${pageMeta.name ?? slug} — exported from Page Editor\n\n` + + `Open index.html in a browser to view the page.\n` + + `Everything is self-contained except CDN-hosted preact + htm.\n\n` + + `Also included for AI search visibility:\n` + + ` - llms.txt — site overview for LLM crawlers (host at /llms.txt)\n` + + ` - robots.txt — AI crawler allow-list (host at /robots.txt)\n` + + ` - JSON-LD @graph in — Organization + WebSite + WebPage\n` + + ` + speakable, plus FAQPage when the page has a FAQ.\n` + + ` The JSON-LD uses fragment @ids (#organization etc.); once you\n` + + ` deploy, find/replace them with absolute URLs for the strongest\n` + + ` entity grounding.\n\n` + + `If you'd rather edit the original multi-file source, see ./src/.\n`, + ), + }, + ]; + + // Also include the raw source files under ./src/ for advanced users. + const enc2 = new TextEncoder(); + const includeRaw = async (name: string, content?: string) => { + if (typeof content === "string") { + files.push({ + relativePath: `src/${name}`, + data: enc2.encode(content), + }); + } + }; + await includeRaw("index.html", indexHtml); + await includeRaw("app.js", appJs); + if (sectionsJs) await includeRaw("sections.js", sectionsJs); + if (pageJs) await includeRaw("page.js", pageJs); + if (tokensCss) await includeRaw("tokens.css", tokensCss); + if (tokensJs) await includeRaw("tokens.js", tokensJs); + const metaSrc = + (await readTextObject(objectStorage, pageObjKey(slug, PAGE_META_FILE))) ?? + ""; + if (metaSrc) await includeRaw("meta.json", metaSrc); + + return { bundleName: `page-${slug}`, files }; +} + +/** + * Build the file set for a design-system export. The demo page already + * loads `./tokens.css` (sibling) so it works from `file://` — but `tokens.js` + * is imported via ES module and would be blocked by file:// CORS. We inline + * tokens.js into demo.html the same way as for pages. + */ +export async function buildDesignSystemExportBundle( + options: PagePreviewOptions & { slug: string }, +): Promise<{ + bundleName: string; + files: Array<{ relativePath: string; data: Uint8Array }>; +}> { + const slug = slugify(options.slug); + if (!slug) throw new Error("Invalid slug"); + const { objectStorage } = options; + const demoHtml = await readTextObject( + objectStorage, + dsObjKey(slug, "demo.html"), + ); + if (demoHtml == null) { + throw new Error(`design system "${slug}" not found`); + } + const [demoJs, tokensJs, tokensCss] = await Promise.all([ + readTextObject(objectStorage, dsObjKey(slug, "demo.js")).then( + (s) => s ?? "", + ), + readTextObject(objectStorage, dsObjKey(slug, "tokens.js")).then( + (s) => s ?? "", + ), + readTextObject(objectStorage, dsObjKey(slug, "tokens.css")).then( + (s) => s ?? "", + ), + ]); + + const stripExports = (src: string) => + src.replace(/^\s*export\s+default\s+/gm, "").replace(/^\s*export\s+/gm, ""); + + // demo.js is plain DOM code (no preact / htm), but tokens.js exports + // BRAND which demo.js imports. Strip all imports from both chunks so we + // can concatenate safely; tokens.js's BRAND becomes a top-level binding. + const normalize = (src: string) => stripAllImports(stripExports(src)); + + const inlineModule = [ + "// === tokens.js ===", + normalize(tokensJs), + "// === demo.js ===", + normalize(demoJs), + ].join("\n\n"); + + let html = demoHtml; + html = html.replace( + /]*?src=["']\.\/demo\.js["'][^>]*?>\s*<\/script>/g, + ``, + ); + + const metaSrc = + (await readTextObject( + objectStorage, + dsObjKey(slug, DESIGN_SYSTEM_META_FILE), + )) ?? ""; + + const enc = new TextEncoder(); + const files: Array<{ relativePath: string; data: Uint8Array }> = [ + { relativePath: "demo.html", data: enc.encode(html) }, + { relativePath: "tokens.css", data: enc.encode(tokensCss) }, + { + relativePath: "README.txt", + data: enc.encode( + `${slug} — design system exported from Page Editor\n\n` + + `Open demo.html in a browser to view the design system gallery.\n` + + `tokens.css carries the brand variables (CSS custom properties).\n`, + ), + }, + ]; + if (metaSrc) { + files.push({ relativePath: "meta.json", data: enc.encode(metaSrc) }); + } + if (tokensJs) { + files.push({ relativePath: "tokens.js", data: enc.encode(tokensJs) }); + } + + return { bundleName: `design-system-${slug}`, files }; +} + +/* --------------------------------------------------------------------------- + * Live block store — server-side mirror of the iframe's active page state. + * + * The browser-as-REPL flow keeps a page's section list IN MEMORY here while + * the agent is authoring. Each PAGE_RENDER_BLOCK / UPDATE_BLOCK / REMOVE_BLOCK + * tool call mutates this store synchronously, returns a tiny payload to the + * agent, then kicks off an async background write to `pages//page.js` + * so the on-disk artifact stays usable for export and tab reloads. + * + * Why this is on the server (and not exclusively in the iframe): + * + * - Tools need to confirm the mutation happened (return `index`, + * `blockCount`) without waiting for a postMessage round-trip back + * from the iframe. + * - Export reads from disk; the async write-through guarantees disk + * catches up. + * - Tab reloads see the in-memory state preserved at the server level + * even if the iframe momentarily loses it. + * ------------------------------------------------------------------------- */ + +export interface PageBlock { + /** Library section name — must match an export in `sections.js`. */ + section: string; + /** Free-form prop bag passed to the section component. */ + props: Record; +} + +/** Keyed by `::` so multi-org dev setups don't collide. */ +const liveBlocks = new Map(); + +function blocksKey(orgId: string, slug: string): string { + return `${orgId}::${slug}`; +} + +function getOrInitBlocks(orgId: string, slug: string): PageBlock[] { + const key = blocksKey(orgId, slug); + let existing = liveBlocks.get(key); + if (!existing) { + existing = []; + liveBlocks.set(key, existing); + } + return existing; +} + +export function getBlocks( + options: PagePreviewOptions & { slug: string }, +): PageBlock[] { + return [...getOrInitBlocks(options.orgId, options.slug)]; +} + +/** + * Reset the in-memory block list — called from `createPage` so a fresh + * scaffold doesn't inherit a stale block list from a prior session. + */ +export function resetBlocks( + options: PagePreviewOptions & { slug: string }, +): void { + liveBlocks.set(blocksKey(options.orgId, options.slug), []); +} + +/** + * Set of valid section names — must match the exports in sections.js + * (page-preview/templates.ts: SECTIONS_JS). Used to fail PAGE_RENDER_BLOCK + * fast when the agent passes a typo or invented name, instead of letting + * the iframe render "Unknown section: X" silently. Exported so the agent's + * INSTRUCTIONS prompt can reference the live list instead of hand-syncing. + */ +export const KNOWN_SECTION_NAMES = new Set([ + // Landing-page sections + "Nav", + "Hero", + "FeatureGrid", + "PricingCards", + "TestimonialQuote", + "TestimonialGrid", + "LogoStrip", + "StatStrip", + "Steps", + "ProblemSolution", + "FAQ", + "EmailCapture", + "CTASection", + "Footer", + // Beyond-landing — internal narrative, OKR / status docs, blog posts, + // light data viz, decision pages. Same factory pattern; same prop + // shape contract; same export bundle pipeline. + "MetricsGrid", + "Timeline", + "Chart", + "Callout", + "KeyTakeaways", + "LongFormBody", + "Byline", + "Comparison", + "BeforeAfter", + "Banner", +]); + +/** + * Cheap closest-match suggestion for an invalid section name. Used to + * help the agent recover from a typo without listing all 14 names. + * Returns null if nothing is suspiciously close. + */ +function closestSectionMatch(name: string): string | null { + const target = name.toLowerCase(); + let best: { name: string; score: number } | null = null; + for (const candidate of KNOWN_SECTION_NAMES) { + const c = candidate.toLowerCase(); + let score = 0; + if (c === target) score = 100; + else if (c.includes(target) || target.includes(c)) score = 60; + else { + // Count shared 3-letter prefixes — catches "feat" → "FeatureGrid". + const prefix = Math.min(c.length, target.length, 4); + for (let i = 0; i < prefix; i++) { + if (c[i] === target[i]) score++; + else break; + } + } + if (!best || score > best.score) best = { name: candidate, score }; + } + return best && best.score >= 3 ? best.name : null; +} + +export interface AppendBlockResult { + index: number; + blockCount: number; + /** Agent-declared section outline (read from state) or null. */ + outline: string[] | null; + /** Section names already rendered (in order). */ + sectionsRendered: string[]; +} + +/** + * Append a section block to the end of the page. + * + * Design choice: no positional inserts. Sections always append. The agent + * is responsible for shipping sections in the order they should appear on + * the page — making the agent track array indices is cognitive overhead + * that empirically degrades quality (sections get skipped or misordered). + * The host iframe and Studio dispatch path mirror this: append-only. + */ +export async function appendBlock( + options: PagePreviewOptions & { + slug: string; + block: PageBlock; + }, +): Promise { + // Fail fast and LLM-first when the agent calls PAGE_RENDER_BLOCK before + // PAGE_PREVIEW_PAGE_CREATE. The previous behavior was to silently spawn + // an in-memory block list for a nonexistent page slug; the agent would + // then think the build was succeeding (no error response) and proceed to + // claim "Done — your page is live" while the user saw nothing rendered. + // Now we throw with the exact remediation: literally the tool call the + // agent needs to make instead. + const slug = slugify(options.slug); + const pageExists = await statObject( + options.objectStorage, + pageObjKey(slug, "index.html"), + ); + if (!pageExists) { + throw new Error( + `Page "${options.slug}" does not exist. You must call PAGE_PREVIEW_PAGE_CREATE first. Required next call: PAGE_PREVIEW_PAGE_CREATE({ slug: "${options.slug}", designSystem: "" }) — then retry this PAGE_RENDER_BLOCK call.`, + ); + } + // Validate the section name against the known library so a typo doesn't + // silently render as "Unknown section: X" in the iframe. + if (!KNOWN_SECTION_NAMES.has(options.block.section)) { + const suggestion = closestSectionMatch(options.block.section); + const suggestionHint = suggestion ? ` Did you mean "${suggestion}"?` : ""; + throw new Error( + `Unknown section "${options.block.section}".${suggestionHint} Valid section names (verbatim, case-sensitive): ${[...KNOWN_SECTION_NAMES].join(", ")}.`, + ); + } + const blocks = getOrInitBlocks(options.orgId, options.slug); + // Reject duplicate section names. The agent has occasionally re-shipped + // already-rendered sections (e.g. two Footers, or a second Hero with + // updated copy) — LLM-first fix: tell it to use PAGE_UPDATE_BLOCK + // instead. This also catches the "page keeps growing after Footer" + // failure mode. + const dupeIdx = blocks.findIndex((b) => b.section === options.block.section); + if (dupeIdx >= 0) { + throw new Error( + `Section "${options.block.section}" was already shipped (at index ${dupeIdx}). To modify its props, call PAGE_UPDATE_BLOCK({ slug: "${options.slug}", index: ${dupeIdx}, props: {...} }). To remove and reship, call PAGE_REMOVE_BLOCK first. Do NOT call PAGE_RENDER_BLOCK with the same section again.`, + ); + } + // Reject any RENDER_BLOCK after Footer has been shipped — Footer + // marks the page is structurally complete; further blocks belong + // below the Footer visually, which is never what the user wants. + const footerIdx = blocks.findIndex((b) => /^footer$/i.test(b.section)); + if (footerIdx >= 0) { + throw new Error( + `Footer has already been shipped (at index ${footerIdx}); the page is structurally complete. Do NOT call PAGE_RENDER_BLOCK again. If you want to add another section, you must first PAGE_REMOVE_BLOCK the Footer, ship your new section, then re-ship Footer last.`, + ); + } + blocks.push({ + section: options.block.section, + props: sanitizeBlockProps(options.block.props ?? {}), + }); + // Bump the refresh counter — frontend observers may still use it as a + // cache key for the legacy file-based flow. Cheap to write. + await bumpRefreshVersion(options); + // Fire-and-forget disk persistence; do NOT block the tool response. + void writeBlocksToPageJs(options.slug, blocks, options).catch((err) => { + console.warn( + "[page-editor] background page.js write failed:", + options.slug, + err, + ); + }); + // Read outline so the tool layer can build an LLM-friendly nextStep + // naming the *remaining* sections. Don't trust the agent to track its + // own outline — surface it from server state. + const state = await readState(options.objectStorage); + return { + index: blocks.length - 1, + blockCount: blocks.length, + outline: state.outline ?? null, + sectionsRendered: blocks.map((b) => b.section), + }; +} + +export interface UpdateBlockResult { + ok: true; + blockCount: number; +} + +export async function updateBlock( + options: PagePreviewOptions & { + slug: string; + index: number; + propsPatch: Record; + /** When true, replace the props object entirely. Default: shallow merge. */ + replace?: boolean; + }, +): Promise { + const blocks = getOrInitBlocks(options.orgId, options.slug); + if (options.index < 0 || options.index >= blocks.length) { + throw new Error( + `Block index ${options.index} out of range (have ${blocks.length} blocks on page "${options.slug}")`, + ); + } + const current = blocks[options.index]!; + const patch = sanitizeBlockProps(options.propsPatch ?? {}); + const nextProps = options.replace ? patch : { ...current.props, ...patch }; + blocks[options.index] = { section: current.section, props: nextProps }; + await bumpRefreshVersion(options); + void writeBlocksToPageJs(options.slug, blocks, options).catch((err) => { + console.warn( + "[page-editor] background page.js write failed:", + options.slug, + err, + ); + }); + return { ok: true, blockCount: blocks.length }; +} + +export interface RemoveBlockResult { + ok: true; + blockCount: number; +} + +export async function removeBlock( + options: PagePreviewOptions & { slug: string; index: number }, +): Promise { + const blocks = getOrInitBlocks(options.orgId, options.slug); + if (options.index < 0 || options.index >= blocks.length) { + throw new Error( + `Block index ${options.index} out of range (have ${blocks.length} blocks on page "${options.slug}")`, + ); + } + blocks.splice(options.index, 1); + await bumpRefreshVersion(options); + void writeBlocksToPageJs(options.slug, blocks, options).catch((err) => { + console.warn( + "[page-editor] background page.js write failed:", + options.slug, + err, + ); + }); + return { ok: true, blockCount: blocks.length }; +} + +/** + * Bump `state.json`'s `refreshVersion` so any legacy observer paths that + * key on it (e.g. iframe module re-imports for the old file-based flow) + * still see the change. Cheap to write. + */ +async function bumpRefreshVersion(options: PagePreviewOptions): Promise { + const { objectStorage } = options; + await withStateLock(options.orgId, async () => { + const current = await readState(objectStorage); + await writeState(objectStorage, { + ...current, + refreshVersion: current.refreshVersion + 1, + updatedAt: new Date().toISOString(), + }); + }); +} + +/** + * Serialize the in-memory block list to `pages//page.js`. Runs in + * the background after every mutation. `JSON.stringify` produces valid JS + * for the array literal — no template renderer required. + */ +async function writeBlocksToPageJs( + slug: string, + blocks: readonly PageBlock[], + options: PagePreviewOptions, +): Promise { + const source = + "/**\n" + + " * Ordered list of section blocks rendered by app.js.\n" + + " *\n" + + " * This file is auto-written by the Page Editor on every block\n" + + " * mutation. The in-iframe preview reads from server state during\n" + + " * authoring; this file is the durable export-target snapshot.\n" + + " */\n" + + `export const PAGE = ${JSON.stringify(blocks, null, 2)};\n`; + await writeTextObject( + options.objectStorage, + pageObjKey(slug, "page.js"), + source, + "application/javascript; charset=utf-8", + ); +} diff --git a/apps/mesh/src/page-preview/templates.ts b/apps/mesh/src/page-preview/templates.ts new file mode 100644 index 0000000000..cf0a2cf50f --- /dev/null +++ b/apps/mesh/src/page-preview/templates.ts @@ -0,0 +1,1498 @@ +/** + * Pre-built templates for instant scaffolding. + * + * The agent provides brand tokens; we render these templates with simple + * `{{TOKEN}}` substitution to produce design systems and page shells + * without round-tripping through the LLM. + */ + +export interface BrandTokens { + name: string; + primary: string; + secondary: string; + accent: string; + bg: string; + surface: string; + fg: string; + muted: string; + border: string; + // Auto-derived at DS-create time — text colors that hit ≥4.5:1 against + // their respective colored backgrounds. Used by .btn-primary, highlighted + // PricingCards plans, accent badges, etc. The agent never passes these; + // they're computed from primary/secondary/accent + fg so that whatever + // hue the agent picks for the brand color, text on top stays legible. + onPrimary: string; + onSecondary: string; + onAccent: string; + headingFont: string; + bodyFont: string; + radius: string; +} + +/** + * Normalize a font-family value into a CSS-valid `font-family` stack. + * + * Agents pass either a single family name (`"Inter"`, `"Press Start 2P"`) or + * a full stack (`"Impact, 'Arial Black', sans-serif"`). We want both forms + * to produce valid CSS when interpolated into a `font-family` declaration. + * + * - Stacks (containing a comma) pass through verbatim: the agent is + * responsible for proper quoting inside their stack. + * - Single names get quoted iff they contain whitespace. + * - Already-quoted names pass through. + */ +function normalizeFont(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + if (trimmed.includes(",")) return trimmed; + const isQuoted = + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")); + if (isQuoted) return trimmed; + if (/\s/.test(trimmed)) return `'${trimmed.replace(/'/g, "")}'`; + return trimmed; +} + +export function renderTemplate( + template: string, + brand: BrandTokens, + extra: Record = {}, +): string { + const vars: Record = { + BRAND_NAME: brand.name, + BRAND_PRIMARY: brand.primary, + BRAND_SECONDARY: brand.secondary, + BRAND_ACCENT: brand.accent, + BRAND_BG: brand.bg, + BRAND_SURFACE: brand.surface, + BRAND_FG: brand.fg, + BRAND_MUTED: brand.muted, + BRAND_BORDER: brand.border, + BRAND_ON_PRIMARY: brand.onPrimary, + BRAND_ON_SECONDARY: brand.onSecondary, + BRAND_ON_ACCENT: brand.onAccent, + BRAND_HEADING_FONT: normalizeFont(brand.headingFont), + BRAND_BODY_FONT: normalizeFont(brand.bodyFont), + BRAND_RADIUS: brand.radius, + ...extra, + }; + return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { + const value = vars[key]; + return value !== undefined ? value : `{{${key}}}`; + }); +} + +/* --------------------------------------------------------------------------- + * Design system: tokens.css + * ------------------------------------------------------------------------- */ + +export const DESIGN_SYSTEM_TOKENS_CSS = `:root { + --brand-primary: {{BRAND_PRIMARY}}; + --brand-secondary: {{BRAND_SECONDARY}}; + --brand-accent: {{BRAND_ACCENT}}; + --brand-bg: {{BRAND_BG}}; + --brand-surface: {{BRAND_SURFACE}}; + --brand-fg: {{BRAND_FG}}; + --brand-muted: {{BRAND_MUTED}}; + --brand-border: {{BRAND_BORDER}}; + /* Auto-derived: text colors guaranteed to hit ≥4.5:1 against their + respective colored backgrounds. Use these in templates instead of + hardcoding 'white' on primary backgrounds — primary may be light + (yellow-green, cream) and 'white' fails contrast then. */ + --brand-on-primary: {{BRAND_ON_PRIMARY}}; + --brand-on-secondary: {{BRAND_ON_SECONDARY}}; + --brand-on-accent: {{BRAND_ON_ACCENT}}; + --brand-radius: {{BRAND_RADIUS}}; + + --font-heading: {{BRAND_HEADING_FONT}}, 'Instrument Serif', Georgia, serif; + --font-body: {{BRAND_BODY_FONT}}, Inter, system-ui, sans-serif; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --space-16: 64px; + --space-24: 96px; + + --text-xs: 12px; + --text-sm: 14px; + --text-base: 16px; + --text-lg: 18px; + --text-xl: 22px; + --text-2xl: 28px; + --text-3xl: 36px; + --text-4xl: 48px; + --text-5xl: 64px; +} + +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-body); + background: var(--brand-bg); + color: var(--brand-fg); + -webkit-font-smoothing: antialiased; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); + border-radius: var(--brand-radius); + font-weight: 600; + font-size: var(--text-base); + border: 1px solid transparent; + cursor: pointer; + transition: transform .08s ease, opacity .15s ease, background .15s ease; +} +.btn:active { transform: translateY(1px); } +.btn-primary { background: var(--brand-primary); color: var(--brand-on-primary); } +.btn-primary:hover { opacity: .9; } +.btn-secondary { background: var(--brand-surface); color: var(--brand-fg); border-color: var(--brand-border); } +.btn-ghost { background: transparent; color: var(--brand-fg); border-color: var(--brand-border); } +.btn-disabled { opacity: .4; cursor: not-allowed; } + +.card { + background: var(--brand-surface); + border: 1px solid var(--brand-border); + border-radius: var(--brand-radius); + padding: var(--space-6); +} + +.input, .select, .textarea { + width: 100%; + background: var(--brand-bg); + color: var(--brand-fg); + border: 1px solid var(--brand-border); + border-radius: var(--brand-radius); + padding: var(--space-3) var(--space-4); + font-family: var(--font-body); + font-size: var(--text-base); +} +.input:focus, .select:focus, .textarea:focus { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; +} + +.heading { font-family: var(--font-heading); font-weight: 500; letter-spacing: -0.01em; } + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--space-6); +} + +/* --------------------------------------------------------------------------- + * Reveal animation + * + * Every top-level child of
(i.e. every section in pages built from + * the template) and every in the design-system demo + * fades + slides in with a per-child stagger. Re-renders on file change + * replay the animation, so the preview always feels alive when content + * appears. Respects \`prefers-reduced-motion\`. + * ------------------------------------------------------------------------- */ + +@keyframes deco-reveal { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +main > *, +.ds-section, +.ds-hero { + animation: deco-reveal 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: 0ms; +} +main > *:nth-child(1) { animation-delay: 40ms; } +main > *:nth-child(2) { animation-delay: 140ms; } +main > *:nth-child(3) { animation-delay: 240ms; } +main > *:nth-child(4) { animation-delay: 340ms; } +main > *:nth-child(5) { animation-delay: 440ms; } +main > *:nth-child(6) { animation-delay: 540ms; } +main > *:nth-child(7) { animation-delay: 640ms; } +main > *:nth-child(8) { animation-delay: 740ms; } +main > *:nth-child(n+9) { animation-delay: 800ms; } + +.ds-hero { animation-delay: 60ms; } +.ds-section:nth-of-type(1) { animation-delay: 180ms; } +.ds-section:nth-of-type(2) { animation-delay: 280ms; } +.ds-section:nth-of-type(3) { animation-delay: 380ms; } +.ds-section:nth-of-type(4) { animation-delay: 480ms; } +.ds-section:nth-of-type(5) { animation-delay: 580ms; } +.ds-section:nth-of-type(6) { animation-delay: 680ms; } + +@media (prefers-reduced-motion: reduce) { + main > *, .ds-section, .ds-hero { animation: none; } +} +`; + +/* --------------------------------------------------------------------------- + * Design system: tokens.js is generated programmatically in service.ts + * via `JSON.stringify(brand)` — emitting JS strings safely with brand + * values that may contain quotes or commas (font stacks). + * ------------------------------------------------------------------------- */ + +/* --------------------------------------------------------------------------- + * Design system: demo.html + * ------------------------------------------------------------------------- */ + +export const DESIGN_SYSTEM_DEMO_HTML = ` + + + + + {{DESIGN_SYSTEM_NAME}} — Design System + + + + + + + +
+
+

Design System

+

{{DESIGN_SYSTEM_NAME}}

+

The visual language for pages built on this design system. Edit tokens.css or meta.json to evolve it; every bound page reskins automatically.

+
+
+ +
+
+

Color

+
+
+ +
+

Typography

+
+
Display heading set in {{BRAND_HEADING_FONT}}
+
Section heading
+
Lead paragraph set in {{BRAND_BODY_FONT}}
+
Body copy reads at 16px with comfortable line height. This is the default reading size for paragraphs across pages bound to this design system.
+
Caption / muted text at 14px.
+
+
+ +
+

Buttons

+
+ + + + +
+
+ +
+

Cards

+
+
+
Feature card
+
Short supporting copy that fits within a card.
+
+
+
Pricing card
+
$29/mo
+ +
+
+
Accent card
+
Use sparingly to draw attention.
+
+
+
+ +
+

Form controls

+
+ + + +
+
+ +
+

Spacing

+
+
+
+ + + + +`; + +/* --------------------------------------------------------------------------- + * Design system: demo.js (renders color swatches + spacing scale) + * ------------------------------------------------------------------------- */ + +export const DESIGN_SYSTEM_DEMO_JS = `import { BRAND } from './tokens.js'; + +const colors = [ + ['primary', BRAND.primary], + ['secondary', BRAND.secondary], + ['accent', BRAND.accent], + ['bg', BRAND.bg], + ['surface', BRAND.surface], + ['fg', BRAND.fg], + ['muted', BRAND.muted], + ['border', BRAND.border], +]; + +const colorsHost = document.getElementById('ds-colors'); +if (colorsHost) { + colorsHost.innerHTML = colors.map(([name, value]) => \` +
+
+
\${name}\${value}
+
+ \`).join(''); +} + +const spacings = [4, 8, 12, 16, 24, 32, 48, 64]; +const spacingHost = document.getElementById('ds-spacing'); +if (spacingHost) { + spacingHost.innerHTML = spacings.map(n => \` +
+
+ \${n}px +
+ \`).join(''); +} +`; + +/* --------------------------------------------------------------------------- + * Page template: index.html + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_INDEX_HTML = ` + + + + + {{PAGE_TITLE}} + + + + + + + + + + +
+ + + +`; + +/* --------------------------------------------------------------------------- + * Page template: app.js + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_APP_JS = `import { h, render, Component } from 'preact'; +import htm from 'htm'; +import { BRAND } from '{{TOKENS_JS_MODULE}}'; +import * as Sections from './sections.js'; +import { PAGE } from './page.js'; + +const html = htm.bind(h); + +/** + * Per-section error boundary so a broken section (e.g. inline string event + * handler that preact rejects) doesn't blank the entire page. The user can + * still see surrounding sections plus a visible hint about which one failed. + */ +class SectionBoundary extends Component { + constructor(props) { + super(props); + this.state = { err: null }; + } + static getDerivedStateFromError(err) { + return { err }; + } + componentDidCatch(err) { + console.error('[page-editor] section error', this.props.name, err); + } + render() { + if (this.state.err) { + return html\` +
+ Section "\${this.props.name}" failed: \${String(this.state.err && this.state.err.message || this.state.err)} +
+ \`; + } + return this.props.children; + } +} + +function EmptyPageState() { + // Rendered when PAGE = []. Soft "waiting" state in the brand's own + // palette so the eventual reveal feels intentional, not jarring. + return html\` +
+
+
+ +
+
Page is ready.
+

Sections will appear here as the agent builds them.

+
+ +
+ \`; +} + +function App() { + if (!PAGE || PAGE.length === 0) { + return html\`<\${EmptyPageState} />\`; + } + return html\` +
+ \${PAGE.map((block, i) => { + const Section = Sections[block.section]; + if (!Section) { + return html\`
Unknown section: \${block.section}
\`; + } + return html\` + <\${SectionBoundary} key=\${i} name=\${block.section}> + <\${Section} brand=\${BRAND} ...\${block.props || {}} /> + + \`; + })} +
+ \`; +} + +render(html\`<\${App} />\`, document.getElementById('root')); +`; + +/* --------------------------------------------------------------------------- + * Page template: sections.js (nav, hero, features, footer scaffolds) + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_SECTIONS_JS = `import { h } from 'preact'; +import htm from 'htm'; + +const html = htm.bind(h); + +/** + * Section library. Each export is a pure function of props that renders one + * section of the page. \`page.js\` lists which sections to render, in order, + * and supplies their props. + * + * Add to this library by exporting new named functions. Compose pages by + * appending block entries to \`PAGE\` in page.js — one block per Edit. + */ + +/** + * Pick a grid-template-columns string that NEVER leaves orphans. + * + * The old approach (\`repeat(auto-fit, minmax(NN, 1fr))\`) was clean in CSS + * but produced ugly 3+1 layouts on the common case of 4 items at typical + * preview-pane widths — the 4th item wrapped to a second row by itself, + * leaving two empty columns of whitespace next to it. + * + * Count-based fixed grids are unambiguous: + * 1 → single column + * 2 → 2 cols + * 3 → 3 cols + * 4 → 2x2 (never 3+1) + * 5 → 5 cols (rare; agents rarely use 5) + * 6 → 3x2 + * else → fall back to auto-fit with the supplied min-width + * + * Mobile is handled by .container's responsive width — the grid collapses + * to fewer columns as the available width drops. + */ +function gridColsFor(n, minPx) { + if (n <= 1) return '1fr'; + if (n === 2) return 'repeat(2, minmax(0, 1fr))'; + if (n === 3) return 'repeat(3, minmax(0, 1fr))'; + if (n === 4) return 'repeat(2, minmax(0, 1fr))'; + if (n === 6) return 'repeat(3, minmax(0, 1fr))'; + if (n === 5 || n === 7 || n === 8) return 'repeat(' + n + ', minmax(0, 1fr))'; + return 'repeat(auto-fit, minmax(' + (minPx || 220) + 'px, 1fr))'; +} + +export function Nav(props) { + const brand = props.brand || {}; + const title = props.title || brand.name || 'Brand'; + const ctaLabel = props.ctaLabel || 'Get started'; + const ctaHref = props.ctaHref || '#'; + const items = Array.isArray(props.links) && props.links.length > 0 + ? props.links + : [ + { label: 'Features', href: '#features' }, + { label: 'Pricing', href: '#pricing' }, + { label: 'FAQ', href: '#faq' }, + ]; + return html\` + + \`; +} + +/** + * Hero — page-opening headline + supporting copy + 1-2 CTAs. + * Tolerates the most common alias the agent reaches for: \`headline\` → title, + * \`sub\` → subtitle. \`stats\` adds an inline KPI row under the CTAs. + * + * props: { eyebrow?, title, subtitle?, ctaPrimary?, ctaPrimaryHref?, + * ctaSecondary?, ctaSecondaryHref?, stats?: [{ value, label }] } + */ +export function Hero(props) { + const eyebrow = props.eyebrow; + const title = props.title || props.headline || 'Build a beautiful page.'; + const subtitle = props.subtitle || props.sub; + const ctaPrimary = props.ctaPrimary; + const ctaPrimaryHref = props.ctaPrimaryHref || '#'; + const ctaSecondary = props.ctaSecondary; + const ctaSecondaryHref = props.ctaSecondaryHref || '#'; + const stats = Array.isArray(props.stats) ? props.stats : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +

\${title}

+ \${subtitle && html\`

\${subtitle}

\`} + \${(ctaPrimary || ctaSecondary) && html\` +
+ \${ctaPrimary && html\`\${ctaPrimary}\`} + \${ctaSecondary && html\`\${ctaSecondary}\`} +
+ \`} + \${stats.length > 0 && html\` +
+ \${stats.map(s => html\`
+
\${s.value || s.number || '—'}
+
\${s.label || ''}
+
\`)} +
+ \`} +
+
+ \`; +} + +/** + * A 3-column (or auto-fit) grid of feature cards. + * props: { eyebrow?, title?, intro?, items: [{ icon?, title, body }] } + */ +export function FeatureGrid({ eyebrow, title, intro, items }) { + const features = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${features.map(f => html\` +
+ \${f.icon && html\`
\${f.icon}
\`} +
\${f.title}
+ \${f.body && html\`
\${f.body}
\`} +
+ \`)} +
+
+
+ \`; +} + +/** + * Pricing cards row, optionally with one highlighted plan + "Most popular" + * badge. Tolerates \`cards\` / \`items\` as aliases for \`plans\` because the + * agent occasionally reaches for those vocabulary variants. + * + * props: { eyebrow?, title?, intro?, plans: [{ name, price, period?, + * description?, badge?, features: string[], cta?, highlight?: boolean }] } + */ +export function PricingCards(props) { + const eyebrow = props.eyebrow; + const title = props.title; + const intro = props.intro; + const raw = props.plans || props.cards || props.items; + const tiers = Array.isArray(raw) ? raw : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${tiers.map(p => html\` +
+ \${p.badge && html\`
\${p.badge}
\`} +
\${p.name}
+ \${p.description && html\`
\${p.description}
\`} +
+ \${p.price} + \${p.period && html\`/\${p.period}\`} +
+ \${Array.isArray(p.features) && p.features.length > 0 && html\` +
    + \${p.features.map(f => html\`
  • \${f}
  • \`)} +
+ \`} + \${p.highlight + ? html\`\` + : html\`\`} +
+ \`)} +
+
+
+ \`; +} + +/** + * A single large pull-quote with attribution. + * props: { quote, author, role? } + */ +export function TestimonialQuote({ quote, author, role }) { + return html\` +
+
+

“\${quote}”

+
— \${author}\${role ? html\`, \${role}\` : null}
+
+
+ \`; +} + +/** + * Logo strip / social proof. + * props: { eyebrow?, items: string[] } // items can be text labels or image URLs + */ +export function LogoStrip({ eyebrow, items }) { + const logos = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +
+ \${logos.map(it => /^https?:\\/\\//.test(String(it)) + ? html\`\` + : html\`\${it}\`)} +
+
+
+ \`; +} + +/** + * FAQ list — accordion-free for simplicity. + * Items accept either \`{ question, answer }\` (the agent's most common + * shape, 28× in usage) or the short \`{ q, a }\` form. + * + * props: { eyebrow?, title?, items: [{ question | q, answer | a }] } + */ +export function FAQ({ eyebrow, title, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+ \${list.map(item => { + const q = item.question || item.q || ''; + const a = item.answer || item.a || ''; + return html\` +
+

\${q}

+ \${a && html\`

\${a}

\`} +
+ \`; + })} +
+
+
+ \`; +} + +/** + * Email-capture form for waitlists / newsletters. + * props: { eyebrow?, title?, body?, cta?, placeholder? } + */ +export function EmailCapture({ eyebrow, title, body, cta, placeholder }) { + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${body && html\`

\${body}

\`} +
e.preventDefault()}> + + +
+
+
+ \`; +} + +/** + * Final CTA strip. + * Body accepts \`subtitle\` as an alias (8 pages reached for that name). + * CTAs accept hrefs for actual navigation. \`note\` renders smaller grey + * text below the buttons (e.g. "No credit card required"). + * + * props: { eyebrow?, title, body? | subtitle?, ctaPrimary?, ctaPrimaryHref?, + * ctaSecondary?, ctaSecondaryHref?, note? } + */ +export function CTASection(props) { + const eyebrow = props.eyebrow; + const title = props.title || 'Ready to ship?'; + const body = props.body || props.subtitle; + const ctaPrimary = props.ctaPrimary; + const ctaPrimaryHref = props.ctaPrimaryHref || '#'; + const ctaSecondary = props.ctaSecondary; + const ctaSecondaryHref = props.ctaSecondaryHref || '#'; + const note = props.note || props.footnote; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +

\${title}

+ \${body && html\`

\${body}

\`} + \${(ctaPrimary || ctaSecondary) && html\` +
+ \${ctaPrimary && html\`\${ctaPrimary}\`} + \${ctaSecondary && html\`\${ctaSecondary}\`} +
+ \`} + \${note && html\`

\${note}

\`} +
+
+ \`; +} + +/** + * Numbered or icon-led "how it works" sequence (vertical or grid layout). + * Each step can supply \`number\` ("01"), \`icon\` (emoji), or both. Body is + * one or two short sentences — keep it tight. + * + * props: { id?, eyebrow?, title?, intro?, + * steps: [{ number?, icon?, title, body? }] } + */ +export function Steps({ id, eyebrow, title, intro, steps }) { + const list = Array.isArray(steps) ? steps : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${list.map((s, i) => html\` +
+
+ \${s.icon + ? html\`
\${s.icon}
\` + : html\`
\${s.number || String(i + 1).padStart(2, '0')}
\`} +
\${s.title}
+
+ \${s.body && html\`
\${s.body}
\`} +
+ \`)} +
+
+
+ \`; +} + +/** + * Number-strip / KPI band — typically used after the Hero or to anchor + * a section break with social-proof metrics. + * + * props: { eyebrow?, title?, items: [{ value, label }] } + * - \`value\` may also be passed as \`number\` (alias). + */ +export function StatStrip({ eyebrow, title, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+ \${list.map(s => html\` +
+
\${s.value || s.number || '—'}
+
\${s.label || ''}
+
+ \`)} +
+
+
+ \`; +} + +/** + * 2–3 column quote grid with optional metric/company/rating per card. + * Use this for richer social-proof sections than the single-pull-quote + * \`TestimonialQuote\`. + * + * props: { eyebrow?, title?, items: [{ quote, author, role?, company?, + * metric?, rating? }] } + */ +export function TestimonialGrid({ eyebrow, title, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+ \${list.map(t => html\` +
+ \${t.rating && html\`
\${'★'.repeat(Math.max(0, Math.min(5, t.rating)))}
\`} +

"\${t.quote}"

+ \${t.metric && html\`
\${t.metric}
\`} +
+
\${t.author}
+ \${(t.role || t.company) && html\`
\${[t.role, t.company].filter(Boolean).join(' · ')}
\`} +
+
+ \`)} +
+
+
+ \`; +} + +/** + * Two-side "before / after" block. Left column lists the problem state + * (typically what life is like today, in bullet form); right column lists + * the solution state with the same number of bullets. + * + * props: { eyebrow?, title?, + * problem: { title, bullets: string[] }, + * solution: { title, bullets: string[] } } + */ +export function ProblemSolution({ eyebrow, title, problem, solution }) { + const p = problem || { title: '', bullets: [] }; + const s = solution || { title: '', bullets: [] }; + const pb = Array.isArray(p.bullets) ? p.bullets : []; + const sb = Array.isArray(s.bullets) ? s.bullets : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+
+
\${p.title || 'Before'}
+
    + \${pb.map(b => html\`
  • \${b}
  • \`)} +
+
+
+
\${s.title || 'After'}
+
    + \${sb.map(b => html\`
  • \${b}
  • \`)} +
+
+
+
+
+ \`; +} + +export function Footer({ brand }) { + return html\` +
+
+ © \${new Date().getFullYear()} \${brand?.name || 'Brand'} + Built with Page Editor +
+
+ \`; +} + +/* --------------------------------------------------------------------------- + * Beyond-landing sections + * + * The 10 sections below extend the library past pure conversion funnels + * into territory we want it to cover too — internal memos, OKR / status + * docs, strategy briefs, lightweight data viz, blog posts. + * ------------------------------------------------------------------------- */ + +/** + * MetricsGrid — KPI / OKR cards. Each card carries a label, current + * value, optional target, and an auto-computed progress bar. + * + * props: { eyebrow?, title?, intro?, items: [{ label, current, target?, + * unit?, note? }] } + */ +export function MetricsGrid({ eyebrow, title, intro, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${list.map(m => { + const current = Number(m.current); + const target = Number(m.target); + const hasTarget = Number.isFinite(target) && target > 0; + const pct = hasTarget && Number.isFinite(current) + ? Math.max(0, Math.min(100, Math.round((current / target) * 100))) + : null; + return html\` +
+
+ \${m.label || 'Metric'} + \${pct !== null && html\`\${pct}%\`} +
+
+ \${m.current == null ? '—' : m.current} + \${hasTarget && html\`/ \${m.target}\${m.unit ? ' ' + m.unit : ''}\`} +
+ \${pct !== null && html\`
+
+
\`} + \${m.note && html\`

\${m.note}

\`} +
+ \`; + })} +
+
+
+ \`; +} + +/** + * Timeline — vertical milestone list with date + title + body per item. + * Use for roadmaps, OKR delivery dates, changelog summaries. + * + * props: { eyebrow?, title?, intro?, items: [{ date?, title, body? }] } + */ +export function Timeline({ eyebrow, title, intro, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
    + \${list.map(it => html\` +
  1. +
    +
    +
    \${it.title || 'Milestone'}
    + \${it.date && html\`
    \${it.date}
    \`} +
    + \${it.body && html\`

    \${it.body}

    \`} +
  2. + \`)} +
+
+
+ \`; +} + +/** + * Chart — minimal horizontal bar chart. Pure CSS, no chart library. + * Each datum: { label, value, max?, unit?, color? }. If \`max\` is set + * on any datum we use the highest \`max\`; otherwise we normalize to + * the largest value. + * + * props: { eyebrow?, title?, intro?, data: [{ label, value, ... }] } + */ +export function Chart({ eyebrow, title, intro, data }) { + const list = Array.isArray(data) ? data : []; + const explicitMax = list.reduce((m, d) => Math.max(m, Number(d.max) || 0), 0); + const dataMax = list.reduce((m, d) => Math.max(m, Number(d.value) || 0), 0); + const max = explicitMax > 0 ? explicitMax : (dataMax || 1); + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ \${list.map(d => { + const val = Number(d.value) || 0; + const pct = max > 0 ? Math.max(0, Math.min(100, (val / max) * 100)) : 0; + const fill = d.color || 'var(--brand-primary)'; + return html\` +
+
+ \${d.label || ''} + \${d.value == null ? '' : d.value}\${d.unit ? ' ' + d.unit : ''} +
+
+
+
+
+ \`; + })} +
+
+
+ \`; +} + +/** + * Callout — boxed note with a variant flag for decision logs, risks, + * TL;DRs, or info asides. Variant defaults to "info". + * + * props: { variant?: 'info'|'tldr'|'warn'|'success'|'risk', title?, body, icon? } + */ +export function Callout({ variant, title, body, icon }) { + const palette = { + info: { accent: 'var(--brand-primary)', wash: '8%' }, + tldr: { accent: 'var(--brand-fg)', wash: '6%' }, + warn: { accent: '#f59e0b', wash: '12%' }, + success: { accent: '#10b981', wash: '12%' }, + risk: { accent: '#ef4444', wash: '12%' }, + }; + const v = palette[variant] || palette.info; + const label = (variant || 'note').toUpperCase(); + const bg = 'color-mix(in srgb, ' + v.accent + ' ' + v.wash + ', transparent)'; + return html\` +
+
+ +
+
+ \`; +} + +/** + * KeyTakeaways — TL;DR bullet list for the top of a long-form page. + * Designed to be skimmed in 5 seconds before the reader commits. + * + * props: { eyebrow?, title?, items: string[] } + */ +export function KeyTakeaways({ eyebrow, title, items }) { + const list = Array.isArray(items) ? items : []; + return html\` +
+
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title || 'Key takeaways'}

\`} +
    + \${list.map(it => html\` +
  • + + \${it} +
  • + \`)} +
+
+
+
+ \`; +} + +/** + * LongFormBody — multi-paragraph article-style prose section. Use for + * memos, strategy docs, blog posts. Each paragraph is one string; + * blank line in source maps to one paragraph in render. + * + * props: { eyebrow?, title?, paragraphs: string[] } + */ +export function LongFormBody({ eyebrow, title, paragraphs }) { + const list = Array.isArray(paragraphs) ? paragraphs : []; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+ \${list.map(p => html\`

\${p}

\`)} +
+
+
+ \`; +} + +/** + * Byline — author + role + date + tags strip for memos and blog posts. + * + * props: { author, role?, date?, tags?: string[] } + */ +export function Byline({ author, role, date, tags }) { + const list = Array.isArray(tags) ? tags : []; + return html\` +
+
+
+ \${author && html\`
+
\${String(author).slice(0,1).toUpperCase()}
+ \${author}\${role ? ' · ' + role : ''} +
\`} + \${date && html\`\`} + \${list.length > 0 && html\`
+ \${list.map(t => html\`\${t}\`)} +
\`} +
+
+
+ \`; +} + +/** + * Comparison — feature matrix table. Each row is one feature; each + * column is one option / plan / vendor. Values can be strings ("Yes", + * "Free", "50/mo") or booleans (true → ✓, false → —). + * + * props: { eyebrow?, title?, intro?, columns: string[], + * rows: [{ label, values: (string|boolean)[], emphasize?: boolean }], + * highlightColumn?: number } + */ +export function Comparison({ eyebrow, title, intro, columns, rows, highlightColumn }) { + const cols = Array.isArray(columns) ? columns : []; + const list = Array.isArray(rows) ? rows : []; + const hi = typeof highlightColumn === 'number' ? highlightColumn : -1; + const cellOf = (v) => v === true ? '✓' : v === false ? '—' : (v == null ? '' : String(v)); + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} + \${intro && html\`

\${intro}

\`} +
+ + + + + \${cols.map((c, i) => html\`\`)} + + + + \${list.map(r => { + const vals = Array.isArray(r.values) ? r.values : []; + return html\` + + \${vals.map((v, i) => html\`\`)} + \`; + })} + +
\${c}
\${r.label || ''}\${cellOf(v)}
+
+
+
+ \`; +} + +/** + * BeforeAfter — split-panel transformation. Different from + * ProblemSolution: this is about state change (where you were → where + * you'll be), not pain → fix. Two side-by-side cards. + * + * props: { eyebrow?, title?, before: { title?, body?, bullets?: string[] }, + * after: { title?, body?, bullets?: string[] } } + */ +export function BeforeAfter({ eyebrow, title, before, after }) { + const b = before || {}; + const a = after || {}; + const panel = (data, isAfter) => html\` +
+

\${isAfter ? 'After' : 'Before'}

+
\${data.title || (isAfter ? 'With us' : 'Today')}
+ \${data.body && html\`

\${data.body}

\`} + \${Array.isArray(data.bullets) && data.bullets.length > 0 && html\`
    + \${data.bullets.map(line => html\`
  • + \${isAfter ? '→' : '·'} + \${line} +
  • \`)} +
\`} +
+ \`; + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} + \${title && html\`

\${title}

\`} +
+ \${panel(b, false)} + \${panel(a, true)} +
+
+
+ \`; +} + +/** + * Banner — top-of-page announcement strip. Goes ABOVE Nav. Single + * short message + optional CTA. Used for launch announcements, sale + * notices, "we raised X" strips. Closes on click. + * + * props: { message, ctaLabel?, ctaHref?, variant?: 'info'|'success'|'warn' } + */ +export function Banner({ message, ctaLabel, ctaHref, variant }) { + const palette = { + info: { bg: 'var(--brand-primary)', fg: 'var(--brand-on-primary, white)' }, + success: { bg: '#10b981', fg: 'white' }, + warn: { bg: '#f59e0b', fg: 'black' }, + }; + const v = palette[variant] || palette.info; + return html\` +
+ \${message || ''} + \${ctaLabel && html\`\${ctaLabel} →\`} +
+ \`; +} + +/** + * Lightweight escape hatch for one-off sections the library doesn't cover. + * props: { title?, body? } — replace via the agent when needed. + */ +export function PlaceholderSection({ title, body }) { + return html\` +
+
+

\${title || 'Section'}

+

\${body || 'Placeholder content — replace via the agent.'}

+
+
+ \`; +} +`; + +/* --------------------------------------------------------------------------- + * Page template: page.js (declarative section list) + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_PAGE_JS = `/** + * Ordered list of section blocks rendered by app.js. + * + * The page starts empty on purpose — the agent appends ONE block at a time + * via Edit, then calls PAGE_PREVIEW_REFRESH. Each refresh adds one section + * to the preview with a staggered fade-in. + * + * Each block is { section: '', props: { ... } }. + * + * Available sections (see sections.js): + * Nav, Hero, FeatureGrid, PricingCards, TestimonialQuote, LogoStrip, + * FAQ, EmailCapture, CTASection, Footer, PlaceholderSection + * + * Example, after editing: + * export const PAGE = [ + * { section: 'Nav', props: { title: 'Acme' } }, + * { section: 'Hero', props: { title: 'Ship faster', subtitle: '…', ctaPrimary: 'Start' } }, + * { section: 'FeatureGrid', props: { title: 'Features', items: [{ title: '…', body: '…' }] } }, + * { section: 'Footer', props: {} }, + * ]; + */ +export const PAGE = []; +`; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index d9db3d976a..2d4887438b 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -34,6 +34,7 @@ import * as SecretsTools from "./secrets"; import * as FileConfigTools from "./file-configs"; import { getPrompts, getResources } from "./guides"; import * as ObjectStorageTools from "./object-storage"; +import * as PagePreviewTools from "./page-preview"; import * as RegistryTools from "./registry/index"; import * as SandboxTools from "./sandbox"; import * as GitHubTools from "./github"; @@ -170,6 +171,23 @@ const CORE_TOOLS = [ ObjectStorageTools.DELETE_OBJECT, ObjectStorageTools.DELETE_OBJECTS, + // Page Preview tools + PagePreviewTools.PAGE_PREVIEW_STATUS, + PagePreviewTools.PAGE_PREVIEW_SET, + PagePreviewTools.PAGE_PREVIEW_REFRESH, + PagePreviewTools.PAGE_PREVIEW_PAGE_CREATE, + PagePreviewTools.PAGE_BOOTSTRAP, + PagePreviewTools.PAGE_PREVIEW_PROGRESS, + PagePreviewTools.DESIGN_SYSTEM_CREATE, + PagePreviewTools.DESIGN_SYSTEM_LIST, + PagePreviewTools.DESIGN_SYSTEM_SET, + PagePreviewTools.DESIGN_SYSTEM_TEMPLATES_LIST, + PagePreviewTools.PAGE_RENDER_BLOCK, + PagePreviewTools.PAGE_UPDATE_BLOCK, + PagePreviewTools.PAGE_REMOVE_BLOCK, + PagePreviewTools.PAGE_REVIEW_SUGGEST, + PagePreviewTools.PAGE_GET_BLOCKS, + // Registry tools ...RegistryTools.tools, diff --git a/apps/mesh/src/tools/page-preview/index.ts b/apps/mesh/src/tools/page-preview/index.ts new file mode 100644 index 0000000000..4237b1b9ac --- /dev/null +++ b/apps/mesh/src/tools/page-preview/index.ts @@ -0,0 +1,844 @@ +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { requireAuth, requireOrganization } from "@/core/mesh-context"; +import { DEFAULT_THEMES } from "@/page-preview/default-themes"; +import { + appendBlock, + createDesignSystem, + createPage, + DEFAULT_BRAND, + getBlocks, + getPagePreviewStatus, + listDesignSystems, + refreshPagePreview, + removeBlock, + setActiveDesignSystem, + setPagePreviewActive, + setPageProgress, + updateBlock, +} from "@/page-preview/service"; + +/** + * Slug input for create/activate operations. A bare `z.string()` would + * accept " " (whitespace) and let it slip through to slugify(), which + * returns ""; the callee then throws "Invalid slug". Reject at the + * boundary so the validation error is closer to the agent's intent. + */ +const SlugSchema = z + .string() + .min(1, "slug cannot be empty") + .refine((s) => /[a-zA-Z0-9]/.test(s), { + message: "slug must contain at least one letter or digit", + }); + +const BrandTokensInputSchema = z.object({ + name: z.string().optional(), + primary: z.string().optional(), + secondary: z.string().optional(), + accent: z.string().optional(), + bg: z.string().optional(), + surface: z.string().optional(), + fg: z.string().optional(), + muted: z.string().optional(), + border: z.string().optional(), + headingFont: z.string().optional(), + bodyFont: z.string().optional(), + radius: z.string().optional(), +}); + +const BrandTokensOutputSchema = z.object({ + name: z.string(), + primary: z.string(), + secondary: z.string(), + accent: z.string(), + bg: z.string(), + surface: z.string(), + fg: z.string(), + muted: z.string(), + border: z.string(), + onPrimary: z.string(), + onSecondary: z.string(), + onAccent: z.string(), + headingFont: z.string(), + bodyFont: z.string(), + radius: z.string(), +}); + +const PagePreviewPageSchema = z.object({ + slug: z.string(), + name: z.string(), + designSystem: z.string().nullable(), + path: z.string(), + relativePath: z.string(), + url: z.string(), + lastModified: z.string(), +}); + +const DesignSystemEntrySchema = z.object({ + slug: z.string(), + name: z.string(), + brand: BrandTokensOutputSchema, + path: z.string(), + relativePath: z.string(), + url: z.string(), + lastModified: z.string(), +}); + +const PagePreviewStatusOutputSchema = z.object({ + pagesDir: z.string(), + activeKind: z.enum(["page", "design-system"]).nullable(), + activePath: z.string().nullable(), + activeRelativePath: z.string().nullable(), + activeUrl: z.string().nullable(), + activeDesignSystem: z.string().nullable(), + refreshVersion: z.number(), + pages: z.array(PagePreviewPageSchema), + designSystems: z.array(DesignSystemEntrySchema), + progressLabel: z.string().nullable(), + progressUpdatedAt: z.string().nullable(), + outline: z.array(z.string()).nullable(), + outlineUpdatedAt: z.string().nullable(), + nextStep: z.string().optional(), +}); + +/** + * Slim output schema for mid-build tools (PROGRESS, RENDER_BLOCK, UPDATE_BLOCK, + * REMOVE_BLOCK, DESIGN_SYSTEM_CREATE, PAGE_PREVIEW_PAGE_CREATE). The pre-cost- + * audit shape returned the full PagePreviewStatusOutputSchema — including the + * `pages` + `designSystems` arrays — on every single one of those tool calls. + * With 14+ calls per build, that's tens of thousands of tokens accumulated in + * conversation history for data the agent doesn't need mid-build (Studio polls + * /api//page-preview/state separately for status). Slim responses keep + * input bills proportional to what the agent actually used. + * + * Agents that genuinely need the full listing use PAGE_PREVIEW_STATUS. + */ +const PagePreviewSlimOutputSchema = z.object({ + ok: z.literal(true), + slug: z.string().optional(), + nextStep: z.string().optional(), +}); + +function pageSlugFromStatus( + status: z.infer, +): string | null { + if (status.activeKind !== "page") return null; + const rel = status.activeRelativePath ?? ""; + const match = rel.match(/^pages\/([^/]+)\//); + return match?.[1] ?? null; +} + +function orgArgs(ctx: Parameters[0]) { + const org = requireOrganization(ctx); + if (!ctx.objectStorage) { + throw new Error( + "Page Editor requires object storage — none was provisioned for this request.", + ); + } + return { + orgId: org.id, + objectStorage: ctx.objectStorage, + orgSlug: org.slug ?? org.id, + baseUrl: ctx.baseUrl, + }; +} + +export const PAGE_PREVIEW_STATUS = defineTool({ + name: "PAGE_PREVIEW_STATUS", + description: + "Return the local Page Editor pages directory, active preview, refresh version, design systems and discovered pages.", + annotations: { + title: "Page Preview Status", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (_input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + return getPagePreviewStatus(args); + }, +}); + +export const PAGE_PREVIEW_SET = defineTool({ + name: "PAGE_PREVIEW_SET", + description: + "Set the Page Editor preview to a page (by slug, e.g. 'pricing', or path under pages/).", + annotations: { + title: "Set Page Preview", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + path: z + .string() + .describe( + "Page slug (e.g. 'pricing'), relative path (e.g. 'pages/pricing/index.html'), or absolute path inside the Page Editor root.", + ), + }), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const status = await setPagePreviewActive({ ...args, path: input.path }); + const slug = pageSlugFromStatus(status) ?? input.path; + return { + ...status, + nextStep: `Page "${slug}" is now live with its Hero. Move on to section 2 immediately — three tool calls: (1) PAGE_PREVIEW_PROGRESS({ label: "Adding …" }) (2) Edit pages/${slug}/page.js to APPEND ONE new block (do not rewrite the whole array; one block per Edit, tight props) (3) PAGE_PREVIEW_REFRESH({}). Repeat for each remaining outline section. Target ~10s per section. DO NOT Read any file. DO NOT call ToolSearch. DO NOT use Write on page.js — only Edit. Each detour costs 5-15s the user watches "Working…" for. Stop at 5 sections unless the user asked for more.`, + }; + }, +}); + +export const PAGE_PREVIEW_REFRESH = defineTool({ + name: "PAGE_PREVIEW_REFRESH", + description: + "Reload the Page Editor iframe by incrementing the local preview refresh version.", + annotations: { + title: "Refresh Page Preview", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (_input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const status = await refreshPagePreview(args); + const slug = pageSlugFromStatus(status); + const pageSlug = slug ?? ""; + return { + ...status, + nextStep: `Preview reloaded. If outline sections remain, your next three tool calls: PAGE_PREVIEW_PROGRESS({ label }) → Edit pages/${pageSlug}/page.js APPEND ONE block (tight props, max 3-4 items in any array, ≤1 short sentence per body) → PAGE_PREVIEW_REFRESH. DO NOT Read any file. DO NOT call ToolSearch. DO NOT use Write on page.js. DO NOT append more than one block per Edit. If you ALREADY built 5 sections (Nav, Hero, one supporting block, CTASection, Footer) or the outline is complete, the page is done — emit a one-sentence wrap-up to the user, that's it. Optional: if a single section (typically Hero or FAQ) looks visibly underweight, do ONE additional Edit on that block to add eyebrow / subtitle / answers / stats — at most one polish per build.`, + }; + }, +}); + +export const DESIGN_SYSTEM_CREATE = defineTool({ + name: "DESIGN_SYSTEM_CREATE", + description: + "Create a design system from a curated `template` slug (preferred) or freestyle brand tokens. The system prompt lists the available templates and when to pick each.", + annotations: { + title: "Create Design System", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: SlugSchema.describe("URL-safe slug (e.g. 'mise-violet')."), + name: z.string().optional().describe("Display name."), + template: z + .string() + .optional() + .describe("Curated theme slug (preferred). See system prompt."), + brand: BrandTokensInputSchema.describe( + "Token overrides on top of `template` (partial OK), or full brand if no template.", + ), + }), + outputSchema: PagePreviewSlimOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const brand = { ...DEFAULT_BRAND, ...input.brand }; + if (input.name) brand.name = input.name; + const result = await createDesignSystem({ + ...args, + slug: input.slug, + name: input.name, + template: input.template, + brand, + }); + return { + ok: true as const, + slug: result.slug, + nextStep: `DS "${result.slug}" ready. Next: PAGE_PREVIEW_PROGRESS({ label: "Setting up the page…" }) then PAGE_PREVIEW_PAGE_CREATE({ slug, designSystem: "${result.slug}" }).`, + }; + }, +}); + +export const DESIGN_SYSTEM_TEMPLATES_LIST = defineTool({ + name: "DESIGN_SYSTEM_TEMPLATES_LIST", + description: + "List the curated, contrast-checked theme templates available to DESIGN_SYSTEM_CREATE. Each entry has a slug, displayName, vibe (one-line aesthetic), and full brand-token preview. Pick one whose vibe matches the user's brief, pass its slug as `template` to DESIGN_SYSTEM_CREATE, and optionally override `primary` + `name` to brand-personalize. Way cheaper (and more aesthetically coherent) than freestyling twelve hex values.", + annotations: { + title: "List Theme Templates", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: z.object({ + templates: z.array( + z.object({ + slug: z.string(), + displayName: z.string(), + vibe: z.string(), + brand: BrandTokensOutputSchema.omit({ + onPrimary: true, + onSecondary: true, + onAccent: true, + }), + }), + ), + }), + handler: async (_input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + return { + templates: DEFAULT_THEMES.map((t) => ({ + slug: t.slug, + displayName: t.displayName, + vibe: t.vibe, + brand: t.brand, + })), + }; + }, +}); + +export const DESIGN_SYSTEM_LIST = defineTool({ + name: "DESIGN_SYSTEM_LIST", + description: "List all design systems available in the Page Editor.", + annotations: { + title: "List Design Systems", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: z.object({ designSystems: z.array(DesignSystemEntrySchema) }), + handler: async (_input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + const designSystems = await listDesignSystems(args); + return { designSystems }; + }, +}); + +export const DESIGN_SYSTEM_SET = defineTool({ + name: "DESIGN_SYSTEM_SET", + description: + "Activate a design system in the preview pane (shows its demo page).", + annotations: { + title: "Set Active Design System", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: SlugSchema.describe("Design system slug to activate."), + }), + outputSchema: PagePreviewStatusOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + return setActiveDesignSystem({ ...args, slug: input.slug }); + }, +}); + +export const PAGE_PREVIEW_PAGE_CREATE = defineTool({ + name: "PAGE_PREVIEW_PAGE_CREATE", + description: + "Scaffold a new page bound to a design system and activate it as the preview target. Sections are added afterward via PAGE_RENDER_BLOCK.", + annotations: { + title: "Create Page", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: SlugSchema.describe("URL-safe page slug (e.g. 'pricing')."), + designSystem: z.string().describe("Design system slug to bind."), + name: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + }), + outputSchema: PagePreviewSlimOutputSchema, + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + // Always activate. The pre-REPL flow kept the DS preview visible until + // the agent shipped the first section to avoid showing a blank page — + // but in the browser-as-REPL flow the iframe renders sections from + // in-memory blocks the moment they arrive, so there's no blank-page + // window. Activating here ensures status.activeKind="page" for the + // whole build, so when the agent's turn ends the Studio intent ladder + // resolves to "page" instead of falling back to "ds-demo" and wiping + // the REPL-rendered blocks from the iframe. + const result = await createPage({ + ...args, + slug: input.slug, + designSystem: input.designSystem, + name: input.name, + title: input.title, + description: input.description, + activate: true, + }); + return { + ok: true as const, + slug: result.slug, + nextStep: buildPageCreateNextStep({ + slug: result.slug, + outline: result.status.outline ?? null, + }), + }; + }, +}); + +/** + * PAGE_BOOTSTRAP — single-call replacement for DESIGN_SYSTEM_CREATE + + * PAGE_PREVIEW_PAGE_CREATE + first PAGE_PREVIEW_PROGRESS. In every build + * the agent did all three back-to-back; combining them eliminates two + * sequential round-trips (~1–2 s of stream overhead) and removes the + * "pick a slug for the DS vs the page" cognitive overhead — the page + * slug is the source of truth; the DS slug is derived as `-ds`. + * + * Internally: creates the DS from the template (+ optional brand + * overrides), creates the page bound to it, activates the page, sets + * the outline on shared state, and returns the same slim output as the + * individual tools. + * + * The system prompt directs the agent to call this as its second tool + * (after the initial PAGE_PREVIEW_PROGRESS that triggers the prelude + * gallery). Studio's Progress label derivation auto-flips the pill to + * "Bootstrapping