#!/usr/bin/env node import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readFile, rm } from "node:fs/promises"; import { businessNameFromProject, docsDir, ensureDir, parseArgs, writeJson, writeText, } from "./lib/common.mjs"; import { createFrontendAutomationState, renderScopeSummary, resolveFrontendContext, updateScopeSummaryCheckpoint, writeFrontendAutomationState, } from "./lib/frontend-automation-state.mjs"; import { discover } from "./discover-urls.mjs"; import { ensureBrowserExtractionReady } from "./lib/browser-tooling.mjs"; import { extractPage } from "./extract-page.mjs"; import { extractAssets } from "./extract-assets.mjs"; import { extractInteractions } from "./extract-interactions.mjs"; import { extractSeoForUrl } from "./extract-seo.mjs"; import { extractDesignSystem } from "./extract-design-system.mjs"; import { generateDesignMd } from "./generate-design-md.mjs"; import { generateSceneContract } from "./generate-scene-contract.mjs"; import { generateLayoutBlueprint } from "./generate-layout-blueprint.mjs"; import { generateUiNormalization } from "./generate-ui-normalization.mjs"; import { generateControlStateContract } from "./generate-control-state-contract.mjs"; import { generateVisualAssets } from "./generate-visual-assets.mjs"; import { createWixProject, ensureYarnProjectBoundary, verifyWixRuntimeProject } from "./create-wix-project.mjs"; import { validateProjectFontFaces } from "./lib/font-contract.mjs"; import { visualQa } from "./visual-qa.mjs"; import { buildExtractedHomepage } from "./build-extracted-homepage.mjs"; import { resolveHomePage } from "./resolve-home-page.mjs"; import { assembleExtraction } from "./assemble-extraction.mjs"; import { finalizeExtractionReport } from "./finalize-extraction-report.mjs"; const execFileAsync = promisify(execFile); async function main() { const args = parseArgs(); const context = await resolveFrontendContext({ args }); const requestedSourceUrl = context.sourceUrl; let sourceUrl = requestedSourceUrl; const scope = context.scope; const outputDir = context.outputDir; const projectName = path.basename(outputDir); const docs = docsDir(outputDir); const runId = `site-clone-${Date.now().toString(36)}`; const automationState = createFrontendAutomationState(context); if (scope !== "home" || context.explicitUrls.length) { throw new Error("Spec 0083 currently implements the home-page workflow only. Remove --urls and use --scope home; additional-page resolution requires a future approved workflow."); } // Standalone runs must own one runnable project directory. Create it before // writing extraction artifacts: the Wix CLI will not scaffold into a folder // that already contains docs/site-clone or downloaded assets. const shouldProvision = context.mode === "standalone" && scope === "home" && !context.explicitUrls.length; let wixProject = null; if (shouldProvision) { wixProject = await ensureStandaloneWixProject({ sourceUrl, outputDir, projectName, args }); } else if (context.mode === "migration_phase" && context.frontendPhase === "build") { await ensureYarnProjectBoundary(outputDir); const runtime = await verifyWixRuntimeProject(outputDir); if (!runtime.ok) { throw new Error(`Migration phase build requires the existing frontend project to be runnable: ${runtime.summary}`); } wixProject = { executed: true, reused: true, migrationManaged: true, ...runtime }; } await ensureDir(docs); await assertNoLegacyResume(docs, args); await prepareCanonicalDocsDir(docs); console.log("[site-clone] resolving home page"); const resolution = await resolveHomePage(requestedSourceUrl); if (!resolution.artifact) { await writeJson(path.join(docs, "page-resolution.spec.json"), { schemaVersion: "0083.1", kind: "page-resolution", gaps: resolution.gaps, }); const error = new Error(resolution.gaps[0]?.reason || "Home-page resolution failed"); error.code = "PAGE_RESOLUTION_BLOCKED"; throw error; } sourceUrl = resolution.artifact.source.resolvedUrl; await writeJson(path.join(docs, "page-resolution.spec.json"), resolution.artifact); await writeJson(path.join(docs, "run-manifest.json"), { runId, sourceUrl, outputDir, mode: context.mode, automationMode: context.automationMode, frontendPhase: context.frontendPhase, handoffPath: context.handoffPath, startedAt: new Date().toISOString(), }); await writeFrontendAutomationState(outputDir, automationState); const explicitUrls = context.explicitUrls; console.log(`[site-clone] discovery started (${scope}${explicitUrls.length ? `, explicit URLs: ${explicitUrls.length}` : ""})`); const discovery = await discover({ sourceUrl, scope, explicitUrls }); await writeJson(path.join(docs, "discovery.json"), discovery); const scopeSummaryPath = path.join(docs, "scope-summary.md"); await writeText(scopeSummaryPath, renderScopeSummary(context, discovery)); console.log(`[site-clone] discovery complete: ${discovery.totalDiscovered} page(s), ${discovery.representativePages.length} representative page(s)`); if (discovery.requiresConfirmation && !args.yes && !context.autoApprove) { updateScopeSummaryCheckpoint(automationState, { status: "pending", discovery, artifactRefs: ["docs/site-clone/discovery.json", "docs/site-clone/scope-summary.md"], notes: "Non-homepage scope requires confirmation before extraction/building in manual mode.", }); await writeFrontendAutomationState(outputDir, automationState); printConfirmationSummary(discovery, outputDir); console.log(""); console.log("Review the scope above, then rerun with --yes to continue or pass --urls/--scope to adjust."); return; } updateScopeSummaryCheckpoint(automationState, { status: "approved", discovery, artifactRefs: ["docs/site-clone/discovery.json", "docs/site-clone/scope-summary.md"], decidedBy: context.autoApprove ? "agent" : discovery.requiresConfirmation ? "user" : "system", notes: context.autoApprove ? "Migration phase 1-click mode auto-approved the frontend scope summary." : discovery.requiresConfirmation ? "Scope summary confirmed by explicit --yes continuation." : "Scope confirmation was not required for this run.", }); await writeFrontendAutomationState(outputDir, automationState); console.log("[site-clone] browser preflight started"); const browserTooling = await ensureBrowserExtractionReady({ startDir: process.cwd() }); console.log("[site-clone] browser preflight complete"); const extractionWarnings = []; const pages = []; const pagesDir = path.join(docs, "pages"); await ensureDir(pagesDir); for (const page of discovery.representativePages) { console.log(`[site-clone] extracting page ${page.path || page.url}`); const extracted = await extractPage(page.url, { outputDir, screenshots: args.screenshots !== "false", browserTooling }); pages.push(extracted); await writeJson(path.join(pagesDir, `${safePageName(page)}.json`), extracted); } console.log("[site-clone] extracting SEO and assets"); const seo = await extractSeoForUrl(sourceUrl); const assets = await extractAssets(sourceUrl, { outputDir, download: args.download !== "false" }); await writeJson(path.join(docs, "seo.json"), seo); await writeJson(path.join(docs, "assets.json"), assets); await writeJson(path.join(docs, "fonts.json"), assets.fonts); console.log("[site-clone] extracting interactions"); const interactionMap = await loadOrExtractInteractions({ sourceUrl, outputDir, browserTooling }); await writeJson(path.join(docs, "interaction-map.json"), interactionMap); const enrichedPages = interactionMap.pages?.length ? interactionMap.pages : pages; pages.length = 0; pages.push(...enrichedPages); for (const page of pages) { await writeJson(path.join(pagesDir, `${safePageName(page)}.json`), page); } const sourceMap = { sourceUrl, normalizedAt: new Date().toISOString(), scope: discovery.scope, project: { name: projectName, outputDir, businessName: args["business-name"] || businessNameFromProject(projectName), }, discovery, pages, assets: assets.assets, fonts: { families: assets.fonts?.families || [], faceCount: assets.fonts?.faces?.length || 0, contentScripts: assets.fonts?.contentScripts || [], }, interactions: { interactionCount: interactionMap.summary?.interactionCount || 0, kinds: interactionMap.summary?.kinds || {}, targetCount: interactionMap.summary?.targetCount || 0, meaningfulCaptureCount: interactionMap.summary?.meaningfulCaptureCount || 0, }, seo, tokens: {}, extractionWarnings, }; await writeJson(path.join(docs, "source-map.json"), sourceMap); console.log("[site-clone] extracting design system"); const designSystem = await extractDesignSystem({ sourceUrl, outputDir, extractor: args["design-extractor"], browserTooling, }); const tokens = designSystem.tokens; sourceMap.tokens = tokens; sourceMap.designExtraction = { selectedExtractor: designSystem.selectedExtractor, actualExtractor: designSystem.actualExtractor || designSystem.extractor, fallbackReason: designSystem.fallbackReason, attempts: designSystem.attempts || [], }; await writeJson(path.join(docs, "tokens.json"), tokens); await writeJson(path.join(docs, "source-map.json"), sourceMap); console.log("[site-clone] projecting interaction, visual, and layout observations"); const sceneContract = await generateSceneContract({ outputDir, pages, interactionMap }); const controlStateContract = await generateControlStateContract({ outputDir, interactionMap }); const visualAssets = await generateVisualAssets({ outputDir, pages, assets }); const layoutBlueprint = await generateLayoutBlueprint({ outputDir, pages, interactionMap, sceneContract }); const uiNormalization = await generateUiNormalization({ outputDir, pages, interactionMap, sceneContract, layoutBlueprint }); const designMd = designSystem.designMd?.trim() ? designSystem.designMd : await generateDesignMd({ outputDir, sourceUrl }); await writeText(path.join(docs, "design.md"), `${designMd.trim()}\n\n${renderVisualAssetsDesignSummary(visualAssets)}\n\n${renderControlStateDesignSummary(controlStateContract)}\n\n${renderLayoutBlueprintDesignSummary(layoutBlueprint)}\n\n${renderUiNormalizationDesignSummary(uiNormalization)}\n`); console.log("[site-clone] assembling and freezing extraction specs"); const decisionPatches = args["decision-patches"] ? JSON.parse(await readFile(path.resolve(String(args["decision-patches"])), "utf8")) : []; const extraction = await assembleExtraction({ outputDir, requestedUrl: requestedSourceUrl, resolvedUrl: sourceUrl, canonicalUrl: resolution.artifact.source.canonicalUrl, page: pages[0], assets, seo, tokens, interactionMap, sceneContract, layoutBlueprint, uiNormalization, controlStateContract, visualAssets, sourceFingerprint: resolution.artifact.source.fingerprint, decisionPatches: Array.isArray(decisionPatches) ? decisionPatches : decisionPatches.patches || [], pageResolutionArtifact: resolution.artifact, }); await removeLegacyExtractionHandoffs(docs); if (context.frontendPhase === "build" && scope === "home") { console.log(`[site-clone] building home page from frozen manifest ${extraction.manifest.manifestHash}`); await installInteractionRuntime(outputDir); await buildExtractedHomepage({ outputDir }); } if (!wixProject && context.mode === "standalone") { console.log("[site-clone] preparing Wix project metadata"); wixProject = await createWixProject({ sourceUrl, outputDir, projectName, businessName: args["business-name"] || businessNameFromProject(projectName), template: args.template, execute: Boolean(args["execute-create"]), }); } if (wixProject?.executed) { const runtime = await verifyWixRuntimeProject(outputDir); if (!runtime.ok) throw new Error(`Wix project contract failed: ${runtime.summary}`); } let qa = null; let fontValidation = null; if (context.frontendPhase === "build") { console.log("[site-clone] running visual QA"); qa = await visualQa({ outputDir }); fontValidation = await validateProjectFontFaces({ projectRoot: outputDir, fontManifest: assets.fonts }); await writeJson(path.join(docs, "qa", "visual-qa.json"), qa); await writeJson(path.join(docs, "qa", "font-validation.json"), fontValidation); const finalReport = await finalizeExtractionReport({ outputDir }); console.log(`[site-clone] reconstruction status: ${finalReport.status}`); } await writeJson(path.join(docs, "run-manifest.json"), { runId, sourceUrl, outputDir, scope: discovery.scope, mode: context.mode, automationMode: context.automationMode, frontendPhase: context.frontendPhase, handoffPath: context.handoffPath, representativePages: discovery.representativePages.map((page) => page.url), actualDesignExtractor: designSystem.actualExtractor || designSystem.extractor, completedAt: new Date().toISOString(), }); console.log(`Site clone artifacts written to ${docs}`); console.log(`Output project: ${outputDir}`); if (context.frontendPhase === "plan") { console.log("Migration phase plan mode completed: extraction and planning artifacts were refreshed, and build-only project verification/QA were intentionally skipped."); } else if (!wixProject?.executed) { console.log("Wix project creation command prepared:"); console.log(wixProject.command); console.log("Run with --execute-create when ready to create the Wix Headless project."); } if (wixProject?.localOutputRename) { console.log(`Wix CLI folder name normalized to "${wixProject.wixFolderName}" and should be renamed to "${projectName}" after creation.`); } if (qa) { console.log(`Heuristic QA score: ${qa.score}/100`); console.log("After implementation: start the clone, run post-build-gap.mjs with --result-url, review every screenshot pair, then complete the bounded gap-fix loop."); } } async function ensureStandaloneWixProject({ sourceUrl, outputDir, projectName, args }) { const packagePath = path.join(outputDir, "package.json"); try { await readFile(packagePath, "utf8"); await ensureYarnProjectBoundary(outputDir); const existing = await verifyWixRuntimeProject(outputDir); if (!existing.ok) throw new Error(`Existing standalone output is not a runnable Wix project: ${existing.summary}`); console.log("[site-clone] reusing existing Wix project"); return { executed: true, reused: true, ...existing }; } catch (error) { if (error?.code !== "ENOENT") throw error; } console.log("[site-clone] provisioning Wix project before extraction"); return createWixProject({ sourceUrl, outputDir, projectName, businessName: args["business-name"] || businessNameFromProject(projectName), template: args.template, execute: true, }); } async function loadOrExtractInteractions({ sourceUrl, outputDir, browserTooling }) { const artifact = path.join(docsDir(outputDir), "interaction-map.json"); try { const existing = JSON.parse(await readFile(artifact, "utf8")); if (existing.sourceUrl === sourceUrl && Array.isArray(existing.interactions)) { console.log("[site-clone] reusing completed interaction map"); return existing; } } catch { // No compatible checkpoint; perform a fresh extraction below. } return extractInteractionsIsolated({ sourceUrl, outputDir, browserTooling }); } async function extractInteractionsIsolated({ sourceUrl, outputDir, browserTooling }) { const script = new URL("./extract-interactions.mjs", import.meta.url); try { await execFileAsync(process.execPath, [ script.pathname, sourceUrl, "--out", outputDir, "--project-root", browserTooling.projectRoot, ], { timeout: 240000, maxBuffer: 1024 * 1024 }); } catch (error) { throw new Error(`interaction extraction failed: ${error.stderr || error.message}`); } const artifact = path.join(docsDir(outputDir), "interaction-map.json"); try { return JSON.parse(await readFile(artifact, "utf8")); } catch { throw new Error("interaction extraction exited without interaction-map.json"); } } async function prepareCanonicalDocsDir(docs) { const generatedPaths = [ "pages", "components", "screenshots", "design-md-generator", ".design-md-generator-run", "design.md", "discovery.json", "routes.json", "repeater-cms.json", "repeater-cms.md", "source-map.json", "seo.json", "assets.json", "interaction-qa.json", "scene-contract.json", "control-state-contract.json", "control-state-contract.md", "visual-assets.json", "visual-assets.md", "layout-blueprint.json", "layout-blueprint.md", "ui-normalization.json", "ui-normalization.md", "fonts.json", "font-validation.json", "tokens.json", "visual-qa.json", "scope-summary.md", "frontend-automation-state.json", "run-manifest.json", "page-resolution.spec.json", "extraction", "build", "qa", "gap-analysis", "final-report.json", "final-report.md", ]; for (const relativePath of generatedPaths) { await rm(path.join(docs, relativePath), { recursive: true, force: true }); } } async function assertNoLegacyResume(docs, args) { try { await readFile(path.join(docs, "run-manifest.json"), "utf8"); } catch (error) { if (error?.code === "ENOENT") return; throw error; } try { await readFile(path.join(docs, "extraction", "latest.json"), "utf8"); return; } catch (error) { if (error?.code !== "ENOENT") throw error; } if (args["restart-0083"] === true || args["restart-0083"] === "true") return; throw new Error("This output contains a pre-0083 headless run and cannot be resumed. Rerun with --restart-0083 to discard its generated frontend evidence and restart from home-page resolution plus fresh capture."); } async function removeLegacyExtractionHandoffs(docs) { const obsolete = [ "pages", "components", "source-map.json", "seo.json", "assets.json", "fonts.json", "tokens.json", "interaction-map.json", "scene-contract.json", "control-state-contract.json", "control-state-contract.md", "visual-assets.json", "visual-assets.md", "layout-blueprint.json", "layout-blueprint.md", "ui-normalization.json", "ui-normalization.md", "routes.json", "repeater-cms.json", "repeater-cms.md", "design.md", "page-resolution.spec.json", ]; for (const relativePath of obsolete) await rm(path.join(docs, relativePath), { recursive: true, force: true }); } export async function installInteractionRuntime(outputDir) { const source = new URL("./lib/interaction-runtime.mjs", import.meta.url); const sourceText = await readFile(source, "utf8"); const target = path.join(outputDir, "src", "lib", "rp-interactions.mjs"); let existing = ""; try { existing = await readFile(target, "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; } if (!existing || existing.includes("@generated-source wix-headless-replatform")) { await ensureDir(path.dirname(target)); await writeText(target, sourceText); } const publicDir = path.join(outputDir, "public", "site-clone"); await ensureDir(publicDir); await writeText(path.join(publicDir, "rp-interactions.mjs"), sourceText); await writeText( path.join(publicDir, "rp-interactions-bootstrap.mjs"), await readFile(new URL("./lib/interaction-bootstrap.mjs", import.meta.url), "utf8"), ); const normalizationCss = await readFile(new URL("./lib/ui-normalization.css", import.meta.url), "utf8"); await writeText(path.join(publicDir, "rp-ui-normalize.css"), normalizationCss); const sourceStyleDir = path.join(outputDir, "src", "styles"); await ensureDir(sourceStyleDir); const sourceStyleTarget = path.join(sourceStyleDir, "rp-ui-normalize.css"); let existingStyle = ""; try { existingStyle = await readFile(sourceStyleTarget, "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; } if (!existingStyle || existingStyle.includes("@generated-source wix-headless-replatform")) { await writeText(sourceStyleTarget, normalizationCss); } } function printConfirmationSummary(discovery, outputDir) { console.log(`Output directory: ${outputDir}`); console.log(`Discovered ${discovery.totalDiscovered} same-origin URL(s) for scope "${discovery.scope}".`); if (discovery.inScopePages) console.log(`In-scope implementation URL(s): ${discovery.inScopePages.length}`); if (discovery.preservedPages) console.log(`Preserved fallback URL(s): ${discovery.preservedPages.length}`); for (const [area, count] of Object.entries(discovery.countsByArea)) { console.log(`- ${area}: ${count}`); } const dynamicAreas = ["product", "product-category", "blog-post", "blog-index", "cms-content", "bookings", "events", "pricing"] .filter((area) => discovery.countsByArea[area]); if (dynamicAreas.length) { console.log(""); console.log(`Dynamic Wix SDK-backed templates: ${dynamicAreas.join(", ")}`); } if (discovery.excluded.length) { console.log(""); console.log(`Excluded URL records: ${discovery.excluded.length}`); } } function parseUrlList(value, sourceUrl) { if (!value) return []; return String(value) .split(",") .map((item) => new URL(item.trim(), sourceUrl).toString()); } function safePageName(page) { return `${page.area}-${String(page.path || "home").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "home"}`; } function renderUiNormalizationDesignSummary(contract) { return `## Identity-Preserving UI Normalization\n\nIdentity locks outrank polish. Preserve content, section order/archetype, brand tokens, media, CTA hierarchy, and interaction model. Do not select a new design style.\n\nLoad once: \`\`\n\nMotion: hover/focus is subtle and non-structural; click/activation may change major geometry only when required by the scene. Captured timing overrides defaults. Use easing and respect \`prefers-reduced-motion\`.\n\nSection recipes:\n${(contract.sections || []).map((section) => `- \`${section.sectionId}\` / \`${section.layout}\`: ${section.recipe.join(" ")}`).join("\n") || "- none"}`; } function renderLayoutBlueprintDesignSummary(contract) { return `## Section Layout Blueprints\n\nBuild each section independently in this order: canvas, background layers, container, semantic regions, relationships, responsive reflow, then UI normalization. Composition and background-media role are identity locks.\n\n${(contract.pages || []).flatMap((page) => page.sections || []).map((section) => `- \`${section.sectionId}\` / \`${section.composition}\` / \`${section.canvas.widthMode}-${section.canvas.heightMode}\`: ${section.implementation.steps.join(" ")}`).join("\n") || "- none"}`; } function renderControlStateDesignSummary(contract) { return `## Control State Fidelity\n\nImplement source-observed rest, hover, focus-visible, pressed, activated, current, and disabled deltas on their measured owner. Nested icon motion must not be promoted to the whole control.\n\n${(contract.controls || []).map((control) => `- \`${control.scope}/${control.role}\` ${(control.members || [control.label]).join(", ")}: ${Object.keys(control.states).map((state) => `\`${state}\``).join(", ") || "rest only"}`).join("\n") || "- no representative controls captured"}`; } function renderVisualAssetsDesignSummary(contract) { return `## Source Visual Assets\n\nLogos are identity locks: use exact source/materialized files, preserve every observed variant and target size, and never recreate a logo as text. Icons follow source asset → source library/sprite → style-matched established library fallback.\n\n${(contract.logos || []).map((logo) => `- \`${logo.id}\`: ${logo.localPath || logo.sourceUrl || logo.useHref || "captured markup"}`).join("\n") || "- BLOCKING: no logo candidate captured; inspect the source before implementing brand chrome."}`; } async function writeComponentSpecs({ docs, pages, routes, sceneContract, layoutBlueprint, uiNormalization, controlStateContract, visualAssets }) { const dir = path.join(docs, "components"); await ensureDir(dir); for (const page of pages) { const relatedRoutes = routes.routes.filter((route) => route.representativeUrls?.includes(page.url)); const pageScenes = (sceneContract?.scenes || []).filter((scene) => scene.id.startsWith(`${page.area || "page"}-`)); const pageLayouts = (layoutBlueprint?.pages || []).find((candidate) => candidate.url === page.url)?.sections || []; const pageNormalization = (uiNormalization?.sections || []).filter((section) => section.pageUrl === page.url); const pageControls = (controlStateContract?.controls || []).filter((control) => control.scope !== "content" || page.url === pages[0]?.url); const pageLogos = (visualAssets?.logos || []).filter((asset) => asset.usages.some((usage) => usage.pageUrl === page.url)); const pageIcons = (visualAssets?.icons || []).filter((asset) => asset.usages.some((usage) => usage.pageUrl === page.url)); const title = page.title || page.seo?.title || page.url; const body = `# ${title} Source URL: ${page.url} Area: ${page.area} Route strategy: ${relatedRoutes.map((route) => `- ${route.kind}: \`${route.targetRoute}\`${route.dataSource ? ` via ${route.dataSource}` : ""}`).join("\n") || "- static or not yet classified"} ## Blocking Interaction Checklist Do this before visual polish. The page is incomplete until every item passes. 1. Add this exact tag once near the end of the document: \`\`. 2. Add this exact stylesheet once in the document head: \`\`. 3. Do not use a raw \`type="module"\` script to import a relative file from \`src/\`; that browser URL will not resolve. The public bootstrap auto-binds marker-complete scenes. 4. Implement the following ${pageScenes.length} core scene(s) and their exact markers: ${pageScenes.map((scene) => ` - \`${scene.id}\` / \`${scene.implementation.primitive}\`: ${(scene.implementation.recipe?.steps || []).join(" ")}`).join("\n") || " - none"} 5. Use the exact logos/icons in the visual asset inventory. A text recreation of a logo is a blocking failure. 6. Implement the dedicated control states below before generic UI normalization. 7. Implement each section's layout blueprint below before applying UI normalization. Composition, text geometry, and background-media role are identity locks. 8. Implement each identity-preserving UI normalization recipe below. Identity locks outrank polish. 9. Start the clone and run \`node skills/wix-headless-replatform/scripts/verify-interactions.mjs --out --clone-url --project-root \`. 10. Fix every failed scene or normalization check. A missing \`interaction-qa.json\` or \`pass: false\` is a blocking failure, even if the static page looks close. Ignored source infrastructure: ${(page.ignoredSurfaces || []).map((surface) => `- \`${surface.kind}\` / provider \`${surface.provider || "unknown"}\`: ${surface.creationPolicy === "ignore" ? "identify only; do not recreate as page UI" : surface.creationPolicy}`).join("\n") || "- none"} ## Source Visual Assets Logos must use these exact source/materialized files and recorded usage sizes. Do not recreate them as text. Icons use source-first resolution; a matching established library is fallback-only. Logos: ${pageLogos.map((asset) => `- \`${asset.id}\`: ${asset.localPath || asset.sourceUrl || asset.useHref || "captured source markup"}; ${asset.usages.filter((usage) => usage.pageUrl === page.url).map((usage) => `${usage.context}/${usage.variant} ${usage.renderedSize?.width || 0}×${usage.renderedSize?.height || 0}`).join(", ")}`).join("\n") || "- BLOCKING: none captured; inspect source chrome before implementation"} Icons: ${pageIcons.map((asset) => `- \`${asset.id}\`: ${asset.localPath || asset.sourceUrl || asset.useHref || "captured source markup"}; ${asset.usages.filter((usage) => usage.pageUrl === page.url).map((usage) => usage.context).join(", ")}`).join("\n") || "- no standalone icon evidence captured"} ## Dedicated Control States Implement state deltas on the recorded owner. \`focus-visible\` remains required for accessibility; nested icon motion must remain nested. ${pageControls.map((control) => `- \`${control.scope}/${control.role}\` ${(control.members || [control.label]).join(", ")}: ${Object.keys(control.states).map((state) => `\`${state}\``).join(", ") || "rest only"}; icon motion: ${control.iconMotion.join(", ") || "none"}`).join("\n") || "- no representative controls captured"} ## Section Layout Blueprints Use the controlled terms and normalized rectangles as the source of truth. Build canvas and ordered background layers first. A background media layer is not a peer column. ${pageLayouts.map((section) => `### ${section.sectionId}: ${section.composition}\n\n- Canvas: \`${section.canvas.widthMode}/${section.canvas.heightMode}\`${section.canvas.pinned ? "; pinned" : ""}\n- Background: \`${section.background.kind}\` — ${section.background.layers.map((layer) => `\`${layer.role}:${layer.kind}\``).join(" -> ") || "none"}\n- Container: \`${section.container.widthMode}\`\n- Relationships: ${section.relationships.map((item) => `\`${item}\``).join(", ") || "none measured"}\n- Forbidden conversions: ${section.identityLocks.forbiddenConversions.map((item) => `\`${item}\``).join(", ") || "none"}\n\nRegions:\n${section.regions.map((region) => `- \`${region.role}\` / \`${region.placement}\` / \`${JSON.stringify(region.normalizedRect)}\`${region.textGeometry ? ` / text geometry \`${JSON.stringify({ ...region.textGeometry, responsive: region.responsiveTextGeometry || [] })}\`` : ""}${region.text ? `: ${region.text}` : ""}`).join("\n") || "- no measured regions; follow scene evidence and source screenshot"}\n\nBuild order:\n${section.implementation.steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}`).join("\n\n") || "_No section layout blueprint captured._"} ## Identity-Preserving UI Normalization Allowed: spacing/alignment cleanup, consistent repeated-item geometry, responsive reflow within the same archetype, subtle hover/focus feedback, eased state transitions, accessibility, and reduced motion. Forbidden: new visual style/palette/type, section reordering, archetype conversion, new CTA hierarchy, replaced media/interaction intent, or hiding source content to simplify layout. ${pageNormalization.map((section) => `### ${section.sectionId}: ${section.kind}/${section.variant}\n\nMarkers: \`${section.markerContract.section}\`; \`${section.markerContract.layout}\`\n\nRecipe:\n${section.recipe.map((step, index) => `${index + 1}. ${step}`).join("\n")}\n\nRules:\n${section.rules.map((rule) => `- ${rule.id}: ${rule.requirement}`).join("\n")}\n\nAssertions:\n${section.assertions.map((assertion) => `- \`${assertion.kind}\`: ${JSON.stringify(assertion)}`).join("\n")}`).join("\n\n") || "_No section normalization contract captured._"} ## Sections ${(page.sections || []).map((section, index) => `### ${index + 1}. ${section.heading || section.tag || "Section"}\n\n${section.text || "_No text captured._"}`).join("\n\n") || "_No sections captured._"} ## Repeaters ${(page.repeaters || []).map((repeater, index) => `### ${index + 1}. ${repeater.label || repeater.kind || "Repeater"}\n\n- kind: \`${repeater.kind}\`\n- source: \`${repeater.source}\`\n- items: ${repeater.itemCount || repeater.items?.length || 0}\n- schema: headings=${Boolean(repeater.schema?.hasHeading)}, paragraphs=${Boolean(repeater.schema?.hasParagraphs)}, images=${Boolean(repeater.schema?.hasImages)}, maxImagesPerItem=${repeater.schema?.maxImagesPerItem ?? 0}\n\n${(repeater.items || []).slice(0, 12).map((item, itemIndex) => `#### ${itemIndex + 1}. ${item.heading || "Item"}\n\n${(item.paragraphs || []).join("\n\n") || "_No text captured._"}\n\nImages:\n${(item.images || []).map((image) => `- ${image.src}`).join("\n") || "_No images captured._"}`).join("\n\n")}`).join("\n\n") || "_No repeaters captured._"} ## Repeater Diagnostics ${page.repeaterDiagnostics ? `- source: \`${page.repeaterDiagnostics.source || "unknown"}\`\n- accessibility snapshot available: ${Boolean(page.repeaterDiagnostics.accessibilitySnapshotAvailable)}\n- signal count: ${page.repeaterDiagnostics.signalCount ?? 0}\n- accepted repeaters: ${page.repeaterDiagnostics.acceptedRepeaterCount ?? 0}\n${(page.repeaterDiagnostics.warnings || []).length ? `- warnings:\n${page.repeaterDiagnostics.warnings.map((warning) => ` - ${warning}`).join("\n")}` : "- warnings: none"}` : "_No repeater diagnostics captured._"} ## Interactions ${page.interactionDiscovery ? `${(page.interactionDiscovery.requiredBehaviors || []).length ? `Required behaviors:\n${page.interactionDiscovery.requiredBehaviors.map((behavior) => `- \`${behavior}\``).join("\n")}` : "Required behaviors: none"}\n\n${(page.interactionDiscovery.sectionInteractions || []).filter((section) => section.interactions?.length).map((section) => `### ${section.sectionId}\n\n${section.interactions.map((interaction) => `- ${interaction.kind} / ${interaction.importance} / ${interaction.trigger?.type || interaction.trigger}${interaction.textChanged ? " / text-changes" : ""}`).join("\n")}`).join("\n\n") || "_No section interactions captured._"}` : "_No interaction discovery captured._"} ## Scene Implementation Contract ${pageScenes.map((scene) => `### ${scene.sectionId}: ${scene.implementation.primitive || scene.implementation.model}\n\nRuntime adapter: \`${scene.implementation.runtimeAdapter || "custom"}\`; preferred startup: \`${scene.implementation.runtimeModule || "/site-clone/rp-interactions-bootstrap.mjs"}\`\n\nScene marker: \`data-rp-scene="${scene.id}"\`\n\nMarkers:\n${Object.entries(scene.implementation.markerContract || {}).map(([role, marker]) => `- ${role}: \`${marker}\``).join("\n") || "- none"}\n\nRecipe:\n${(scene.implementation.recipe?.steps || []).map((step, index) => `${index + 1}. ${step}`).join("\n") || "- custom"}\n\nAssertions:\n${(scene.implementation.assertions || []).map((assertion) => `- \`${assertion.kind}\`: ${JSON.stringify(assertion)}`).join("\n") || "- none"}\n\nRequirements:\n${scene.implementation.requirements.map((requirement) => `- ${requirement}`).join("\n")}\n\nAcceptance:\n${scene.implementation.acceptance.map((criterion) => `- ${criterion}`).join("\n")}\n\nEvidence:\n${scene.states.map((state) => `- ${state.kind}: ${state.screenshot || "state data in scene-contract.json"}`).join("\n")}\n${scene.manualContentInventory ? "\n- Manual content inventory required: this interactive collection was not captured as a structured repeater; preserve all source items before coding.\n" : ""}`).join("\n\n") || "_No stateful scene contract captured._"} ## Media ${(page.interactionDiscovery?.media || []).map((media) => `- ${media.role} ${media.provider || media.tag}: ${media.src || "deferred source"} (autoplay: ${Boolean(media.playback?.autoplay)}, loop: ${Boolean(media.playback?.loop)}, muted: ${Boolean(media.playback?.muted)})`).join("\n") || "_No embedded media captured._"} ## Footer ${page.footer?.legalText || "_No structured footer content captured._"} ## Links ${(page.links || []).slice(0, 80).map((link) => `- [${link.text || link.url}](${link.url})`).join("\n") || "_No links captured._"} `; await writeText(path.join(dir, `${safePageName(page)}.spec.md`), body); } } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((error) => { console.error(error.stack || error.message); process.exit(1); }); }