design-clone 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.env.example +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/SKILL.md +239 -0
  5. package/bin/cli.js +45 -0
  6. package/bin/commands/help.js +29 -0
  7. package/bin/commands/init.js +126 -0
  8. package/bin/commands/verify.js +99 -0
  9. package/bin/utils/copy.js +65 -0
  10. package/bin/utils/validate.js +122 -0
  11. package/docs/basic-clone.md +63 -0
  12. package/docs/cli-reference.md +94 -0
  13. package/docs/design-clone-architecture.md +247 -0
  14. package/docs/pixel-perfect.md +86 -0
  15. package/docs/troubleshooting.md +97 -0
  16. package/package.json +57 -0
  17. package/requirements.txt +5 -0
  18. package/src/ai/analyze-structure.py +305 -0
  19. package/src/ai/extract-design-tokens.py +439 -0
  20. package/src/ai/prompts/__init__.py +2 -0
  21. package/src/ai/prompts/design_tokens.py +183 -0
  22. package/src/ai/prompts/structure_analysis.py +273 -0
  23. package/src/core/cookie-handler.js +76 -0
  24. package/src/core/css-extractor.js +107 -0
  25. package/src/core/dimension-extractor.js +366 -0
  26. package/src/core/dimension-output.js +208 -0
  27. package/src/core/extract-assets.js +468 -0
  28. package/src/core/filter-css.js +499 -0
  29. package/src/core/html-extractor.js +102 -0
  30. package/src/core/lazy-loader.js +188 -0
  31. package/src/core/page-readiness.js +161 -0
  32. package/src/core/screenshot.js +380 -0
  33. package/src/post-process/enhance-assets.js +157 -0
  34. package/src/post-process/fetch-images.js +398 -0
  35. package/src/post-process/inject-icons.js +311 -0
  36. package/src/utils/__init__.py +16 -0
  37. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  39. package/src/utils/browser.js +103 -0
  40. package/src/utils/env.js +153 -0
  41. package/src/utils/env.py +134 -0
  42. package/src/utils/helpers.js +71 -0
  43. package/src/utils/puppeteer.js +281 -0
  44. package/src/verification/verify-layout.js +424 -0
  45. package/src/verification/verify-menu.js +422 -0
  46. package/templates/base.css +705 -0
  47. package/templates/base.html +293 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Standalone Puppeteer browser wrapper for design-clone scripts
3
+ * Provides browser automation without requiring chrome-devtools skill
4
+ *
5
+ * Features:
6
+ * - Auto-detects Chrome installation path (macOS, Linux, Windows)
7
+ * - Session persistence via WebSocket endpoint file
8
+ * - Graceful connect/disconnect lifecycle
9
+ *
10
+ * @note Session file may have race conditions in concurrent execution scenarios.
11
+ * For production use with multiple parallel scripts, consider external lock.
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ // Session file for browser reuse across script invocations
21
+ const SESSION_FILE = path.join(__dirname, '..', '.browser-session.json');
22
+ const SESSION_MAX_AGE = 3600000; // 1 hour
23
+
24
+ let browserInstance = null;
25
+ let pageInstance = null;
26
+ let puppeteer = null;
27
+
28
+ /**
29
+ * Detect Chrome executable path by platform
30
+ * @returns {string|null} Chrome path or null if not found
31
+ */
32
+ function detectChromePath() {
33
+ const platform = process.platform;
34
+
35
+ const paths = {
36
+ darwin: [
37
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
38
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
39
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'
40
+ ],
41
+ linux: [
42
+ '/usr/bin/google-chrome',
43
+ '/usr/bin/google-chrome-stable',
44
+ '/usr/bin/chromium',
45
+ '/usr/bin/chromium-browser',
46
+ '/snap/bin/chromium'
47
+ ],
48
+ win32: [
49
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
50
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
51
+ `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`
52
+ ]
53
+ };
54
+
55
+ const candidates = paths[platform] || [];
56
+ for (const chromePath of candidates) {
57
+ if (fs.existsSync(chromePath)) {
58
+ return chromePath;
59
+ }
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Load puppeteer module (try puppeteer first, then puppeteer-core)
67
+ * @returns {Promise<Object>} Puppeteer module
68
+ * @throws {Error} If neither puppeteer nor puppeteer-core is installed
69
+ */
70
+ async function loadPuppeteer() {
71
+ if (puppeteer) return puppeteer;
72
+
73
+ try {
74
+ // Try full puppeteer first (includes bundled Chrome)
75
+ puppeteer = (await import('puppeteer')).default;
76
+ return puppeteer;
77
+ } catch (e1) {
78
+ try {
79
+ // Fall back to puppeteer-core (requires Chrome)
80
+ puppeteer = (await import('puppeteer-core')).default;
81
+ return puppeteer;
82
+ } catch (e2) {
83
+ throw new Error(
84
+ 'Puppeteer not found. Install with: npm install puppeteer\n' +
85
+ 'Or for smaller install: npm install puppeteer-core\n' +
86
+ `Details: puppeteer: ${e1.message}, puppeteer-core: ${e2.message}`
87
+ );
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read browser session from file
94
+ * @returns {Object|null} Session data with wsEndpoint and timestamp, or null if invalid/missing
95
+ */
96
+ function readSession() {
97
+ try {
98
+ if (fs.existsSync(SESSION_FILE)) {
99
+ const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
100
+ // Validate session age
101
+ if (data.timestamp && Date.now() - data.timestamp < SESSION_MAX_AGE) {
102
+ return data;
103
+ }
104
+ // Session expired, clean up
105
+ clearSession();
106
+ }
107
+ } catch (err) {
108
+ console.error(`[browser] Failed to read session: ${err.message}`);
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Write browser session to file with PID tracking
115
+ * @param {string} wsEndpoint - WebSocket endpoint URL
116
+ */
117
+ function writeSession(wsEndpoint) {
118
+ try {
119
+ fs.writeFileSync(SESSION_FILE, JSON.stringify({
120
+ wsEndpoint,
121
+ timestamp: Date.now(),
122
+ pid: process.pid
123
+ }));
124
+ } catch (err) {
125
+ console.error(`[browser] Failed to write session: ${err.message}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Clear browser session file
131
+ */
132
+ function clearSession() {
133
+ try {
134
+ if (fs.existsSync(SESSION_FILE)) {
135
+ fs.unlinkSync(SESSION_FILE);
136
+ }
137
+ } catch (err) {
138
+ console.error(`[browser] Failed to clear session: ${err.message}`);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Launch or connect to browser instance
144
+ * Reuses existing session if available and valid
145
+ *
146
+ * @param {Object} options - Browser options
147
+ * @param {boolean} [options.headless=true] - Run in headless mode
148
+ * @param {Object} [options.viewport] - Default viewport dimensions
149
+ * @param {string} [options.executablePath] - Chrome executable path override
150
+ * @param {string[]} [options.args] - Additional Chrome arguments
151
+ * @returns {Promise<Browser>} Puppeteer browser instance
152
+ * @throws {Error} If Chrome not found and no executablePath provided
153
+ */
154
+ export async function getBrowser(options = {}) {
155
+ const pptr = await loadPuppeteer();
156
+
157
+ // Reuse existing browser in this process
158
+ if (browserInstance && browserInstance.isConnected()) {
159
+ return browserInstance;
160
+ }
161
+
162
+ // Try to connect to existing browser from session
163
+ const session = readSession();
164
+ if (session?.wsEndpoint) {
165
+ try {
166
+ browserInstance = await pptr.connect({
167
+ browserWSEndpoint: session.wsEndpoint
168
+ });
169
+ console.error('[browser] Connected to existing session');
170
+ return browserInstance;
171
+ } catch (err) {
172
+ console.error(`[browser] Failed to connect to existing session: ${err.message}`);
173
+ clearSession();
174
+ }
175
+ }
176
+
177
+ // Determine executable path
178
+ let executablePath = options.executablePath;
179
+ if (!executablePath && pptr.executablePath) {
180
+ try {
181
+ // Full puppeteer has built-in Chrome
182
+ executablePath = pptr.executablePath();
183
+ } catch {
184
+ // puppeteer-core needs manual path
185
+ executablePath = detectChromePath();
186
+ }
187
+ }
188
+
189
+ if (!executablePath) {
190
+ throw new Error(
191
+ 'Chrome not found. Either:\n' +
192
+ '1. Install Google Chrome\n' +
193
+ '2. Use full puppeteer (npm install puppeteer)\n' +
194
+ '3. Set executablePath option'
195
+ );
196
+ }
197
+
198
+ // Launch new browser
199
+ const launchOptions = {
200
+ headless: options.headless !== false,
201
+ executablePath,
202
+ args: [
203
+ '--no-sandbox',
204
+ '--disable-setuid-sandbox',
205
+ '--disable-dev-shm-usage',
206
+ '--disable-gpu',
207
+ ...(options.args || [])
208
+ ],
209
+ defaultViewport: options.viewport || {
210
+ width: 1920,
211
+ height: 1080
212
+ }
213
+ };
214
+
215
+ browserInstance = await pptr.launch(launchOptions);
216
+
217
+ // Save session for reuse
218
+ const wsEndpoint = browserInstance.wsEndpoint();
219
+ writeSession(wsEndpoint);
220
+ console.error('[browser] Launched new browser');
221
+
222
+ return browserInstance;
223
+ }
224
+
225
+ /**
226
+ * Get current page or create new one
227
+ * Reuses existing page if available
228
+ *
229
+ * @param {Browser} browser - Puppeteer browser instance
230
+ * @returns {Promise<Page>} Puppeteer page instance
231
+ * @throws {Error} If browser is null or disconnected
232
+ */
233
+ export async function getPage(browser) {
234
+ if (!browser || !browser.isConnected()) {
235
+ throw new Error('Browser not connected. Call getBrowser() first.');
236
+ }
237
+
238
+ if (pageInstance && !pageInstance.isClosed()) {
239
+ return pageInstance;
240
+ }
241
+
242
+ const pages = await browser.pages();
243
+ pageInstance = pages.length > 0 ? pages[0] : await browser.newPage();
244
+
245
+ return pageInstance;
246
+ }
247
+
248
+ /**
249
+ * Close browser and clear session
250
+ * Use when completely done with browser
251
+ */
252
+ export async function closeBrowser() {
253
+ if (browserInstance) {
254
+ try {
255
+ await browserInstance.close();
256
+ } catch (err) {
257
+ console.error(`[browser] Error closing browser: ${err.message}`);
258
+ }
259
+ browserInstance = null;
260
+ pageInstance = null;
261
+ clearSession();
262
+ console.error('[browser] Closed and cleared session');
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Disconnect from browser without closing it
268
+ * Use to keep browser running for future script executions
269
+ */
270
+ export async function disconnectBrowser() {
271
+ if (browserInstance) {
272
+ try {
273
+ browserInstance.disconnect();
274
+ } catch (err) {
275
+ console.error(`[browser] Error disconnecting: ${err.message}`);
276
+ }
277
+ browserInstance = null;
278
+ pageInstance = null;
279
+ console.error('[browser] Disconnected (browser still running)');
280
+ }
281
+ }
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Layout Verification Script
4
+ *
5
+ * Compares generated HTML against original website screenshots
6
+ * using Gemini Vision to identify layout discrepancies.
7
+ *
8
+ * Usage:
9
+ * node verify-layout.js --html <path> --original <dir> [--output <dir>] [--verbose]
10
+ *
11
+ * Options:
12
+ * --html Path to generated HTML file
13
+ * --original Directory containing original screenshots (desktop.png, tablet.png, mobile.png)
14
+ * --output Output directory for comparison screenshots and report
15
+ * --verbose Show detailed progress
16
+ */
17
+
18
+ import fs from 'fs/promises';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ // Import browser abstraction (auto-detects chrome-devtools or standalone)
23
+ import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from '../utils/browser.js';
24
+
25
+ // Import Gemini for vision comparison
26
+ const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
27
+
28
+ // Viewport configurations matching original screenshots
29
+ const VIEWPORTS = {
30
+ desktop: { width: 1920, height: 1080, deviceScaleFactor: 1 },
31
+ tablet: { width: 768, height: 1024, deviceScaleFactor: 1 },
32
+ mobile: { width: 375, height: 812, deviceScaleFactor: 2 }
33
+ };
34
+
35
+ /**
36
+ * Capture screenshot of generated HTML at specific viewport
37
+ */
38
+ async function captureGeneratedScreenshot(page, viewport, outputPath) {
39
+ await page.setViewport(viewport);
40
+ await new Promise(r => setTimeout(r, 500)); // Wait for CSS to apply
41
+
42
+ await page.screenshot({
43
+ path: outputPath,
44
+ fullPage: true
45
+ });
46
+
47
+ return outputPath;
48
+ }
49
+
50
+ /**
51
+ * Compare two screenshots using Gemini Vision
52
+ */
53
+ async function compareWithGemini(originalPath, generatedPath, viewportName) {
54
+ if (!GEMINI_API_KEY) {
55
+ return {
56
+ success: false,
57
+ error: 'GEMINI_API_KEY not set',
58
+ discrepancies: [],
59
+ similarity: 0
60
+ };
61
+ }
62
+
63
+ try {
64
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
65
+ const genai = new GoogleGenerativeAI(GEMINI_API_KEY);
66
+
67
+ // Read both images as base64
68
+ const originalBuffer = await fs.readFile(originalPath);
69
+ const generatedBuffer = await fs.readFile(generatedPath);
70
+
71
+ const originalBase64 = originalBuffer.toString('base64');
72
+ const generatedBase64 = generatedBuffer.toString('base64');
73
+
74
+ const prompt = `You are a UI/UX expert comparing two website screenshots for layout accuracy.
75
+
76
+ IMAGE 1 (left/first): Original website screenshot
77
+ IMAGE 2 (right/second): Generated HTML clone screenshot
78
+
79
+ Viewport: ${viewportName} (${VIEWPORTS[viewportName].width}x${VIEWPORTS[viewportName].height})
80
+
81
+ Analyze and compare these two images. Focus on:
82
+ 1. **Layout Structure** - Are sections positioned correctly? Any misalignment?
83
+ 2. **Spacing** - Are margins, padding, gaps correct?
84
+ 3. **Typography** - Font sizes, line heights, text alignment
85
+ 4. **Colors** - Background colors, text colors, borders
86
+ 5. **Responsive Elements** - Menu, grid layouts, card widths
87
+ 6. **Components** - Buttons, forms, icons positioning
88
+
89
+ Return a JSON object with this exact structure:
90
+ {
91
+ "similarity_score": <0-100 number>,
92
+ "overall_assessment": "<brief assessment>",
93
+ "discrepancies": [
94
+ {
95
+ "section": "<section name>",
96
+ "severity": "<critical|major|minor>",
97
+ "issue": "<description of the issue>",
98
+ "css_fix": "<suggested CSS fix or null>"
99
+ }
100
+ ],
101
+ "recommendations": ["<actionable fix 1>", "<actionable fix 2>"]
102
+ }
103
+
104
+ Be specific about CSS selectors and property values when suggesting fixes.
105
+ If similarity is >90%, discrepancies array can be empty.`;
106
+
107
+ const model = genai.getGenerativeModel({ model: 'gemini-2.5-flash' });
108
+
109
+ const response = await model.generateContent([
110
+ prompt,
111
+ {
112
+ inlineData: {
113
+ mimeType: 'image/png',
114
+ data: originalBase64
115
+ }
116
+ },
117
+ {
118
+ inlineData: {
119
+ mimeType: 'image/png',
120
+ data: generatedBase64
121
+ }
122
+ }
123
+ ]);
124
+
125
+ const text = response.response.text();
126
+
127
+ // Extract JSON from response
128
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
129
+ if (!jsonMatch) {
130
+ return {
131
+ success: false,
132
+ error: 'Could not parse Gemini response',
133
+ raw: text
134
+ };
135
+ }
136
+
137
+ const result = JSON.parse(jsonMatch[0]);
138
+ return {
139
+ success: true,
140
+ viewport: viewportName,
141
+ ...result
142
+ };
143
+
144
+ } catch (error) {
145
+ return {
146
+ success: false,
147
+ error: error.message,
148
+ discrepancies: []
149
+ };
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Alternative: Use image comparison without API
155
+ * Basic pixel difference calculation
156
+ */
157
+ async function basicImageCompare(originalPath, generatedPath) {
158
+ // This is a fallback that just checks file sizes
159
+ // Real comparison should use Gemini
160
+ try {
161
+ const originalStats = await fs.stat(originalPath);
162
+ const generatedStats = await fs.stat(generatedPath);
163
+
164
+ // Crude estimation based on file size difference
165
+ const sizeDiff = Math.abs(originalStats.size - generatedStats.size) / originalStats.size;
166
+ const similarity = Math.max(0, 100 - (sizeDiff * 100));
167
+
168
+ return {
169
+ success: true,
170
+ method: 'basic',
171
+ similarity_score: Math.round(similarity),
172
+ note: 'Basic comparison - use Gemini for accurate analysis'
173
+ };
174
+ } catch (error) {
175
+ return { success: false, error: error.message };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Generate CSS fix suggestions based on discrepancies
181
+ */
182
+ function generateCSSFixes(discrepancies) {
183
+ const fixes = [];
184
+
185
+ for (const disc of discrepancies) {
186
+ if (disc.css_fix) {
187
+ fixes.push({
188
+ section: disc.section,
189
+ severity: disc.severity,
190
+ issue: disc.issue,
191
+ fix: disc.css_fix
192
+ });
193
+ }
194
+ }
195
+
196
+ return fixes;
197
+ }
198
+
199
+ /**
200
+ * Write comparison report
201
+ */
202
+ async function writeReport(outputDir, results) {
203
+ const reportPath = path.join(outputDir, 'layout-verification.md');
204
+
205
+ let report = `# Layout Verification Report
206
+
207
+ Generated: ${new Date().toISOString()}
208
+
209
+ ## Summary
210
+
211
+ | Viewport | Similarity | Issues |
212
+ |----------|------------|--------|
213
+ `;
214
+
215
+ for (const [viewport, result] of Object.entries(results.viewports)) {
216
+ const score = result.similarity_score || 0;
217
+ const issues = result.discrepancies?.length || 0;
218
+ const status = score >= 90 ? 'āœ…' : score >= 70 ? 'āš ļø' : 'āŒ';
219
+ report += `| ${viewport} | ${status} ${score}% | ${issues} |\n`;
220
+ }
221
+
222
+ report += `\n## Overall Score: ${results.overall_score}%\n\n`;
223
+
224
+ // Detail each viewport
225
+ for (const [viewport, result] of Object.entries(results.viewports)) {
226
+ report += `## ${viewport.charAt(0).toUpperCase() + viewport.slice(1)} (${VIEWPORTS[viewport].width}x${VIEWPORTS[viewport].height})\n\n`;
227
+
228
+ if (result.overall_assessment) {
229
+ report += `**Assessment:** ${result.overall_assessment}\n\n`;
230
+ }
231
+
232
+ if (result.discrepancies?.length > 0) {
233
+ report += `### Discrepancies\n\n`;
234
+ for (const disc of result.discrepancies) {
235
+ const icon = disc.severity === 'critical' ? 'šŸ”“' : disc.severity === 'major' ? '🟠' : '🟔';
236
+ report += `${icon} **${disc.section}** (${disc.severity})\n`;
237
+ report += ` - Issue: ${disc.issue}\n`;
238
+ if (disc.css_fix) {
239
+ report += ` - Fix: \`${disc.css_fix}\`\n`;
240
+ }
241
+ report += '\n';
242
+ }
243
+ } else {
244
+ report += `āœ… No significant discrepancies found.\n\n`;
245
+ }
246
+
247
+ if (result.recommendations?.length > 0) {
248
+ report += `### Recommendations\n\n`;
249
+ for (const rec of result.recommendations) {
250
+ report += `- ${rec}\n`;
251
+ }
252
+ report += '\n';
253
+ }
254
+ }
255
+
256
+ // Consolidated CSS fixes
257
+ const allFixes = [];
258
+ for (const result of Object.values(results.viewports)) {
259
+ if (result.discrepancies) {
260
+ allFixes.push(...generateCSSFixes(result.discrepancies));
261
+ }
262
+ }
263
+
264
+ if (allFixes.length > 0) {
265
+ report += `## Suggested CSS Fixes\n\n\`\`\`css\n`;
266
+ for (const fix of allFixes) {
267
+ report += `/* ${fix.section}: ${fix.issue} */\n`;
268
+ report += `${fix.fix}\n\n`;
269
+ }
270
+ report += `\`\`\`\n`;
271
+ }
272
+
273
+ await fs.writeFile(reportPath, report);
274
+ return reportPath;
275
+ }
276
+
277
+ /**
278
+ * Main verification function
279
+ */
280
+ async function verifyLayout() {
281
+ const args = parseArgs(process.argv.slice(2));
282
+
283
+ if (!args.html) {
284
+ outputError(new Error('--html is required'));
285
+ process.exit(1);
286
+ }
287
+
288
+ if (!args.original) {
289
+ outputError(new Error('--original directory is required'));
290
+ process.exit(1);
291
+ }
292
+
293
+ const verbose = args.verbose === 'true';
294
+ const outputDir = args.output || path.dirname(args.html);
295
+
296
+ // Ensure output directory exists
297
+ await fs.mkdir(outputDir, { recursive: true });
298
+
299
+ try {
300
+ if (verbose) console.error('\nšŸ” Starting layout verification...\n');
301
+
302
+ // Check original screenshots exist
303
+ const originalScreenshots = {};
304
+ for (const viewport of ['desktop', 'tablet', 'mobile']) {
305
+ const screenshotPath = path.join(args.original, `${viewport}.png`);
306
+ try {
307
+ await fs.access(screenshotPath);
308
+ originalScreenshots[viewport] = screenshotPath;
309
+ if (verbose) console.error(` āœ“ Found ${viewport}.png`);
310
+ } catch {
311
+ if (verbose) console.error(` ⚠ Missing ${viewport}.png`);
312
+ }
313
+ }
314
+
315
+ if (Object.keys(originalScreenshots).length === 0) {
316
+ outputError(new Error('No original screenshots found'));
317
+ process.exit(1);
318
+ }
319
+
320
+ // Launch browser and capture generated screenshots
321
+ if (verbose) console.error('\nšŸ“ø Capturing generated screenshots...\n');
322
+
323
+ const browser = await getBrowser({ headless: args.headless !== 'false' });
324
+ const page = await getPage(browser);
325
+
326
+ // Navigate to generated HTML
327
+ const absolutePath = path.resolve(args.html);
328
+ const targetUrl = `file://${absolutePath}`;
329
+
330
+ await page.goto(targetUrl, {
331
+ waitUntil: 'networkidle2',
332
+ timeout: 30000
333
+ });
334
+
335
+ // Capture screenshots at each viewport
336
+ const generatedScreenshots = {};
337
+ for (const [viewport, config] of Object.entries(VIEWPORTS)) {
338
+ if (originalScreenshots[viewport]) {
339
+ const outputPath = path.join(outputDir, `generated-${viewport}.png`);
340
+ await captureGeneratedScreenshot(page, config, outputPath);
341
+ generatedScreenshots[viewport] = outputPath;
342
+ if (verbose) console.error(` āœ“ Captured ${viewport}`);
343
+ }
344
+ }
345
+
346
+ // Close browser
347
+ if (args.close === 'true') {
348
+ await closeBrowser();
349
+ } else {
350
+ await disconnectBrowser();
351
+ }
352
+
353
+ // Compare screenshots
354
+ if (verbose) console.error('\nšŸ”¬ Comparing layouts...\n');
355
+
356
+ const results = {
357
+ success: true,
358
+ html: args.html,
359
+ viewports: {},
360
+ overall_score: 0,
361
+ all_fixes: []
362
+ };
363
+
364
+ let totalScore = 0;
365
+ let viewportCount = 0;
366
+
367
+ for (const [viewport, originalPath] of Object.entries(originalScreenshots)) {
368
+ const generatedPath = generatedScreenshots[viewport];
369
+ if (!generatedPath) continue;
370
+
371
+ if (verbose) console.error(` Comparing ${viewport}...`);
372
+
373
+ let comparison;
374
+ if (GEMINI_API_KEY) {
375
+ comparison = await compareWithGemini(originalPath, generatedPath, viewport);
376
+ } else {
377
+ comparison = await basicImageCompare(originalPath, generatedPath);
378
+ if (verbose) console.error(' ⚠ Using basic comparison (set GEMINI_API_KEY for accurate analysis)');
379
+ }
380
+
381
+ results.viewports[viewport] = comparison;
382
+
383
+ if (comparison.success && comparison.similarity_score !== undefined) {
384
+ totalScore += comparison.similarity_score;
385
+ viewportCount++;
386
+
387
+ const icon = comparison.similarity_score >= 90 ? 'āœ…' : comparison.similarity_score >= 70 ? 'āš ļø' : 'āŒ';
388
+ if (verbose) console.error(` ${icon} Similarity: ${comparison.similarity_score}%`);
389
+
390
+ if (comparison.discrepancies?.length > 0) {
391
+ if (verbose) console.error(` Found ${comparison.discrepancies.length} discrepancies`);
392
+ results.all_fixes.push(...generateCSSFixes(comparison.discrepancies));
393
+ }
394
+ } else if (!comparison.success) {
395
+ if (verbose) console.error(` āŒ Error: ${comparison.error}`);
396
+ }
397
+ }
398
+
399
+ results.overall_score = viewportCount > 0 ? Math.round(totalScore / viewportCount) : 0;
400
+ results.success = results.overall_score >= 70;
401
+
402
+ // Write report
403
+ const reportPath = await writeReport(outputDir, results);
404
+ results.report = reportPath;
405
+
406
+ // Final summary
407
+ if (verbose) {
408
+ console.error('\nšŸ“Š Summary:');
409
+ console.error(` Overall Score: ${results.overall_score}%`);
410
+ console.error(` Status: ${results.success ? 'āœ“ PASS' : 'āœ— NEEDS FIXES'}`);
411
+ console.error(` Report: ${reportPath}\n`);
412
+ }
413
+
414
+ outputJSON(results);
415
+ process.exit(results.success ? 0 : 1);
416
+
417
+ } catch (error) {
418
+ outputError(error);
419
+ process.exit(1);
420
+ }
421
+ }
422
+
423
+ // Run
424
+ verifyLayout();