#!/usr/bin/env node /** * build-page-card.mjs — deterministic page-card skeleton generator. * * Populates the `demo-page-scroll-spotlight` blueprint's fixed DOM template with * THIS site's captured content (headline, nav, features, a pop-target) + brand * colors + LOCAL images, and emits a standalone, renderable `page-card.html`. * * It is a REFERENCE skeleton, not a final composition. The scene worker still: * 1. prefixes class/id with its scene id (s-) when pasting into compositions/.html * 2. fills each .kw `data-glow-start/end` from the voiceover ASR word timings * 3. verifies/tunes SCROLL_DISTANCE by measuring the pop-target rect * * Inputs (all auto-discovered from the project dir, override with flags): * /capture/extracted/tokens.json (enriched sections + page dims + local assets) * /design-system/inference.json (brand.{primary,secondary,accent}; optional) * * Usage: * node build-page-card.mjs [] [--capture ] [--design ] * [--out ] [--duration ] */ import fs from "node:fs"; import path from "node:path"; // ─────────────── args ─────────────── const argv = process.argv.slice(2); let projectDir = ".", cliCapture = null, cliDesign = null, cliOut = null, cliDuration = 9; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--capture" && argv[i + 1]) cliCapture = argv[++i]; else if (a === "--design" && argv[i + 1]) cliDesign = argv[++i]; else if (a === "--out" && argv[i + 1]) cliOut = argv[++i]; else if (a === "--duration" && argv[i + 1]) cliDuration = parseFloat(argv[++i]); else if (!a.startsWith("--")) projectDir = a; } projectDir = path.resolve(projectDir); const captureDir = cliCapture ? path.resolve(cliCapture) : path.join(projectDir, "capture"); const designDir = cliDesign ? path.resolve(cliDesign) : path.join(projectDir, "design-system"); const outFile = cliOut ? path.resolve(cliOut) : path.join(projectDir, "page-card.html"); const DURATION = Number.isFinite(cliDuration) ? cliDuration : 9; // section.assets paths are relative to the capture dir (e.g. "assets/x.png" = // capture/assets/x.png). Rewrite them relative to where page-card.html is // written so the resolves both for preview and at render time. const assetSrc = (localPath) => path.relative(path.dirname(outFile), path.join(captureDir, localPath)).split(path.sep).join("/"); const readJSON = (p, fb) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fb; } }; const tokens = readJSON(path.join(captureDir, "extracted", "tokens.json"), null); if (!tokens) { console.error( `✗ ${path.join(captureDir, "extracted", "tokens.json")} not found — run capture first.`, ); process.exit(1); } const inference = readJSON(path.join(designDir, "inference.json"), null); // ─────────────── color helpers ─────────────── const rgb = (hex) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex || ""); return m ? [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)) : null; }; const lum = (hex) => { const c = rgb(hex); return c ? (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255 : 0.5; }; const sat = (hex) => { const c = rgb(hex); if (!c) return 0; const mx = Math.max(...c), mn = Math.min(...c); return mx === 0 ? 0 : (mx - mn) / mx; }; const isNeutral = (hex) => { const c = rgb(hex); return !c || Math.max(...c) - Math.min(...c) < 18; }; const rgba = (hex, a) => { const c = rgb(hex) || [0, 0, 0]; return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`; }; const esc = (s) => String(s || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); // ─────────────── brand colors ─────────────── const brand = inference?.brand || {}; let primary = brand.primary; if (!primary || isNeutral(primary)) { // fallback: most-saturated chromatic color used as a fill const cand = (tokens.colorStats || []) .filter((s) => s.bgCount > 0 && !isNeutral(s.hex)) .sort((a, b) => sat(b.hex) - sat(a.hex))[0]; primary = cand?.hex || "#5B7CFA"; } const accent = brand.accent && !isNeutral(brand.accent) ? brand.accent : primary; // Glow needs a vivid color — a pale/near-white accent gives no visible glow, // so fall back to primary for the keyword/pop glow + pop border. const glow = lum(accent) > 0.72 || sat(accent) < 0.35 ? primary : accent; // page surface: prefer the hero section bg, else most common large section bg const sections = Array.isArray(tokens.sections) ? tokens.sections : []; const hero = sections.find((s) => s.type === "hero") || sections[0] || {}; let pageBg = hero.backgroundColor && /^#[0-9a-f]{6}$/i.test(hero.backgroundColor) ? hero.backgroundColor : "#ffffff"; const dark = lum(pageBg) < 0.45; const textPrimary = dark ? "#ffffff" : "#0a0a0f"; const textSecondary = dark ? "rgba(255,255,255,0.62)" : "rgba(10,10,15,0.6)"; const cardSurface = dark ? "#16161c" : "#f4f4f6"; const navBrandColor = textPrimary; const headingFont = (tokens.fonts && tokens.fonts[0] && tokens.fonts[0].family) || "Inter, system-ui, sans-serif"; // ─────────────── content selection ─────────────── const title = tokens.title || ""; const brandName = title.split(/[|–—\-:·]/)[0].trim() || "Product"; // headline → words; wrap the first ~8 in .kw (worker fills data-glow from ASR) const headlineRaw = (tokens.headings && tokens.headings[0]?.text) || hero.heading || brandName; // First sentence, word-capped — captured h1 is often a long concatenation; the // hero title wants a short punchy line (worker can refine). const firstSentence = headlineRaw .replace(/\s+/g, " ") .trim() .split(/(?<=[.!?])\s/)[0] || headlineRaw; const words = firstSentence.split(" ").filter(Boolean).slice(0, 12); const headline = words.join(" "); const KW_MAX = 8; const heroTitleHtml = words .map((w, i) => i < KW_MAX ? `${esc(w)}` : esc(w), ) .join(" "); const heroSub = (tokens.description || hero.text || "").replace(/\s+/g, " ").trim().slice(0, 160); // nav items: short distinct cta/link-ish labels const navItems = [...new Set((tokens.ctas || []).map((c) => (c.text || "").trim()))] .filter((t) => t && t.length >= 2 && t.length <= 16 && !/\s{2,}/.test(t)) .slice(0, 5); const primaryCta = (tokens.ctas || []) .map((c) => (c.text || "").trim()) .find((t) => /sign up|get started|start|try|demo|contact/i.test(t)) || navItems[0] || "Get started"; // feature sections that carry a local image const featureSecs = sections.filter( (s) => (s.type === "features" || s.type === "content") && s.assets && s.assets.length && s.heading, ); // pop-target: the largest-area section that has a local image (the demo focal point) const popSec = [...sections] .filter((s) => s.assets && s.assets.length) .sort((a, b) => b.width * b.height - a.width * a.height)[0] || featureSecs[0] || {}; const popImg = (popSec.assets && popSec.assets[0]) || null; const popLabel = (popSec.heading || "Product").slice(0, 60); // feature cards (exclude the pop-target's section), up to 2 const featureCards = featureSecs .filter((s) => s !== popSec) .slice(0, 2) .map((s) => ({ heading: s.heading.slice(0, 60), img: s.assets[0] })); // ─────────────── SCROLL_DISTANCE estimate (must be verified by measuring) ─────────────── // Rebuilt-layout estimate, NOT the original page geometry: navbar(72) is fixed; // scroll-content has pad-top 140 + hero (~340) + feature cards (~408 each) before // the carousel/pop-target. Scroll so the pop-target lands ~60% down the card. const CARD_VISIBLE_H = Math.round(1080 * 0.88); const contentBeforePop = 140 + 340 + featureCards.length * 408 + 48; const scrollDistance = Math.max(160, Math.round(contentBeforePop - CARD_VISIBLE_H * 0.42)); // ─────────────── emit ─────────────── const navItemsHtml = navItems .map((t) => `${esc(t)}`) .join("\n "); const featureCardsHtml = featureCards .map( (f) => `
${esc(f.heading)}
${esc(f.heading)}
`, ) .join(""); const popMediaHtml = popImg ? `${esc(popLabel)}` : ``; const html = ` Page Card — ${esc(brandName)}

${heroTitleHtml}

${heroSub ? `

${esc(heroSub)}

` : ""} ${esc(primaryCta)}
${featureCardsHtml}
`; fs.writeFileSync(outFile, html); console.log(`✓ ${path.relative(process.cwd(), outFile)}`); console.log( ` brand: ${primary} primary · ${accent} accent · page-bg ${pageBg} (${dark ? "dark" : "light"})`, ); console.log( ` headline: "${headline}" → ${Math.min(words.length, KW_MAX)} .kw words (data-glow empty)`, ); console.log(` nav: ${navItems.length} items · CTA "${primaryCta}"`); console.log( ` features: ${featureCards.length} cards · pop-target "${popLabel}" ${popImg ? "(" + popImg + ")" : "(placeholder)"}`, ); console.log( ` scroll: SCROLL_DISTANCE≈${scrollDistance} (estimate — worker must verify by measuring #pop-target)`, ); console.log(` worker TODO: prefix s- · fill .kw data-glow from ASR · verify SCROLL_DISTANCE`);