diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index b7da100ee..d75cfba72 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -55,14 +55,18 @@ const FREEFORM_BASE = [ const FREEFORM_STYLE = [ "", "STYLE:", - "- You may use inline `style` objects, `@posthog/quill` components, or a ``; + +// A LAYERED element reset for the LEGACY v3 path only. v3's Play CDN preflight is +// unlayered (it clobbers Quill's `@layer components`), so we run it with preflight +// off and ship this minimal reset in `@layer base` — pinned below `components` so +// Quill keeps winning and bare HTML elements still get tamed. v4 doesn't need it +// (its own preflight is correctly layered). +const LEGACY_RESET = ``; + +// Legacy Tailwind v3 Play CDN path (preflight off + hand-mirrored token map). +// Retained as a fallback behind TAILWIND_ENGINE while v4 is validated. +const TAILWIND_V3 = ` ` +`; + +export function buildSandboxDocument( + mode: SandboxMode, + // The PostHog host, when in-iframe analytics/replay is enabled. Opens CSP for + // posthog-js to load its recorder and POST events/replay to ingest. + analyticsApiHost?: string, +): string { + const importMap = JSON.stringify(buildImportMap()); + const csp = contentSecurityPolicy(mode, analyticsApiHost); + + // Quill components emit Tailwind utility classes (layout — `inline-flex`, + // `items-center` — AND token colors like `bg-card`, `text-muted-foreground`) + // ALONGSIDE their `.quill-*` BEM classes. The linked Quill stylesheets style + // the BEM half; the utilities are dead without Tailwind, so the sandbox runs a + // JIT-in-browser Tailwind in EDIT mode (View/published mode forbids the CDN — + // that tier self-hosts a compiled stylesheet, Phase 2). Quill is authored for + // Tailwind v4, so we run the v4 browser engine: its preflight is properly + // `@layer base` (sorts BELOW Quill's `@layer components`, so it can't clobber + // them — no preflight-off hack, no hand-rolled reset), it has native `not-*` + // variants (no `not-disabled` shim), and `@theme inline` maps Quill's tokens + // straight to v4 color keys. The whole hand-mirrored color map + reset the v3 + // Play CDN forced us into collapses to the token block below. + const tailwind = + mode === "edit" + ? TAILWIND_ENGINE === "v4" + ? TAILWIND_V4 + : TAILWIND_V3 : ""; + // v4 preflight is the layered reset; only the legacy v3 path needs the manual + // `@layer base` reset (v3's Play CDN preflight is unlayered, so it's off). + const reset = mode === "edit" && TAILWIND_ENGINE === "v3" ? LEGACY_RESET : ""; // The bootstrap module. It is static (no user input) so it can be inlined // safely. It waits for `init`, transpiles the canvas with Babel, runs it from @@ -298,6 +361,7 @@ export function buildSandboxDocument( ${tailwind} +${reset} ${FREEFORM_QUILL_CSS_URLS.map( (href) => ``, ).join("\n")} @@ -337,20 +401,29 @@ function contentSecurityPolicy( : ""; if (mode === "edit") { + // Only the ACTIVE Tailwind engine's CDN is trusted (not both), and the v4 + // build is path-scoped to the @tailwindcss namespace on jsdelivr rather than + // the whole origin — both narrow the code-execution sandbox's egress to + // exactly what it fetches. v3's Play CDN loads from arbitrary sub-paths, so + // it stays origin-scoped (it's only the fallback, off by default). + const twCdn = + TAILWIND_ENGINE === "v4" + ? "https://cdn.jsdelivr.net/npm/@tailwindcss/" + : "https://cdn.tailwindcss.com"; return [ "default-src 'none'", // Inline bootstrap + esm.sh modules + the transpiled Blob module + the - // posthog-js recorder script + the Tailwind Play CDN (which JIT-compiles - // in-browser, so it needs 'unsafe-eval'). The CDN is edit-mode ONLY — view - // mode keeps egress locked and self-hosts styles instead. - `script-src 'unsafe-inline' 'unsafe-eval' blob: https://cdn.tailwindcss.com ${esm} ${ph}`, + // posthog-js recorder script + the in-browser Tailwind engine (JIT-compiles, + // so 'unsafe-eval' is required). Edit-mode ONLY — view mode keeps egress + // locked and self-hosts styles instead. + `script-src 'unsafe-inline' 'unsafe-eval' blob: ${twCdn} ${esm} ${ph}`, `style-src 'unsafe-inline' ${esm}`, `font-src data: ${esm}`, "img-src data: blob: https:", `worker-src blob:`, - // esm.sh sub-fetches; canvas DATA goes over postMessage (not connect), but - // posthog-js events/replay DO use connect to the PostHog hosts. - `connect-src ${esm} ${ph}`, + // esm.sh + Tailwind CDN sub-fetches; canvas DATA goes over postMessage (not + // connect), but posthog-js events/replay DO use connect to the PostHog hosts. + `connect-src ${esm} ${twCdn} ${ph}`, ].join("; "); } // view / published: self-hosted, frozen. Only egress is PostHog analytics. diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index 08d300c43..32631fc0c 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -1,4 +1,5 @@ import { freeformSystemPromptFor } from "@posthog/core/canvas/canvasTemplates"; +import { FREEFORM_STARTER_CODE } from "@posthog/core/canvas/freeformStarter"; // Builds the prompt for the task that generates a freeform (React) canvas. Like // CONTEXT.md generation, this runs as a normal repo-less agent task (no repo @@ -17,6 +18,10 @@ export function buildFreeformGenerationPrompt(input: { instruction: string; // The current source, when editing an existing canvas. Omitted for a first build. currentCode?: string; + // Default on (opt out via the generate bar): seed a known-good starter + // scaffold as the agent's baseline on a FIRST build, so it edits a compiling + // app instead of authoring boilerplate from scratch. Ignored when editing. + useStarter?: boolean; }): string { const { dashboardId, @@ -25,6 +30,7 @@ export function buildFreeformGenerationPrompt(input: { templateId, instruction, currentCode, + useStarter, } = input; const contract = freeformSystemPromptFor(templateId); @@ -41,6 +47,14 @@ export function buildFreeformGenerationPrompt(input: { ? `\n[Current code] — the canvas as it stands now. Rewrite the WHOLE file with the change applied; do not output a partial file.\n\n\`\`\`tsx\n${currentCode}\n\`\`\`\n` : ""; + // First-build only: hand the agent a working scaffold to build ON instead of + // authoring from zero. It already wires the easy-to-get-wrong bits (date + // picker, theme tokens, loading skeletons, typed-node result reading). + const starterBlock = + !isEdit && useStarter + ? `\n[Starter scaffold] — begin from this WORKING baseline instead of authoring from scratch. It already wires the things that are easy to get wrong: the date picker, theme-aware tokens, per-card loading skeletons, and reading a typed-node result correctly. KEEP that wiring; replace the sample "total events" metric and the layout with what the user asked for, and output the COMPLETE rewritten file.\n\n\`\`\`tsx\n${FREEFORM_STARTER_CODE}\n\`\`\`\n` + : ""; + // The standing authoring contract + publishing/data rules are the same // boilerplate on every canvas generation — the user never typed them. Wrap // them in a `` element so the conversation UI @@ -48,7 +62,7 @@ export function buildFreeformGenerationPrompt(input: { // inline (see extractCanvasInstructions). Kept after the user's instruction so // the request leads, mirroring how channel CONTEXT.md is appended. const instructions = `${header} -${currentBlock} +${currentBlock}${starterBlock} Follow this authoring contract for the canvas (imports, the \`ph\` data shim, and style rules): diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index f01465a2e..7391e40c8 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -53,6 +53,8 @@ export function useGenerateFreeformCanvas(args: { async (opts: { instruction: string; currentCode?: string; + // Default on (opt out in the bar): seed the starter scaffold on first build. + useStarter?: boolean; }): Promise => { setIsStarting(true); try { @@ -65,6 +67,7 @@ export function useGenerateFreeformCanvas(args: { templateId, instruction: opts.instruction, currentCode: opts.currentCode, + useStarter: opts.useStarter, }), taskDescription: `Generate canvas "${name}"`, // Unattended generation: run in auto mode so it doesn't stall on edit-approval prompts.