real-prototypes-skill 0.1.1 → 0.1.2

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.
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Visual Diff Comparison Tool
5
+ *
6
+ * Compares generated prototype screenshots against reference captures
7
+ * to verify visual accuracy.
8
+ *
9
+ * Features:
10
+ * - Screenshot capture of prototype pages (via Playwright)
11
+ * - Pixel-level comparison using pixelmatch
12
+ * - Generates diff images highlighting differences
13
+ * - Calculates similarity scores
14
+ * - Creates detailed comparison reports
15
+ *
16
+ * Usage:
17
+ * node visual-diff.js --project <name> --page <page>
18
+ * node visual-diff.js --reference ./ref.png --generated ./gen.png --output ./diff.png
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { PNG } = require('pngjs');
24
+ const pixelmatch = require('pixelmatch');
25
+
26
+ class VisualDiffComparator {
27
+ constructor(options = {}) {
28
+ this.options = {
29
+ threshold: 0.1, // Matching threshold (0-1, lower = stricter)
30
+ includeAA: false, // Whether to detect anti-aliasing
31
+ alpha: 0.1, // Blending factor of unchanged pixels
32
+ diffColor: [255, 0, 0], // Color of diff pixels [R, G, B]
33
+ diffColorAlt: [0, 255, 0], // Alternative diff color for anti-aliasing
34
+ outputDir: './diff',
35
+ ...options
36
+ };
37
+
38
+ this.results = [];
39
+ }
40
+
41
+ /**
42
+ * Load PNG image from file
43
+ */
44
+ loadImage(imagePath) {
45
+ return new Promise((resolve, reject) => {
46
+ if (!fs.existsSync(imagePath)) {
47
+ reject(new Error(`Image not found: ${imagePath}`));
48
+ return;
49
+ }
50
+
51
+ fs.createReadStream(imagePath)
52
+ .pipe(new PNG())
53
+ .on('parsed', function() {
54
+ resolve(this);
55
+ })
56
+ .on('error', reject);
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Save PNG image to file
62
+ */
63
+ saveImage(png, outputPath) {
64
+ return new Promise((resolve, reject) => {
65
+ const dir = path.dirname(outputPath);
66
+ if (!fs.existsSync(dir)) {
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ }
69
+
70
+ const buffer = PNG.sync.write(png);
71
+ fs.writeFileSync(outputPath, buffer);
72
+ resolve(outputPath);
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Resize image to match target dimensions
78
+ */
79
+ resizeImage(img, targetWidth, targetHeight) {
80
+ const resized = new PNG({ width: targetWidth, height: targetHeight });
81
+
82
+ const scaleX = img.width / targetWidth;
83
+ const scaleY = img.height / targetHeight;
84
+
85
+ for (let y = 0; y < targetHeight; y++) {
86
+ for (let x = 0; x < targetWidth; x++) {
87
+ const srcX = Math.floor(x * scaleX);
88
+ const srcY = Math.floor(y * scaleY);
89
+ const srcIdx = (srcY * img.width + srcX) * 4;
90
+ const dstIdx = (y * targetWidth + x) * 4;
91
+
92
+ resized.data[dstIdx] = img.data[srcIdx];
93
+ resized.data[dstIdx + 1] = img.data[srcIdx + 1];
94
+ resized.data[dstIdx + 2] = img.data[srcIdx + 2];
95
+ resized.data[dstIdx + 3] = img.data[srcIdx + 3];
96
+ }
97
+ }
98
+
99
+ return resized;
100
+ }
101
+
102
+ /**
103
+ * Compare two images and generate diff
104
+ */
105
+ async compare(referencePath, generatedPath, diffOutputPath = null) {
106
+ const reference = await this.loadImage(referencePath);
107
+ let generated = await this.loadImage(generatedPath);
108
+
109
+ // Resize generated to match reference if different sizes
110
+ if (reference.width !== generated.width || reference.height !== generated.height) {
111
+ console.log(`\x1b[33mNote:\x1b[0m Resizing generated image from ${generated.width}x${generated.height} to ${reference.width}x${reference.height}`);
112
+ generated = this.resizeImage(generated, reference.width, reference.height);
113
+ }
114
+
115
+ const { width, height } = reference;
116
+ const diff = new PNG({ width, height });
117
+
118
+ // Run pixel comparison
119
+ const mismatchedPixels = pixelmatch(
120
+ reference.data,
121
+ generated.data,
122
+ diff.data,
123
+ width,
124
+ height,
125
+ {
126
+ threshold: this.options.threshold,
127
+ includeAA: this.options.includeAA,
128
+ alpha: this.options.alpha,
129
+ diffColor: this.options.diffColor,
130
+ diffColorAlt: this.options.diffColorAlt
131
+ }
132
+ );
133
+
134
+ const totalPixels = width * height;
135
+ const matchedPixels = totalPixels - mismatchedPixels;
136
+ const similarity = (matchedPixels / totalPixels) * 100;
137
+
138
+ const result = {
139
+ reference: referencePath,
140
+ generated: generatedPath,
141
+ diff: diffOutputPath,
142
+ dimensions: { width, height },
143
+ totalPixels,
144
+ mismatchedPixels,
145
+ matchedPixels,
146
+ similarity: similarity.toFixed(2),
147
+ passed: similarity >= (this.options.minSimilarity || 95)
148
+ };
149
+
150
+ // Save diff image if output path provided
151
+ if (diffOutputPath) {
152
+ await this.saveImage(diff, diffOutputPath);
153
+ result.diff = diffOutputPath;
154
+ }
155
+
156
+ this.results.push(result);
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * Compare multiple page pairs
162
+ */
163
+ async comparePages(comparisons) {
164
+ const results = [];
165
+
166
+ for (const comparison of comparisons) {
167
+ try {
168
+ const result = await this.compare(
169
+ comparison.reference,
170
+ comparison.generated,
171
+ comparison.diff
172
+ );
173
+ results.push({ ...result, name: comparison.name });
174
+ } catch (error) {
175
+ results.push({
176
+ name: comparison.name,
177
+ error: error.message,
178
+ passed: false
179
+ });
180
+ }
181
+ }
182
+
183
+ return results;
184
+ }
185
+
186
+ /**
187
+ * Generate comparison report
188
+ */
189
+ generateReport() {
190
+ const passed = this.results.filter(r => r.passed).length;
191
+ const failed = this.results.filter(r => !r.passed).length;
192
+ const errors = this.results.filter(r => r.error).length;
193
+
194
+ return {
195
+ timestamp: new Date().toISOString(),
196
+ summary: {
197
+ total: this.results.length,
198
+ passed,
199
+ failed,
200
+ errors,
201
+ overallPassed: failed === 0 && errors === 0
202
+ },
203
+ results: this.results,
204
+ options: this.options
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Format results for CLI output
210
+ */
211
+ formatResults() {
212
+ const lines = [];
213
+
214
+ for (const result of this.results) {
215
+ if (result.error) {
216
+ lines.push(`\x1b[31m✗ ${result.name || path.basename(result.reference)}\x1b[0m`);
217
+ lines.push(` Error: ${result.error}`);
218
+ continue;
219
+ }
220
+
221
+ const status = result.passed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
222
+ const name = result.name || path.basename(result.reference);
223
+
224
+ lines.push(`${status} ${name}`);
225
+ lines.push(` Similarity: ${result.similarity}%`);
226
+ lines.push(` Dimensions: ${result.dimensions.width}x${result.dimensions.height}`);
227
+ lines.push(` Mismatched pixels: ${result.mismatchedPixels.toLocaleString()} / ${result.totalPixels.toLocaleString()}`);
228
+
229
+ if (result.diff) {
230
+ lines.push(` Diff image: ${result.diff}`);
231
+ }
232
+
233
+ if (!result.passed) {
234
+ lines.push(` \x1b[33mThreshold: ${(this.options.minSimilarity || 95)}%\x1b[0m`);
235
+ }
236
+
237
+ lines.push('');
238
+ }
239
+
240
+ const report = this.generateReport();
241
+ lines.push('\x1b[1mSummary:\x1b[0m');
242
+ lines.push(` Total: ${report.summary.total}`);
243
+ lines.push(` Passed: ${report.summary.passed}`);
244
+ lines.push(` Failed: ${report.summary.failed}`);
245
+ if (report.summary.errors > 0) {
246
+ lines.push(` Errors: ${report.summary.errors}`);
247
+ }
248
+
249
+ return lines.join('\n');
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Find matching reference screenshot for a page
255
+ */
256
+ function findReferenceScreenshot(refsDir, pageName) {
257
+ const screenshotsDir = path.join(refsDir, 'screenshots');
258
+
259
+ if (!fs.existsSync(screenshotsDir)) {
260
+ return null;
261
+ }
262
+
263
+ const files = fs.readdirSync(screenshotsDir);
264
+
265
+ // Try exact match first
266
+ const exactMatch = files.find(f =>
267
+ f.toLowerCase() === `${pageName.toLowerCase()}.png` ||
268
+ f.toLowerCase() === `${pageName.toLowerCase()}-desktop.png`
269
+ );
270
+
271
+ if (exactMatch) {
272
+ return path.join(screenshotsDir, exactMatch);
273
+ }
274
+
275
+ // Try partial match
276
+ const partialMatch = files.find(f =>
277
+ f.toLowerCase().includes(pageName.toLowerCase()) && f.endsWith('.png')
278
+ );
279
+
280
+ if (partialMatch) {
281
+ return path.join(screenshotsDir, partialMatch);
282
+ }
283
+
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * List all available reference screenshots
289
+ */
290
+ function listReferenceScreenshots(refsDir) {
291
+ const screenshotsDir = path.join(refsDir, 'screenshots');
292
+
293
+ if (!fs.existsSync(screenshotsDir)) {
294
+ return [];
295
+ }
296
+
297
+ return fs.readdirSync(screenshotsDir)
298
+ .filter(f => f.endsWith('.png'))
299
+ .map(f => ({
300
+ name: f.replace('.png', '').replace(/-desktop$/, ''),
301
+ path: path.join(screenshotsDir, f)
302
+ }));
303
+ }
304
+
305
+ // CLI execution
306
+ if (require.main === module) {
307
+ const args = process.argv.slice(2);
308
+ let referencePath = null;
309
+ let generatedPath = null;
310
+ let diffOutputPath = null;
311
+ let projectName = null;
312
+ let pageName = null;
313
+ let threshold = 0.1;
314
+ let minSimilarity = 95;
315
+ let listPages = false;
316
+ let jsonOutput = false;
317
+
318
+ for (let i = 0; i < args.length; i++) {
319
+ switch (args[i]) {
320
+ case '--reference':
321
+ case '-r':
322
+ referencePath = args[++i];
323
+ break;
324
+ case '--generated':
325
+ case '-g':
326
+ generatedPath = args[++i];
327
+ break;
328
+ case '--output':
329
+ case '-o':
330
+ diffOutputPath = args[++i];
331
+ break;
332
+ case '--project':
333
+ projectName = args[++i];
334
+ break;
335
+ case '--page':
336
+ pageName = args[++i];
337
+ break;
338
+ case '--threshold':
339
+ threshold = parseFloat(args[++i]);
340
+ break;
341
+ case '--min-similarity':
342
+ minSimilarity = parseFloat(args[++i]);
343
+ break;
344
+ case '--list':
345
+ listPages = true;
346
+ break;
347
+ case '--json':
348
+ jsonOutput = true;
349
+ break;
350
+ case '--help':
351
+ case '-h':
352
+ console.log(`
353
+ Usage: node visual-diff.js [options]
354
+
355
+ Options:
356
+ --reference, -r <path> Reference screenshot path
357
+ --generated, -g <path> Generated screenshot path
358
+ --output, -o <path> Diff output path
359
+ --project <name> Project name
360
+ --page <name> Page name (used with --project)
361
+ --threshold <0-1> Matching threshold (default: 0.1, lower = stricter)
362
+ --min-similarity <0-100> Minimum similarity % to pass (default: 95)
363
+ --list List available reference screenshots
364
+ --json Output results as JSON
365
+ --help, -h Show this help
366
+
367
+ Examples:
368
+ # Compare two specific images
369
+ node visual-diff.js -r ./ref.png -g ./gen.png -o ./diff.png
370
+
371
+ # Compare page in project
372
+ node visual-diff.js --project my-app --page homepage
373
+
374
+ # List available references
375
+ node visual-diff.js --project my-app --list
376
+
377
+ # Strict comparison
378
+ node visual-diff.js -r ref.png -g gen.png --threshold 0.01 --min-similarity 99
379
+ `);
380
+ process.exit(0);
381
+ }
382
+ }
383
+
384
+ // Handle project-based paths
385
+ if (projectName) {
386
+ const SKILL_DIR = path.dirname(__dirname);
387
+ const PROJECTS_DIR = path.resolve(SKILL_DIR, '../../../projects');
388
+ const projectDir = path.join(PROJECTS_DIR, projectName);
389
+ const refsDir = path.join(projectDir, 'references');
390
+ const protoDir = path.join(projectDir, 'prototype');
391
+
392
+ // List mode
393
+ if (listPages) {
394
+ const screenshots = listReferenceScreenshots(refsDir);
395
+ if (screenshots.length === 0) {
396
+ console.log('No reference screenshots found');
397
+ } else {
398
+ console.log('\x1b[1mAvailable Reference Screenshots:\x1b[0m');
399
+ for (const ss of screenshots) {
400
+ console.log(` ${ss.name}`);
401
+ }
402
+ }
403
+ process.exit(0);
404
+ }
405
+
406
+ // Set paths based on page
407
+ if (pageName) {
408
+ referencePath = findReferenceScreenshot(refsDir, pageName);
409
+ if (!referencePath) {
410
+ console.error(`\x1b[31mError:\x1b[0m Reference screenshot not found for page: ${pageName}`);
411
+ console.log('Use --list to see available screenshots');
412
+ process.exit(1);
413
+ }
414
+
415
+ // Look for generated screenshot
416
+ const generatedScreenshotsDir = path.join(protoDir, 'screenshots');
417
+ if (fs.existsSync(generatedScreenshotsDir)) {
418
+ const genFile = fs.readdirSync(generatedScreenshotsDir)
419
+ .find(f => f.toLowerCase().includes(pageName.toLowerCase()) && f.endsWith('.png'));
420
+ if (genFile) {
421
+ generatedPath = path.join(generatedScreenshotsDir, genFile);
422
+ }
423
+ }
424
+
425
+ // Set default diff output
426
+ const diffDir = path.join(refsDir, 'diff');
427
+ diffOutputPath = diffOutputPath || path.join(diffDir, `${pageName}-diff.png`);
428
+ }
429
+ }
430
+
431
+ // Validation
432
+ if (!referencePath || !generatedPath) {
433
+ console.error('\x1b[31mError:\x1b[0m Both --reference and --generated are required');
434
+ console.log('Use --help for usage information');
435
+ process.exit(1);
436
+ }
437
+
438
+ // Run comparison
439
+ (async () => {
440
+ try {
441
+ console.log(`\n\x1b[1mVisual Diff Comparison\x1b[0m`);
442
+ console.log(`Reference: ${referencePath}`);
443
+ console.log(`Generated: ${generatedPath}`);
444
+ console.log(`Threshold: ${threshold}`);
445
+ console.log(`Min Similarity: ${minSimilarity}%`);
446
+ console.log('');
447
+
448
+ const comparator = new VisualDiffComparator({
449
+ threshold,
450
+ minSimilarity
451
+ });
452
+
453
+ const result = await comparator.compare(referencePath, generatedPath, diffOutputPath);
454
+
455
+ if (jsonOutput) {
456
+ console.log(JSON.stringify(comparator.generateReport(), null, 2));
457
+ } else {
458
+ console.log(comparator.formatResults());
459
+ }
460
+
461
+ process.exit(result.passed ? 0 : 1);
462
+
463
+ } catch (error) {
464
+ console.error(`\x1b[31mError:\x1b[0m ${error.message}`);
465
+ process.exit(1);
466
+ }
467
+ })();
468
+ }
469
+
470
+ module.exports = {
471
+ VisualDiffComparator,
472
+ findReferenceScreenshot,
473
+ listReferenceScreenshots
474
+ };