diff --git a/.gitignore b/.gitignore index 983925dfd..037b814d3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ Thumbs.db # non-generated assets (logos, svgs) that should stay in the repo. docs/images/ +videos/ + # IDE .vscode/ .idea/ @@ -74,6 +76,7 @@ examples/* !examples/k8s-jobs !examples/k8s-jobs/** packages/studio/data/ + .desloppify/ .worktrees/ @@ -103,10 +106,22 @@ captures/ cursor-tests/ basecamp-video/ launch-video*/ +!skills/launch-video/ ab-test/ compositions/ video-6-2-patched/ claude-design-hyperframes-video/ +# Per-site video work at the repo root (huly-*, raycast-*, etc.) +# Anything under videos/ is already covered above, but agents sometimes write +# project dirs to the repo root when iterating. Catch the common per-brand +# patterns and any *-demo-N variants the *-demo/ rule above misses. +huly-*/ +raycast-*/ +*-demo-*/ +test-runs/ +test-outputs/ + +# Claude Code worktrees + superpowers docs .claude/worktrees/ .claude/ docs/superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index 4c8b6990a..2f3edad0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,29 @@ packages/ studio/ → Browser-based composition editor UI ``` +## Local CLI + +The published `npx hyperframes` is a released version and does **not** include local changes. Use the local CLI for commands where changes are in flight: + +```bash +# Local CLI (use instead of `npx hyperframes` for capture and snapshot): +npx tsx packages/cli/src/cli.ts + +# Examples: +npx tsx packages/cli/src/cli.ts capture -o videos//capture +npx tsx packages/cli/src/cli.ts snapshot videos/ --frames +``` + +Commands with local changes: **capture** (paginated contact sheets, fit:contain, SVG root scan), **snapshot** (3-col contact sheet). All other commands (lint, validate, preview, render) can use `npx hyperframes` as-is. + +For **shader transitions**, copy the local build into the video project instead of using the CDN — the local build includes CSS crossfade mixing support and other fixes not yet published: + +```bash +cp packages/shader-transitions/dist/index.global.js /hyper-shader-local.js +``` + +Then reference `hyper-shader-local.js` instead of the `@hyperframes/shader-transitions` CDN URL in `index.html`. + ## Development ```bash diff --git a/bun.lock b/bun.lock index 8c86b1b31..2d52b7a44 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.27", + "version": "0.6.29", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.27", + "version": "0.6.29", "bin": { "hyperframes": "./dist/cli.js", }, @@ -65,6 +65,7 @@ "citty": "^0.2.1", "compare-versions": "^6.1.1", "esbuild": "^0.25.12", + "fontkit": "^2.0.4", "giget": "^3.2.0", "hono": "^4.0.0", "onnxruntime-node": "^1.20.0", @@ -72,7 +73,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", @@ -82,6 +83,7 @@ "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", + "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", "@types/node": "^25.0.10", "linkedom": "^0.18.12", @@ -97,7 +99,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.27", + "version": "0.6.29", "dependencies": { "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", @@ -124,7 +126,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.27", + "version": "0.6.29", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -142,7 +144,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.27", + "version": "0.6.29", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -154,7 +156,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.27", + "version": "0.6.29", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -194,7 +196,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.27", + "version": "0.6.29", "dependencies": { "html2canvas": "^1.4.1", }, @@ -206,7 +208,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.27", + "version": "0.6.29", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -941,6 +943,8 @@ "@sparticuz/chromium": ["@sparticuz/chromium@148.0.0", "", { "dependencies": { "tar-fs": "^3.1.2" } }, "sha512-na5beDSZkrlcEWEMt+eHu4Xe+MLUgCtHBjHaXGsNaQu5tJWwXE+McxAcMtyumEM/JzXrxGpkO5vAPD9TWhil3g=="], + "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -969,6 +973,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/fontkit": ["@types/fontkit@2.0.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw=="], + "@types/jsdom": ["@types/jsdom@28.0.2", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-zZYItekplnGirFhVDrcB0+103TMakXfKfIp7uECxaFzFG3Ws5kYQSwVb1d4pQfJMMjQda6pfuZxueAv9CMiJbw=="], "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], @@ -1083,6 +1089,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], @@ -1125,6 +1133,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1203,6 +1213,8 @@ "devtools-protocol": ["devtools-protocol@0.0.1608973", "", {}, "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -1303,6 +1315,8 @@ "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], @@ -1581,6 +1595,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="], @@ -1669,6 +1685,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -1775,6 +1793,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1825,6 +1845,10 @@ "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index d1fc8b27c..3c3ed7f29 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,6 +30,7 @@ "citty": "^0.2.1", "compare-versions": "^6.1.1", "esbuild": "^0.25.12", + "fontkit": "^2.0.4", "giget": "^3.2.0", "hono": "^4.0.0", "onnxruntime-node": "^1.20.0", @@ -37,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", @@ -47,6 +48,7 @@ "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", + "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", "@types/node": "^25.0.10", "linkedom": "^0.18.12", 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/fontMetadataExtractor.ts b/packages/cli/src/capture/fontMetadataExtractor.ts new file mode 100644 index 000000000..2595bb324 --- /dev/null +++ b/packages/cli/src/capture/fontMetadataExtractor.ts @@ -0,0 +1,308 @@ +/** + * Extract font metadata from downloaded font files. + * + * Modern web frameworks (Next.js, Webpack) rename fonts with content hashes for + * cache-busting, leaving downloaded files like `19cfc7226ec3afaa-s.woff2` with + * no human-readable identification. The CSS @font-face mapping that originally + * tied each hash back to a family name is often lost during capture. + * + * Every OpenType / WOFF / WOFF2 file embeds a `name` table (part of the spec + * since 1996) containing the family, subfamily, full name, PostScript name, + * weight class, and variation axes. Subsetting and hashing do not strip it. + * This extractor uses `fontkit` to read the name table from each downloaded + * font and writes a manifest the rest of the pipeline can consult instead of + * guessing from filename patterns. + * + * Output: extracted/fonts-manifest.json with per-file metadata + per-family + * aggregation. See FontsManifest type for shape. + */ + +import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import * as fontkit from "fontkit"; +import type { Font, FontCollection } from "fontkit"; + +function isFontCollection(value: Font | FontCollection): value is FontCollection { + return value.type === "TTC" || value.type === "DFont"; +} + +export interface FontFileMetadata { + /** Filename relative to capture/assets/fonts/ (e.g. "19cfc7226ec3afaa-s.woff2") */ + file: string; + /** + * Canonical family name. Many static-weight font files package each weight as + * a separate "family" in nameID 1 (e.g. "Inter Medium" instead of "Inter"). + * This field strips trailing weight tokens so multiple weights of the same + * typographic family aggregate cleanly. See rawFamily for the unmodified value. + */ + family: string; + /** Raw family name from the OpenType name table (nameID 16 preferred, then nameID 1). Empty if unidentifiable. */ + rawFamily: string; + /** Subfamily / style name from nameID 17 or 2 (e.g. "Regular", "Bold Italic") */ + subfamily: string; + /** PostScript name from nameID 6 (e.g. "Inter-Regular") */ + postscript: string; + /** OS/2 usWeightClass (100–900). Approximate for variable fonts — see variationAxes. */ + weight: number; + /** "normal" or "italic" — derived from subfamily and OS/2 fsSelection */ + style: "normal" | "italic"; + /** If this is a variable font, the axes present (e.g. ["wght", "slnt"]). Empty for static fonts. */ + variationAxes: string[]; + /** Whether identification came from the binary name table (the trustworthy source). */ + identified: boolean; +} + +export interface FontFamilySummary { + /** Family name */ + family: string; + /** Distinct weights captured (from OS/2 weight class — for variable fonts shows the default) */ + weights: number[]; + /** Whether any file in this family is a variable font */ + variable: boolean; + /** Number of files in this family (typically subsets of the same weight) */ + fileCount: number; + /** Files in this family — useful for picking the @font-face src */ + files: string[]; +} + +export interface FontsManifest { + /** Per-file metadata, one entry per downloaded font */ + files: FontFileMetadata[]; + /** Aggregated per-family summary — most useful for DESIGN.md authoring */ + families: FontFamilySummary[]; + /** Files where identification failed entirely. Should be empty for typical captures. */ + unidentified: string[]; + /** Generated-at timestamp + tool version for debugging */ + meta: { generatedAt: string; tool: string }; +} + +/** + * Read all font files in fontsDir, extract metadata via fontkit, and write + * the manifest to outputPath. Returns the manifest in case callers want to log it. + * + * Failures are non-fatal: if a single font's name table is missing or corrupt, + * the file is added to `unidentified` and the rest continue. If the fonts + * directory doesn't exist, returns an empty manifest without throwing. + */ +export function extractFontMetadata(fontsDir: string, outputPath: string): FontsManifest { + const files: FontFileMetadata[] = []; + const unidentified: string[] = []; + + if (existsSync(fontsDir)) { + const fontFiles = readdirSync(fontsDir).filter((f) => /\.(woff2?|ttf|otf)$/i.test(f)); + for (const filename of fontFiles) { + const fullPath = join(fontsDir, filename); + const meta = readSingleFont(fullPath, filename); + if (meta.identified) { + files.push(meta); + } else { + files.push(meta); + unidentified.push(filename); + } + } + } + + const families = aggregateFamilies(files); + + const manifest: FontsManifest = { + files, + families, + unidentified, + meta: { + generatedAt: new Date().toISOString(), + tool: "fontkit@2.0.4", + }, + }; + + writeFileSync(outputPath, JSON.stringify(manifest, null, 2), "utf-8"); + return manifest; +} + +// fallow-ignore-next-line complexity +function readSingleFont(fullPath: string, filename: string): FontFileMetadata { + const empty: FontFileMetadata = { + file: filename, + family: "", + rawFamily: "", + subfamily: "", + postscript: "", + weight: 0, + style: "normal", + variationAxes: [], + identified: false, + }; + + try { + const buf = readFileSync(fullPath); + // fontkit.create returns Font | FontCollection. For TTC/DFont collections, + // take the first font inside; otherwise the value is already a single Font. + const created: Font | FontCollection = fontkit.create(buf); + const font: Font | undefined = isFontCollection(created) ? created.fonts[0] : created; + if (!font) return empty; + + const rawFamily = (font.familyName || "").trim(); + const subfamily = (font.subfamilyName || "").trim(); + const postscript = (font.postscriptName || "").trim(); + const fsSelection = font["OS/2"]?.fsSelection; + const italicBit = Boolean(fsSelection?.italic || fsSelection?.oblique); + const style: "normal" | "italic" = + italicBit || /italic|oblique/i.test(subfamily) ? "italic" : "normal"; + const variationAxes = font.variationAxes ? Object.keys(font.variationAxes) : []; + + if (!rawFamily && !postscript) return empty; // name table empty — cannot identify + + const familyForCanonicalization = rawFamily || deriveFamilyFromPostscript(postscript); + const { canonical, inferredWeight } = canonicalizeFamily(familyForCanonicalization); + const weight = + font["OS/2"]?.usWeightClass ?? inferredWeight ?? inferWeightFromSubfamily(subfamily); + + return { + file: filename, + family: canonical || familyForCanonicalization, + rawFamily: familyForCanonicalization, + subfamily, + postscript, + weight, + style, + variationAxes, + identified: true, + }; + } catch { + return empty; + } +} + +/** Aggregate per-file entries into per-family summaries — most useful shape for DESIGN.md. */ +// fallow-ignore-next-line complexity +function aggregateFamilies(files: FontFileMetadata[]): FontFamilySummary[] { + const byFamily = new Map(); + for (const f of files) { + if (!f.family) continue; + let entry = byFamily.get(f.family); + if (!entry) { + entry = { family: f.family, weights: [], variable: false, fileCount: 0, files: [] }; + byFamily.set(f.family, entry); + } + entry.fileCount++; + entry.files.push(f.file); + if (f.variationAxes.length > 0) entry.variable = true; + if (f.weight && !entry.weights.includes(f.weight)) entry.weights.push(f.weight); + } + for (const entry of byFamily.values()) { + entry.weights.sort((a, b) => a - b); + entry.files.sort(); + } + return Array.from(byFamily.values()).sort((a, b) => a.family.localeCompare(b.family)); +} + +/** + * PostScript names follow the convention `Family-Style`. When the family name + * record (nameID 1) is missing but PostScript is present, recover the family + * portion as a best-effort fallback. + */ +function deriveFamilyFromPostscript(postscript: string): string { + if (!postscript) return ""; + const dashIdx = postscript.indexOf("-"); + return (dashIdx > 0 ? postscript.slice(0, dashIdx) : postscript).trim(); +} + +/** Fallback when OS/2 table is missing — guess weight from "Bold", "Light", etc. */ +// fallow-ignore-next-line complexity +function inferWeightFromSubfamily(subfamily: string): number { + const s = subfamily.toLowerCase(); + if (s.includes("thin")) return 100; + if (s.includes("extralight") || s.includes("ultralight")) return 200; + if (s.includes("light")) return 300; + if (s.includes("medium")) return 500; + if (s.includes("semibold") || s.includes("demibold")) return 600; + if (s.includes("extrabold") || s.includes("ultrabold")) return 800; + if (s.includes("black") || s.includes("heavy")) return 900; + if (s.includes("bold")) return 700; + return 400; +} + +/** + * Map of trailing weight tokens found in family names (e.g. "Inter Medium" → + * "Inter") to their numeric OS/2 weight equivalent. Used to canonicalize family + * names when a foundry packaged each weight as a separate "family" instead of + * setting nameID 16 / 17 (Preferred Family / Subfamily). + * + * Conservative: only strips well-known English weight tokens. Width modifiers + * like "Tight", "Condensed", "Extended" are intentionally NOT stripped — they + * denote separate typographic families, not weight variants. Localized weight + * tokens (German "Fett", "Extrafett"; French "Maigre"; etc.) and abbreviations + * ("ExtBd", "ExtBlk") are not stripped either — the resulting family stays + * separate, which is an honest representation of what's in the file. + */ +const WEIGHT_TOKEN_TO_VALUE: Record = { + Thin: 100, + Hairline: 100, + ExtraLight: 200, + UltraLight: 200, + Light: 300, + Book: 400, + Regular: 400, + Normal: 400, + Medium: 500, + SemiBold: 600, + DemiBold: 600, + Bold: 700, + ExtraBold: 800, + UltraBold: 800, + Black: 900, + Heavy: 900, + ExtraBlack: 950, + UltraBlack: 950, +}; + +const WEIGHT_TOKEN_RE = new RegExp(`\\s+(${Object.keys(WEIGHT_TOKEN_TO_VALUE).join("|")})$`, "i"); + +/** + * Strip a trailing weight token from a family name and return both the + * canonicalized form and the weight value the stripped token implied. + * + * Examples: + * "Inter Medium" → { canonical: "Inter", inferredWeight: 500 } + * "Inter Tight Medium" → { canonical: "Inter Tight", inferredWeight: 500 } + * "Funnel Display Light" → { canonical: "Funnel Display", inferredWeight: 300 } + * "Tiempos Headline" → { canonical: "Tiempos Headline", inferredWeight: null } + * "Söhne Breit Extrafett" → { canonical: "Söhne Breit Extrafett", inferredWeight: null } + * + * Trailing "Italic"/"Oblique" is stripped before weight detection so families + * like "Inter Italic" or "Inter Medium Italic" canonicalize correctly. The + * italic flag is recovered separately from the OS/2 fsSelection bit, so no + * information is lost. + */ +// fallow-ignore-next-line complexity +function canonicalizeFamily(family: string): { + canonical: string; + inferredWeight: number | null; +} { + if (!family) return { canonical: family, inferredWeight: null }; + let result = family.trim(); + // Strip trailing "Italic" or "Oblique" first — handled by the style field. + result = result.replace(/\s+(Italic|Oblique)$/i, "").trim(); + // Normalize compound weight tokens written with a space ("Semi Bold" → "SemiBold") + // so the single-token matcher below catches them. Anchored to end-of-string to + // avoid touching family names that legitimately contain these words mid-string. + result = result.replace( + /\s+(Semi|Extra|Ultra|Demi)\s+(Bold|Black|Light)$/i, + (_, prefix: string, suffix: string) => ` ${capitalize(prefix)}${capitalize(suffix)}`, + ); + // Strip trailing weight token if any. + const match = result.match(WEIGHT_TOKEN_RE); + if (match && match[1]) { + // Look up the canonical (case-sensitive) key for the matched token. + const matchedKey = Object.keys(WEIGHT_TOKEN_TO_VALUE).find( + (k) => k.toLowerCase() === match[1]!.toLowerCase(), + ); + const inferredWeight = matchedKey ? WEIGHT_TOKEN_TO_VALUE[matchedKey]! : null; + result = result.slice(0, result.length - match[0].length).trim(); + return { canonical: result, inferredWeight }; + } + return { canonical: result, inferredWeight: null }; +} + +function capitalize(s: string): string { + return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1).toLowerCase(); +} diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index a3f6ca1a6..89121c0ea 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -15,7 +15,9 @@ 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 import { setupAnimationCapture, @@ -316,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); @@ -419,6 +445,33 @@ export async function captureWebsite( // Download fonts and rewrite URLs to local paths extracted.headHtml = await downloadAndRewriteFonts(extracted.headHtml, outputDir); + // Identify each downloaded font by reading its OpenType name table. + // Modern frameworks hash font filenames; this manifest tells the + // downstream pipeline (DESIGN.md authoring, beat sub-agents) which file + // belongs to which family without guessing from filename patterns. + try { + const fontsManifest = extractFontMetadata( + join(outputDir, "assets", "fonts"), + join(outputDir, "extracted", "fonts-manifest.json"), + ); + if (fontsManifest.families.length > 0) { + const summary = fontsManifest.families + .map((f) => `${f.family}${f.variable ? " (variable)" : ""} × ${f.fileCount}`) + .join(", "); + console.log(`Font metadata extracted: ${summary}`); + if (fontsManifest.unidentified.length > 0) { + console.warn( + ` ${fontsManifest.unidentified.length} font file(s) could not be identified — DESIGN.md should flag these explicitly.`, + ); + } + } + } catch (err) { + console.warn( + "Font metadata extraction failed (non-fatal):", + err instanceof Error ? err.message : err, + ); + } + // Save animation catalog — lean version for the agent (not 745 raw CSS declarations) if (animationCatalog) { // Extract just what's useful: counts, named animations, a few representative keyframed entries @@ -458,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); @@ -500,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