1
+#!/usr/bin/env node
2
+/**
3
+ * verify-visual.mjs
4
+ * Playwright-based visual verification for SquadScope design changes.
5
+ *
6
+ * Usage:
7
+ * node scripts/design/verify-visual.mjs
8
+ * node scripts/design/verify-visual.mjs --base http://localhost:1313/SquadScope/
9
+ * node scripts/design/verify-visual.mjs --out screenshots/my-branch/
10
+ *
11
+ * Prerequisites:
12
+ * npx playwright install chromium
13
+ *
14
+ * Run:
15
+ * npx -y playwright install chromium --with-deps 2>/dev/null; node scripts/design/verify-visual.mjs
16
+ */
17
+
18
+import { chromium } from 'playwright';
19
+import { mkdir, writeFile } from 'fs/promises';
20
+import { existsSync } from 'fs';
21
+import { join } from 'path';
22
+
23
+// ---------------------------------------------------------------------------
24
+// Config
25
+// ---------------------------------------------------------------------------
26
+
27
+const args = process.argv.slice(2);
28
+
29
+function getArg(name, fallback) {
30
+ const idx = args.indexOf(name);
31
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
32
+}
33
+
34
+const BASE_URL = getArg('--base', 'http://localhost:1313/SquadScope/').replace(/\/$/, '');
35
+const DATE_SLUG = getArg('--date', '2026-05-25');
36
+const OUT_DIR = getArg('--out', join('screenshots', 'design-verification', DATE_SLUG));
37
+
38
+const VIEWPORTS = [
39
+ { name: 'mobile', width: 375, height: 667 },
40
+ { name: 'tablet', width: 768, height: 1024 },
41
+ { name: 'desktop', width: 1280, height: 800 },
42
+ { name: 'wide', width: 1920, height: 1080 },
43
+];
44
+
45
+const THEMES = ['light', 'dark'];
46
+
47
+const PAGES = [
48
+ { key: 'home', path: '/' },
49
+ { key: 'weekly-w22', path: '/weekly/2026/w22/' },
50
+ { key: 'monthly-may', path: '/monthly/2026/05/' },
51
+ { key: 'yearly-2026', path: '/yearly/2026/' },
52
+];
53
+
54
+/**
55
+ * CSS injected into every page before screenshotting.
56
+ * Hides dynamic content that changes every run and creates screenshot noise.
57
+ */
58
+const NOISE_SUPPRESSION_CSS = `
59
+ /* Cost dashboard run date — changes every crawl */
60
+ .cost-dashboard__date,
61
+ [data-cost-date],
62
+ .run-date,
63
+ .last-updated { visibility: hidden !important; }
64
+
65
+ /* Live run counters */
66
+ .dynamic-counter,
67
+ .repo-count,
68
+ [data-live] { visibility: hidden !important; }
69
+
70
+ /* Disable CSS animations / transitions for stable screenshots */
71
+ *, *::before, *::after {
72
+ animation-duration: 0s !important;
73
+ transition-duration: 0s !important;
74
+ }
75
+`;
76
+
77
+// ---------------------------------------------------------------------------
78
+// Helpers
79
+// ---------------------------------------------------------------------------
80
+
81
+function pad(n) { return String(n).padStart(3, ' '); }
82
+
83
+async function ensureDir(dir) {
84
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
85
+}
86
+
87
+async function captureScreenshot(page, url, viewport, theme, outPath) {
88
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
89
+ await page.emulateMedia({ colorScheme: theme });
90
+
91
+ try {
92
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
93
+ } catch {
94
+ // Fallback for Hugo pages that may not fully reach networkidle
95
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
96
+ await page.waitForTimeout(800);
97
+ }
98
+
99
+ await page.addStyleTag({ content: NOISE_SUPPRESSION_CSS });
100
+ // Extra settle time for web fonts
101
+ await page.waitForTimeout(300);
102
+
103
+ await page.screenshot({ path: outPath, fullPage: true });
104
+ return true;
105
+}
106
+
107
+// ---------------------------------------------------------------------------
108
+// Main
109
+// ---------------------------------------------------------------------------
110
+
111
+async function main() {
112
+ await ensureDir(OUT_DIR);
113
+
114
+ const browser = await chromium.launch({ headless: true });
115
+
116
+ const results = [];
117
+ let passed = 0;
118
+ let failed = 0;
119
+
120
+ console.log(`\n🎨 SquadScope Visual Verification`);
121
+ console.log(` Base URL : ${BASE_URL}`);
122
+ console.log(` Output : ${OUT_DIR}`);
123
+ console.log(` Matrix : ${PAGES.length} pages × ${VIEWPORTS.length} viewports × ${THEMES.length} themes = ${PAGES.length * VIEWPORTS.length * THEMES.length} screenshots\n`);
124
+
125
+ for (const theme of THEMES) {
126
+ for (const viewport of VIEWPORTS) {
127
+ const context = await browser.newContext({
128
+ viewport: { width: viewport.width, height: viewport.height },
129
+ colorScheme: theme,
130
+ });
131
+ const page = await context.newPage();
132
+
133
+ for (const pg of PAGES) {
134
+ const filename = `${pg.key}-${viewport.name}-${theme}.png`;
135
+ const outPath = join(OUT_DIR, filename);
136
+ const url = `${BASE_URL}${pg.path}`;
137
+
138
+ let status = '✅';
139
+ let note = '';
140
+
141
+ try {
142
+ await captureScreenshot(page, url, viewport, theme, outPath);
143
+ passed++;
144
+ } catch (err) {
145
+ status = '❌';
146
+ note = err.message.split('\n')[0].slice(0, 60);
147
+ failed++;
148
+ }
149
+
150
+ results.push({ page: pg.key, viewport: viewport.name, theme, status, note, filename });
151
+ process.stdout.write(` ${status} ${filename.padEnd(45)} ${note}\n`);
152
+ }
153
+
154
+ await context.close();
155
+ }
156
+ }
157
+
158
+ await browser.close();
159
+
160
+ // Summary table
161
+ console.log('\n─────────────────────────────────────────────────────────────');
162
+ console.log(' Page Viewport Light Dark');
163
+ console.log('─────────────────────────────────────────────────────────────');
164
+
165
+ for (const pg of PAGES) {
166
+ for (const vp of VIEWPORTS) {
167
+ const light = results.find(r => r.page === pg.key && r.viewport === vp.name && r.theme === 'light');
168
+ const dark = results.find(r => r.page === pg.key && r.viewport === vp.name && r.theme === 'dark');
169
+ const col1 = `${pg.key}`.padEnd(20);
170
+ const col2 = vp.name.padEnd(10);
171
+ console.log(` ${col1}${col2} ${light?.status ?? '?'} ${dark?.status ?? '?'}`);
172
+ }
173
+ }
174
+
175
+ console.log('─────────────────────────────────────────────────────────────');
176
+ console.log(` Total: ${pad(passed + failed)} screenshots — ${pad(passed)} passed, ${pad(failed)} failed`);
177
+ console.log(` Output dir: ${OUT_DIR}\n`);
178
+
179
+ // Write a manifest
180
+ const manifest = {
181
+ generatedAt: DATE_SLUG,
182
+ baseUrl: BASE_URL,
183
+ outDir: OUT_DIR,
184
+ results,
185
+ };
186
+ await writeFile(join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
187
+ console.log(` Manifest written to ${join(OUT_DIR, 'manifest.json')}\n`);
188
+
189
+ if (failed > 0) process.exit(1);
190
+}
191
+
192
+main().catch(err => {
193
+ console.error('Fatal error:', err);
194
+ process.exit(1);
195
+});