Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 112 additions & 16 deletions packages/cli/src/capture/agentPromptGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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. |",
Expand All @@ -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}

Expand Down
126 changes: 102 additions & 24 deletions packages/cli/src/capture/assetDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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");
Expand Down Expand Up @@ -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<string>();
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" });
Expand Down Expand Up @@ -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>,
): 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;
}
Loading
Loading