prism-design 2.13.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 (90) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/LICENSE +21 -0
  3. package/README.md +203 -0
  4. package/bin/clone-architect.mjs +476 -0
  5. package/bin/prism.mjs +467 -0
  6. package/catalog/index.json +1155 -0
  7. package/extractions/airbnb.com/DESIGN.md +1068 -0
  8. package/extractions/airbnb.com/tokens.json +507 -0
  9. package/extractions/attio.com/DESIGN.md +1295 -0
  10. package/extractions/attio.com/tokens.json +438 -0
  11. package/extractions/auroxdashboard.com/DESIGN.md +724 -0
  12. package/extractions/auroxdashboard.com/tokens.json +195 -0
  13. package/extractions/careerexplorer.com/DESIGN.md +1178 -0
  14. package/extractions/careerexplorer.com/tokens.json +141 -0
  15. package/extractions/chance.co/DESIGN.md +1209 -0
  16. package/extractions/chance.co/tokens.json +160 -0
  17. package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
  18. package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
  19. package/extractions/example.com/DESIGN.md +436 -0
  20. package/extractions/example.com/tokens.json +91 -0
  21. package/extractions/getdesign.md/DESIGN.md +1009 -0
  22. package/extractions/getdesign.md/tokens.json +219 -0
  23. package/extractions/github.com/DESIGN.md +1130 -0
  24. package/extractions/github.com/tokens.json +2092 -0
  25. package/extractions/hello-charly.com/DESIGN.md +1146 -0
  26. package/extractions/hello-charly.com/tokens.json +322 -0
  27. package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
  28. package/extractions/hyperliquid.xyz/tokens.json +598 -0
  29. package/extractions/instagram.com/DESIGN.md +996 -0
  30. package/extractions/instagram.com/tokens.json +1240 -0
  31. package/extractions/jobirl.com/DESIGN.md +1160 -0
  32. package/extractions/jobirl.com/tokens.json +139 -0
  33. package/extractions/life360.com/DESIGN.md +1133 -0
  34. package/extractions/life360.com/tokens.json +491 -0
  35. package/extractions/lifesum.com/DESIGN.md +965 -0
  36. package/extractions/lifesum.com/tokens.json +170 -0
  37. package/extractions/linear.app/DESIGN.md +1301 -0
  38. package/extractions/linear.app/tokens.json +732 -0
  39. package/extractions/mavoie.org/DESIGN.md +1148 -0
  40. package/extractions/mavoie.org/tokens.json +128 -0
  41. package/extractions/miro.com/DESIGN.md +1237 -0
  42. package/extractions/miro.com/tokens.json +401 -0
  43. package/extractions/notion.so/DESIGN.md +1319 -0
  44. package/extractions/notion.so/tokens.json +906 -0
  45. package/extractions/onetonline.org/DESIGN.md +909 -0
  46. package/extractions/onetonline.org/tokens.json +280 -0
  47. package/extractions/posthog.com/DESIGN.md +1024 -0
  48. package/extractions/posthog.com/tokens.json +197 -0
  49. package/extractions/revolut.com/DESIGN.md +1080 -0
  50. package/extractions/revolut.com/tokens.json +401 -0
  51. package/extractions/stripe.com/DESIGN.md +1272 -0
  52. package/extractions/stripe.com/tokens.json +794 -0
  53. package/extractions/switchcollective.com/DESIGN.md +1040 -0
  54. package/extractions/switchcollective.com/tokens.json +98 -0
  55. package/extractions/truity.com/DESIGN.md +970 -0
  56. package/extractions/truity.com/tokens.json +166 -0
  57. package/extractions/uniquekicks.be/DESIGN.md +1171 -0
  58. package/extractions/uniquekicks.be/tokens.json +237 -0
  59. package/package.json +122 -0
  60. package/scripts/analyze.ts +281 -0
  61. package/scripts/bank-register.ts +379 -0
  62. package/scripts/bank.ts +374 -0
  63. package/scripts/browser-stealth.ts +189 -0
  64. package/scripts/clone.ts +198 -0
  65. package/scripts/compare-vs-gd-final.ts +273 -0
  66. package/scripts/compare-vs-gd.ts +269 -0
  67. package/scripts/compare.ts +405 -0
  68. package/scripts/deploy-site.ts +181 -0
  69. package/scripts/diff-snapshots.ts +340 -0
  70. package/scripts/enrich-catalog.ts +212 -0
  71. package/scripts/extract.ts +2038 -0
  72. package/scripts/extractors/advanced.ts +524 -0
  73. package/scripts/extractors/widgets.ts +711 -0
  74. package/scripts/generate-design-md.ts +5775 -0
  75. package/scripts/generate-final-pdf.ts +274 -0
  76. package/scripts/generate-og-image.ts +87 -0
  77. package/scripts/generate-showcase.ts +1588 -0
  78. package/scripts/generate-site.ts +847 -0
  79. package/scripts/mass-extract.sh +91 -0
  80. package/scripts/post-process-all.sh +55 -0
  81. package/scripts/regen-catalog.ts +203 -0
  82. package/scripts/shared/cache.ts +149 -0
  83. package/scripts/shared/css-helpers.ts +263 -0
  84. package/scripts/shared/logger.ts +57 -0
  85. package/scripts/shared/named-colors.ts +355 -0
  86. package/scripts/shared/types.ts +220 -0
  87. package/scripts/sync-catalog.ts +105 -0
  88. package/scripts/tokenize.ts +988 -0
  89. package/templates/layout-template.md +52 -0
  90. package/templates/tokens-template.json +34 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Prism vs GetDesign โ€” Comparison Framework
3
+ *
4
+ * Pour chaque brand qui existe ร  la fois dans extractions/ (CA) et compare-gd/ (GD),
5
+ * compute des mรฉtriques side-by-side et output un JSON structurรฉ + Markdown report.
6
+ *
7
+ * Usage: npx tsx scripts/compare-vs-gd.ts [brands...]
8
+ * npx tsx scripts/compare-vs-gd.ts --all
9
+ */
10
+
11
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ interface BrandMetrics {
15
+ domain: string;
16
+ hasCA: boolean;
17
+ hasGD: boolean;
18
+ ca?: {
19
+ designMdLines: number;
20
+ sectionsCount: number;
21
+ colorsCount: number;
22
+ fontFamiliesCount: number;
23
+ hasScreenshots: boolean;
24
+ hasTokensJson: boolean;
25
+ completenessScore: number;
26
+ sizeKb: number;
27
+ dark: boolean;
28
+ extractedAt: string;
29
+ };
30
+ gd?: {
31
+ designMdLines: number;
32
+ sectionsCount: number;
33
+ colorsNamed: number;
34
+ fontFamiliesCount: number;
35
+ hasFrontmatter: boolean;
36
+ sizeKb: number;
37
+ };
38
+ winner?: {
39
+ volume: 'CA' | 'GD' | 'tie';
40
+ colorRichness: 'CA' | 'GD' | 'tie';
41
+ verifiability: 'CA' | 'GD';
42
+ narrative: 'CA' | 'GD';
43
+ };
44
+ }
45
+
46
+ const ROOT = process.cwd();
47
+
48
+ const GD_DOMAIN_MAP: Record<string, string> = {
49
+ 'airbnb': 'airbnb.com', 'cal': 'cal.com', 'clickhouse': 'clickhouse.com',
50
+ 'cursor': 'cursor.com', 'figma': 'figma.com', 'framer': 'framer.com',
51
+ 'github': 'github.com', 'linear': 'linear.app', 'loom': 'loom.com',
52
+ 'lovable': 'lovable.dev', 'minimax': 'minimax.io', 'mintlify': 'mintlify.com',
53
+ 'miro': 'miro.com', 'mistral.ai': 'mistral.ai', 'mongodb': 'mongodb.com',
54
+ 'nike': 'nike.com', 'notion': 'notion.so', 'nvidia': 'nvidia.com',
55
+ 'ollama': 'ollama.com', 'opencode.ai': 'opencode.ai', 'posthog': 'posthog.com',
56
+ 'revolut': 'revolut.com', 'starbucks': 'starbucks.com', 'stripe': 'stripe.com',
57
+ 'theverge': 'theverge.com', 'together.ai': 'together.ai', 'vercel': 'vercel.com',
58
+ 'vodafone': 'vodafone.com',
59
+ };
60
+
61
+ const DOMAIN_TO_GD: Record<string, string> = Object.fromEntries(
62
+ Object.entries(GD_DOMAIN_MAP).map(([gd, dom]) => [dom, gd])
63
+ );
64
+
65
+ function countSections(content: string): number {
66
+ // Count lines starting with "## " (H2 markdown headers)
67
+ const lines = content.split('\n');
68
+ return lines.filter(l => l.match(/^##\s+\d?\.?\s*[A-Z]/)).length;
69
+ }
70
+
71
+ function countColorsInDesignMd(content: string): number {
72
+ // Count hex codes referenced
73
+ const hexes = content.match(/#[0-9a-fA-F]{6}\b/g) || [];
74
+ const rgbs = content.match(/rgb\([^)]+\)/g) || [];
75
+ return new Set([...hexes, ...rgbs]).size;
76
+ }
77
+
78
+ function countFontFamilies(content: string): number {
79
+ const matches = content.match(/font[-_]family[^:\n]*:[^\n]+/gi) || [];
80
+ return matches.length;
81
+ }
82
+
83
+ function analyzeCABrand(domain: string): BrandMetrics['ca'] | undefined {
84
+ const extractDir = join(ROOT, 'extractions', domain);
85
+ const designMdPath = join(extractDir, 'DESIGN.md');
86
+ const tokensPath = join(extractDir, 'tokens.json');
87
+ const screenshotsPath = join(extractDir, 'screenshots');
88
+ const summaryPath = join(extractDir, 'extraction-summary.json');
89
+
90
+ if (!existsSync(designMdPath)) return undefined;
91
+
92
+ const content = readFileSync(designMdPath, 'utf-8');
93
+ const lines = content.split('\n').length;
94
+ const sectionsCount = countSections(content);
95
+ const colorsCount = countColorsInDesignMd(content);
96
+ const fontFamiliesCount = countFontFamilies(content);
97
+ const sizeKb = Math.round(Buffer.byteLength(content, 'utf-8') / 1024);
98
+
99
+ // Read catalog for completeness score
100
+ const catalogPath = join(ROOT, 'catalog', 'index.json');
101
+ const catalog = JSON.parse(readFileSync(catalogPath, 'utf-8'));
102
+ const brandEntry = catalog.brands.find((b: any) => b.domain === domain);
103
+
104
+ return {
105
+ designMdLines: lines,
106
+ sectionsCount,
107
+ colorsCount,
108
+ fontFamiliesCount,
109
+ hasScreenshots: existsSync(screenshotsPath),
110
+ hasTokensJson: existsSync(tokensPath),
111
+ completenessScore: brandEntry?.completeness || 0,
112
+ sizeKb,
113
+ dark: brandEntry?.dark || false,
114
+ extractedAt: brandEntry?.extractedAt || '?',
115
+ };
116
+ }
117
+
118
+ function analyzeGDBrand(domain: string): BrandMetrics['gd'] | undefined {
119
+ const gdSlug = DOMAIN_TO_GD[domain];
120
+ if (!gdSlug) return undefined;
121
+
122
+ const gdPath = join(ROOT, 'compare-gd', gdSlug, 'GD-DESIGN.md');
123
+ if (!existsSync(gdPath)) return undefined;
124
+
125
+ const content = readFileSync(gdPath, 'utf-8');
126
+ const lines = content.split('\n').length;
127
+ const sectionsCount = countSections(content);
128
+ const colorsNamed = countColorsInDesignMd(content);
129
+ const fontFamiliesCount = countFontFamilies(content);
130
+ const hasFrontmatter = content.startsWith('---\n') || content.startsWith('---\r\n');
131
+ const sizeKb = Math.round(Buffer.byteLength(content, 'utf-8') / 1024);
132
+
133
+ return { designMdLines: lines, sectionsCount, colorsNamed, fontFamiliesCount, hasFrontmatter, sizeKb };
134
+ }
135
+
136
+ function determineWinner(ca: BrandMetrics['ca'], gd: BrandMetrics['gd']): BrandMetrics['winner'] {
137
+ if (!ca || !gd) return undefined;
138
+ return {
139
+ volume: ca.designMdLines > gd.designMdLines * 1.1 ? 'CA'
140
+ : gd.designMdLines > ca.designMdLines * 1.1 ? 'GD' : 'tie',
141
+ colorRichness: ca.colorsCount > gd.colorsNamed * 1.1 ? 'CA'
142
+ : gd.colorsNamed > ca.colorsCount * 1.1 ? 'GD' : 'tie',
143
+ verifiability: ca.hasScreenshots && ca.hasTokensJson ? 'CA' : 'GD',
144
+ narrative: gd.hasFrontmatter ? 'GD' : 'CA', // GD has YAML frontmatter narrative
145
+ };
146
+ }
147
+
148
+ async function main() {
149
+ const args = process.argv.slice(2);
150
+ const allMode = args.includes('--all');
151
+
152
+ // Build the list of domains to compare
153
+ let domains: string[];
154
+ if (allMode) {
155
+ domains = Object.values(GD_DOMAIN_MAP);
156
+ } else if (args.length > 0) {
157
+ domains = args.filter(a => !a.startsWith('--'));
158
+ } else {
159
+ domains = Object.values(GD_DOMAIN_MAP);
160
+ }
161
+
162
+ console.log(`๐Ÿ”ฌ Prism vs GetDesign โ€” comparison of ${domains.length} brands\n`);
163
+
164
+ const results: BrandMetrics[] = [];
165
+ let caWins = { volume: 0, color: 0, verif: 0, narrative: 0 };
166
+ let gdWins = { volume: 0, color: 0, verif: 0, narrative: 0 };
167
+
168
+ for (const domain of domains) {
169
+ const ca = analyzeCABrand(domain);
170
+ const gd = analyzeGDBrand(domain);
171
+
172
+ const metrics: BrandMetrics = {
173
+ domain,
174
+ hasCA: !!ca,
175
+ hasGD: !!gd,
176
+ ca,
177
+ gd,
178
+ winner: ca && gd ? determineWinner(ca, gd) : undefined,
179
+ };
180
+
181
+ if (metrics.winner) {
182
+ if (metrics.winner.volume === 'CA') caWins.volume++;
183
+ else if (metrics.winner.volume === 'GD') gdWins.volume++;
184
+ if (metrics.winner.colorRichness === 'CA') caWins.color++;
185
+ else if (metrics.winner.colorRichness === 'GD') gdWins.color++;
186
+ if (metrics.winner.verifiability === 'CA') caWins.verif++;
187
+ else gdWins.verif++;
188
+ if (metrics.winner.narrative === 'CA') caWins.narrative++;
189
+ else gdWins.narrative++;
190
+ }
191
+
192
+ results.push(metrics);
193
+
194
+ const statusCA = ca ? `โœ… ${ca.designMdLines}L ${ca.colorsCount}c ${ca.completenessScore}/100` : 'โŒ';
195
+ const statusGD = gd ? `โœ… ${gd.designMdLines}L ${gd.colorsNamed}c` : 'โŒ';
196
+ console.log(`${domain.padEnd(20)} CA: ${statusCA.padEnd(30)} GD: ${statusGD}`);
197
+ }
198
+
199
+ // Aggregate stats
200
+ const compared = results.filter(r => r.hasCA && r.hasGD);
201
+ const avgCALines = compared.reduce((s, r) => s + (r.ca?.designMdLines || 0), 0) / compared.length;
202
+ const avgGDLines = compared.reduce((s, r) => s + (r.gd?.designMdLines || 0), 0) / compared.length;
203
+ const avgCAColors = compared.reduce((s, r) => s + (r.ca?.colorsCount || 0), 0) / compared.length;
204
+ const avgGDColors = compared.reduce((s, r) => s + (r.gd?.colorsNamed || 0), 0) / compared.length;
205
+ const avgCAScore = compared.reduce((s, r) => s + (r.ca?.completenessScore || 0), 0) / compared.length;
206
+
207
+ console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
208
+ console.log('๐Ÿ“Š AGGREGATE METRICS');
209
+ console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
210
+ console.log(`Compared brands : ${compared.length} / ${domains.length}`);
211
+ console.log(`Avg lines : CA ${avgCALines.toFixed(0)} vs GD ${avgGDLines.toFixed(0)} (CA ${(avgCALines/avgGDLines).toFixed(1)}x)`);
212
+ console.log(`Avg colors : CA ${avgCAColors.toFixed(1)} vs GD ${avgGDColors.toFixed(1)} (GD ${(avgGDColors/avgCAColors).toFixed(1)}x)`);
213
+ console.log(`Avg completeness CA : ${avgCAScore.toFixed(0)}/100`);
214
+ console.log('');
215
+ console.log('๐Ÿ† WINS PER DIMENSION');
216
+ console.log(`Volume : CA ${caWins.volume} | GD ${gdWins.volume}`);
217
+ console.log(`Color richness : CA ${caWins.color} | GD ${gdWins.color}`);
218
+ console.log(`Verifiability : CA ${caWins.verif} | GD ${gdWins.verif}`);
219
+ console.log(`Narrative : CA ${caWins.narrative} | GD ${gdWins.narrative}`);
220
+
221
+ // Save outputs
222
+ const outDir = join(ROOT, 'docs', 'comparison');
223
+ mkdirSync(outDir, { recursive: true });
224
+
225
+ const jsonPath = join(outDir, 'ca-vs-gd-metrics.json');
226
+ writeFileSync(jsonPath, JSON.stringify({
227
+ generatedAt: new Date().toISOString(),
228
+ totalBrands: domains.length,
229
+ comparedCount: compared.length,
230
+ avg: { caLines: avgCALines, gdLines: avgGDLines, caColors: avgCAColors, gdColors: avgGDColors, caScore: avgCAScore },
231
+ wins: { ca: caWins, gd: gdWins },
232
+ brands: results,
233
+ }, null, 2));
234
+ console.log(`\n๐Ÿ’พ JSON output โ†’ ${jsonPath}`);
235
+
236
+ // Markdown report
237
+ const mdPath = join(outDir, 'ca-vs-gd-report.md');
238
+ let md = `# Prism vs getdesign.md โ€” Comparison Report\n\n`;
239
+ md += `Generated: ${new Date().toISOString()}\n\n`;
240
+ md += `## Aggregate Metrics\n\n`;
241
+ md += `| Metric | Prism | getdesign.md | Ratio |\n`;
242
+ md += `|---|---|---|---|\n`;
243
+ md += `| Brands compared | ${compared.length} / ${domains.length} | โ€” | โ€” |\n`;
244
+ md += `| Avg DESIGN.md lines | ${avgCALines.toFixed(0)} | ${avgGDLines.toFixed(0)} | CA ${(avgCALines/avgGDLines).toFixed(1)}ร— |\n`;
245
+ md += `| Avg colors referenced | ${avgCAColors.toFixed(1)} | ${avgGDColors.toFixed(1)} | GD ${(avgGDColors/avgCAColors).toFixed(1)}ร— |\n`;
246
+ md += `| Avg CA completeness | ${avgCAScore.toFixed(0)}/100 | โ€” | โ€” |\n`;
247
+ md += `\n## Wins per Dimension\n\n`;
248
+ md += `| Dimension | CA wins | GD wins |\n`;
249
+ md += `|---|---|---|\n`;
250
+ md += `| Volume (lines) | ${caWins.volume} | ${gdWins.volume} |\n`;
251
+ md += `| Color richness | ${caWins.color} | ${gdWins.color} |\n`;
252
+ md += `| Verifiability (screenshots+tokens) | ${caWins.verif} | ${gdWins.verif} |\n`;
253
+ md += `| Narrative editorial | ${caWins.narrative} | ${gdWins.narrative} |\n`;
254
+ md += `\n## Per-Brand Details\n\n`;
255
+ md += `| Brand | CA lines | GD lines | CA colors | GD colors | CA score | Winner volume | Winner color |\n`;
256
+ md += `|---|---|---|---|---|---|---|---|\n`;
257
+ for (const r of results) {
258
+ if (!r.hasCA || !r.hasGD) {
259
+ md += `| ${r.domain} | ${r.hasCA ? 'โœ…' : 'โŒ'} | ${r.hasGD ? 'โœ…' : 'โŒ'} | โ€” | โ€” | โ€” | โ€” | โ€” |\n`;
260
+ } else {
261
+ md += `| ${r.domain} | ${r.ca?.designMdLines} | ${r.gd?.designMdLines} | ${r.ca?.colorsCount} | ${r.gd?.colorsNamed} | ${r.ca?.completenessScore} | ${r.winner?.volume} | ${r.winner?.colorRichness} |\n`;
262
+ }
263
+ }
264
+
265
+ writeFileSync(mdPath, md);
266
+ console.log(`๐Ÿ“„ Markdown report โ†’ ${mdPath}`);
267
+ }
268
+
269
+ main().catch(err => { console.error(err); process.exit(1); });
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Prism โ€” Visual Comparison (pixel-level)
3
+ *
4
+ * Compare le screenshot original avec le code genere via pixelmatch.
5
+ * Produit un diff image (pixels rouges = differences) et un score de similarite.
6
+ */
7
+
8
+ import { chromium } from 'playwright';
9
+ import { readFile, writeFile, mkdir, readdir, stat } from 'fs/promises';
10
+ import { existsSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { PNG } from 'pngjs';
13
+ import pixelmatch from 'pixelmatch';
14
+
15
+ interface ComparisonResult {
16
+ domain: string;
17
+ timestamp: string;
18
+ originalScreenshot: string;
19
+ generatedScreenshot: string;
20
+ diffImage: string;
21
+ viewport: string;
22
+ differences: string[];
23
+ score: number;
24
+ ssim: number;
25
+ verdict: 'PASS' | 'NEEDS-REVISION' | 'FAIL';
26
+ pixelStats: {
27
+ totalPixels: number;
28
+ diffPixels: number;
29
+ matchPercent: number;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Parse a PNG file buffer into a PNG object.
35
+ */
36
+ function decodePNG(buffer: Buffer): PNG {
37
+ return PNG.sync.read(buffer);
38
+ }
39
+
40
+ // โ”€โ”€ Structural similarity (SSIM) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
41
+ // v2.13 โ€” pixelmatch is dominated by large flat fills (background), so it can't tell a
42
+ // structural clone from a token re-skin (both get the background right). SSIM compares LOCAL
43
+ // luminance/contrast/structure in windows, so it actually rewards reproducing the layout
44
+ // (hero position, heading mass, banding). We report BOTH: pixelmatch = flat-area match,
45
+ // SSIM = structural match. The honest "fidelity" headline is the structure-aware one.
46
+
47
+ /** Downsample an RGBA PNG to grayscale at a target width (box filter). */
48
+ function toGray(png: PNG, targetW: number): { w: number; h: number; data: Float64Array } {
49
+ const w = Math.max(1, Math.min(targetW, png.width));
50
+ const h = Math.max(1, Math.round(png.height * (w / png.width)));
51
+ const data = new Float64Array(w * h);
52
+ const sx = png.width / w, sy = png.height / h;
53
+ for (let y = 0; y < h; y++) {
54
+ const y0 = Math.floor(y * sy), y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
55
+ for (let x = 0; x < w; x++) {
56
+ const x0 = Math.floor(x * sx), x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
57
+ let sum = 0, n = 0;
58
+ for (let yy = y0; yy < Math.min(y1, png.height); yy++) {
59
+ for (let xx = x0; xx < Math.min(x1, png.width); xx++) {
60
+ const i = (yy * png.width + xx) * 4;
61
+ sum += 0.2126 * png.data[i] + 0.7152 * png.data[i + 1] + 0.0722 * png.data[i + 2];
62
+ n++;
63
+ }
64
+ }
65
+ data[y * w + x] = n ? sum / n : 0;
66
+ }
67
+ }
68
+ return { w, h, data };
69
+ }
70
+
71
+ /** Mean SSIM over non-overlapping 8ร—8 blocks โ†’ 0..1. */
72
+ function meanSSIM(a: { w: number; h: number; data: Float64Array }, b: { w: number; h: number; data: Float64Array }): number {
73
+ const w = Math.min(a.w, b.w), h = Math.min(a.h, b.h);
74
+ const C1 = 6.5025, C2 = 58.5225, B = 8, N = B * B;
75
+ let total = 0, blocks = 0;
76
+ for (let by = 0; by + B <= h; by += B) {
77
+ for (let bx = 0; bx + B <= w; bx += B) {
78
+ let ma = 0, mb = 0;
79
+ for (let y = 0; y < B; y++) for (let x = 0; x < B; x++) {
80
+ ma += a.data[(by + y) * a.w + bx + x];
81
+ mb += b.data[(by + y) * b.w + bx + x];
82
+ }
83
+ ma /= N; mb /= N;
84
+ let va = 0, vb = 0, cov = 0;
85
+ for (let y = 0; y < B; y++) for (let x = 0; x < B; x++) {
86
+ const da = a.data[(by + y) * a.w + bx + x] - ma;
87
+ const db = b.data[(by + y) * b.w + bx + x] - mb;
88
+ va += da * da; vb += db * db; cov += da * db;
89
+ }
90
+ va /= N - 1; vb /= N - 1; cov /= N - 1;
91
+ total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
92
+ blocks++;
93
+ }
94
+ }
95
+ return blocks ? total / blocks : 0;
96
+ }
97
+
98
+ /** Structure-aware similarity 0..100 between two same-sized PNGs (downsampled to 512px wide). */
99
+ function structuralSimilarity(png1: PNG, png2: PNG): number {
100
+ const s = meanSSIM(toGray(png1, 512), toGray(png2, 512));
101
+ return Math.round(Math.max(0, Math.min(1, s)) * 100);
102
+ }
103
+
104
+ /**
105
+ * Resize a PNG to target dimensions by cropping (top-left) or padding (transparent).
106
+ * Returns a new PNG with exactly targetWidth x targetHeight.
107
+ */
108
+ function resizePNG(src: PNG, targetWidth: number, targetHeight: number): PNG {
109
+ const dst = new PNG({ width: targetWidth, height: targetHeight, fill: true });
110
+ // fill with transparent black
111
+ dst.data.fill(0);
112
+
113
+ const copyW = Math.min(src.width, targetWidth);
114
+ const copyH = Math.min(src.height, targetHeight);
115
+
116
+ for (let y = 0; y < copyH; y++) {
117
+ const srcOffset = y * src.width * 4;
118
+ const dstOffset = y * targetWidth * 4;
119
+ src.data.copy(dst.data, dstOffset, srcOffset, srcOffset + copyW * 4);
120
+ }
121
+
122
+ return dst;
123
+ }
124
+
125
+ async function screenshotGenerated(
126
+ htmlPath: string,
127
+ outputPath: string,
128
+ viewport: { width: number; height: number },
129
+ ): Promise<void> {
130
+ const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
131
+ // v2.12 โ€” match the originals' 2ร— device scale so we compare like-for-like (originals are
132
+ // captured at deviceScaleFactor 2 โ†’ 2880px wide). Comparing a 1ร— render against a 2ร— original
133
+ // silently inflated/distorted scores via the crop-to-common-area fallback.
134
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 2 });
135
+ const page = await context.newPage();
136
+
137
+ await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle' });
138
+ await page.waitForTimeout(1000);
139
+ await page.screenshot({ path: outputPath, fullPage: true });
140
+
141
+ await browser.close();
142
+ }
143
+
144
+ /**
145
+ * Pixel-level comparison using pixelmatch.
146
+ * Generates a diff image and returns a similarity score.
147
+ */
148
+ async function comparePixels(
149
+ img1Path: string,
150
+ img2Path: string,
151
+ diffOutputPath: string,
152
+ ): Promise<{ score: number; ssim: number; differences: string[]; pixelStats: { totalPixels: number; diffPixels: number; matchPercent: number } }> {
153
+ const differences: string[] = [];
154
+
155
+ // Read both images
156
+ let buf1: Buffer;
157
+ let buf2: Buffer;
158
+ try {
159
+ buf1 = await readFile(img1Path);
160
+ } catch {
161
+ return {
162
+ score: 0,
163
+ differences: [`Original image not found: ${img1Path}`],
164
+ ssim: 0,
165
+ pixelStats: { totalPixels: 0, diffPixels: 0, matchPercent: 0 },
166
+ };
167
+ }
168
+ try {
169
+ buf2 = await readFile(img2Path);
170
+ } catch {
171
+ return {
172
+ score: 0,
173
+ differences: [`Generated image not found: ${img2Path}`],
174
+ ssim: 0,
175
+ pixelStats: { totalPixels: 0, diffPixels: 0, matchPercent: 0 },
176
+ };
177
+ }
178
+
179
+ // Decode PNGs
180
+ let png1: PNG;
181
+ let png2: PNG;
182
+ try {
183
+ png1 = decodePNG(buf1);
184
+ png2 = decodePNG(buf2);
185
+ } catch (err) {
186
+ return {
187
+ score: 0,
188
+ differences: [`Failed to decode PNG: ${err}`],
189
+ ssim: 0,
190
+ pixelStats: { totalPixels: 0, diffPixels: 0, matchPercent: 0 },
191
+ };
192
+ }
193
+
194
+ // Log original dimensions
195
+ if (png1.width !== png2.width || png1.height !== png2.height) {
196
+ differences.push(
197
+ `Size mismatch: original ${png1.width}x${png1.height} vs generated ${png2.width}x${png2.height} โ€” cropped to common area`,
198
+ );
199
+ }
200
+
201
+ // Use the smaller dimensions so we compare the common area
202
+ const width = Math.min(png1.width, png2.width);
203
+ const height = Math.min(png1.height, png2.height);
204
+
205
+ // Resize both to the common dimensions
206
+ const resized1 = resizePNG(png1, width, height);
207
+ const resized2 = resizePNG(png2, width, height);
208
+
209
+ // Create diff output image
210
+ const diff = new PNG({ width, height });
211
+
212
+ // Run pixelmatch
213
+ const diffPixels = pixelmatch(
214
+ resized1.data,
215
+ resized2.data,
216
+ diff.data,
217
+ width,
218
+ height,
219
+ {
220
+ threshold: 0.1,
221
+ includeAA: false,
222
+ alpha: 0.1,
223
+ },
224
+ );
225
+
226
+ // Write diff image
227
+ const diffBuffer = PNG.sync.write(diff);
228
+ await writeFile(diffOutputPath, diffBuffer);
229
+
230
+ const totalPixels = width * height;
231
+ const matchPercent = totalPixels > 0 ? ((1 - diffPixels / totalPixels) * 100) : 0;
232
+ const score = Math.round(matchPercent);
233
+ const ssim = structuralSimilarity(resized1, resized2);
234
+
235
+ // Add diagnostic differences
236
+ if (diffPixels > 0) {
237
+ const diffPercent = ((diffPixels / totalPixels) * 100).toFixed(2);
238
+ differences.push(`${diffPixels.toLocaleString()} pixels differ (${diffPercent}% of ${totalPixels.toLocaleString()} total)`);
239
+ }
240
+
241
+ if (score < 60) {
242
+ differences.push('Major structural differences detected โ€” layout likely diverges significantly');
243
+ } else if (score < 85) {
244
+ differences.push('Moderate differences โ€” colors, spacing, or minor layout issues');
245
+ }
246
+
247
+ if (score >= 60 && ssim < 45) {
248
+ differences.push(`Structure diverges (SSIM ${ssim}/100) despite ${score}% flat-area match โ€” background matches, layout does not`);
249
+ }
250
+
251
+ return {
252
+ score,
253
+ ssim,
254
+ differences,
255
+ pixelStats: { totalPixels, diffPixels, matchPercent: Math.round(matchPercent * 100) / 100 },
256
+ };
257
+ }
258
+
259
+ async function compare(domain: string): Promise<void> {
260
+ const baseDir = join(process.cwd(), 'extractions', domain);
261
+ const comparisonDir = join(baseDir, 'comparison');
262
+ const screenshotDir = join(baseDir, 'screenshots');
263
+ const outputDir = join(baseDir, 'output');
264
+
265
+ await mkdir(comparisonDir, { recursive: true });
266
+
267
+ console.log(`\n๐Ÿ” Comparing results for ${domain}...`);
268
+
269
+ // Check if output exists
270
+ try {
271
+ await readdir(outputDir);
272
+ } catch {
273
+ console.error(`No output directory found at ${outputDir}`);
274
+ console.error('Generate code first, then run compare.');
275
+ process.exit(1);
276
+ }
277
+
278
+ // Find HTML files in output
279
+ const outputFiles = await readdir(outputDir);
280
+ const htmlFiles = outputFiles.filter((f) => f.endsWith('.html'));
281
+
282
+ if (htmlFiles.length === 0) {
283
+ console.error('No HTML files found in output directory');
284
+ process.exit(1);
285
+ }
286
+
287
+ const results: ComparisonResult[] = [];
288
+
289
+ for (const htmlFile of htmlFiles) {
290
+ const htmlPath = join(outputDir, htmlFile);
291
+
292
+ for (const [vpName, vpSize] of Object.entries({
293
+ desktop: { width: 1440, height: 900 },
294
+ mobile: { width: 390, height: 844 },
295
+ })) {
296
+ const baseName = htmlFile.replace('.html', '');
297
+ const generatedScreenshot = join(comparisonDir, `generated-${vpName}-${baseName}.png`);
298
+ // v2.12 โ€” prefer the ABOVE-FOLD original for a fair, region-matched score. Comparing a
299
+ // token reconstruction against the FULL scrolled page (often 7000px+) was apples-to-oranges
300
+ // and the crop fallback flattered dark-background sites. Fold-to-fold is the honest measure.
301
+ const aboveFold = join(screenshotDir, `above-fold-${vpName}.png`);
302
+ const originalScreenshot = existsSync(aboveFold) ? aboveFold : join(screenshotDir, `full-page-${vpName}.png`);
303
+ const diffImage = join(comparisonDir, `diff-${vpName}.png`);
304
+
305
+ console.log(` ๐Ÿ“ธ Screenshotting ${htmlFile} (${vpName})...`);
306
+ await screenshotGenerated(htmlPath, generatedScreenshot, vpSize);
307
+
308
+ // Check if original exists
309
+ try {
310
+ await stat(originalScreenshot);
311
+ } catch {
312
+ console.log(` โš ๏ธ No original screenshot for ${vpName}, skipping comparison`);
313
+ continue;
314
+ }
315
+
316
+ console.log(` ๐Ÿ” Comparing ${vpName} (pixel-level)...`);
317
+ const { score, ssim, differences, pixelStats } = await comparePixels(
318
+ originalScreenshot,
319
+ generatedScreenshot,
320
+ diffImage,
321
+ );
322
+
323
+ const verdict: ComparisonResult['verdict'] =
324
+ score >= 85 ? 'PASS' : score >= 60 ? 'NEEDS-REVISION' : 'FAIL';
325
+
326
+ results.push({
327
+ domain,
328
+ timestamp: new Date().toISOString(),
329
+ originalScreenshot,
330
+ generatedScreenshot,
331
+ diffImage,
332
+ viewport: vpName,
333
+ differences,
334
+ score,
335
+ ssim,
336
+ verdict,
337
+ pixelStats,
338
+ });
339
+
340
+ const icon = verdict === 'PASS' ? 'โœ…' : verdict === 'NEEDS-REVISION' ? 'โš ๏ธ' : 'โŒ';
341
+ console.log(` ${icon} ${vpName}: ${score}/100 flat ยท ${ssim}/100 structure โ€” ${verdict}`);
342
+ if (pixelStats.totalPixels > 0) {
343
+ console.log(` ${pixelStats.diffPixels.toLocaleString()} differing pixels / ${pixelStats.totalPixels.toLocaleString()} total`);
344
+ }
345
+ if (differences.length > 0) {
346
+ differences.forEach((d) => console.log(` โ†’ ${d}`));
347
+ }
348
+ console.log(` Diff image: ${diffImage}`);
349
+ }
350
+ }
351
+
352
+ // Save report
353
+ const overallScore = Math.round(
354
+ results.reduce((sum, r) => sum + r.score, 0) / Math.max(results.length, 1),
355
+ );
356
+ const overallSsim = Math.round(
357
+ results.reduce((sum, r) => sum + r.ssim, 0) / Math.max(results.length, 1),
358
+ );
359
+ const overallVerdict: ComparisonResult['verdict'] = results.every((r) => r.verdict === 'PASS')
360
+ ? 'PASS'
361
+ : results.some((r) => r.verdict === 'FAIL')
362
+ ? 'FAIL'
363
+ : 'NEEDS-REVISION';
364
+
365
+ // Read the rebuild mode (structural vs token re-skin) so the score is self-describing
366
+ let rebuildMode = 'unknown';
367
+ try { rebuildMode = JSON.parse(await readFile(join(outputDir, 'rebuild-meta.json'), 'utf-8')).mode || 'unknown'; } catch { /* optional */ }
368
+
369
+ const report = {
370
+ domain,
371
+ comparedAt: new Date().toISOString(),
372
+ rebuildMode,
373
+ results,
374
+ overallScore,
375
+ overallSsim,
376
+ overallVerdict,
377
+ };
378
+
379
+ await writeFile(join(comparisonDir, 'comparison-report.json'), JSON.stringify(report, null, 2));
380
+ // v2.12 โ€” compact score.json the catalog scorer + DESIGN.md injector can read
381
+ // v2.13 โ€” report SSIM (structure-aware) alongside pixelmatch (flat-area). SSIM is the honest
382
+ // fidelity headline; pixelmatch alone is background-dominated and can't see layout.
383
+ await writeFile(join(comparisonDir, 'score.json'), JSON.stringify({
384
+ domain, score: overallScore, ssim: overallSsim, verdict: overallVerdict,
385
+ rebuildMode,
386
+ region: 'above-fold',
387
+ method: 'pixelmatch (flat-area) + SSIM (structure) @ 2ร— device scale, fold-to-fold',
388
+ comparedAt: report.comparedAt,
389
+ }, null, 2));
390
+
391
+ console.log(`\n๐Ÿ“Š Overall: ${overallScore}/100 flat-area ยท ${overallSsim}/100 structure โ€” ${overallVerdict} (above-fold, 2ร— matched, ${rebuildMode})`);
392
+ console.log(`๐Ÿ“ Report: ${comparisonDir}/comparison-report.json`);
393
+ }
394
+
395
+ // โ”€โ”€ CLI โ”€โ”€
396
+ const domain = process.argv[2];
397
+ if (!domain) {
398
+ console.error('Usage: npm run compare -- <domain>');
399
+ process.exit(1);
400
+ }
401
+
402
+ compare(domain).catch((err) => {
403
+ console.error('Error:', err);
404
+ process.exit(1);
405
+ });