#!/usr/bin/env node /* * screenshot.mjs — Playwright capture toolchain (D-14) * ------------------------------------------------------------------ * Produces the DSGN-02 gallery captures as REAL-BROWSER screenshots * (chromium via playwright) — the same toolchain later UI phases * reuse for their gallery rows (this script is the committed * evidence generator; nothing here is scratch tooling). * * npm run capture (root delegate → theme workspace) * * Outputs (theme/screenshots/ — regenerated artifacts, gitignored): * sheet-full.png component sheet, 390px, full page * mockup-.png ×4 the four mockup screens rendered * via file:// (CDN Tailwind + fonts, * network required); on capture * failure or a blank/undersized shot * the screen's committed screen.png * is copied in as fallback — mockups * are read-only reference (D-06) and * are NEVER edited * basemap-kl.png Rams Liberty basemap over KL * (1280×800, waits for the proof * page's RAMS-BASEMAP-READY console * signal + tile settle) * * The static server below serves theme/ as docroot so that * ./dist/rams.css, the self-hosted woff2 fonts, and * basemap/liberty-rams.json resolve exactly as they do for a user * serving the theme workspace (e.g. npm run basemap:preview). */ import { createServer } from 'node:http'; import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { dirname, extname, join, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { chromium } from 'playwright'; const SCRIPTS = dirname(fileURLToPath(import.meta.url)); const THEME = resolve(SCRIPTS, '..'); const SHOTS = join(THEME, 'screenshots'); const MOCKUPS = resolve(THEME, '..', 'stitch_minimalist_route_navigator'); /* Minimum plausible non-blank PNG (the plan's own blank bar). */ const MIN_BYTES = 10240; /* The four mockup screens (stitch directory names verbatim). */ const SCREENS = [ 'active_route_guidance', 'community_incident_reporter', 'navigate_map_overview', 'telegram_live_traffic_feed', ]; const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.woff2': 'font/woff2', }; /* Tiny static server: theme/ as docroot (no deps beyond node itself). */ function serveTheme() { const server = createServer(async (req, res) => { try { const url = new URL(req.url, 'http://127.0.0.1'); const target = join(THEME, decodeURIComponent(url.pathname)); /* docroot guard: the prefix must end with a separator — a bare * startsWith(THEME) lets sibling theme* paths (e.g. a decoded * ..%2FthemeX) escape the docroot (WR-05). */ if (target !== THEME && !target.startsWith(THEME + sep)) { res.writeHead(403); res.end('forbidden'); return; } const body = await readFile(target); res.writeHead(200, { 'content-type': MIME[extname(target)] || 'application/octet-stream' }); res.end(body); } catch { res.writeHead(404); res.end('not found'); } }); return new Promise((resolveReady) => { server.listen(0, '127.0.0.1', () => resolveReady({ server, port: server.address().port })); }); } function sizeOf(file) { return statSync(file).size; } const log = []; const failures = []; mkdirSync(SHOTS, { recursive: true }); const { server, port } = await serveTheme(); const base = `http://127.0.0.1:${port}`; log.push(`serving theme/ → ${base}/`); const browser = await chromium.launch(); try { /* 1 — component sheet (the implementation render): 390 viewport, full page. */ { const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${base}/src/sheet.html`, { waitUntil: 'networkidle', timeout: 45000 }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(400); // font raster settle await page.screenshot({ path: join(SHOTS, 'sheet-full.png'), fullPage: true }); await page.close(); log.push(`captured sheet-full.png (${sizeOf(join(SHOTS, 'sheet-full.png'))}B, fullPage)`); } /* 2 — mockup screens via file:// (CDN Tailwind + Google fonts need network). * Fallback: committed /screen.png copied in — never edit mockups (D-06). */ for (const screen of SCREENS) { const target = join(SHOTS, `mockup-${screen}.png`); const url = pathToFileURL(join(MOCKUPS, screen, 'code.html')).href; try { const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(800); // plan's settle window await page.screenshot({ path: target }); await page.close(); if (sizeOf(target) < MIN_BYTES) { throw new Error(`capture undersized (${sizeOf(target)}B < ${MIN_BYTES}B) — likely blank`); } log.push(`captured mockup-${screen}.png (${sizeOf(target)}B, network render)`); } catch (err) { const committed = join(MOCKUPS, screen, 'screen.png'); if (!existsSync(committed)) { failures.push(`mockup-${screen}.png: capture failed AND committed screen.png missing (${err.message})`); continue; } copyFileSync(committed, target); log.push(`FALLBACK mockup-${screen}.png ← committed screen.png (${err.message})`); } } /* 3 — basemap proof (1280×800): wait for the page's own READY signal, then settle. */ { const target = join(SHOTS, 'basemap-kl.png'); try { const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); const ready = new Promise((resolveReady) => { page.on('console', (m) => { if (m.text().startsWith('RAMS-BASEMAP-READY')) resolveReady(true); }); }); await page.goto(`${base}/basemap/index.html`, { waitUntil: 'domcontentloaded', timeout: 60000 }); const signal = await Promise.race([ ready, new Promise((r) => setTimeout(() => r('timeout'), 30000)), ]); if (signal === 'timeout') { throw new Error('RAMS-BASEMAP-READY console signal never arrived (tiles/network?)'); } await page.waitForTimeout(2000); // tile render settle (plan: first render + 2s) await page.screenshot({ path: target }); await page.close(); if (sizeOf(target) < MIN_BYTES) { throw new Error(`capture undersized (${sizeOf(target)}B < ${MIN_BYTES}B)`); } log.push(`captured basemap-kl.png (${sizeOf(target)}B, READY-signaled)`); } catch (err) { failures.push(`basemap-kl.png: ${err.message}`); } } } finally { await browser.close(); server.close(); } /* ————— report + blank gate ————— */ for (const line of log) console.log(`[capture] ${line}`); const expected = ['sheet-full.png', 'basemap-kl.png', ...SCREENS.map((s) => `mockup-${s}.png`)]; const missing = expected.filter((f) => !existsSync(join(SHOTS, f))); const undersized = expected.filter((f) => existsSync(join(SHOTS, f)) && sizeOf(join(SHOTS, f)) < MIN_BYTES); if (missing.length || undersized.length || failures.length) { for (const f of failures) console.error(`[capture] FAILED ${f}`); for (const f of missing) console.error(`[capture] MISSING ${f}`); for (const f of undersized) console.error(`[capture] UNDERSIZED ${f} (<${MIN_BYTES}B)`); console.error(`CAPTURE FAILED — ${expected.length - missing.length}/${expected.length} usable captures`); process.exit(1); } console.log(`CAPTURE OK — ${expected.length}/${expected.length} non-blank captures under theme/screenshots/`); process.exit(0);