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.