From b52d9ec0e0e9f7c354aec1a42bc2560fc63c0a77 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sun, 24 May 2026 23:03:45 -0300 Subject: [PATCH 01/10] feat(page-editor): Page Editor agent with live preview, design systems, choreographed build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Page Editor: a new builtin agent that builds landing pages section-by-section in front of the user via a real-time preview pane. The agent emits PAGE_* tool calls (PAGE_BOOTSTRAP, PAGE_RENDER_BLOCK, PAGE_UPDATE_BLOCK, PAGE_REMOVE_BLOCK, PAGE_REVIEW_SUGGEST, and DS management) which Studio observes from the chat stream and dispatches straight into an iframe as host:* postMessages — a browser-as-REPL pipeline that skips HTTP round-trips per block for ~10× faster builds. Main pieces: - apps/mesh/src/tools/page-preview/ — MCP tool surface - apps/mesh/src/page-preview/service.ts — server-side persistence - apps/mesh/src/page-preview/templates.ts — ~24 section library - apps/mesh/src/page-preview/host-html.ts — self-contained Preact runtime served into the preview iframe - apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx — Studio-side host + bridge - apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx — seven-question welcome quiz Build choreography (in host-html.ts): - Phases: prelude → design → layout → building → done - UnifiedDesignPhase: split-screen DS gallery + section library with the agent's outline highlighted (staggered pill animations) - OutlineStepper: sticky stepper with click-to-time-travel preview - queueReveal: paced reveal queue (MIN_REVEAL_INTERVAL_MS = 1500) so each new section gets reading room before the page scrolls - Refresh of a done page short-circuits all choreography Also adds: - Contrast math (onPrimary/onSecondary/onAccent tokens) for readable buttons on brand backgrounds - Auto-bubble of preview runtime errors back to the agent - GEO/SEO baseline (JSON-LD, llms.txt, robots.txt) - WELL_KNOWN_AGENT_TEMPLATES entry + home-screen tile Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mesh/index.css | 23 + apps/mesh/package.json | 1 + apps/mesh/scripts/test-page-preview-mcp.ts | 280 ++ apps/mesh/src/api/app.ts | 16 +- apps/mesh/src/api/routes/org-scoped.ts | 2 + apps/mesh/src/api/routes/page-preview.ts | 166 ++ apps/mesh/src/api/routes/proxy.ts | 23 + apps/mesh/src/harnesses/claude-code/index.ts | 36 + .../src/harnesses/claude-code/model/index.ts | 10 +- apps/mesh/src/mcp-clients/client.ts | 25 + apps/mesh/src/mcp-clients/lazy-client.ts | 15 +- apps/mesh/src/page-preview/contrast.test.ts | 72 + apps/mesh/src/page-preview/contrast.ts | 174 ++ apps/mesh/src/page-preview/default-themes.ts | 238 ++ apps/mesh/src/page-preview/host-html.ts | 2488 +++++++++++++++++ apps/mesh/src/page-preview/service.test.ts | 500 ++++ apps/mesh/src/page-preview/service.ts | 2216 +++++++++++++++ apps/mesh/src/page-preview/templates.ts | 1498 ++++++++++ apps/mesh/src/tools/index.ts | 18 + apps/mesh/src/tools/page-preview/index.ts | 827 ++++++ apps/mesh/src/tools/registry-metadata.ts | 68 + apps/mesh/src/tools/virtual/create.ts | 48 + apps/mesh/src/web/components/chat/input.tsx | 31 + .../src/web/layouts/main-panel-tabs/index.tsx | 4 + .../main-panel-tabs/page-preview-tab.tsx | 1895 +++++++++++++ .../main-panel-tabs/resolve-tab-icon.ts | 9 +- .../src/web/layouts/main-panel-tabs/tab-id.ts | 1 + .../main-panel-tabs/use-main-panel-tabs.ts | 9 +- apps/mesh/src/web/lib/chat-input-bridge.ts | 78 + apps/mesh/src/web/lib/query-keys.ts | 3 + apps/mesh/src/web/views/virtual-mcp/index.tsx | 6 + bun.lock | 989 +++---- 32 files changed, 11177 insertions(+), 592 deletions(-) create mode 100644 apps/mesh/scripts/test-page-preview-mcp.ts create mode 100644 apps/mesh/src/api/routes/page-preview.ts create mode 100644 apps/mesh/src/page-preview/contrast.test.ts create mode 100644 apps/mesh/src/page-preview/contrast.ts create mode 100644 apps/mesh/src/page-preview/default-themes.ts create mode 100644 apps/mesh/src/page-preview/host-html.ts create mode 100644 apps/mesh/src/page-preview/service.test.ts create mode 100644 apps/mesh/src/page-preview/service.ts create mode 100644 apps/mesh/src/page-preview/templates.ts create mode 100644 apps/mesh/src/tools/page-preview/index.ts create mode 100644 apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx create mode 100644 apps/mesh/src/web/lib/chat-input-bridge.ts 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/scripts/test-page-preview-mcp.ts b/apps/mesh/scripts/test-page-preview-mcp.ts new file mode 100644 index 0000000000..747e5074ed --- /dev/null +++ b/apps/mesh/scripts/test-page-preview-mcp.ts @@ -0,0 +1,280 @@ +/** + * Closed-loop test for the Page Editor virtual MCP, full flow. + * + * Mints a fresh API key (the same way dispatch-run does), then drives the + * live dev server's /mcp/virtual-mcp/ endpoint to: + * 1. initialize + * 2. tools/list — assert design-system + page-create tools exist + * 3. DESIGN_SYSTEM_CREATE — scaffold a design system instantly + * 4. PAGE_PREVIEW_PAGE_CREATE — scaffold a page bound to it + * 5. PAGE_PREVIEW_REFRESH — bump version + * 6. GET /api//page-preview/export?kind=page&slug=... — validate zip + * + * Run with the dev server already up: + * bun run apps/mesh/scripts/test-page-preview-mcp.ts + */ + +import { auth } from "../src/auth/index"; + +const SERVER = process.env.MCP_SERVER ?? "http://localhost:3001"; +const ORG_ID = process.env.ORG_ID ?? "qI79UDgd5ine21jyRaNFZb2XxR4jknBd"; +const ORG_SLUG = process.env.ORG_SLUG ?? "guilherme-local"; +const AGENT_ID = process.env.AGENT_ID ?? "vir_mi8wsIteDBpzmeRaNa4gO"; +const USER_ID = process.env.USER_ID ?? "RTM5qUesVqB30TH8Ey2crCTTTuqfM1xH"; + +async function mintApiKey(): Promise { + const result = await auth.api.createApiKey({ + body: { + name: "closed-loop-test", + expiresIn: 600, + userId: USER_ID, + permissions: { self: ["*"] }, + metadata: { + organization: { id: ORG_ID, slug: ORG_SLUG, name: "Guilherme Local" }, + }, + } as never, + }); + // biome-ignore lint/suspicious/noExplicitAny: SDK type is loose + const key = (result as any).key as string; + if (!key) throw new Error("createApiKey returned no key"); + return key; +} + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: number | string; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +function mcpHeaders( + apiKey: string, + sessionId?: string, +): Record { + const h: Record = { + Authorization: `Bearer ${apiKey}`, + "x-org-id": ORG_ID, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (sessionId) h["Mcp-Session-Id"] = sessionId; + return h; +} + +async function mcpCall( + apiKey: string, + body: Record, + sessionId?: string, +): Promise<{ body: JsonRpcResponse; sessionId: string | null }> { + const res = await fetch(`${SERVER}/mcp/virtual-mcp/${AGENT_ID}`, { + method: "POST", + headers: mcpHeaders(apiKey, sessionId), + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error( + `HTTP ${res.status} ${res.statusText} for ${JSON.stringify(body).slice(0, 80)}`, + ); + } + const sid = res.headers.get("Mcp-Session-Id"); + const ct = res.headers.get("content-type") ?? ""; + let parsed: JsonRpcResponse; + if (ct.includes("text/event-stream")) { + const text = await res.text(); + const match = text.match(/^data:\s*(\{.*\})\s*$/m); + if (!match) throw new Error(`No SSE data frame in response:\n${text}`); + parsed = JSON.parse(match[1]!) as JsonRpcResponse; + } else { + parsed = (await res.json()) as JsonRpcResponse; + } + return { body: parsed, sessionId: sid }; +} + +async function main() { + console.log(`[test] Server=${SERVER} agent=${AGENT_ID} org=${ORG_ID}`); + + const apiKey = await mintApiKey(); + console.log(`[test] Minted apiKey prefix=${apiKey.slice(0, 8)}...`); + + // 1. initialize + const init = await mcpCall(apiKey, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + clientInfo: { name: "closed-loop-test", version: "1.0.0" }, + }, + }); + if (init.body.error) + throw new Error(`initialize failed: ${init.body.error.message}`); + const sessionId = init.sessionId; + console.log(`[test] initialize OK; session=${sessionId ?? ""}`); + + await fetch(`${SERVER}/mcp/virtual-mcp/${AGENT_ID}`, { + method: "POST", + headers: mcpHeaders(apiKey, sessionId ?? undefined), + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }); + + // 2. tools/list — find the namespaced tool names we need + const list = await mcpCall( + apiKey, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + sessionId ?? undefined, + ); + if (list.body.error) + throw new Error(`tools/list failed: ${list.body.error.message}`); + // biome-ignore lint/suspicious/noExplicitAny: ad-hoc JSON-RPC payload + const tools = ((list.body.result as any)?.tools ?? []) as Array<{ + name: string; + }>; + console.log(`[test] tools/list returned ${tools.length} tools`); + + function resolveTool(suffix: string): string { + const exact = tools.find((t) => t.name === suffix); + if (exact) return exact.name; + const suff = tools.find((t) => t.name.endsWith(`_${suffix}`)); + if (suff) return suff.name; + throw new Error(`tool not exposed: ${suffix}`); + } + + const T = { + DESIGN_SYSTEM_CREATE: resolveTool("DESIGN_SYSTEM_CREATE"), + PAGE_PREVIEW_PAGE_CREATE: resolveTool("PAGE_PREVIEW_PAGE_CREATE"), + PAGE_PREVIEW_REFRESH: resolveTool("PAGE_PREVIEW_REFRESH"), + PAGE_PREVIEW_STATUS: resolveTool("PAGE_PREVIEW_STATUS"), + }; + console.log("[test] Tool name resolution:"); + for (const [k, v] of Object.entries(T)) console.log(` ${k} -> ${v}`); + + async function callTool( + name: string, + args: Record, + id: number, + ) { + const res = await mcpCall( + apiKey, + { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args }, + }, + sessionId ?? undefined, + ); + if (res.body.error) + throw new Error( + `${name} failed: ${res.body.error.message}\n full=${JSON.stringify(res.body.error)}`, + ); + // biome-ignore lint/suspicious/noExplicitAny: ad-hoc JSON-RPC payload + const result = res.body.result as any; + if (result?.isError) { + throw new Error( + `${name} returned isError: ${JSON.stringify(result.content).slice(0, 200)}`, + ); + } + return result?.structuredContent ?? result; + } + + const dsSlug = `closedloop-${Date.now().toString(36)}`; + const dsResult = await callTool( + T.DESIGN_SYSTEM_CREATE, + { + slug: dsSlug, + name: "Closed Loop DS", + brand: { primary: "#22D3EE", accent: "#F472B6", name: "Closed Loop" }, + }, + 3, + ); + if (dsResult?.slug !== dsSlug) + throw new Error(`DESIGN_SYSTEM_CREATE slug mismatch: ${dsResult?.slug}`); + if (dsResult?.status?.activeKind !== "design-system") + throw new Error( + `DESIGN_SYSTEM_CREATE did not activate as design-system: ${dsResult?.status?.activeKind}`, + ); + console.log(`[test] DESIGN_SYSTEM_CREATE OK slug=${dsSlug}`); + + const pageSlug = `closedloop-page-${Date.now().toString(36)}`; + const pageResult = await callTool( + T.PAGE_PREVIEW_PAGE_CREATE, + { + slug: pageSlug, + designSystem: dsSlug, + title: "Closed Loop Page", + description: "scaffolded by closed-loop test", + // Verify the new behavior: page exists but preview stays on the DS. + }, + 4, + ); + if (pageResult?.slug !== pageSlug) + throw new Error(`PAGE_CREATE slug mismatch: ${pageResult?.slug}`); + if (pageResult?.status?.activeKind !== "design-system") + throw new Error( + `PAGE_CREATE should leave preview on design system; got ${pageResult?.status?.activeKind}`, + ); + const created = pageResult?.status?.pages?.find( + (p: { slug: string }) => p.slug === pageSlug, + ); + if (!created) + throw new Error(`PAGE_CREATE did not record the page in status.pages`); + if (created.designSystem !== dsSlug) + throw new Error(`PAGE_CREATE binding mismatch: ${created.designSystem}`); + console.log( + `[test] PAGE_PREVIEW_PAGE_CREATE OK slug=${pageSlug} (preview stays on DS)`, + ); + + const refreshBefore = pageResult.status.refreshVersion; + const refreshResult = await callTool(T.PAGE_PREVIEW_REFRESH, {}, 5); + if (refreshResult?.refreshVersion <= refreshBefore) + throw new Error( + `REFRESH did not bump version (${refreshBefore} -> ${refreshResult?.refreshVersion})`, + ); + console.log( + `[test] PAGE_PREVIEW_REFRESH OK ${refreshBefore} -> ${refreshResult?.refreshVersion}`, + ); + + // Verify export endpoint returns a zip + const exportRes = await fetch( + `${SERVER}/api/${ORG_SLUG}/page-preview/export?kind=page&slug=${pageSlug}`, + { headers: { Authorization: `Bearer ${apiKey}`, "x-org-id": ORG_ID } }, + ); + if (!exportRes.ok) + throw new Error(`export endpoint returned HTTP ${exportRes.status}`); + const ct = exportRes.headers.get("content-type"); + if (!ct?.includes("application/zip")) + throw new Error(`export content-type unexpected: ${ct}`); + const buf = new Uint8Array(await exportRes.arrayBuffer()); + // ZIP files start with PK\x03\x04 + if (buf[0] !== 0x50 || buf[1] !== 0x4b || buf[2] !== 0x03 || buf[3] !== 0x04) + throw new Error( + `export bytes are not a valid zip header: ${buf.slice(0, 4).join(",")}`, + ); + console.log(`[test] export endpoint OK; ${buf.byteLength} bytes`); + + // Status reflects the new page + design system + const statusResult = await callTool(T.PAGE_PREVIEW_STATUS, {}, 6); + const hasDs = statusResult?.designSystems?.some( + (d: { slug: string }) => d.slug === dsSlug, + ); + const hasPage = statusResult?.pages?.some( + (p: { slug: string }) => p.slug === pageSlug, + ); + if (!hasDs || !hasPage) + throw new Error( + `STATUS missing entries (ds=${hasDs} page=${hasPage}): ${JSON.stringify(statusResult).slice(0, 300)}`, + ); + console.log(`[test] PAGE_PREVIEW_STATUS reflects both entries`); + + console.log("\n[test] PASS — full Page Editor flow works end-to-end"); + process.exit(0); +} + +main().catch((err) => { + console.error("[test] ERROR:", err); + process.exit(1); +}); diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index fd02e70058..a77f4a5b5d 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -1091,10 +1091,20 @@ 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..988974ece5 --- /dev/null +++ b/apps/mesh/src/api/routes/page-preview.ts @@ -0,0 +1,166 @@ +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, + resolvePagePreviewAsset, +} from "@/page-preview/service"; +import { PAGE_PREVIEW_HOST_HTML } from "@/page-preview/host-html"; + +type Variables = { meshContext: MeshContext }; + +function getContentType(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + const types: Record = { + html: "text/html; charset=utf-8", + htm: "text/html; charset=utf-8", + js: "application/javascript; charset=utf-8", + mjs: "application/javascript; charset=utf-8", + css: "text/css; charset=utf-8", + json: "application/json; charset=utf-8", + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + ico: "image/x-icon", + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + otf: "font/otf", + }; + return types[ext] ?? "application/octet-stream"; +} + +function routeBaseUrl(reqUrl: string): string { + const url = new URL(reqUrl); + return `${url.protocol}//${url.host}`; +} + +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. + app.get("/host", () => { + return new Response(PAGE_PREVIEW_HOST_HTML, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + }); + + app.get("/state", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + return c.json( + await getPagePreviewStatus({ + orgId: org.id, + orgSlug: org.slug ?? c.req.param("org"), + baseUrl: routeBaseUrl(c.req.url), + }), + ); + }); + + app.get("/files/*", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id) { + throw new HTTPException(401, { + message: "Organization context required", + }); + } + + const prefix = `/api/${c.req.param("org") ?? ""}/page-preview/files/`; + const rawPath = c.req.path.replace(prefix, ""); + let filePath: string; + try { + filePath = decodeURIComponent(rawPath); + } catch { + throw new HTTPException(400, { message: "Invalid file path" }); + } + + try { + const resolved = await resolvePagePreviewAsset({ + orgId: org.id, + path: filePath, + }); + const file = Bun.file(resolved.absolutePath); + return new Response(file.stream(), { + headers: { + "Content-Type": getContentType(resolved.absolutePath), + "Content-Length": file.size.toString(), + "Cache-Control": "no-store", + }, + }); + } catch { + throw new HTTPException(404, { message: "File not found" }); + } + }); + + app.get("/export", async (c) => { + const ctx = c.get("meshContext"); + const org = ctx.organization; + if (!org?.id) { + 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 { + bundle = + kind === "page" + ? await buildPageExportBundle({ orgId: org.id, slug }) + : await buildDesignSystemExportBundle({ orgId: org.id, slug }); + } 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..c688554756 --- /dev/null +++ b/apps/mesh/src/page-preview/host-html.ts @@ -0,0 +1,2488 @@ +/** + * 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..35d8cfd8fd --- /dev/null +++ b/apps/mesh/src/page-preview/service.test.ts @@ -0,0 +1,500 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + buildDesignSystemExportBundle, + buildPageExportBundle, + createDesignSystem, + createPage, + defaultBrand, + discoverHtmlPages, + getPagePreviewPaths, + getPagePreviewStatus, + refreshPagePreview, + setActiveDesignSystem, + setPagePreviewActive, +} from "./service"; +import { contrastRatio, parseHex } from "./contrast"; + +const ORG_ID = "org/test"; +const ORG_SLUG = "acme"; + +let dataDir: string; + +beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "page-preview-")); +}); + +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +async function writePage(relativePath: string, body = "") { + const { pagesDir } = getPagePreviewPaths({ orgId: ORG_ID, dataDir }); + const absolutePath = join(pagesDir, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, body, "utf8"); + return absolutePath; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("page preview service", () => { + test("uses one well-known local pages directory for every org", () => { + const first = getPagePreviewPaths({ orgId: "org-one", dataDir }); + const second = getPagePreviewPaths({ orgId: "org-two", dataDir }); + + expect(first.pagesDir).toBe(join(dataDir, "page-editor", "pages")); + expect(second.pagesDir).toBe(first.pagesDir); + expect(second.statePath).toBe(first.statePath); + expect(first.designSystemsDir).toBe( + join(dataDir, "page-editor", "design-systems"), + ); + }); + + test("normalizes page slug to index.html and sets active preview", async () => { + const absolutePath = await writePage("pricing/index.html"); + + const status = await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: "pricing", + }); + + expect(status.activePath).toBe(absolutePath); + 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/page-preview/files/pages/pricing/index.html?v=1", + ); + }); + + test("accepts absolute HTML paths inside the pages directory", async () => { + const absolutePath = await writePage("absolute/index.html"); + + const status = await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: absolutePath, + }); + + expect(status.activePath).toBe(absolutePath); + expect(status.activeRelativePath).toBe("pages/absolute/index.html"); + }); + + test("rejects relative traversal outside the pages directory", async () => { + await expect( + setPagePreviewActive({ + orgId: ORG_ID, + dataDir, + path: "../escape/index.html", + }), + ).rejects.toThrow(); + }); + + test("rejects absolute paths outside the pages directory", async () => { + const outsidePath = join(dataDir, "outside.html"); + await writeFile(outsidePath, "", "utf8"); + + await expect( + setPagePreviewActive({ + orgId: ORG_ID, + dataDir, + path: outsidePath, + }), + ).rejects.toThrow(); + }); + + test("discovers HTML pages under the local pages directory", async () => { + await writePage("landing/index.html"); + await writePage("pricing/index.html"); + await writePage("pricing/app.js", "console.log('ignored')"); + + const pages = await discoverHtmlPages({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + 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("launch/index.html"); + await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: "launch/index.html", + }); + + const refreshed = await refreshPagePreview({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + 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("fallback/index.html"); + + const status = await getPagePreviewStatus({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + 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("first/index.html"); + await setPagePreviewActive({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + path: "first/index.html", + }); + + await sleep(10); + await writePage("second/index.html"); + + const status = await getPagePreviewStatus({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + + expect(status.activeRelativePath).toBe("pages/second/index.html"); + }); +}); + +describe("design system scaffolding", () => { + test("creates a design system with tokens, demo and meta", async () => { + const brand = { ...defaultBrand(), primary: "#FF00AA" }; + const { slug, status } = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + 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 root = join(dataDir, "page-editor", "design-systems", "pristine"); + const tokensCss = await readFile(join(root, "tokens.css"), "utf8"); + expect(tokensCss).toContain("--brand-primary: #FF00AA"); + const meta = JSON.parse(await readFile(join(root, "meta.json"), "utf8")); + expect(meta.brand.primary).toBe("#FF00AA"); + }); + + test("setActiveDesignSystem requires the design system to exist", async () => { + await expect( + setActiveDesignSystem({ + orgId: ORG_ID, + dataDir, + 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, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + label: "Picking a design system…", + }); + expect(set.progressLabel).toBe("Picking a design system…"); + + const created = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + expect(created.status.progressLabel).toBeNull(); + + const set2 = await setPageProgress({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + label: "Building the hero", + }); + expect(set2.progressLabel).toBe("Building the hero"); + + const refreshed = await refreshPagePreview({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + }); + expect(refreshed.progressLabel).toBeNull(); + }); + + test("auto-corrects illegible muted/border on a light bg", async () => { + const { status } = await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "lavender", + brand: { + ...defaultBrand(), + bg: "#F3EBFF", + surface: "#FFFFFF", + fg: "#1A1A1A", + // Agent supplied an illegible pastel for muted and a vivid yellow + // for border — exactly the kind of mistake we want to correct. + 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); + // border has a softer threshold but must still be visible. + const border = parseHex(ds.brand.border)!; + expect(contrastRatio(border, bg)).toBeGreaterThanOrEqual(1.5); + // fg must hit AAA. + 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, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "deepnight", + brand: { + ...defaultBrand(), + bg: "#0B0B12", + surface: "#15151F", + fg: "#F6F6F8", + muted: "#1A1A22", // way too dark on dark bg + 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", () => { + test("creates a page bound to an existing design system", async () => { + await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + + const { slug, status } = await createPage({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "Landing!", + designSystem: "pristine", + title: "Landing", + description: "A new landing page", + }); + + expect(slug).toBe("landing"); + // Default: do NOT switch preview to the new (empty) page; keep design + // system visible until the agent edits page.js + calls PAGE_PREVIEW_SET. + expect(status.activeKind).toBe("design-system"); + expect(status.activeDesignSystem).toBe("pristine"); + + const pageDir = join(dataDir, "page-editor", "pages", "landing"); + const index = await readFile(join(pageDir, "index.html"), "utf8"); + expect(index).toContain("Landing"); + expect(index).toContain("../../design-systems/pristine/tokens.css"); + const meta = JSON.parse(await readFile(join(pageDir, "meta.json"), "utf8")); + expect(meta.designSystem).toBe("pristine"); + // page.js ships empty so the page renders the EmptyPageState until the + // agent populates it. + const pageJs = await readFile(join(pageDir, "page.js"), "utf8"); + expect(pageJs).toMatch(/export const PAGE = \[\];?\s*$/); + }); + + test("createPage with activate=true switches the preview immediately", async () => { + await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + const { status } = await createPage({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + 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, + dataDir, + slug: "landing", + designSystem: "ghost", + }), + ).rejects.toThrow(); + }); +}); + +describe("export", () => { + test("page export bundles a self-contained index.html plus raw src/", async () => { + await createDesignSystem({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "pristine", + brand: defaultBrand(), + }); + await createPage({ + orgId: ORG_ID, + orgSlug: ORG_SLUG, + baseUrl: "http://localhost:3000", + dataDir, + slug: "landing", + designSystem: "pristine", + }); + + const { bundleName, files } = await buildPageExportBundle({ + orgId: ORG_ID, + dataDir, + 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, + ); + // Stylesheet must be inlined; no remaining ../../design-systems ref. + 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 readUtf8(join(pageDir, PAGE_META_FILE)).catch(() => ""); + 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 { designSystemsDir } = getPagePreviewPaths(options); + const dsDir = join(designSystemsDir, slug); + const dsStat = await stat(dsDir).catch(() => null); + if (!dsStat?.isDirectory()) { + throw new Error(`design system "${slug}" not found`); + } + + const [demoHtml, demoJs, tokensJs, tokensCss] = await Promise.all([ + readUtf8(join(dsDir, "demo.html")), + readUtf8(join(dsDir, "demo.js")).catch(() => ""), + readUtf8(join(dsDir, "tokens.js")).catch(() => ""), + readUtf8(join(dsDir, "tokens.css")).catch(() => ""), + ]); + + 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 readUtf8(join(dsDir, DESIGN_SYSTEM_META_FILE)).catch( + () => "", + ); + + 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. + */ +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 { pagesDir } = getPagePreviewPaths(options); + const pageDir = join(pagesDir, slugify(options.slug)); + const pageStat = await stat(pageDir).catch(() => null); + if (!pageStat?.isDirectory()) { + 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(options.block); + // 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 { statePath } = getPagePreviewPaths(options); + const state = await readState(statePath); + 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 nextProps = options.replace + ? options.propsPatch + : { ...current.props, ...options.propsPatch }; + 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 { statePath } = getPagePreviewPaths(options); + const current = await readState(statePath); + await writeState(statePath, { + ...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 { pagesDir } = getPagePreviewPaths(options); + const pageDir = join(pagesDir, slug); + 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 writeFile(join(pageDir, "page.js"), source, "utf8"); +} 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..afefd0f827 --- /dev/null +++ b/apps/mesh/src/tools/page-preview/index.ts @@ -0,0 +1,827 @@ +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, + defaultBrand, + getBlocks, + getPagePreviewStatus, + listDesignSystems, + refreshPagePreview, + removeBlock, + setActiveDesignSystem, + setPagePreviewActive, + setPageProgress, + updateBlock, +} from "@/page-preview/service"; + +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); + return { + orgId: org.id, + 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: z.string().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 = { ...defaultBrand(), ...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: z.string().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: z.string().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