From 361bd117e174f18a6ec2747bf3f6fabdc066c5b4 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 26 Jun 2026 12:32:36 +0100 Subject: [PATCH 1/2] feat(canvas): scaffold-by-default freeform builds + Tailwind v4 JIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed freeform canvas generation with a known-good starter scaffold by default (opt out in the generate bar): the agent edits a compiling app instead of authoring boilerplate from scratch — faster, fewer first-render errors. Threaded as a useStarter flag through the generate hook + prompt builder; the scaffold wires the easy-to-get-wrong bits (date picker, theme tokens, loading skeletons, typed-node result reading, refresh). Swap the edit-mode sandbox Tailwind engine from the v3 Play CDN to the v4 browser JIT (Quill is authored for v4). v4's layered preflight, native not-* variants, and @theme inline token mapping let us drop the preflight-off hack, the not-disabled shim, the hand-rolled reset, and the hand-mirrored color map. Pinned to 4.3.1; v3 kept behind a flag. Tighten the authoring contract: Tailwind utilities over inline style; token-only color with the status-token foreground convention (text-success-foreground, not bare text-success); date picker rendered bare with PopoverContent w-auto p-0 and no compact. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/canvas/canvasTemplates.ts | 15 +- packages/core/src/canvas/freeformSchemas.ts | 2 +- packages/core/src/canvas/freeformStarter.ts | 186 ++++++++++++++++++ packages/core/src/canvas/posthogApi.ts | 2 +- .../canvas/freeform/FreeformGenerateBar.tsx | 52 +++-- .../canvas/freeform/sandboxRuntime.ts | 163 ++++++++++----- .../ui/src/features/canvas/freeformPrompt.ts | 16 +- .../canvas/hooks/useGenerateFreeformCanvas.ts | 3 + 8 files changed, 369 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/canvas/freeformStarter.ts diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index b7da100ee5..d75cfba72b 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")} @@ -340,17 +404,18 @@ function contentSecurityPolicy( 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 (v4 browser + // build from jsdelivr, or the legacy v3 Play CDN) — both JIT-compile, 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: https://cdn.tailwindcss.com https://cdn.jsdelivr.net ${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 + jsdelivr sub-fetches; canvas DATA goes over postMessage (not + // connect), but posthog-js events/replay DO use connect to the PostHog hosts. + `connect-src ${esm} https://cdn.jsdelivr.net ${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 08d300c431..32631fc0c7 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 f01465a2e7..7391e40c81 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. From 891932702f51dd4eed7863fbaf1b06d01d71f13d Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 26 Jun 2026 12:46:27 +0100 Subject: [PATCH 2/2] fix(canvas): scope edit-mode CSP to the active Tailwind CDN; fix stale starter doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: - CSP now trusts only the ACTIVE engine's CDN (not both v3 + v4), and the v4 build is path-scoped to cdn.jsdelivr.net/npm/@tailwindcss/ rather than the whole jsdelivr origin — narrows the sandbox's egress to what it fetches. Re-validated live: v4 still compiles, canvas renders styled, no CSP errors. - Correct the freeformStarter.ts header (drop stale "COMPACT" / "opt-in"; scaffold is on by default and the picker self-sizes without compact). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/canvas/freeformStarter.ts | 14 ++++++------ .../canvas/freeform/sandboxRuntime.ts | 22 +++++++++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/core/src/canvas/freeformStarter.ts b/packages/core/src/canvas/freeformStarter.ts index 424ca5128e..6570e1a5b2 100644 --- a/packages/core/src/canvas/freeformStarter.ts +++ b/packages/core/src/canvas/freeformStarter.ts @@ -1,10 +1,10 @@ -// A known-good STARTER scaffold for a freeform (React) canvas. Experimental: -// instead of authoring the whole single-file app from scratch every time, the -// generation path can seed this working baseline as the agent's starting point -// (opt-in via the generate bar toggle). It already wires the pieces that are -// easy to get wrong — the COMPACT date picker, theme-aware tokens, per-card -// loading skeletons, and reading a TYPED-NODE result correctly — so the agent -// edits a compiling app instead of re-deriving boilerplate. +// A known-good STARTER scaffold for a freeform (React) canvas. Instead of +// authoring the whole single-file app from scratch every time, the generation +// path seeds this working baseline as the agent's starting point (on by default; +// opt out via the generate bar toggle). It already wires the pieces that are +// easy to get wrong — the date picker (self-sizing, no `compact`), theme-aware +// tokens, per-card loading skeletons, and reading a TYPED-NODE result correctly +// — so the agent edits a compiling app instead of re-deriving boilerplate. // // Stored as a string (like the prompt contracts) — it is injected into the // generation prompt, not compiled here. It imports ONLY whitelisted packages diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 4010925f5d..8f7f6fee9b 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -401,21 +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 in-browser Tailwind engine (v4 browser - // build from jsdelivr, or the legacy v3 Play CDN) — both JIT-compile, 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: https://cdn.tailwindcss.com https://cdn.jsdelivr.net ${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 + jsdelivr sub-fetches; canvas DATA goes over postMessage (not + // 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} https://cdn.jsdelivr.net ${ph}`, + `connect-src ${esm} ${twCdn} ${ph}`, ].join("; "); } // view / published: self-hosted, frozen. Only egress is PostHog analytics.