From 069486e8888b6e3719be7dec4d93b8c3bc44a2b2 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 14 May 2026 22:55:59 -0300 Subject: [PATCH 01/24] feat(page-editor): add Page Editor agent with design systems and live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a local-first Page Editor agent that builds zero-build landing pages with Claude Code, with a dedicated preview pane and a scaffolding pipeline that splits pages from design systems. Highlights: - Storage layout under .deco/page-editor/: pages// (index.html, app.js, sections.js, page.js, meta.json) and design-systems// (tokens.css, tokens.js, demo.html, meta.json). - New MCP tools: DESIGN_SYSTEM_CREATE / LIST / SET, PAGE_PREVIEW_PAGE_CREATE, plus the existing PAGE_PREVIEW_STATUS / SET / REFRESH. Scaffolding is template-driven so the agent doesn't hand-roll boilerplate. - Templates: design-system demo (typography, swatches, buttons, cards, forms, spacing) and page layout (nav + hero + sections + footer). Tokens are CSS custom properties plus a JSON-encoded BRAND module. Staggered fade-in animation on every page section + design-system block. - Preview pane (apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx): dual selector (page + design system) with the bound design system surfaced for the active page, fresh-chat defaults to the welcome quiz, an Export button, and an iframe that re-keys on file changes. - Welcome quiz: 3-question card grid (what to build, vibe, audience) that composes a prompt and posts it to the chat input. - Export: self-contained zip. index.html inlines the design system's tokens.css as `, + ); + html = html.replace( + /]*?src=["']\.\/app\.js["'][^>]*?>\s*<\/script>/g, + ``, + ); + + const enc = new TextEncoder(); + const files: Array<{ relativePath: string; data: Uint8Array }> = [ + { relativePath: "index.html", data: enc.encode(html) }, + { + 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` + + `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, ""); + + const demoWithoutTokensImport = demoJs.replace( + /import\s*\{\s*BRAND\s*\}\s*from\s+['"][^'"]*tokens\.js['"]\s*;?\s*\n?/g, + "", + ); + + const inlineModule = [ + "// === tokens.js ===", + stripExports(tokensJs), + "// === demo.js ===", + demoWithoutTokensImport, + ].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 }; +} diff --git a/apps/mesh/src/page-preview/templates.ts b/apps/mesh/src/page-preview/templates.ts new file mode 100644 index 0000000000..4c85ba9739 --- /dev/null +++ b/apps/mesh/src/page-preview/templates.ts @@ -0,0 +1,548 @@ +/** + * 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; + 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_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}}; + --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: white; } +.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 App() { + 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); + +export function Nav({ brand, title }) { + return html\` + + \`; +} + +export function Hero({ eyebrow, title, subtitle, ctaPrimary, ctaSecondary }) { + return html\` +
+
+ \${eyebrow && html\`

\${eyebrow}

\`} +

\${title || 'Build a beautiful page.'}

+

\${subtitle || 'A starter section. The agent will refine this.'}

+
+ + +
+
+
+ \`; +} + +export function PlaceholderSection({ title, body }) { + return html\` +
+
+

\${title || 'Section'}

+

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

+
+
+ \`; +} + +export function Footer({ brand }) { + return html\` +
+
+ © \${new Date().getFullYear()} \${brand?.name || 'Brand'} + Built with Page Editor +
+
+ \`; +} +`; + +/* --------------------------------------------------------------------------- + * Page template: page.js (declarative section list) + * ------------------------------------------------------------------------- */ + +export const PAGE_TEMPLATE_PAGE_JS = `export const PAGE = [ + { section: 'Nav', props: { title: '{{PAGE_TITLE}}' } }, + { section: 'Hero', props: { + eyebrow: '{{PAGE_SLUG}}', + title: '{{PAGE_TITLE}}', + subtitle: '{{PAGE_DESCRIPTION}}', + ctaPrimary: 'Get started', + ctaSecondary: 'Learn more', + } }, + { section: 'PlaceholderSection', props: { title: 'Features', body: 'The agent will fill this section in next.' } }, + { section: 'PlaceholderSection', props: { title: 'Pricing', body: 'The agent will fill this section in next.' } }, + { section: 'PlaceholderSection', props: { title: 'FAQ', body: 'The agent will fill this section in next.' } }, + { section: 'Footer', props: {} }, +]; +`; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 4c5fd15b8e..2ff7c3521f 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -32,6 +32,7 @@ import * as UserTools from "./user"; import * as AiProvidersTools from "./ai-providers"; 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 VmTools from "./vm"; import * as GitHubTools from "./github"; @@ -156,6 +157,15 @@ 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.DESIGN_SYSTEM_CREATE, + PagePreviewTools.DESIGN_SYSTEM_LIST, + PagePreviewTools.DESIGN_SYSTEM_SET, + // 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..f780c6a6e5 --- /dev/null +++ b/apps/mesh/src/tools/page-preview/index.ts @@ -0,0 +1,277 @@ +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { requireAuth, requireOrganization } from "@/core/mesh-context"; +import { + createDesignSystem, + createPage, + defaultBrand, + getPagePreviewStatus, + listDesignSystems, + refreshPagePreview, + setActiveDesignSystem, + setPagePreviewActive, +} 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(), + 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), +}); + +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(); + return setPagePreviewActive({ ...args, path: input.path }); + }, +}); + +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(); + return refreshPagePreview(args); + }, +}); + +export const DESIGN_SYSTEM_CREATE = defineTool({ + name: "DESIGN_SYSTEM_CREATE", + description: + "Instantly scaffold a design system from brand tokens. Renders a ready-made demo page (typography, colors, buttons, cards, forms, spacing) and persists tokens.css/tokens.js + meta.json. Pages bound to this design system reskin automatically.", + annotations: { + title: "Create Design System", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + slug: z + .string() + .describe( + "URL-safe slug for the design system (e.g. 'pristine', 'glassmorphism').", + ), + name: z.string().optional().describe("Human-readable display name."), + brand: BrandTokensInputSchema.describe( + "Brand tokens. Missing fields are filled with sensible defaults.", + ), + }), + outputSchema: z.object({ + slug: z.string(), + status: PagePreviewStatusOutputSchema, + }), + 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; + return createDesignSystem({ + ...args, + slug: input.slug, + name: input.name, + 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: + "Instantly scaffold a new page bound to a design system. Writes index.html, app.js, sections.js, page.js and meta.json from a ready-made layout template (nav + hero + sections + footer) using the design system's tokens. The agent then edits sections.js/page.js to build the real page.", + 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("Slug of the design system this page binds to."), + name: z.string().optional().describe("Human-readable name."), + title: z.string().optional().describe("HTML for the page."), + description: z.string().optional().describe("Meta description."), + }), + outputSchema: z.object({ + slug: z.string(), + status: PagePreviewStatusOutputSchema, + }), + handler: async (input, ctx) => { + requireAuth(ctx); + const args = orgArgs(ctx); + await ctx.access.check(); + return createPage({ + ...args, + slug: input.slug, + designSystem: input.designSystem, + name: input.name, + title: input.title, + description: input.description, + }); + }, +}); diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 817a8f6d5b..92a25b5cf4 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -31,6 +31,7 @@ export type ToolCategory = | "AI Providers" | "Automations" | "Object Storage" + | "Page Preview" | "Registry" | "GitHub" | "VM"; @@ -147,6 +148,15 @@ const ALL_TOOL_NAMES = [ "DELETE_OBJECT", "DELETE_OBJECTS", + // Page Preview tools + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_LIST", + "DESIGN_SYSTEM_SET", + // Registry tools "COLLECTION_REGISTRY_APP_LIST", "COLLECTION_REGISTRY_APP_GET", @@ -707,6 +717,42 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ category: "Object Storage", dangerous: true, }, + // Page Preview tools + { + name: "PAGE_PREVIEW_STATUS", + description: "Get local Page Editor preview status", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_SET", + description: "Set the active local Page Editor preview", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_REFRESH", + description: "Refresh the local Page Editor preview", + category: "Page Preview", + }, + { + name: "PAGE_PREVIEW_PAGE_CREATE", + description: "Scaffold a new page from a layout template", + category: "Page Preview", + }, + { + name: "DESIGN_SYSTEM_CREATE", + description: "Scaffold a design system from brand tokens", + category: "Page Preview", + }, + { + name: "DESIGN_SYSTEM_LIST", + description: "List design systems", + category: "Page Preview", + }, + { + name: "DESIGN_SYSTEM_SET", + description: "Set active design system in preview", + category: "Page Preview", + }, // Registry tools { name: "COLLECTION_REGISTRY_APP_LIST", @@ -936,6 +982,14 @@ const PERMISSION_CAPABILITIES: PermissionCapability[] = [ "GET_OBJECT_METADATA", "GET_PRESIGNED_URL", "PUT_PRESIGNED_URL", + // Page Editor preview control + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_LIST", + "DESIGN_SYSTEM_SET", // VM previews "VM_START", "VM_DELETE", diff --git a/apps/mesh/src/tools/virtual/create.ts b/apps/mesh/src/tools/virtual/create.ts index 5fb12ffd54..0718bfb828 100644 --- a/apps/mesh/src/tools/virtual/create.ts +++ b/apps/mesh/src/tools/virtual/create.ts @@ -13,6 +13,16 @@ import { requireAuth, requireOrganization, } from "../../core/mesh-context"; +import { getBaseUrl } from "../../core/server-constants"; +import { + getWellKnownDevAssetsConnection, + getWellKnownSelfConnection, + WellKnownOrgMCPId, +} from "@decocms/mesh-sdk"; +import { + isDevAssetsConnection, + usesLocalObjectStorage, +} from "../connection/dev-assets"; import { VirtualMCPCreateDataSchema, VirtualMCPEntitySchema } from "./schema"; /** * Random icon+color for new agents (server-side, no React deps). @@ -109,6 +119,44 @@ export const COLLECTION_VIRTUAL_MCP_CREATE = defineTool({ throw new Error("User ID required to create virtual MCP"); } + // Materialize well-known pseudo-connections (dev-assets, SELF) that + // the caller may reference in `connections[]`. These are normally + // auto-injected at list time, so they have no DB row — the FK in + // `connection_aggregations.child_connection_id` would reject them + // otherwise. Mirrors the on-demand creation pattern in + // `plugin-config-update.ts`. + const selfId = WellKnownOrgMCPId.SELF(organization.id); + const referenced = new Set( + (input.data.connections ?? []).map((c) => c.connection_id), + ); + if (referenced.size > 0) { + const baseUrl = getBaseUrl(); + const ensure = async (id: string) => { + const existing = await ctx.storage.connections.findById(id); + if (existing) return; + + let seed: ReturnType<typeof getWellKnownDevAssetsConnection> | null = + null; + if ( + isDevAssetsConnection(id, organization.id) && + usesLocalObjectStorage() + ) { + seed = getWellKnownDevAssetsConnection(baseUrl, organization.id); + } else if (id === selfId) { + seed = getWellKnownSelfConnection(baseUrl, organization.id); + } + if (!seed) return; + + await ctx.storage.connections.create({ + ...seed, + id: seed.id!, + organization_id: organization.id, + created_by: userId, + }); + }; + await Promise.all([...referenced].map(ensure)); + } + // Create the virtual MCP (input.data is already in the correct format) // Note: The facade creates a VIRTUAL connection in the connections table // Use a random icon+color if no icon is provided diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 7a1cce829e..38a8631e7c 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -50,6 +50,7 @@ import { import { isTiptapDocEmpty } from "./tiptap/utils"; import { ToolsPopover } from "./tools-popover"; import { SessionStats } from "./usage-stats"; +import { setActiveChatInputHandleRef } from "@/web/lib/chat-input-bridge"; import { authClient } from "@/web/lib/auth-client.ts"; import { track } from "@/web/lib/posthog-client"; import { useSound } from "@/web/hooks/use-sound.ts"; @@ -322,6 +323,17 @@ export function ChatInput({ const tiptapRef = useRef<TiptapInputHandle | null>(null); + // Publish the *ref object* (not its current value) to the bridge so + // callers dereference at click time, after `useImperativeHandle` in + // TiptapInput has populated `tiptapRef.current`. Registering + // `.current` from `useEffect([])` race-loses against the child's + // imperative-handle effect. + // oxlint-disable-next-line ban-use-effect/ban-use-effect — registers a module-level singleton; cleanup runs on unmount + useEffect(() => { + setActiveChatInputHandleRef(tiptapRef); + return () => setActiveChatInputHandleRef(null); + }, []); + const isPlanMode = chatMode === "plan"; // Focus chat input on Cmd+L, toggle plan mode on Cmd+Shift+L diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx index 4bb644d54e..7bef16d221 100644 --- a/apps/mesh/src/web/components/home/agents-list.tsx +++ b/apps/mesh/src/web/components/home/agents-list.tsx @@ -33,6 +33,7 @@ import { ImportFromDecoDialog } from "@/web/components/import-from-deco-dialog.t import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { AiImageRecruitModal } from "@/web/components/home/ai-image-recruit-modal.tsx"; import { AiResearchRecruitModal } from "@/web/components/home/ai-research-recruit-modal.tsx"; +import { PageEditorRecruitModal } from "@/web/components/home/page-editor-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { SelfHealingRepoFlow } from "@/web/components/self-healing-repo/self-healing-repo-flow.tsx"; @@ -199,6 +200,7 @@ type RecruitModalKey = | "diagnostics" | "ai-image" | "ai-research" + | "page-editor" | "lean-canvas" | "studio-pack" | "self-healing"; @@ -212,6 +214,7 @@ type HomeTile = | "site-diagnostics" | "ai-image" | "ai-research" + | "page-editor" | "lean-canvas" | "studio-pack" | "self-healing-storefront"; @@ -249,6 +252,7 @@ function AgentsListContent() { const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); const [aiImageModalOpen, setAiImageModalOpen] = useState(false); const [aiResearchModalOpen, setAiResearchModalOpen] = useState(false); + const [pageEditorModalOpen, setPageEditorModalOpen] = useState(false); const [leanCanvasModalOpen, setLeanCanvasModalOpen] = useState(false); const [studioPackModalOpen, setStudioPackModalOpen] = useState(false); const [selfHealingOpen, setSelfHealingOpen] = useState(false); @@ -267,6 +271,9 @@ function AgentsListContent() { const aiResearchAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "ai-research", )!; + const pageEditorAgent = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "page-editor", + )!; const leanCanvasAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "lean-canvas", )!; @@ -292,6 +299,11 @@ function AgentsListContent() { aiResearchAgent.id, aiResearchAgent.title, ); + const existingPageEditor = findExistingForTemplate( + virtualMcps, + pageEditorAgent.id, + pageEditorAgent.title, + ); const existingLeanCanvas = findExistingForTemplate( virtualMcps, leanCanvasAgent.id, @@ -401,6 +413,23 @@ function AgentsListContent() { onClick: "ai-research", }; } + if (id === pageEditorAgent.id) { + if (existingPageEditor) { + return { + key: existingPageEditor.id, + kind: "existing", + templateId: "page-editor", + agent: existingPageEditor, + }; + } + return { + key: id, + kind: "template-recruit", + templateId: "page-editor", + agent: pageEditorAgent, + onClick: "page-editor", + }; + } const custom = virtualMcps.find( (a): a is typeof a & { id: string } => a.id !== null && a.id === id && !isDecopilot(a.id), @@ -430,6 +459,7 @@ function AgentsListContent() { // opted into the experimental flag (resolveTile returns null otherwise). const templateIds = [ siteEditorAgent.id, + pageEditorAgent.id, selfHealingStorefrontAgent.id, siteDiagnosticsAgent.id, aiImageAgent.id, @@ -449,7 +479,8 @@ function AgentsListContent() { (a) => a.id !== existingDiagnostics?.id && a.id !== existingAiImage?.id && - a.id !== existingAiResearch?.id, + a.id !== existingAiResearch?.id && + a.id !== existingPageEditor?.id, ) .sort((a, b) => { const aIdx = recentIds.indexOf(a.id); @@ -485,6 +516,7 @@ function AgentsListContent() { diagnostics: () => setDiagnosticsModalOpen(true), "ai-image": () => setAiImageModalOpen(true), "ai-research": () => setAiResearchModalOpen(true), + "page-editor": () => setPageEditorModalOpen(true), "lean-canvas": () => setLeanCanvasModalOpen(true), "studio-pack": () => setStudioPackModalOpen(true), "self-healing": () => setSelfHealingOpen(true), @@ -502,6 +534,20 @@ function AgentsListContent() { /> ); } + if (tile.templateId === "page-editor") { + return ( + <AgentPreview + key={tile.key} + agent={tile.agent} + onSpecialClick={() => setPageEditorModalOpen(true)} + tracking={{ + template_id: tile.templateId, + tile_kind: "existing", + action: "open_modal", + }} + /> + ); + } return ( <AgentPreview key={tile.key} @@ -549,6 +595,12 @@ function AgentsListContent() { existingAgent={existingAiResearch} /> + <PageEditorRecruitModal + open={pageEditorModalOpen} + onOpenChange={setPageEditorModalOpen} + existingAgent={existingPageEditor} + /> + <LeanCanvasRecruitModal open={leanCanvasModalOpen} onOpenChange={setLeanCanvasModalOpen} diff --git a/apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx b/apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx new file mode 100644 index 0000000000..c0dc9631a5 --- /dev/null +++ b/apps/mesh/src/web/components/home/page-editor-recruit-modal.tsx @@ -0,0 +1,402 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@deco/ui/components/drawer.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { + WELL_KNOWN_AGENT_TEMPLATES, + WellKnownOrgMCPId, + useProjectContext, + useVirtualMCPActions, +} from "@decocms/mesh-sdk"; +import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; +import { track } from "@/web/lib/posthog-client"; + +function buildPageEditorSystemPrompt(pagesDir: string | null) { + const resolvedRoot = pagesDir ?? "<decoHome>/page-editor"; + return `You are Page Editor, a local-first deco Studio agent that builds zero-build landing pages with Claude Code. + +# CRITICAL RULE — read this first, every time + +When a user asks for a concrete page, your **very first two tool calls** in that response **must** be: + +1. \`DESIGN_SYSTEM_CREATE({ slug, name, brand })\` +2. \`PAGE_PREVIEW_PAGE_CREATE({ slug, designSystem })\` + +These two tools each take ~50ms and they make the preview iframe show the design system, then the page shell, before the user can finish reading your first sentence. That is the entire point — visible progress in seconds. + +**Before those two tool calls, you may not call \`Write\`, \`Edit\`, \`Bash\`, \`Grep\`, or \`Read\`.** No exploration, no listing the data dir, no checking what already exists, no inspecting templates. Just call the two scaffold tools. They are idempotent at the slug level — if the slug already exists, pick a new one. There is nothing to investigate. + +If brand context tools are available (\`BRAND_CONTEXT_GET\` / \`BRAND_CONTEXT_LIST\` / \`BRAND_CONTEXT_EXTRACT\`) you may call **one** of them between the user's message and \`DESIGN_SYSTEM_CREATE\` to pull real brand tokens. Skip it if the user gave you a brand description in the prompt — just translate that into the \`brand\` argument inline. + +After the two scaffold tool calls land, the preview already shows nav + hero + sections + footer styled with the brand. Only then do you start Stage 3 (Write/Edit). + +# Why this matters + +Every \`Read\`/\`Bash\` before the scaffold tools is dead time the user stares at a blank preview. The templates already produce a working starting point. There is no boilerplate worth writing by hand. + +# Local file contract + +Your runtime is Claude Code. Studio scaffolds files for you from templates via MCP tools — you should call those tools instead of writing the boilerplate yourself. Use Claude Code's native Write and Edit tools only for Stage 3 edits to \`sections.js\` / \`page.js\` inside an already-scaffolded page folder. + +Everything Page Editor manages lives under: + +\`\`\` +${resolvedRoot} + design-systems/<slug>/ # tokens.css, tokens.js, demo.html, meta.json + pages/<slug>/ # index.html, app.js, sections.js, page.js, meta.json +\`\`\` + +Studio serves this directory in the preview pane. Never ask the user to manually reload the preview. + +# MCP tools + +- \`DESIGN_SYSTEM_CREATE({ slug, name, brand })\`: instantly scaffolds \`design-systems/<slug>/\` from a template (tokens.css/tokens.js/demo.html) using the brand tokens you pass. Activates the design system in the preview pane immediately. +- \`DESIGN_SYSTEM_LIST({})\` / \`DESIGN_SYSTEM_SET({ slug })\`: list available design systems / make one active in the preview. +- \`PAGE_PREVIEW_PAGE_CREATE({ slug, designSystem, name?, title?, description? })\`: instantly scaffolds \`pages/<slug>/\` from a layout template (nav + hero + sections + footer) bound to the named design system. Activates the page in the preview. +- \`PAGE_PREVIEW_SET({ path })\`: select an existing page (slug or path). Use after Stage-3 edits when switching pages. +- \`PAGE_PREVIEW_REFRESH({})\`: bump the preview iframe. Call after every Stage-3 Edit/Write to files under \`pages/<slug>/\`. +- \`PAGE_PREVIEW_STATUS({})\`: list pages, design systems and active selection. **Don't call this at the start of a response — it just delays Stage 1.** Use it only when the user explicitly asks what exists. + +Runtimes may prefix MCP tool names (e.g. \`mcp__cms__DESIGN_SYSTEM_CREATE\`). Use whatever form is available. + +# The brand argument — token roles and contrast rules + +\`DESIGN_SYSTEM_CREATE\` accepts these brand fields (all optional, sensible defaults filled in). The roles matter — many of them are NOT for accent color and getting that wrong yields illegible pages: + +| token | role | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| \`bg\` | Page background. Pick a calm, **low-saturation** base — near-white, near-black, or near-cream. Never a vivid hue. | +| \`surface\` | Card / elevated panel background. Slightly differentiated from \`bg\` (a few shades lighter on dark, lighter on light, or pure white). | +| \`fg\` | **Primary body text and headings.** Must hit ≥ 4.5:1 contrast with \`bg\`. Usually near-black on light bg, near-white on dark. | +| \`muted\` | Secondary text, captions, eyebrow labels, hints. Must still read clearly — **≥ 4.5:1 with \`bg\`** for body copy. A desaturated mid-tone, **never** a saturated hue like hot pink. | +| \`border\` | Subtle dividers and card outlines. Low contrast — a near-\`bg\` tone (e.g. \`#1f1f29\` on a near-black bg). **Never** a saturated color. | +| \`primary\` | The single dominant brand color. Used for primary buttons, key highlights. Saturated is fine; this is where the page gets its personality. | +| \`secondary\` | Optional second saturated color. Coordinated with \`primary\` (analogous or complementary, not random). | +| \`accent\` | Rare 3rd hit color for callouts. Use sparingly. If unsure, set it equal to \`primary\`. | + +**Hard rules:** + +- Saturated colors are limited to \`primary\`, \`secondary\`, \`accent\`. Everything else is low-saturation. +- \`muted\` and \`border\` must NEVER be the same value as \`primary\`/\`secondary\`/\`accent\`. +- Pick \`fg\` such that \`fg\` on \`bg\` passes WCAG AA (≥ 4.5:1). Test mentally — if \`bg\` is cream and you set \`fg\` to mid-gray, that fails. +- Headings inherit \`fg\` by default. If you want a colored heading effect, do it locally in a section, not by changing \`fg\`. +- \`headingFont\` and \`bodyFont\` are **Google Font family names** — pass a single name (e.g. \`"Inter"\`, \`"Space Grotesk"\`, \`"Press Start 2P"\`). Do not pass a CSS fallback stack; the template already adds appropriate fallbacks. +- Radius is a CSS length (\`"4px"\`, \`"12px"\`, \`"0px"\` for sharp/brutalist). + +## Design-language anchors + +Pick **one** of these or stay close to a real brand. Don't free-style a chaotic palette: + +- **Minimal mono** — bg \`#FFFFFF\`/\`#FAFAFA\`, fg \`#0A0A0A\`, single primary (deep blue or black), no secondary, radius 4–8px. +- **Dark neon** — bg \`#0A0A0F\`, surface \`#15151F\`, fg \`#F6F6F8\`, primary one neon (\`#A595FF\` violet / \`#22D3EE\` cyan / \`#22C55E\` green), radius 12px. +- **Editorial** — bg \`#FAF7F2\` (warm white), fg \`#171717\`, primary a deep accent (oxblood / forest), serif heading + sans body, radius 0–4px. +- **Soft pastel** — bg \`#FFF7F2\` (peach), fg \`#1A1A1A\`, primary a desaturated mid-tone (\`#7C9CFF\`, \`#FFB3C6\`), generous radius 16–24px. +- **Retro 80s/90s** — bg \`#0B0024\` (deep purple/black), primary \`#FF006E\`/\`#FFE600\`, accent neon, chunky display font, radius 0–4px. +- **Brutalist** — bg \`#F0F0F0\`, fg \`#000\`, primary \`#000\` or \`#FF3B00\`, hard 2–4px borders in \`fg\`, monospace headings, radius 0. + +When the user requests something close to one of these, pin most tokens to the anchor and only personalize \`primary\` + fonts. + +**Anti-examples** (do NOT do this): + +- \`muted: "#FF1493"\` ← hot pink for "secondary text" makes body copy unreadable on most backgrounds. +- \`border: "#FFD700"\` ← saturated yellow dividers look broken. +- \`fg: "#888888"\` on \`bg: "#FFFFFF"\` ← contrast too low (about 3.5:1), fails AA. +- All five of \`primary/secondary/accent/border/muted\` saturated ← no visual hierarchy; the page screams. + +# Stage 3 — implement sections one at a time + +After the two scaffold tool calls: + +1. Edit \`pages/<slug>/sections.js\` to flesh out **one** section at a time. After each section, call \`PAGE_PREVIEW_REFRESH\`. +2. Edit \`pages/<slug>/page.js\` for content/order changes. +3. To re-skin the page mid-flow: call \`DESIGN_SYSTEM_CREATE\` with a different slug, update the page's \`meta.json\` \`designSystem\` field and the \`<link rel="stylesheet">\` href in \`index.html\` to point at the new design system, then \`PAGE_PREVIEW_REFRESH\`. +4. Final pass: polish responsiveness, accessibility labels, metadata, empty/error states. + +# Authoring rules + +- Stages 1 and 2 are tool calls. Period. Don't hand-roll \`index.html\`, \`tokens.css\`, etc. +- Stage 3 edits stay inside \`${resolvedRoot}/pages/<slug>/\`. Don't write outside that directory. +- Keep sections as pure functions of props; no DOM side effects. +- After every Stage-3 edit, call \`PAGE_PREVIEW_REFRESH\`. +- Never claim the page is done before Stage 3's polish pass. +- Don't paste full file contents into chat after writing files. The preview is the source of truth. + +# Tech stack (already wired by the templates) + +- Preact + htm via an importmap. +- Brand tokens as CSS custom properties (\`--brand-primary\`, \`--font-heading\`, etc.) plus a \`BRAND\` JS module. +- No build step, no package.json, no Tailwind CDN. + +## htm rules that break naïve HTML copy-paste + +These look like HTML but they aren't. **Event handlers must be functions, not strings**, or preact crashes with "Cannot create property 'u' on string": + +- **Wrong:** \`<button onclick="alert('hi')">\` or \`<div onmouseover="this.style.transform='...'">\` +- **Right:** \`<button onClick=\${() => alert('hi')}>\` and \`<div onMouseOver=\${(e) => { e.currentTarget.style.transform = '...' }}>\` + +Other gotchas: +- Use camelCase JSX-style event names (\`onClick\`, \`onMouseOver\`, \`onInput\`), not lowercase HTML. +- For hover effects, **prefer CSS \`:hover\`** over JS handlers. Add a class and a CSS rule — it's shorter and won't crash. +- Use \`class\` (works in htm) or \`className\`. + +If the user asks for something requiring a bundler or server runtime, offer a zero-build equivalent. + +For vague prompts, ask what page the user wants. For concrete prompts, run Stage 1 + Stage 2 **as your first two tool calls**, then start Stage 3.`; +} + +interface PageEditorRecruitModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + existingAgent?: { id: string } | null; +} + +const CAPABILITIES = [ + "Generate a complete landing page in seconds", + "Pulls your brand colors, fonts, and logo automatically", + "Writes local zero-build files that Claude Code can edit directly", + "Studio auto-detects local HTML files for preview", + "React-ready output you can port to a full site later", +]; + +async function fetchPagePreviewPagesDir( + orgSlug: string, +): Promise<string | null> { + try { + const response = await fetch( + `/api/${encodeURIComponent(orgSlug)}/page-preview/state`, + ); + if (!response.ok) return null; + const body = (await response.json()) as { pagesDir?: unknown }; + return typeof body.pagesDir === "string" ? body.pagesDir : null; + } catch { + return null; + } +} + +function RecruitContent({ + onRecruit, + isRecruiting, + existingAgent, +}: { + onRecruit: () => void; + isRecruiting: boolean; + existingAgent: boolean; +}) { + return ( + <div className="flex flex-col gap-6"> + <p className="text-sm text-muted-foreground"> + Add a Page Editor agent that writes local zero-build page files and lets + Studio auto-detect them for preview. No bundler, no dev server, no + object storage upload flow. + </p> + + <div className="space-y-2"> + <p className="text-sm font-medium text-foreground">Capabilities</p> + <ul className="space-y-1.5"> + {CAPABILITIES.map((cap) => ( + <li + key={cap} + className="text-sm text-muted-foreground flex items-start gap-2" + > + <span className="text-violet-500 mt-0.5 shrink-0">+</span> + {cap} + </li> + ))} + </ul> + </div> + + <Button + onClick={onRecruit} + disabled={isRecruiting} + className="w-full cursor-pointer" + > + {isRecruiting + ? "Setting up..." + : existingAgent + ? "Update and open Page Editor" + : "Add Page Editor"} + </Button> + </div> + ); +} + +export function PageEditorRecruitModal({ + open, + onOpenChange, + existingAgent, +}: PageEditorRecruitModalProps) { + const isMobile = useIsMobile(); + const navigateToAgent = useNavigateToAgent(); + const virtualMcpActions = useVirtualMCPActions(); + const { org } = useProjectContext(); + const [isRecruiting, setIsRecruiting] = useState(false); + + const template = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "page-editor", + )!; + + const headerIcon = ( + <IntegrationIcon icon={template.icon} name={template.title} size="sm" /> + ); + + const handleRecruit = async () => { + if (existingAgent) { + setIsRecruiting(true); + try { + const pagesDir = await fetchPagePreviewPagesDir(org.slug); + const instructions = buildPageEditorSystemPrompt(pagesDir); + await virtualMcpActions.update.mutateAsync({ + id: existingAgent.id, + data: { + description: + "Local zero-build landing page authoring with auto preview", + connections: [ + { + connection_id: WellKnownOrgMCPId.SELF(org.id), + selected_tools: [ + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "BRAND_CONTEXT_LIST", + "BRAND_CONTEXT_GET", + "BRAND_CONTEXT_EXTRACT", + ], + selected_resources: null, + selected_prompts: null, + }, + ], + metadata: { + type: "page-editor", + instructions, + ui: { + layout: { + defaultMainView: { type: "page-preview" }, + chatDefaultOpen: true, + }, + }, + }, + }, + }); + onOpenChange(false); + navigateToAgent(existingAgent.id); + } catch (error) { + track("agent_recruit_failed", { + template_id: "page-editor", + agent_id: existingAgent.id, + error: error instanceof Error ? error.message : String(error), + }); + console.error("Failed to update Page Editor agent:", error); + } finally { + setIsRecruiting(false); + } + return; + } + + setIsRecruiting(true); + try { + const pagesDir = await fetchPagePreviewPagesDir(org.slug); + const instructions = buildPageEditorSystemPrompt(pagesDir); + const virtualMcp = await virtualMcpActions.create.mutateAsync({ + title: template.title, + description: + "Local zero-build landing page authoring with auto preview", + icon: template.icon, + status: "active", + connections: [ + { + connection_id: WellKnownOrgMCPId.SELF(org.id), + selected_tools: [ + "PAGE_PREVIEW_STATUS", + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_LIST", + "DESIGN_SYSTEM_SET", + "BRAND_CONTEXT_LIST", + "BRAND_CONTEXT_GET", + "BRAND_CONTEXT_EXTRACT", + ], + selected_resources: null, + selected_prompts: null, + }, + ], + metadata: { + type: "page-editor", + instructions, + ui: { + layout: { + defaultMainView: { type: "page-preview" }, + chatDefaultOpen: true, + }, + }, + }, + }); + + track("agent_recruit_confirmed", { + template_id: "page-editor", + agent_id: virtualMcp.id!, + }); + onOpenChange(false); + navigateToAgent(virtualMcp.id!); + } catch (error) { + track("agent_recruit_failed", { + template_id: "page-editor", + error: error instanceof Error ? error.message : String(error), + }); + console.error("Failed to create Page Editor agent:", error); + } finally { + setIsRecruiting(false); + } + }; + + const title = `${existingAgent ? "Update" : "Add"} ${template.title}`; + + return isMobile ? ( + <Drawer open={open} onOpenChange={onOpenChange}> + <DrawerContent className="h-[70dvh]"> + <DrawerHeader className="px-4 pt-4 pb-4 shrink-0"> + <div className="flex items-center gap-3"> + {headerIcon} + <DrawerTitle className="text-xl font-semibold">{title}</DrawerTitle> + </div> + </DrawerHeader> + <div className="flex flex-col flex-1 min-h-0 px-4 pb-8"> + <RecruitContent + onRecruit={handleRecruit} + isRecruiting={isRecruiting} + existingAgent={Boolean(existingAgent)} + /> + </div> + </DrawerContent> + </Drawer> + ) : ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px] p-8"> + <DialogHeader className="mb-4"> + <div className="flex items-center gap-3"> + {headerIcon} + <DialogTitle className="text-xl font-semibold">{title}</DialogTitle> + </div> + </DialogHeader> + <RecruitContent + onRecruit={handleRecruit} + isRecruiting={isRecruiting} + existingAgent={Boolean(existingAgent)} + /> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/mesh/src/web/components/home/page-editor-welcome-html.ts b/apps/mesh/src/web/components/home/page-editor-welcome-html.ts new file mode 100644 index 0000000000..6b81be40f3 --- /dev/null +++ b/apps/mesh/src/web/components/home/page-editor-welcome-html.ts @@ -0,0 +1,269 @@ +/** + * Self-contained welcome screen for the Page Editor agent. + * + * Rendered via `<iframe srcdoc>` in the preview pane whenever the chat + * is fresh (no prior task) or no page has been built yet. Provides a + * 3-question quiz; the "Generate prompt" button posts the composed + * sentence back to the parent window, where PagePreviewTab's message + * listener drops it into the chat input. + * + * Pure HTML + Tailwind CDN — zero runtime dependencies, must work + * inside a sandboxed iframe. + */ +export const PAGE_EDITOR_WELCOME_MARKER = "DECO_PAGE_EDITOR_WELCOME_V2"; + +export const PAGE_EDITOR_WELCOME_HTML = `<!doctype html> +<!-- ${PAGE_EDITOR_WELCOME_MARKER} --> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Page Editor — Compose a prompt + + + + + + + + +
+

Page Editor

+

+ What do you want to build? +

+

+ Pick a few options and we'll write the prompt for you. +

+ +
+ +
+ + +
+ + + +

+ Or skip this and type your own prompt in the chat. +

+
+ + + + +`; diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx b/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx index 3f71c491b1..642e274cda 100644 --- a/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx +++ b/apps/mesh/src/web/layouts/main-panel-tabs/index.tsx @@ -14,6 +14,7 @@ import { useMainPanelTabs } from "./use-main-panel-tabs"; import { SettingsTab } from "./settings-tab"; import { GitTab } from "@/web/components/thread/github/git-tab"; import { PreviewTab } from "./preview-tab"; +import { PagePreviewTab } from "./page-preview-tab"; import { AutomationTab } from "./automation-tab"; import { AutomationsListTab } from "./automations-list-tab"; import { isLegacySettingsTab, parsePinnedViewTabId } from "./tab-id"; @@ -49,6 +50,9 @@ export function MainPanelContent({ if (activeTab === "preview") { return ; } + if (activeTab === "page-preview") { + return ; + } if (automationTabParsed) { return ; } diff --git a/apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx b/apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx new file mode 100644 index 0000000000..8e2056a5a4 --- /dev/null +++ b/apps/mesh/src/web/layouts/main-panel-tabs/page-preview-tab.tsx @@ -0,0 +1,512 @@ +import { useEffect, useState } from "react"; +import { + Check, + ChevronDown, + Download01, + FileCode01, + Loading01, + Palette, + Plus, +} from "@untitledui/icons"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { useQuery } from "@tanstack/react-query"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@deco/ui/components/dropdown-menu.tsx"; +import { PAGE_EDITOR_WELCOME_HTML } from "@/web/components/home/page-editor-welcome-html"; +import { composeChatInput } from "@/web/lib/chat-input-bridge"; +import { KEYS } from "@/web/lib/query-keys"; +import { useChatTask } from "@/web/components/chat"; +import { useOptionalChatStream } from "@/web/components/chat/context"; + +const PROMPT_MESSAGE_TYPE = "page-editor:prompt"; +const PREVIEW_STATE_TOOLS = new Set([ + "PAGE_PREVIEW_SET", + "PAGE_PREVIEW_REFRESH", + "PAGE_PREVIEW_PAGE_CREATE", + "DESIGN_SYSTEM_CREATE", + "DESIGN_SYSTEM_SET", +]); + +type PageEntry = { + slug: string; + name: string; + designSystem: string | null; + path: string; + relativePath: string; + url: string; + lastModified: string; +}; + +type DesignSystemEntry = { + slug: string; + name: string; + brand: Record; + path: string; + relativePath: string; + url: string; + lastModified: string; +}; + +type PreviewKind = "page" | "design-system"; + +type PagePreviewStatus = { + pagesDir: string; + activeKind: PreviewKind | null; + activePath: string | null; + activeRelativePath: string | null; + activeUrl: string | null; + activeDesignSystem: string | null; + refreshVersion: number; + pages: PageEntry[]; + designSystems: DesignSystemEntry[]; +}; + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return ""; + const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86_400)}d ago`; +} + +function partToolName(part: unknown): string | null { + if (!part || typeof part !== "object") return null; + const record = part as Record; + if (typeof record.toolName === "string") { + for (const toolName of PREVIEW_STATE_TOOLS) { + if (record.toolName.includes(toolName)) return toolName; + } + return record.toolName; + } + if (typeof record.name === "string") { + for (const toolName of PREVIEW_STATE_TOOLS) { + if (record.name.includes(toolName)) return toolName; + } + return record.name; + } + if (typeof record.type === "string") { + for (const toolName of PREVIEW_STATE_TOOLS) { + if ( + record.type === `tool-${toolName}` || + record.type.includes(toolName) + ) { + return toolName; + } + } + } + return null; +} + +function previewStateToolKey(messages: Array<{ parts?: unknown[] }>): string { + const keys: string[] = []; + for (const message of messages) { + for (const part of message.parts ?? []) { + const toolName = partToolName(part); + if (!toolName || !PREVIEW_STATE_TOOLS.has(toolName)) continue; + const record = part as Record; + const state = + typeof record.state === "string" ? record.state : "output-available"; + if (!state.startsWith("output-")) continue; + keys.push( + typeof record.toolCallId === "string" + ? `${toolName}:${record.toolCallId}` + : `${toolName}:${keys.length}`, + ); + } + } + return keys.join("|"); +} + +export function PagePreviewTab() { + const { org } = useProjectContext(); + const { taskId, tasks } = useChatTask(); + const stream = useOptionalChatStream(); + const [refreshNonce, setRefreshNonce] = useState(0); + const [forceWelcome, setForceWelcome] = useState(false); + const [pageMenuOpen, setPageMenuOpen] = useState(false); + const [dsMenuOpen, setDsMenuOpen] = useState(false); + const [override, setOverride] = useState< + | { kind: "page"; slug: string } + | { kind: "design-system"; slug: string } + | null + >(null); + const taskStatus = tasks.find((task) => task.id === taskId)?.status ?? null; + const [lastTaskStatus, setLastTaskStatus] = useState( + taskStatus, + ); + const [lastPreviewRefreshKey, setLastPreviewRefreshKey] = useState(() => + previewStateToolKey(stream?.messages ?? []), + ); + + const { + data: status, + isLoading, + refetch, + } = useQuery({ + queryKey: KEYS.pagePreviewStatus(org.slug, refreshNonce), + queryFn: async () => { + const response = await fetch( + `/api/${encodeURIComponent(org.slug)}/page-preview/state`, + ); + if (!response.ok) { + throw new Error(`Failed to load page preview: ${response.status}`); + } + return (await response.json()) as PagePreviewStatus; + }, + staleTime: 0, + retry: false, + }); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — DOM event listener; cleanup runs on unmount + useEffect(() => { + const handler = (event: MessageEvent) => { + const data = event.data; + if ( + !data || + typeof data !== "object" || + (data as { type?: unknown }).type !== PROMPT_MESSAGE_TYPE + ) { + return; + } + const text = (data as { text?: unknown }).text; + if (typeof text !== "string" || !text.trim()) return; + composeChatInput(text); + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — reload when the agent calls preview-state tools + useEffect(() => { + const nextKey = previewStateToolKey(stream?.messages ?? []); + if (nextKey === lastPreviewRefreshKey) return; + setLastPreviewRefreshKey(nextKey); + setForceWelcome(false); + setOverride(null); + setRefreshNonce((k) => k + 1); + void refetch(); + }, [stream?.messages, lastPreviewRefreshKey, refetch]); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect — reload preview once when a task run finishes + useEffect(() => { + if (lastTaskStatus === taskStatus) return; + const wasRunning = + lastTaskStatus === "in_progress" || lastTaskStatus === "expired"; + const isRunning = taskStatus === "in_progress" || taskStatus === "expired"; + setLastTaskStatus(taskStatus); + if (wasRunning && !isRunning) { + setForceWelcome(false); + setOverride(null); + setRefreshNonce((k) => k + 1); + void refetch(); + } + }, [lastTaskStatus, taskStatus, refetch]); + + const pages = status?.pages ?? []; + const designSystems = status?.designSystems ?? []; + + const handleSelectPage = async (page: PageEntry) => { + setOverride({ kind: "page", slug: page.slug }); + setForceWelcome(false); + setPageMenuOpen(false); + setRefreshNonce((k) => k + 1); + await refetch(); + }; + + const handleSelectDesignSystem = async (ds: DesignSystemEntry) => { + setOverride({ kind: "design-system", slug: ds.slug }); + setForceWelcome(false); + setDsMenuOpen(false); + setRefreshNonce((k) => k + 1); + await refetch(); + }; + + const handleNewPage = () => { + setForceWelcome(true); + setPageMenuOpen(false); + setDsMenuOpen(false); + }; + + // Determine what to render. + const overridePage = + override?.kind === "page" + ? (pages.find((p) => p.slug === override.slug) ?? null) + : null; + const overrideDs = + override?.kind === "design-system" + ? (designSystems.find((d) => d.slug === override.slug) ?? null) + : null; + + const activePage = + overridePage ?? + (status?.activeKind === "page" + ? (pages.find((p) => p.relativePath === status?.activeRelativePath) ?? + null) + : null); + + // Which one is in the iframe? Override wins; otherwise honor state.activeKind. + const showKind: PreviewKind | null = override + ? override.kind + : status?.activeKind === "design-system" + ? "design-system" + : status?.activeKind === "page" + ? "page" + : activePage + ? "page" + : status?.designSystems?.length + ? "design-system" + : null; + + // The design-system selector should always reflect *something*: when a + // page is showing, it shows the design system that page is bound to; + // when a design system is the live preview, it shows that one. + const dsByPageBinding = activePage?.designSystem + ? (designSystems.find((d) => d.slug === activePage.designSystem) ?? null) + : null; + const activeDs = + overrideDs ?? + (showKind === "design-system" && status?.activeDesignSystem + ? (designSystems.find((d) => d.slug === status.activeDesignSystem) ?? + null) + : null) ?? + dsByPageBinding ?? + (showKind === "design-system" ? (designSystems[0] ?? null) : null); + + // A "fresh chat" is one where no preview-state tool has fired in the + // current stream. We default to the welcome quiz in that case, even + // when pages/design-systems already exist on disk from previous chats — + // so opening a new conversation starts at "what do you want to build?" + // not at the previous run's output. Clicking a page in the dropdown + // (sets `override`) takes the user out of fresh-chat mode. + const isFreshChat = !lastPreviewRefreshKey && !override; + + const liveUrl = + forceWelcome || isFreshChat + ? null + : showKind === "page" + ? (activePage?.url ?? null) + : showKind === "design-system" + ? (activeDs?.url ?? null) + : null; + const usingFallback = !liveUrl; + + const pageLabel = activePage ? activePage.name : "no page"; + const dsLabel = activeDs ? activeDs.name : "no design system"; + + const handleExport = () => { + if (!status) return; + const target = override + ? override + : showKind === "page" && activePage + ? { kind: "page" as const, slug: activePage.slug } + : showKind === "design-system" && activeDs + ? { kind: "design-system" as const, slug: activeDs.slug } + : null; + if (!target) return; + const url = `/api/${encodeURIComponent(org.slug)}/page-preview/export?kind=${encodeURIComponent(target.kind)}&slug=${encodeURIComponent(target.slug)}`; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + if (isLoading && !status) { + return ( +
+ +
+ ); + } + + const exportDisabled = + !status || + usingFallback || + (showKind === "page" && !activePage) || + (showKind === "design-system" && !activeDs); + + return ( +
+
+
+ {/* Page selector */} + + + + + + Pages + {pages.length === 0 ? ( +
+ No pages yet. Ask the agent to build one. +
+ ) : ( + pages.map((p) => { + const selected = + showKind === "page" && activePage?.slug === p.slug; + return ( + void handleSelectPage(p)} + className="flex items-center justify-between gap-2" + > + + + + {p.slug} + + {p.designSystem && ( + + · {p.designSystem} + + )} + + + {formatRelative(p.lastModified)} + + + ); + }) + )} + + + + New page + +
+
+ + · + + {/* Design system selector */} + + + + + + + Design systems + + {designSystems.length === 0 ? ( +
+ No design systems yet. Ask the agent to create one. +
+ ) : ( + designSystems.map((d) => { + const selected = activeDs?.slug === d.slug; + return ( + void handleSelectDesignSystem(d)} + className="flex items-center justify-between gap-2" + > + + + + {d.name} + + + {formatRelative(d.lastModified)} + + + ); + }) + )} +
+
+ + {usingFallback && ( + + (welcome) + + )} +
+ + +
+ {usingFallback ? ( +