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.
- package/.claude/skills/real-prototypes-skill/SKILL.md +212 -16
- package/.claude/skills/real-prototypes-skill/cli.js +523 -17
- package/.claude/skills/real-prototypes-skill/scripts/detect-prototype.js +652 -0
- package/.claude/skills/real-prototypes-skill/scripts/extract-components.js +731 -0
- package/.claude/skills/real-prototypes-skill/scripts/extract-css.js +557 -0
- package/.claude/skills/real-prototypes-skill/scripts/generate-plan.js +744 -0
- package/.claude/skills/real-prototypes-skill/scripts/html-to-react.js +645 -0
- package/.claude/skills/real-prototypes-skill/scripts/inject-component.js +604 -0
- package/.claude/skills/real-prototypes-skill/scripts/project-structure.js +457 -0
- package/.claude/skills/real-prototypes-skill/scripts/visual-diff.js +474 -0
- package/.claude/skills/real-prototypes-skill/validation/color-validator.js +496 -0
- package/bin/cli.js +1 -1
- package/package.json +4 -1
|
@@ -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
|
+
};
|