diff --git a/packages/cli/package.json b/packages/cli/package.json index 8be75f490..3c3ed7f29 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,7 +38,7 @@ "postcss": "^8.5.8", "prettier": "^3.8.1", "puppeteer-core": "^24.39.1", - "sharp": "^0.34.0" + "sharp": "^0.34.5" }, "devDependencies": { "@clack/prompts": "^1.1.0", diff --git a/packages/cli/src/capture/agentPromptGenerator.ts b/packages/cli/src/capture/agentPromptGenerator.ts index a94aa58b9..8b52953d8 100644 --- a/packages/cli/src/capture/agentPromptGenerator.ts +++ b/packages/cli/src/capture/agentPromptGenerator.ts @@ -10,12 +10,35 @@ * website-to-hyperframes skill — this file points agents there. */ -import { writeFileSync } from "node:fs"; +import { writeFileSync, readdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import type { DesignTokens } from "./types.js"; import type { AnimationCatalog } from "./animationCataloger.js"; import type { CatalogedAsset } from "./assetCataloger.js"; +/** + * Infer a human-readable role hint from a hex color based on luminance and saturation. + * Not a substitute for DESIGN.md — just helps orient agents scanning the brand summary. + */ +function inferColorRole(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + if (isNaN(r) || isNaN(g) || isNaN(b)) return "color"; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const saturation = max === 0 ? 0 : (max - min) / max; + + if (luminance < 0.04) return "bg-dark"; + if (luminance > 0.9) return "bg-light"; + if (saturation > 0.4 && luminance > 0.05 && luminance < 0.7) return "accent"; + if (luminance < 0.2) return "surface-dark"; + if (luminance > 0.7) return "surface-light"; + return "neutral"; +} + export function generateAgentPrompt( outputDir: string, url: string, @@ -25,25 +48,28 @@ export function generateAgentPrompt( hasLottie?: boolean, hasShaders?: boolean, _catalogedAssets?: CatalogedAsset[], // reserved for future asset inventory - detectedLibraries?: string[], + _detectedLibraries?: string[], ): void { - const prompt = buildPrompt(url, tokens, hasScreenshot, hasLottie, hasShaders, detectedLibraries); + const prompt = buildPrompt(outputDir, url, tokens, hasScreenshot, hasLottie, hasShaders); writeFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8"); writeFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8"); writeFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8"); } function buildPrompt( + outputDir: string, url: string, tokens: DesignTokens, hasScreenshot: boolean, hasLottie?: boolean, hasShaders?: boolean, - detectedLibraries?: string[], ): string { const title = tokens.title || new URL(url).hostname.replace(/^www\./, ""); - const colorSummary = tokens.colors.slice(0, 10).join(", "); + const colorSummary = tokens.colors + .slice(0, 10) + .map((hex) => `${hex} (${inferColorRole(hex)})`) + .join(", "); const fontSummary = tokens.fonts .map( @@ -58,17 +84,66 @@ function buildPrompt( .join(", ") || "none detected"; // Build the data inventory table rows + // Helper: find all contact sheet pages for a given base name. Matches the + // exact base file plus paginated variants only (e.g. `contact-sheet.jpg`, + // `contact-sheet-2.jpg`, `contact-sheet-3.jpg`). The "-NNN" suffix is digits + // only, so unrelated files that happen to share the prefix (notably the + // `contact-sheet-svgs.jpg` SVG fallback sheet in assets/) don't get mixed in. + function contactSheetRows(dir: string, baseFile: string, label: string): string[] { + const fullDir = join(outputDir, dir); + if (!existsSync(fullDir)) return []; + const baseName = baseFile.replace(/\.jpg$/, ""); + // Escape regex metacharacters in baseName so future callers can pass + // filenames containing `.`, `+`, `(`, etc. without the regex breaking. + const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const paginatedRe = new RegExp(`^${escapedBase}(?:-(\\d+))?\\.jpg$`); + // Sort by the numeric page suffix so `contact-sheet-10.jpg` lands after + // `contact-sheet-2.jpg`, not before (default string sort orders them + // lexicographically and breaks at 10+ pages). Unpaginated `contact-sheet.jpg` + // gets page 0 so it sorts first if it co-exists with paginated files. + const all = readdirSync(fullDir) + .filter((f) => paginatedRe.test(f)) + .map((f) => ({ name: f, page: parseInt(f.match(paginatedRe)?.[1] ?? "0", 10) })) + .sort((a, b) => a.page - b.page) + .map((entry) => entry.name); + if (all.length === 0) return []; + if (all.length === 1) { + return [`| \`${dir}/${all[0]}\` | ${label} |`]; + } + return all.map((f, i) => `| \`${dir}/${f}\` | ${label} — page ${i + 1} of ${all.length} |`); + } + const tableRows: string[] = []; if (hasScreenshot) { + const screenshotRows = contactSheetRows( + "screenshots", + "contact-sheet.jpg", + "**View this first.** All scroll screenshots in labeled grid — see the entire page at a glance", + ); + if (screenshotRows.length > 0) { + tableRows.push(...screenshotRows); + } else { + tableRows.push( + "| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in one labeled grid. |", + ); + } tableRows.push( - "| `screenshots/scroll-*.png` | Viewport screenshots of the full page. Start with `scroll-000.png` (hero). |", + "| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |", ); } tableRows.push( - "| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. **Read this first.** |", + `| \`extracted/tokens.json\` | Design tokens: ${tokens.colors.length} colors, ${tokens.fonts.length} fonts, ${tokens.headings?.length ?? 0} headings, ${tokens.ctas?.length ?? 0} CTAs |`, ); + // design-styles.json is written from a try/catch in capture/index.ts and + // gets skipped when the live-DOM style extraction fails. Only list it in the + // agent prompt when it actually exists, so the agent isn't pointed at a 404. + if (existsSync(join(outputDir, "extracted", "design-styles.json"))) { + tableRows.push( + "| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |", + ); + } tableRows.push( - `| \`extracted/tokens.json\` | Design tokens: ${tokens.colors.length} colors, ${tokens.fonts.length} fonts, ${tokens.headings?.length ?? 0} headings, ${tokens.ctas?.length ?? 0} CTAs |`, + "| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |", ); tableRows.push( "| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |", @@ -81,20 +156,41 @@ function buildPrompt( if (hasShaders) { tableRows.push("| `extracted/shaders.json` | WebGL shader source (GLSL). |"); } - if (detectedLibraries && detectedLibraries.length > 0) { - tableRows.push( - `| \`extracted/detected-libraries.json\` | Libraries: ${detectedLibraries.join(", ")} |`, - ); + + // Asset contact sheets — dynamically list all pages + const assetSheetRows = contactSheetRows( + "assets", + "contact-sheet.jpg", + "Downloaded images in labeled grid — view before opening individual files", + ); + if (assetSheetRows.length > 0) { + tableRows.push(...assetSheetRows); + } else { + tableRows.push("| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |"); } - tableRows.push("| `assets/` | Downloaded images, SVGs, and font files. |"); + + // SVG contact sheets — check both assets/svgs/ and assets/ root fallback + const svgSubdirRows = contactSheetRows( + "assets/svgs", + "contact-sheet.jpg", + "SVGs rendered as thumbnails in labeled grid", + ); + const svgRootRows = contactSheetRows( + "assets", + "contact-sheet-svgs.jpg", + "SVGs rendered as thumbnails in labeled grid", + ); + const svgRows = svgSubdirRows.length > 0 ? svgSubdirRows : svgRootRows; + if (svgRows.length > 0) { + tableRows.push(...svgRows); + } + + tableRows.push("| `assets/` | Individual downloaded images, SVGs, and font files. |"); // Brand summary — just the essentials const brandLines: string[] = []; brandLines.push(`- **Colors**: ${colorSummary || "see tokens.json"}`); brandLines.push(`- **Fonts**: ${fontSummary}`); - if (detectedLibraries && detectedLibraries.length > 0) { - brandLines.push(`- **Built with**: ${detectedLibraries.join(", ")}`); - } return `# ${title} diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index fb591cc74..106fd1d24 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -24,11 +24,21 @@ export async function downloadAssets( // 1. ALL inline SVGs — save as files (logos get priority naming) mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true }); + const usedSvgNames = new Set(); for (let i = 0; i < tokens.svgs.length && i < 30; i++) { const svg = tokens.svgs[i]!; if (!svg.outerHTML || svg.outerHTML.length < 50) continue; const label = svg.label?.replace(/[^a-zA-Z0-9-_ ]/g, "").trim(); - const name = label ? slugify(label) + ".svg" : svg.isLogo ? `logo-${i}.svg` : `icon-${i}.svg`; + let slug = label ? slugify(label) : svg.isLogo ? `logo-${i}` : `icon-${i}`; + // Deduplicate — two SVGs with same aria-label get suffixed + let finalSlug = slug; + let suffix = 2; + while (usedSvgNames.has(finalSlug)) { + finalSlug = `${slug}-${suffix}`; + suffix++; + } + usedSvgNames.add(finalSlug); + const name = `${finalSlug}.svg`; const localPath = `assets/svgs/${name}`; try { writeFileSync(join(outputDir, localPath), svg.outerHTML, "utf-8"); @@ -86,55 +96,49 @@ export async function downloadAssets( } } - // Download all images (no arbitrary cap) — Claude Code needs to see every asset to use them creatively. - // The 10KB minimum size filter handles tracking pixels and tiny icons. + // Download all images — use catalog context for human-readable filenames. // Pre-filter to deduplicate before downloading. - const toDownload: { url: string; isPoster: boolean; normalized: string }[] = []; + const toDownload: { + url: string; + isPoster: boolean; + normalized: string; + catalog?: CatalogedAsset; + }[] = []; for (const { url, isPoster } of imageUrls) { const normalized = normalizeUrl(url); if (downloadedUrls.has(normalized)) continue; - downloadedUrls.add(normalized); // Reserve to prevent duplicates in parallel batches - toDownload.push({ url, isPoster, normalized }); + downloadedUrls.add(normalized); + const catalog = catalogedAssets?.find((a) => normalizeUrl(a.url) === normalized); + toDownload.push({ url, isPoster, normalized, catalog }); } // Download in parallel batches of 5 const BATCH_SIZE = 5; let imgIdx = 0; + const usedNames = new Set(); for (let i = 0; i < toDownload.length; i += BATCH_SIZE) { const batch = toDownload.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( - batch.map(async ({ url, isPoster }) => { + batch.map(async ({ url, isPoster, catalog }) => { const parsedUrl = new URL(url); const pathExt = extname(parsedUrl.pathname); const ext = pathExt && pathExt.length <= 5 ? pathExt : ".jpg"; const buffer = await fetchBuffer(url); if (!buffer) return null; - // SVGs are inherently small — don't apply the 10KB minimum to them const isSvg = ext === ".svg" || url.includes(".svg"); const minSize = isSvg ? 200 : 10000; if (buffer.length < minSize) return null; - return { url, isPoster, parsedUrl, ext, buffer }; + return { url, isPoster, parsedUrl, ext, buffer, catalog }; }), ); for (const result of results) { if (result.status !== "fulfilled" || !result.value) continue; - const { url, isPoster, parsedUrl, ext, buffer } = result.value; + const { url, isPoster, parsedUrl, ext, buffer, catalog } = result.value; try { - const prefix = isPoster ? "poster" : "image"; - const rawName = - parsedUrl.pathname - .split("/") - .pop() - ?.replace(/\.[^.]+$/, "") || ""; - const isMeaningful = - rawName.length > 2 && - rawName.length < 50 && - !/^[a-f0-9]{8,}$/i.test(rawName) && - !/^\d+$/.test(rawName) && - !rawName.includes("_next") && - !rawName.includes("?"); - const slug = isMeaningful ? slugify(rawName) : `${prefix}-${imgIdx}`; + // Generate human-readable name from catalog context + const slug = deriveAssetName(parsedUrl, catalog, isPoster, imgIdx, usedNames); const name = `${slug}${ext}`; + usedNames.add(slug); const localPath = `assets/${name}`; writeFileSync(join(outputDir, localPath), buffer); assets.push({ url, localPath, type: "image" }); @@ -303,3 +307,77 @@ function slugify(text: string): string { .replace(/^-|-$/g, "") .slice(0, 40); } + +/** + * Derive a human-readable filename from catalog context. + * Priority: alt text > nearest heading > meaningful URL path > fallback index. + */ +function deriveAssetName( + parsedUrl: URL, + catalog: CatalogedAsset | undefined, + isPoster: boolean, + idx: number, + usedNames: Set, +): string { + const candidates: string[] = []; + + // 1. Alt text / description from catalog + if (catalog?.description) { + const desc = catalog.description.replace(/[^a-zA-Z0-9 -]/g, "").trim(); + if (desc.length > 3 && desc.length < 80) candidates.push(desc); + } + + // 2. Nearest heading context + if (catalog?.nearestHeading) { + const heading = catalog.nearestHeading.replace(/[^a-zA-Z0-9 -]/g, "").trim(); + if (heading.length > 3 && heading.length < 60) candidates.push(heading); + } + + // 3. Meaningful URL path segment + const rawName = + parsedUrl.pathname + .split("/") + .pop() + ?.replace(/\.[^.]+$/, "") || ""; + const isMeaningful = + rawName.length > 2 && + rawName.length < 50 && + !/^[a-f0-9]{8,}$/i.test(rawName) && + !/^\d+$/.test(rawName) && + !rawName.includes("_next") && + !rawName.includes("?"); + if (isMeaningful) candidates.push(rawName); + + // 4. Section classes as context + if (catalog?.sectionClasses) { + const classes = catalog.sectionClasses + .split(/\s+/) + .filter((c) => c.length > 3 && c.length < 30 && !/^(w-|h-|p-|m-|flex|grid|block)/.test(c)) + .slice(0, 2) + .join("-"); + if (classes.length > 3) candidates.push(classes); + } + + // Pick the best candidate + const prefix = isPoster ? "poster" : catalog?.aboveFold ? "hero" : "image"; + let slug = ""; + + for (const c of candidates) { + slug = slugify(c); + if (slug.length > 3 && !usedNames.has(slug)) break; + } + + if (!slug || slug.length <= 3 || usedNames.has(slug)) { + slug = `${prefix}-${idx}`; + } + + // Deduplicate + let final = slug; + let suffix = 2; + while (usedNames.has(final)) { + final = `${slug}-${suffix}`; + suffix++; + } + + return final; +} diff --git a/packages/cli/src/capture/contactSheet.ts b/packages/cli/src/capture/contactSheet.ts new file mode 100644 index 000000000..0f9d00207 --- /dev/null +++ b/packages/cli/src/capture/contactSheet.ts @@ -0,0 +1,348 @@ +/** + * Generate labeled contact sheet grids from images. + * + * Stitches images into a numbered grid with cell labels. + * Saves 50-65% tokens vs. AI agents reading images individually. + */ + +import sharp from "sharp"; +import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs"; +import { join, extname, basename, dirname } from "node:path"; + +interface ContactSheetOptions { + cols?: number; + maxImages?: number; + padding?: number; + labelMode?: "index" | "filename" | "custom"; + labels?: string[]; + quality?: number; + /** Target width per cell in pixels (default: 600) */ + cellWidth?: number; +} + +/** + * Create a contact sheet from a list of image paths. + * Returns the output file path, or null if no images. + */ +export async function createContactSheet( + imagePaths: string[], + outputPath: string, + opts: ContactSheetOptions = {}, +): Promise { + const { + cols = 3, + maxImages = 16, + padding = 4, + labelMode = "index", + labels, + quality = 88, + cellWidth = 600, + } = opts; + + const files = imagePaths.slice(0, maxImages); + if (files.length === 0) return null; + + // Read first image to determine aspect ratio + const firstMeta = await sharp(files[0]!).metadata(); + const srcW = firstMeta.width || 1920; + const srcH = firstMeta.height || 1080; + + // Scale to target cell width, maintain aspect ratio + const scale = cellWidth / srcW; + const cellW = cellWidth; + const cellH = Math.round(srcH * scale); + + const rows = Math.ceil(files.length / cols); + const labelH = 26; + const totalW = cols * cellW + (cols + 1) * padding; + const totalH = rows * (cellH + labelH) + (rows + 1) * padding; + + const overlays: sharp.OverlayOptions[] = []; + + for (let i = 0; i < files.length; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = padding + col * (cellW + padding); + const y = padding + row * (cellH + labelH + padding); + + // Resize image to cell size — contain keeps full image visible (no cropping) + const resized = await sharp(files[i]!) + .resize(cellW, cellH, { fit: "contain", background: { r: 26, g: 26, b: 26 } }) + .toBuffer(); + + overlays.push({ input: resized, left: x, top: y + labelH }); + + // Label text + let labelText = `${i + 1}`; + if (labelMode === "filename") { + labelText = `${i + 1}. ${basename(files[i]!).replace(extname(files[i]!), "")}`; + } else if (labelMode === "custom" && labels?.[i]) { + labelText = `${i + 1}. ${labels[i]}`; + } + + // Truncate label to fit cell + if (labelText.length > 60) labelText = labelText.slice(0, 57) + "..."; + + const labelSvg = Buffer.from( + `` + + `` + + `${escapeXml(labelText)}` + + ``, + ); + + overlays.push({ input: labelSvg, left: x, top: y }); + } + + await sharp({ + create: { + width: totalW, + height: totalH, + channels: 3, + background: { r: 26, g: 26, b: 26 }, + }, + }) + .composite(overlays) + .jpeg({ quality }) + .toFile(outputPath); + + return outputPath; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Split imagePaths into pages of `pageSize`, write one contact sheet per page. + * Output files: basePath → base-1.jpg, base-2.jpg, ... + * Returns the list of written file paths (empty if no images). + */ +async function createContactSheetPages( + imagePaths: string[], + outputBasePath: string, + opts: ContactSheetOptions & { pageSize?: number } = {}, + labelOffset = 0, + customLabels?: string[], +): Promise { + if (imagePaths.length === 0) return []; + const { pageSize = imagePaths.length, ...sheetOpts } = opts; + const ext = outputBasePath.match(/\.[^.]+$/)?.[0] ?? ".jpg"; + const base = outputBasePath.slice(0, -ext.length); + + const pages = Math.ceil(imagePaths.length / pageSize); + const results: string[] = []; + + for (let p = 0; p < pages; p++) { + const chunk = imagePaths.slice(p * pageSize, (p + 1) * pageSize); + const chunkLabels = customLabels?.slice(p * pageSize, (p + 1) * pageSize); + const outPath = pages === 1 ? outputBasePath : `${base}-${p + 1}${ext}`; + + const labelsForChunk = chunkLabels + ? { labelMode: "custom" as const, labels: chunkLabels } + : sheetOpts.labelMode === "filename" + ? { labelMode: "filename" as const } + : { labelMode: "index" as const }; + + const written = await createContactSheet(chunk, outPath, { + ...sheetOpts, + ...labelsForChunk, + maxImages: chunk.length, + }); + if (written) results.push(written); + void labelOffset; // used by callers that pre-compute labels + } + return results; +} + +/** + * Contact sheet for scroll screenshots. Paginated — all screenshots covered. + * Labels: "1. 0% scroll", "2. 23% scroll", etc. + * Returns array of written file paths. + */ +export async function createScrollContactSheet( + screenshotsDir: string, + outputPath: string, +): Promise { + if (!existsSync(screenshotsDir)) return []; + + const scrollFiles = readdirSync(screenshotsDir) + .filter((f) => f.startsWith("scroll-") && f.endsWith(".png")) + .sort(); + + if (scrollFiles.length === 0) return []; + + const paths = scrollFiles.map((f) => join(screenshotsDir, f)); + const labels = scrollFiles.map((f) => { + const m = f.match(/scroll-(\d+)\.png/); + return m ? `${m[1]}% scroll` : f; + }); + + // 3 cols max for readability; 9 per page (3×3) so cells stay large enough to read + return createContactSheetPages( + paths, + outputPath, + { cols: 3, cellWidth: 600, pageSize: 9 }, + 0, + labels, + ); +} + +/** + * Contact sheet for snapshot frames. All frames covered across pages. + * Labels: "1. 1.0s", "2. 3.0s", etc. + * Returns array of written file paths. + */ +export async function createSnapshotContactSheet( + snapshotsDir: string, + outputPath: string, +): Promise { + if (!existsSync(snapshotsDir)) return []; + + const snapshotFiles = readdirSync(snapshotsDir) + .filter((f) => f.startsWith("frame-") && f.endsWith(".png")) + .sort(); + + if (snapshotFiles.length === 0) return []; + + const paths = snapshotFiles.map((f) => join(snapshotsDir, f)); + const labels = snapshotFiles.map((f) => { + const m = f.match(/at-([\d.]+)s/); + return m ? `${m[1]}s` : f; + }); + + // 3 cols, 9 per page (3×3) + return createContactSheetPages( + paths, + outputPath, + { cols: 3, cellWidth: 600, pageSize: 9 }, + 0, + labels, + ); +} + +/** + * Contact sheet for captured assets. Paginated — all assets covered. + * Labels: "1. filename" + * Returns array of written file paths. + */ +export async function createAssetContactSheet( + assetsDir: string, + outputPath: string, +): Promise { + if (!existsSync(assetsDir)) return []; + + const imageExts = new Set([".png", ".jpg", ".jpeg", ".webp"]); + const assetFiles = readdirSync(assetsDir) + .filter((f) => imageExts.has(extname(f).toLowerCase()) && !f.includes("contact-sheet")) + .sort(); + + if (assetFiles.length === 0) return []; + + const paths = assetFiles.map((f) => join(assetsDir, f)); + + // 4 cols, 12 per page (4×3) — covers all assets across as many pages as needed + return createContactSheetPages(paths, outputPath, { + cols: 4, + cellWidth: 480, + labelMode: "filename", + pageSize: 12, + }); +} + +/** + * Contact sheet for SVGs — renders each SVG to a thumbnail PNG, then grids them. + * Sharp supports SVG input natively, so no browser needed. + * Labels: "1. filename" + * + * Accepts one or two directories: the primary svgs/ subdir and optionally the + * parent assets/ root (for external SVGs downloaded as ). + * Files are deduplicated by basename so duplicates across dirs are collapsed. + */ +export async function createSvgContactSheet( + svgsDir: string, + outputPath: string, + assetsRootDir?: string, +): Promise { + const dirsToScan = [svgsDir, assetsRootDir].filter( + (d): d is string => d !== undefined && existsSync(d), + ); + if (dirsToScan.length === 0) return []; + + const seen = new Set(); + const svgPaths: string[] = []; + + for (const dir of dirsToScan) { + for (const f of readdirSync(dir) + .filter((f) => f.endsWith(".svg")) + .sort()) { + if (!seen.has(f)) { + seen.add(f); + svgPaths.push(join(dir, f)); + } + } + } + + if (svgPaths.length === 0) return []; + + const svgFileNames = svgPaths.map((p) => p.split("/").pop()!); + + // Render ALL SVGs to PNG thumbnails first, then paginate the sheets + const thumbSize = 200; + const tmpDir = dirname(outputPath); + const tmpPaths: string[] = []; + const labels: string[] = []; + + for (let i = 0; i < svgPaths.length; i++) { + const svgPath = svgPaths[i]!; + const tmpPath = join(tmpDir, `.thumb-${i}.png`); + try { + const svgBuf = readFileSync(svgPath); + const thumb = await sharp(svgBuf) + .resize(thumbSize, thumbSize, { + fit: "contain", + background: { r: 245, g: 245, b: 245, alpha: 1 }, + }) + .flatten({ background: { r: 245, g: 245, b: 245 } }) + .png() + .toBuffer(); + writeFileSync(tmpPath, thumb); + tmpPaths.push(tmpPath); + labels.push(svgFileNames[i]!.replace(".svg", "")); + } catch { + // SVG might be malformed — skip + } + } + + if (tmpPaths.length === 0) return []; + + // 5 cols, 15 per page (5×3) — all SVGs covered across pages + let results: string[] = []; + try { + results = await createContactSheetPages( + tmpPaths, + outputPath, + { + cols: 5, + cellWidth: thumbSize, + pageSize: 15, + }, + 0, + labels, + ); + } finally { + for (const tmp of tmpPaths) { + try { + unlinkSync(tmp); + } catch { + /* best effort */ + } + } + } + + return results; +} diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index cde0562b7..edb69cc52 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -9,7 +9,7 @@ */ import type { Page } from "puppeteer-core"; -import { readdirSync, statSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, statSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { DesignTokens } from "./types.js"; @@ -231,6 +231,69 @@ export async function captionImagesWithGemini( ); } progress("design", `${Object.keys(geminiCaptions).length} images captioned with Gemini`); + + // Caption SVGs by sending source code as text (vision API rejects image/svg+xml). + const svgFiles: Array<{ file: string; relPath: string }> = []; + const assetsDir = join(outputDir, "assets"); + for (const f of readdirSync(assetsDir)) { + if (/\.svg$/i.test(f)) svgFiles.push({ file: f, relPath: f }); + } + const svgsSubdir = join(assetsDir, "svgs"); + if (existsSync(svgsSubdir)) { + for (const f of readdirSync(svgsSubdir)) { + if (/\.svg$/i.test(f)) svgFiles.push({ file: f, relPath: `svgs/${f}` }); + } + } + + if (svgFiles.length > 0) { + progress("design", `Captioning ${svgFiles.length} SVGs via code analysis...`); + const SVG_BATCH = 20; + const MAX_SVG_CHARS = 10_000; + for (let i = 0; i < svgFiles.length; i += SVG_BATCH) { + const batch = svgFiles.slice(i, i + SVG_BATCH); + const results = await Promise.allSettled( + batch.map(async ({ relPath }) => { + const filePath = join(assetsDir, relPath); + let svgText = readFileSync(filePath, "utf-8"); + if (svgText.length > MAX_SVG_CHARS) { + svgText = svgText.slice(0, MAX_SVG_CHARS) + "\n"; + } + const response = await ai.models.generateContent({ + model, + contents: [ + { + role: "user", + parts: [ + { + text: + "This SVG code is from a website. Describe what it renders in ONE short sentence " + + "for a video storyboard. Focus on: what shape/icon/illustration it is, its colors. " + + "Be factual.\n\n" + + svgText, + }, + ], + }, + ], + config: { maxOutputTokens: 300 }, + }); + return { file: relPath, caption: response.text?.trim() || "" }; + }), + ); + for (const result of results) { + if (result.status === "fulfilled" && result.value.caption) { + geminiCaptions[result.value.file] = result.value.caption; + } + } + if (i + SVG_BATCH < svgFiles.length) { + await new Promise((r) => setTimeout(r, 2000)); + } + progress( + "design", + `Captioned ${Math.min(i + SVG_BATCH, svgFiles.length)}/${svgFiles.length} SVGs...`, + ); + } + progress("design", `${Object.keys(geminiCaptions).length} total assets captioned`); + } } catch (err) { warnings.push(`Gemini captioning failed: ${err}`); } @@ -295,6 +358,11 @@ export function generateAssetDescriptions( const svgsPath = join(assetsPath, "svgs"); for (const file of readdirSync(svgsPath)) { if (!file.endsWith(".svg")) continue; + const geminiCaption = geminiCaptions[`svgs/${file}`]; + if (geminiCaption) { + svgLines.push(`svgs/${file} — ${geminiCaption}`); + continue; + } const svgMatch = tokens.svgs.find( (s) => s.label && diff --git a/packages/cli/src/capture/designStyleExtractor.ts b/packages/cli/src/capture/designStyleExtractor.ts new file mode 100644 index 000000000..b27529be3 --- /dev/null +++ b/packages/cli/src/capture/designStyleExtractor.ts @@ -0,0 +1,282 @@ +/** + * Extract computed design styles from key DOM elements. + * + * Targets ~50 elements (headings, body text, buttons, cards, nav) and extracts + * only design-relevant CSS properties. Output is a compact, pre-clustered + * design system summary — not raw computed styles per element. + * + * All page.evaluate() calls use string expressions to avoid + * tsx/esbuild __name injection (see esbuild issue #1031). + */ + +import type { Page } from "puppeteer-core"; +import type { DesignStyles } from "./types.js"; + +const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => { + var isVisible = (el) => { + var s = getComputedStyle(el); + return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0" && el.getBoundingClientRect().height > 0; + }; + + function rgbToHex(color) { + if (!color) return ""; + if (color.startsWith('#')) return color.toUpperCase(); + var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/); + if (!m) return color; + return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase(); + } + + function cleanFont(f) { + return f.split(",")[0].replace(/['"]/g, "").trim(); + } + + function getStyles(el) { + var s = getComputedStyle(el); + return { + fontFamily: cleanFont(s.fontFamily), + fontSize: s.fontSize, + fontWeight: s.fontWeight, + lineHeight: s.lineHeight, + letterSpacing: s.letterSpacing, + color: rgbToHex(s.color), + background: rgbToHex(s.backgroundColor), + padding: s.padding, + borderRadius: s.borderRadius, + border: s.border, + boxShadow: s.boxShadow === "none" ? "none" : s.boxShadow, + height: s.height + }; + } + + // ── 1. Typography hierarchy ── + // Sample each text role and deduplicate by fontSize + var typographyMap = {}; + var roleSelectors = [ + { role: "display", sel: "h1", max: 3 }, + { role: "heading-2", sel: "h2", max: 5 }, + { role: "heading-3", sel: "h3", max: 5 }, + { role: "heading-4", sel: "h4", max: 3 }, + { role: "body", sel: "p", max: 10 }, + { role: "body-small", sel: "figcaption, .caption, [class*='caption'], [class*='subtitle'], small", max: 5 }, + { role: "label", sel: "label, [class*='label'], [class*='tag'], [class*='badge']", max: 5 }, + { role: "link", sel: "a:not([class*='btn']):not([class*='button']):not([role='button'])", max: 5 }, + { role: "code", sel: "code, pre, [class*='mono']", max: 3 } + ]; + + for (var ri = 0; ri < roleSelectors.length; ri++) { + var spec = roleSelectors[ri]; + var els = Array.from(document.querySelectorAll(spec.sel)).slice(0, spec.max); + for (var ei = 0; ei < els.length; ei++) { + if (!isVisible(els[ei])) continue; + var s = getStyles(els[ei]); + var key = s.fontSize + "|" + s.fontWeight + "|" + s.fontFamily; + if (!typographyMap[key]) { + var text = (els[ei].textContent || "").trim().replace(/\\s+/g, " ").slice(0, 60); + typographyMap[key] = { + role: spec.role, + fontFamily: s.fontFamily, + fontSize: s.fontSize, + fontWeight: s.fontWeight, + lineHeight: s.lineHeight, + letterSpacing: s.letterSpacing, + color: s.color, + sampleText: text + }; + } + } + } + + // Sort by font size descending + var typography = Object.values(typographyMap); + typography.sort(function(a, b) { + return parseFloat(b.fontSize) - parseFloat(a.fontSize); + }); + + // Deduplicate roles — keep only the first (largest) for each role prefix + var seenRoles = {}; + var uniqueTypo = []; + for (var ti = 0; ti < typography.length; ti++) { + var baseRole = typography[ti].role.replace(/-\\d+$/, ""); + if (!seenRoles[baseRole]) { + seenRoles[baseRole] = true; + typography[ti].role = baseRole; + uniqueTypo.push(typography[ti]); + } else if (baseRole === "heading") { + // Keep multiple heading levels + uniqueTypo.push(typography[ti]); + } + } + + // ── 2. Buttons ── + var buttonEls = Array.from(document.querySelectorAll( + 'button, a[class*="btn"], a[class*="button"], a[role="button"], ' + + '[class*="btn-"], [class*="button-"], [class*="cta"]' + )).filter(function(el) { + return isVisible(el) && !el.closest('nav, [role="navigation"]'); + }).slice(0, 10); + + var buttonMap = {}; + for (var bi = 0; bi < buttonEls.length; bi++) { + var bs = getStyles(buttonEls[bi]); + // Deduplicate by visual appearance + var bKey = bs.background + "|" + bs.borderRadius + "|" + bs.border; + if (!buttonMap[bKey]) { + var btnText = (buttonEls[bi].textContent || "").trim().slice(0, 40); + buttonMap[bKey] = { + label: btnText || "button", + background: bs.background, + color: bs.color, + padding: bs.padding, + borderRadius: bs.borderRadius, + border: bs.border, + boxShadow: bs.boxShadow, + fontSize: bs.fontSize, + fontWeight: bs.fontWeight, + height: bs.height + }; + } + } + var buttons = Object.values(buttonMap).slice(0, 4); + + // ── 3. Cards / containers ── + var cardEls = Array.from(document.querySelectorAll( + '[class*="card"], [class*="Card"], [class*="tile"], [class*="Tile"], ' + + '[class*="panel"], [class*="Panel"], [class*="feature"], ' + + 'article, [class*="box"]:not(select):not(input)' + )).filter(function(el) { + var rect = el.getBoundingClientRect(); + return isVisible(el) && rect.width > 100 && rect.height > 80; + }).slice(0, 10); + + var cardMap = {}; + for (var ci = 0; ci < cardEls.length; ci++) { + var cs = getStyles(cardEls[ci]); + var cKey = cs.background + "|" + cs.borderRadius + "|" + cs.border; + if (!cardMap[cKey]) { + cardMap[cKey] = { + label: "card", + background: cs.background, + color: cs.color, + padding: cs.padding, + borderRadius: cs.borderRadius, + border: cs.border, + boxShadow: cs.boxShadow, + fontSize: cs.fontSize, + fontWeight: cs.fontWeight, + height: cs.height + }; + } + } + var cards = Object.values(cardMap).slice(0, 4); + + // ── 4. Navigation ── + var navEl = document.querySelector('nav, header, [role="navigation"], [class*="navbar"], [class*="header"]'); + var nav = null; + if (navEl && isVisible(navEl)) { + var ns = getStyles(navEl); + nav = { + label: "navigation", + background: ns.background, + color: ns.color, + padding: ns.padding, + borderRadius: ns.borderRadius, + border: ns.border, + boxShadow: ns.boxShadow, + fontSize: ns.fontSize, + fontWeight: ns.fontWeight, + height: ns.height + }; + } + + // ── 5. Spacing scale ── + // Collect padding and margin values from visible elements + var spacingCounts = {}; + var spacingSamples = Array.from(document.querySelectorAll( + "section, div, article, main, aside, header, footer, nav, " + + "button, a, p, h1, h2, h3, h4, li, ul, ol" + )).slice(0, 200); + + for (var si = 0; si < spacingSamples.length; si++) { + if (!isVisible(spacingSamples[si])) continue; + var ss = getComputedStyle(spacingSamples[si]); + var props = [ss.paddingTop, ss.paddingRight, ss.paddingBottom, ss.paddingLeft, + ss.marginTop, ss.marginRight, ss.marginBottom, ss.marginLeft, + ss.gap, ss.rowGap, ss.columnGap]; + for (var pi = 0; pi < props.length; pi++) { + var val = parseFloat(props[pi]); + if (val > 0 && val <= 200) { + var rounded = Math.round(val); + spacingCounts[rounded] = (spacingCounts[rounded] || 0) + 1; + } + } + } + + // Find the most common spacing values (at least 3 occurrences) + var spacingEntries = Object.entries(spacingCounts) + .filter(function(e) { return e[1] >= 3; }) + .sort(function(a, b) { return b[1] - a[1]; }); + var observedSpacing = spacingEntries.map(function(e) { return parseInt(e[0]); }).sort(function(a,b) { return a - b; }); + + // Detect base unit — GCD of the top spacing values, clamped to 4 or 8 + var baseUnit = 8; + if (observedSpacing.length >= 3) { + var divisible4 = observedSpacing.filter(function(v) { return v % 4 === 0; }).length; + var divisible8 = observedSpacing.filter(function(v) { return v % 8 === 0; }).length; + baseUnit = (divisible4 > divisible8 * 1.5) ? 4 : 8; + } + + // ── 6. Border radius scale ── + var radiusCounts = {}; + var radiusSamples = Array.from(document.querySelectorAll( + "button, a, [class*='card'], [class*='btn'], input, select, textarea, " + + "[class*='badge'], [class*='tag'], [class*='chip'], img, video" + )).slice(0, 100); + + for (var rsi = 0; rsi < radiusSamples.length; rsi++) { + if (!isVisible(radiusSamples[rsi])) continue; + var br = getComputedStyle(radiusSamples[rsi]).borderRadius; + if (br && br !== "0px") { + radiusCounts[br] = (radiusCounts[br] || 0) + 1; + } + } + + var radius = Object.entries(radiusCounts) + .filter(function(e) { return e[1] >= 2; }) + .sort(function(a, b) { return parseFloat(a[0]) - parseFloat(b[0]); }) + .map(function(e) { return e[0]; }); + + // ── 7. Box shadows ── + var shadowCounts = {}; + var shadowSamples = Array.from(document.querySelectorAll( + "[class*='card'], [class*='Card'], button, [class*='btn'], " + + "[class*='dropdown'], [class*='modal'], [class*='popover'], " + + "nav, header, [class*='panel'], article" + )).slice(0, 100); + + for (var shi = 0; shi < shadowSamples.length; shi++) { + if (!isVisible(shadowSamples[shi])) continue; + var shVal = getComputedStyle(shadowSamples[shi]).boxShadow; + if (shVal && shVal !== "none") { + shadowCounts[shVal] = (shadowCounts[shVal] || 0) + 1; + } + } + + var shadows = Object.entries(shadowCounts) + .sort(function(a, b) { return b[1] - a[1]; }) + .slice(0, 5) + .map(function(e) { return { value: e[0], count: e[1] }; }); + + return { + typography: uniqueTypo, + spacing: { observed: observedSpacing.slice(0, 15), baseUnit: baseUnit }, + radius: radius, + shadows: shadows, + buttons: buttons, + cards: cards, + nav: nav + }; +})()`; + +export async function extractDesignStyles(page: Page): Promise { + return page.evaluate(EXTRACT_DESIGN_STYLES_SCRIPT) as Promise; +} diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index d5f59a1ad..89121c0ea 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -15,6 +15,7 @@ import { join } from "node:path"; import { extractHtml } from "./htmlExtractor.js"; // captureScreenshots removed — full-page screenshot replaces per-section shots import { extractTokens } from "./tokenExtractor.js"; +import { extractDesignStyles } from "./designStyleExtractor.js"; import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js"; import { extractFontMetadata } from "./fontMetadataExtractor.js"; // briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them @@ -317,12 +318,36 @@ export async function captureWebsite( // Extract design tokens progress("tokens", "Extracting design tokens..."); const tokens = await extractTokens(page1); + // Save tokens.json without SVG outerHTML (kept in memory for asset downloader) + const tokensForDisk = { + ...tokens, + svgs: tokens.svgs.map(({ outerHTML: _, ...rest }) => rest), + }; writeFileSync( join(outputDir, "extracted", "tokens.json"), - JSON.stringify(tokens, null, 2), + JSON.stringify(tokensForDisk, null, 2), "utf-8", ); + // Extract computed design styles (typography, buttons, cards, spacing, shadows) + progress("style", "Extracting design styles..."); + try { + const designStyles = await extractDesignStyles(page1); + writeFileSync( + join(outputDir, "extracted", "design-styles.json"), + JSON.stringify(designStyles, null, 2), + "utf-8", + ); + progress( + "tokens", + `${designStyles.typography.length} typography roles, ${designStyles.buttons.length} button styles, ${designStyles.shadows.length} shadow values extracted`, + ); + } catch (err) { + const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err); + console.error(` ⚠ Design style extraction failed: ${errMsg}`); + warnings.push(`Design style extraction failed: ${errMsg}`); + } + // Collect animation catalog progress("animations", "Cataloging animations..."); animationCatalog = await collectAnimationCatalog(page1, cdpAnims, cdp); @@ -486,23 +511,7 @@ export async function captureWebsite( writeFileSync(join(outputDir, "extracted", "visible-text.txt"), visibleTextContent, "utf-8"); } - // Save cataloged assets as JSON for AI agent - if (catalogedAssets.length > 0) { - writeFileSync( - join(outputDir, "extracted", "assets-catalog.json"), - JSON.stringify(catalogedAssets, null, 2), - "utf-8", - ); - } - - // Save detected libraries - if (detectedLibraries.length > 0) { - writeFileSync( - join(outputDir, "extracted", "detected-libraries.json"), - JSON.stringify(detectedLibraries, null, 2), - "utf-8", - ); - } + // detected-libraries and assets-catalog removed — 0/8 agents read them in v6 testing // AI-powered image captioning via Gemini (optional — enriches asset descriptions) const geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings); @@ -528,6 +537,52 @@ export async function captureWebsite( progress("design", "DESIGN.md will be created by your AI agent"); + // Generate contact sheets (saves AI agents 50-65% tokens vs reading images individually) + // All functions return string[] — paginated so every image is covered + try { + const { createScrollContactSheet, createAssetContactSheet, createSvgContactSheet } = + await import("./contactSheet.js"); + + const scrollSheets = await createScrollContactSheet( + join(outputDir, "screenshots"), + join(outputDir, "screenshots", "contact-sheet.jpg"), + ); + if (scrollSheets.length > 0) + progress( + "design", + `Screenshot contact sheet generated (${scrollSheets.length} page${scrollSheets.length > 1 ? "s" : ""})`, + ); + + const assetsImgDir = join(outputDir, "assets"); + if (existsSync(assetsImgDir)) { + const assetSheets = await createAssetContactSheet( + assetsImgDir, + join(outputDir, "assets", "contact-sheet.jpg"), + ); + if (assetSheets.length > 0) + progress( + "design", + `Asset contact sheet generated (${assetSheets.length} page${assetSheets.length > 1 ? "s" : ""})`, + ); + } + + // Scan assets/svgs/ (inline SVGs) AND assets/ root (external SVGs from ) + // so sites like huly.io that only use external SVGs still get a grid + const svgsDir = join(outputDir, "assets", "svgs"); + const assetsRootDir = join(outputDir, "assets"); + const svgOutputPath = existsSync(svgsDir) + ? join(outputDir, "assets", "svgs", "contact-sheet.jpg") + : join(outputDir, "assets", "contact-sheet-svgs.jpg"); + const svgSheets = await createSvgContactSheet(svgsDir, svgOutputPath, assetsRootDir); + if (svgSheets.length > 0) + progress( + "design", + `SVG contact sheet generated (${svgSheets.length} page${svgSheets.length > 1 ? "s" : ""})`, + ); + } catch { + /* contact sheets are non-critical — agent can still read images individually */ + } + // Generate project scaffold (index.html, meta.json, CLAUDE.md) await generateProjectScaffold( outputDir, diff --git a/packages/cli/src/capture/screenshotCapture.ts b/packages/cli/src/capture/screenshotCapture.ts index 2c9f8008d..05c294991 100644 --- a/packages/cli/src/capture/screenshotCapture.ts +++ b/packages/cli/src/capture/screenshotCapture.ts @@ -29,6 +29,82 @@ export async function captureScrollScreenshots(page: Page, outputDir: string): P const filePaths: string[] = []; try { + // Dismiss marketing banners, cookie consents, and popups before scrolling. + // These overlay content and contaminate screenshots with UI that doesn't + // belong in video compositions (cookie popups, newsletter modals, etc.) + await page + .evaluate(() => { + // Click common dismiss/accept buttons + const selectors = [ + // Cookie consent + '[id*="cookie"] button[class*="accept"]', + '[id*="cookie"] button[class*="agree"]', + '[id*="cookie"] button[class*="allow"]', + '[class*="cookie"] button[class*="accept"]', + '[class*="consent"] button', + // Generic close buttons on overlays/modals + '[class*="banner"] [class*="close"]', + '[class*="banner"] [class*="dismiss"]', + '[class*="popup"] [class*="close"]', + '[class*="modal"] [class*="close"]', + '[class*="overlay"] [class*="close"]', + // Common GDPR patterns — scoped under a cookie/consent/gdpr ancestor + // so we don't click "Accept invitation" / "Accept terms" / etc. on + // unrelated buttons elsewhere on the page. + '[id*="cookie" i] button[id*="accept" i]', + '[id*="consent" i] button[id*="accept" i]', + '[id*="gdpr" i] button[id*="accept" i]', + '[class*="cookie" i] button[class*="accept-all" i]', + '[class*="cookie" i] button[class*="acceptAll" i]', + '[class*="consent" i] button[class*="accept-all" i]', + // Notification prompts + 'button[class*="decline"]', + 'button[class*="not-now"]', + 'button[class*="no-thanks"]', + ]; + for (const sel of selectors) { + try { + const el = document.querySelector(sel); + if (el) el.click(); + } catch { + /* ignore */ + } + } + // Hide fixed/sticky overlays that aren't the main nav. Scanning every + // element with querySelectorAll('*') + getComputedStyle is O(n) DOM + // calls and can dominate evaluate() time on large pages. Narrow the + // candidate set with a TreeWalker that early-exits on viewport-sized + // rect checks (cheap) before reaching the expensive getComputedStyle. + const SCAN_CAP = 5000; + const minWidth = window.innerWidth * 0.3; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); + let visited = 0; + let node = walker.nextNode(); + while (node && visited < SCAN_CAP) { + visited++; + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); + // Cheap viewport-size filter first — eliminates the vast majority of + // tiny / hidden / off-screen elements without touching getComputedStyle. + if (rect.height > 80 && rect.width > minWidth) { + const tag = el.tagName; + if (tag !== "HEADER" && tag !== "NAV" && !el.closest("header") && !el.closest("nav")) { + const style = window.getComputedStyle(el); + if ( + (style.position === "fixed" || style.position === "sticky") && + style.zIndex !== "auto" && + parseInt(style.zIndex) > 100 + ) { + el.style.display = "none"; + } + } + } + node = walker.nextNode(); + } + }) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 400)); + const scrollHeight = (await page.evaluate( `Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)`, )) as number; @@ -74,6 +150,8 @@ export async function captureScrollScreenshots(page: Page, outputDir: string): P // Reset scroll await page.evaluate(`window.scrollTo(0, 0)`); await new Promise((r) => setTimeout(r, 200)); + + // full-page.png removed — 1/8 agents read it, contact sheet covers the same content } catch { /* scroll screenshots are non-critical */ } diff --git a/packages/cli/src/capture/tokenExtractor.ts b/packages/cli/src/capture/tokenExtractor.ts index 0993fcf07..d31f8813d 100644 --- a/packages/cli/src/capture/tokenExtractor.ts +++ b/packages/cli/src/capture/tokenExtractor.ts @@ -304,9 +304,12 @@ const EXTRACT_SCRIPT = `(() => { // Keep SVGs that have a label OR are at least 16px wide OR are inside a logo/brand context var inLogoContext = svg.closest('[class*="logo"], [class*="brand"], [class*="partner"], [class*="customer"], [class*="marquee"]') !== null; if (!label && !inLogoContext && (!w || parseInt(w) < 16)) return null; + var rect = svg.getBoundingClientRect(); return { label: label || undefined, viewBox: svg.getAttribute("viewBox") || undefined, + width: Math.round(rect.width), + height: Math.round(rect.height), outerHTML: svg.outerHTML.slice(0, 10000), isLogo: (label && label.toLowerCase().indexOf("logo") !== -1) || svg.closest('[class*="logo"], [class*="brand"], [class*="home"], [class*="marquee"], [class*="partner"], [class*="customer"]') !== null }; diff --git a/packages/cli/src/capture/types.ts b/packages/cli/src/capture/types.ts index 102aa7cec..b5431968e 100644 --- a/packages/cli/src/capture/types.ts +++ b/packages/cli/src/capture/types.ts @@ -102,10 +102,12 @@ export interface DesignTokens { }>; /** CTA button/link text */ ctas: Array<{ text: string; href?: string }>; - /** SVG elements with labels */ + /** SVG elements with labels (outerHTML kept in memory for asset downloader, stripped from saved JSON) */ svgs: Array<{ label?: string; viewBox?: string; + width: number; + height: number; outerHTML: string; isLogo: boolean; }>; @@ -121,6 +123,45 @@ export interface DesignTokens { }>; } +// ── Design Styles (computed from live DOM) ────────────────────────────────── + +export interface TypographyRole { + role: string; + fontFamily: string; + fontSize: string; + fontWeight: string; + lineHeight: string; + letterSpacing: string; + color: string; + sampleText: string; +} + +export interface ComponentStyle { + label: string; + background: string; + color: string; + padding: string; + borderRadius: string; + border: string; + boxShadow: string; + fontSize: string; + fontWeight: string; + height: string; +} + +export interface DesignStyles { + typography: TypographyRole[]; + spacing: { + observed: number[]; + baseUnit: number; + }; + radius: string[]; + shadows: Array<{ value: string; count: number }>; + buttons: ComponentStyle[]; + cards: ComponentStyle[]; + nav: ComponentStyle | null; +} + // ── Assets ────────────────────────────────────────────────────────────────── export interface DownloadedAsset { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 06a748a46..2dd147f14 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -18,6 +18,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; +// fallow-ignore-next-line complexity (() => { const here = dirname(fileURLToPath(import.meta.url)); const shader = join(here, "shaderTransitionWorker.js"); @@ -47,6 +48,42 @@ if (rootVersionRequested) { process.exit(0); } +// ── Load .env from CWD ───────────────────────────────────────────────────── +// Agents run from the project directory where .env holds API keys (Gemini, +// HeyGen, ElevenLabs). Load it automatically so they don't need `source .env`. +try { + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const envPath = resolve(process.cwd(), ".env"); + const envContent = readFileSync(envPath, "utf-8"); + for (const rawLine of envContent.split("\n")) { + let line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + // Tolerate `export FOO=bar` (common in dotfile-style .env files). + if (line.startsWith("export ")) line = line.slice(7).trim(); + const eqIdx = line.indexOf("="); + if (eqIdx < 1) continue; + const key = line.slice(0, eqIdx).trim(); + let val = line.slice(eqIdx + 1).trim(); + if (val.startsWith('"') || val.startsWith("'")) { + // Quoted value: take until the matching closing quote; leave the rest. + // Anything after a closing quote (including `# comment`) is dropped. + const quote = val.charAt(0); + const end = val.indexOf(quote, 1); + if (end > 0) val = val.slice(1, end); + else val = val.slice(1); // unterminated quote — best-effort, strip opener + } else { + // Unquoted value: strip inline `# comment` (requires whitespace before # + // to avoid eating `pass#word` style values). + const commentMatch = val.match(/\s+#/); + if (commentMatch?.index !== undefined) val = val.slice(0, commentMatch.index).trim(); + } + if (key && !(key in process.env)) process.env[key] = val; + } +} catch { + /* .env not present — fine, env vars may be set another way */ +} + // ── Lazy imports ──────────────────────────────────────────────────────────── // Telemetry, update checks, and heavy modules are imported only when needed. // For --help we skip telemetry entirely. diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 9a9f18bb4..341fa911d 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { defineCommand } from "citty"; -import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve, join, relative, isAbsolute } from "node:path"; import { resolveProject } from "../utils/project.js"; @@ -139,25 +139,88 @@ async function captureSnapshots( }) .catch(() => {}); - // Wait for sub-compositions to be mounted by the runtime - // (they're fetched and injected asynchronously via data-composition-src) + // Wait for ALL sub-compositions to be mounted by the runtime. + // The old check resolved when the first sub-timeline registered, causing + // "last beat black" bugs: beat-5's sub-comp hadn't loaded yet when the + // snapshot seeked into its time range. Now we count data-composition-src + // host elements and wait until we have a matching number of sub-timelines. await page .waitForFunction( () => { const tls = (window as any).__timelines; if (!tls) return false; - const keys = Object.keys(tls); - // Wait until at least one sub-composition timeline is registered - // (not counting "main" or empty registrations) - return keys.length >= 2 || keys.some((k) => k !== "main"); + const hosts = document.querySelectorAll("[data-composition-src]").length; + if (hosts === 0) return Object.keys(tls).length >= 1; + const subKeys = Object.keys(tls).filter((k) => k !== "main"); + return subKeys.length >= hosts; }, { timeout: timeoutMs }, ) .catch(() => {}); + // Wait for shader transition pre-rendering to complete (if active). + // + // Two failure modes existed with the previous overlay-only check: + // 1. Cold cache: HyperShader creates [data-hyper-shader-loading] but never + // removes it from the DOM — it only sets display:none. Checking for + // element *absence* never resolved, so the wait always timed out at 60s. + // 2. Warm cache: HyperShader loads frames from IndexedDB without showing + // the overlay at all. Checking for element absence resolved instantly + // (no element) while hydration was still running in the background. + // + // Fix: use window.__hf.shaderTransitions[].ready as the primary signal + // (set after both warm and cold cache paths complete), with the overlay + // display:none as a fallback for older builds that lack the ready state. + await page + .waitForFunction( + () => { + const win = window as unknown as { + __hf?: { shaderTransitions?: Record }; + }; + // Primary: HyperShader ready state — authoritative for both cache paths + const shaderTransitions = win.__hf?.shaderTransitions; + if (shaderTransitions !== undefined) { + return Object.values(shaderTransitions).every((s) => s.ready === true); + } + // Fallback: overlay visibility (older builds without ready state). + // Check display:none rather than element absence — element stays in + // the DOM when hidden. + const overlay = document.querySelector( + "[data-hyper-shader-loading]", + ) as HTMLElement | null; + if (!overlay) return true; + return window.getComputedStyle(overlay).display === "none"; + }, + { timeout: 90_000 }, + ) + .catch(() => {}); + // Extra settle time for media, fonts, and animations to initialize await new Promise((r) => setTimeout(r, 1500)); + // Font verification — report which fonts loaded vs fell back + const fontReport = await page + .evaluate(() => { + const loaded: string[] = []; + const failed: string[] = []; + (document as any).fonts.forEach((f: any) => { + const entry = `${f.family} (${f.weight} ${f.style})`; + if (f.status === "loaded") loaded.push(entry); + else failed.push(entry + ` [${f.status}]`); + }); + return { loaded, failed }; + }) + .catch(() => ({ loaded: [] as string[], failed: [] as string[] })); + + if (fontReport.loaded.length > 0 || fontReport.failed.length > 0) { + console.log( + `\n ${c.dim("Fonts loaded:")} ${fontReport.loaded.length > 0 ? fontReport.loaded.join(", ") : "none"}`, + ); + if (fontReport.failed.length > 0) { + console.log(` ${c.error("Fonts FAILED:")} ${fontReport.failed.join(", ")}`); + } + } + // Get composition duration const duration = await page.evaluate(() => { const win = window as any; @@ -186,9 +249,20 @@ async function captureSnapshots( ? [duration / 2] : Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration); - // Create output directory + // Create output directory and clear previous frames so old captures + // don't mix with the current run in contact sheets. const snapshotDir = join(projectDir, "snapshots"); mkdirSync(snapshotDir, { recursive: true }); + try { + const { readdirSync, rmSync } = await import("node:fs"); + for (const file of readdirSync(snapshotDir)) { + if (/\.(png|jpg|jpeg)$/i.test(file)) { + rmSync(join(snapshotDir, file), { force: true }); + } + } + } catch { + /* best-effort clear — proceed even if cleanup fails */ + } // Lazily load the engine's -overlay injector. Chrome-headless cannot // reliably advance