1010 * website-to-hyperframes skill — this file points agents there.
1111 */
1212
13- import { writeFileSync } from "node:fs" ;
13+ import { writeFileSync , readdirSync , existsSync } from "node:fs" ;
1414import { join } from "node:path" ;
1515import type { DesignTokens } from "./types.js" ;
1616import type { AnimationCatalog } from "./animationCataloger.js" ;
1717import type { CatalogedAsset } from "./assetCataloger.js" ;
1818
19+ /**
20+ * Infer a human-readable role hint from a hex color based on luminance and saturation.
21+ * Not a substitute for DESIGN.md — just helps orient agents scanning the brand summary.
22+ */
23+ function inferColorRole ( hex : string ) : string {
24+ const r = parseInt ( hex . slice ( 1 , 3 ) , 16 ) / 255 ;
25+ const g = parseInt ( hex . slice ( 3 , 5 ) , 16 ) / 255 ;
26+ const b = parseInt ( hex . slice ( 5 , 7 ) , 16 ) / 255 ;
27+ if ( isNaN ( r ) || isNaN ( g ) || isNaN ( b ) ) return "color" ;
28+
29+ const max = Math . max ( r , g , b ) ;
30+ const min = Math . min ( r , g , b ) ;
31+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b ;
32+ const saturation = max === 0 ? 0 : ( max - min ) / max ;
33+
34+ if ( luminance < 0.04 ) return "bg-dark" ;
35+ if ( luminance > 0.9 ) return "bg-light" ;
36+ if ( saturation > 0.4 && luminance > 0.05 && luminance < 0.7 ) return "accent" ;
37+ if ( luminance < 0.2 ) return "surface-dark" ;
38+ if ( luminance > 0.7 ) return "surface-light" ;
39+ return "neutral" ;
40+ }
41+
1942export function generateAgentPrompt (
2043 outputDir : string ,
2144 url : string ,
@@ -25,25 +48,28 @@ export function generateAgentPrompt(
2548 hasLottie ?: boolean ,
2649 hasShaders ?: boolean ,
2750 _catalogedAssets ?: CatalogedAsset [ ] , // reserved for future asset inventory
28- detectedLibraries ?: string [ ] ,
51+ _detectedLibraries ?: string [ ] ,
2952) : void {
30- const prompt = buildPrompt ( url , tokens , hasScreenshot , hasLottie , hasShaders , detectedLibraries ) ;
53+ const prompt = buildPrompt ( outputDir , url , tokens , hasScreenshot , hasLottie , hasShaders ) ;
3154 writeFileSync ( join ( outputDir , "AGENTS.md" ) , prompt , "utf-8" ) ;
3255 writeFileSync ( join ( outputDir , "CLAUDE.md" ) , prompt , "utf-8" ) ;
3356 writeFileSync ( join ( outputDir , ".cursorrules" ) , prompt , "utf-8" ) ;
3457}
3558
3659function buildPrompt (
60+ outputDir : string ,
3761 url : string ,
3862 tokens : DesignTokens ,
3963 hasScreenshot : boolean ,
4064 hasLottie ?: boolean ,
4165 hasShaders ?: boolean ,
42- detectedLibraries ?: string [ ] ,
4366) : string {
4467 const title = tokens . title || new URL ( url ) . hostname . replace ( / ^ w w w \. / , "" ) ;
4568
46- const colorSummary = tokens . colors . slice ( 0 , 10 ) . join ( ", " ) ;
69+ const colorSummary = tokens . colors
70+ . slice ( 0 , 10 )
71+ . map ( ( hex ) => `${ hex } (${ inferColorRole ( hex ) } )` )
72+ . join ( ", " ) ;
4773 const fontSummary =
4874 tokens . fonts
4975 . map (
@@ -58,17 +84,66 @@ function buildPrompt(
5884 . join ( ", " ) || "none detected" ;
5985
6086 // Build the data inventory table rows
87+ // Helper: find all contact sheet pages for a given base name. Matches the
88+ // exact base file plus paginated variants only (e.g. `contact-sheet.jpg`,
89+ // `contact-sheet-2.jpg`, `contact-sheet-3.jpg`). The "-NNN" suffix is digits
90+ // only, so unrelated files that happen to share the prefix (notably the
91+ // `contact-sheet-svgs.jpg` SVG fallback sheet in assets/) don't get mixed in.
92+ function contactSheetRows ( dir : string , baseFile : string , label : string ) : string [ ] {
93+ const fullDir = join ( outputDir , dir ) ;
94+ if ( ! existsSync ( fullDir ) ) return [ ] ;
95+ const baseName = baseFile . replace ( / \. j p g $ / , "" ) ;
96+ // Escape regex metacharacters in baseName so future callers can pass
97+ // filenames containing `.`, `+`, `(`, etc. without the regex breaking.
98+ const escapedBase = baseName . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
99+ const paginatedRe = new RegExp ( `^${ escapedBase } (?:-(\\d+))?\\.jpg$` ) ;
100+ // Sort by the numeric page suffix so `contact-sheet-10.jpg` lands after
101+ // `contact-sheet-2.jpg`, not before (default string sort orders them
102+ // lexicographically and breaks at 10+ pages). Unpaginated `contact-sheet.jpg`
103+ // gets page 0 so it sorts first if it co-exists with paginated files.
104+ const all = readdirSync ( fullDir )
105+ . filter ( ( f ) => paginatedRe . test ( f ) )
106+ . map ( ( f ) => ( { name : f , page : parseInt ( f . match ( paginatedRe ) ?. [ 1 ] ?? "0" , 10 ) } ) )
107+ . sort ( ( a , b ) => a . page - b . page )
108+ . map ( ( entry ) => entry . name ) ;
109+ if ( all . length === 0 ) return [ ] ;
110+ if ( all . length === 1 ) {
111+ return [ `| \`${ dir } /${ all [ 0 ] } \` | ${ label } |` ] ;
112+ }
113+ return all . map ( ( f , i ) => `| \`${ dir } /${ f } \` | ${ label } — page ${ i + 1 } of ${ all . length } |` ) ;
114+ }
115+
61116 const tableRows : string [ ] = [ ] ;
62117 if ( hasScreenshot ) {
118+ const screenshotRows = contactSheetRows (
119+ "screenshots" ,
120+ "contact-sheet.jpg" ,
121+ "**View this first.** All scroll screenshots in labeled grid — see the entire page at a glance" ,
122+ ) ;
123+ if ( screenshotRows . length > 0 ) {
124+ tableRows . push ( ...screenshotRows ) ;
125+ } else {
126+ tableRows . push (
127+ "| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in one labeled grid. |" ,
128+ ) ;
129+ }
63130 tableRows . push (
64- "| `screenshots/scroll-*.png` | Viewport screenshots of the full page. Start with `scroll-000.png` (hero) . |" ,
131+ "| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section . |" ,
65132 ) ;
66133 }
67134 tableRows . push (
68- "| `extracted/asset-descriptions.md ` | One-line description of every downloaded asset. **Read this first.** |" ,
135+ `| \ `extracted/tokens.json\ ` | Design tokens: ${ tokens . colors . length } colors, ${ tokens . fonts . length } fonts, ${ tokens . headings ?. length ?? 0 } headings, ${ tokens . ctas ?. length ?? 0 } CTAs |` ,
69136 ) ;
137+ // design-styles.json is written from a try/catch in capture/index.ts and
138+ // gets skipped when the live-DOM style extraction fails. Only list it in the
139+ // agent prompt when it actually exists, so the agent isn't pointed at a 404.
140+ if ( existsSync ( join ( outputDir , "extracted" , "design-styles.json" ) ) ) {
141+ tableRows . push (
142+ "| `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. |" ,
143+ ) ;
144+ }
70145 tableRows . push (
71- `| \ `extracted/tokens.json\ ` | Design tokens: ${ tokens . colors . length } colors, ${ tokens . fonts . length } fonts, ${ tokens . headings ?. length ?? 0 } headings, ${ tokens . ctas ?. length ?? 0 } CTAs |` ,
146+ "| `extracted/asset-descriptions.md ` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |" ,
72147 ) ;
73148 tableRows . push (
74149 "| `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(
81156 if ( hasShaders ) {
82157 tableRows . push ( "| `extracted/shaders.json` | WebGL shader source (GLSL). |" ) ;
83158 }
84- if ( detectedLibraries && detectedLibraries . length > 0 ) {
85- tableRows . push (
86- `| \`extracted/detected-libraries.json\` | Libraries: ${ detectedLibraries . join ( ", " ) } |` ,
87- ) ;
159+
160+ // Asset contact sheets — dynamically list all pages
161+ const assetSheetRows = contactSheetRows (
162+ "assets" ,
163+ "contact-sheet.jpg" ,
164+ "Downloaded images in labeled grid — view before opening individual files" ,
165+ ) ;
166+ if ( assetSheetRows . length > 0 ) {
167+ tableRows . push ( ...assetSheetRows ) ;
168+ } else {
169+ tableRows . push ( "| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |" ) ;
88170 }
89- tableRows . push ( "| `assets/` | Downloaded images, SVGs, and font files. |" ) ;
171+
172+ // SVG contact sheets — check both assets/svgs/ and assets/ root fallback
173+ const svgSubdirRows = contactSheetRows (
174+ "assets/svgs" ,
175+ "contact-sheet.jpg" ,
176+ "SVGs rendered as thumbnails in labeled grid" ,
177+ ) ;
178+ const svgRootRows = contactSheetRows (
179+ "assets" ,
180+ "contact-sheet-svgs.jpg" ,
181+ "SVGs rendered as thumbnails in labeled grid" ,
182+ ) ;
183+ const svgRows = svgSubdirRows . length > 0 ? svgSubdirRows : svgRootRows ;
184+ if ( svgRows . length > 0 ) {
185+ tableRows . push ( ...svgRows ) ;
186+ }
187+
188+ tableRows . push ( "| `assets/` | Individual downloaded images, SVGs, and font files. |" ) ;
90189
91190 // Brand summary — just the essentials
92191 const brandLines : string [ ] = [ ] ;
93192 brandLines . push ( `- **Colors**: ${ colorSummary || "see tokens.json" } ` ) ;
94193 brandLines . push ( `- **Fonts**: ${ fontSummary } ` ) ;
95- if ( detectedLibraries && detectedLibraries . length > 0 ) {
96- brandLines . push ( `- **Built with**: ${ detectedLibraries . join ( ", " ) } ` ) ;
97- }
98194
99195 return `# ${ title }
100196
0 commit comments