forked from c-fu/arah2
feat(01-04): Playwright capture toolchain + DSGN-02 approval gallery (D-14, D-12)
- screenshot.mjs: committed real-browser capture tool — tiny node static server (theme/ docroot), sheet fullPage capture (390px), 4 mockup captures via file:// (network CDN renders; committed screen.png fallback on failure — mockups never edited, D-06), basemap capture gated on the proof page's RAMS-BASEMAP-READY console signal + 2s tile settle (1280x800); blank gate >=10KB per capture; wired as root+theme `capture` - gallery.html: the DSGN-02 mechanism template — 4 mockup-vs-sheet comparison rows + basemap direction row + token ledger (13 typography specimens + 6 hero color swatches, names only per D-07) + approval scope strip; CDN-free, built from dist/rams.css + var()-only page shell; every later UI phase extends this same page - audit.mjs: gallery.html registered as an implementation page — audit covers its <style> block + style attributes + class harvest (354 declarations, still 0 off-token) - verified: CAPTURE OK 6/6 (45KB-1.2MB), gallery render check 0 failed requests / all 6 images decode, AUDIT CLEAN
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
#!/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-<screen>.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 } 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));
|
||||
if (!target.startsWith(THEME)) {
|
||||
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>/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);
|
||||
Reference in New Issue
Block a user