From 01e59d96bdea3d26d9db0af04b495c548c458f4e Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Sat, 4 Jul 2026 15:59:57 -0700 Subject: [PATCH] Emit a flat preview.html at generate time for instant first-page display A self-contained, runtime-free render of the entry page, built directly from the IR using the SAME decision code as the app: collectNodeRules/ assembleCss for per-node banded CSS, propsList/resolveTag for tags, attributes, and asset mapping - so the preview cannot drift from what the built app resolves to. No build toolchain involved (~2s, pure string building, byte-stable). Lottie/video render as captured poster stills; zero script tags; asset paths relative to the app dir so the file serves from the artifact file map as-is. Multi-page runs emit the entry page only. The manifest records preview_html so consumers can key on the generated event, show the preview immediately, and swap to the deployed app when the build finishes. Consistency vs the built app's own 1280px renders: 0.8-1.8% pixel diff on reference runs (5.3% on an animation-heavy page - the frozen-animation class, as expected). 520 tests pass (5 new), typecheck clean. Co-Authored-By: Claude Fable 5 --- compiler/src/generate/app.ts | 15 +- compiler/src/generate/manifest.ts | 9 +- compiler/src/generate/pipeline.ts | 2 +- compiler/src/generate/preview.ts | 243 ++++++++++++++++++++++++++++++ compiler/src/site/generateSite.ts | 17 +++ compiler/test/preview.test.ts | 137 +++++++++++++++++ 6 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 compiler/src/generate/preview.ts create mode 100644 compiler/test/preview.test.ts diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 13db98b..7b19c03 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -14,6 +14,7 @@ import type { FontGraph } from "../infer/fonts.js"; import { SYSTEM_FALLBACK } from "../infer/fonts.js"; import { detectComponents, type ComponentPlan, type ComponentCluster } from "../infer/components.js"; import { buildClassMap } from "./classMap.js"; +import { generatePreviewHtml } from "./preview.js"; import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js"; import { planSections, type SectionPlan } from "./sectionSplit.js"; import type { RecipeReport } from "../infer/recipes.js"; @@ -2659,7 +2660,7 @@ export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | und }; } -export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[] } { +export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[]; previewHtml: string } { const { ir, assetGraph, fontGraph, appDir, sourceUrl } = input; const assetMap = buildAssetMap(assetGraph); const framework = input.framework ?? "next"; @@ -2789,6 +2790,16 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals); } writeText(join(rootDir, "ditto.css"), cloneCss); + // Fast, self-contained static preview (runtime-free single HTML file) the service can + // render within seconds of `generate`, BEFORE the Next build + deploy completes. Emitted + // at generated/app/preview.html (assets relative to public/); shares the same IR + css.ts + // rule/banding collectors and the same propsList/resolveTag decision code as the app, + // so it shows what the built app will show without a second maintained emission path. + const previewPath = "preview.html"; + writeText(join(appDir, previewPath), generatePreviewHtml({ + ir, assetMap, fontGraph, tokensCss, sourceUrl, + colorVar: input.colorVar, tokenResolver: input.tokenResolver, + })); writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE); // SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files). if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE); @@ -2830,7 +2841,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: ], }); - return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] }; + return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [], previewHtml: previewPath }; } /** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at diff --git a/compiler/src/generate/manifest.ts b/compiler/src/generate/manifest.ts index 57ee6b2..e18b9b6 100644 --- a/compiler/src/generate/manifest.ts +++ b/compiler/src/generate/manifest.ts @@ -18,8 +18,12 @@ export function buildManifest(args: { capture: CaptureResult; componentCount: number; patternHints?: PatternHints; + // Relative path (within generated/app) of the static preview artifact, when emitted. + // Lets the service/client show the cloned page from the file map BEFORE the Next build + // + deploy finishes, then swap to the deployed app. A fixed string — no timestamps. + previewHtml?: string; }): Record { - const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints } = args; + const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints, previewHtml } = args; const byType: Record = {}; let downloaded = 0, skipped = 0; @@ -58,6 +62,9 @@ export function buildManifest(args: { fallback: fontGraph.entries.filter((f) => f.status === "fallback").length, }, components: { count: componentCount }, + // Static preview artifact (runtime-free, single HTML file) the client can render + // immediately from the file map. Omitted when not emitted. + ...(previewHtml ? { preview_html: previewHtml } : {}), // Frozen-catalog pattern evidence (hint-only, additive): library/platform fingerprints // detected in the IR, with the node cids that carried each signature (bounded pre-order // sample). Deterministic — matches are id-sorted and cids are pre-order, so the same diff --git a/compiler/src/generate/pipeline.ts b/compiler/src/generate/pipeline.ts index 8411a45..5706ac7 100644 --- a/compiler/src/generate/pipeline.ts +++ b/compiler/src/generate/pipeline.ts @@ -111,7 +111,7 @@ export function generateAll(opts: { const codeQuality = buildCodeQualityReport(appDir, recipeReport); writeJSON(join(outDir, "code-quality.json"), codeQuality); writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality)); - const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints }); + const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints, previewHtml: gen.previewHtml }); writeJSON(join(outDir, "manifest.json"), manifest); return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing }; diff --git a/compiler/src/generate/preview.ts b/compiler/src/generate/preview.ts new file mode 100644 index 0000000..f1a78ab --- /dev/null +++ b/compiler/src/generate/preview.ts @@ -0,0 +1,243 @@ +import { isTextChild, type IR, type IRNode, type IRChild } from "../normalize/ir.js"; +import type { FontGraph } from "../infer/fonts.js"; +import { SYSTEM_FALLBACK } from "../infer/fonts.js"; +import { collectNodeRules, keyframesCss, computeBands, assembleCss, RESET_CSS } from "./css.js"; +import type { TokenResolver } from "../infer/tokens.js"; +import { propsList, resolveTag, resolveHtmlBg, htmlBgRule } from "./app.js"; + +/** + * Fast, self-contained static preview artifact (`preview.html`). + * + * Product moment: the service shows the cloned page within seconds of `generate` + * finishing — BEFORE the Next.js build + deploy completes — then swaps to the + * deployed app. This artifact renders WITHOUT any build toolchain (no next / tailwind + * / esbuild): it is pure string building over the same frozen IR + rules the JSX + * emitter consumes. + * + * SHARED DECISION CODE (not a parallel emission path): + * - CSS: `collectNodeRules` + `keyframesCss` + `computeBands` + `assembleCss` from + * css.ts — the identical per-node rule collection + media-query banding the semantic + * class-map emitter (classMap.ts) and the legacy per-node emitter (generateCss) use. + * We emit `.c` selectors, exactly like `generateCss`, so the preview's computed + * styles are byte-for-byte the same rules the built app resolves to. Crucially this + * holds even though the app's DEFAULT output is Tailwind (utilities in the JSX, no + * `.c` rules) — the preview reuses the css-mode rule collector directly. + * - HTML: `propsList` + `resolveTag` from app.ts — the SAME tag resolution, attribute + * filtering, asset-URL mapping (src/poster/srcset), video/lottie poster stills, and + * class assignment the JSX renderer uses. We translate the JSX-flavoured attribute + * pairs to raw HTML attributes; we do NOT re-derive which attrs/assets to keep. + * + * Runtime-free: no DittoWire / DittoLottie / DittoMotion scripts. Lottie mounts and + *