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:
araharah-executor
2026-10-22 08:31:14 +08:00
parent 0b3eebf7d0
commit b462936678
4 changed files with 483 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
<!DOCTYPE html>
<!--
araharah — DSGN-02 approval gallery (the fidelity gate mechanism)
==================================================================
THIS PAGE IS THE DSGN-02 MECHANISM TEMPLATE. Every later UI phase
(Mini App shell, search, route pick, guidance, backgrounded
turn-cards, ...) extends THIS SAME PAGE with one comparison row
per new product screen:
<section> — h2.card-header plate: SCREEN NAME + card-ref
left : the mockup reference render (read-only, D-06)
right : the implementation capture (Playwright, D-14)
note : which sheet/product families the row compares
The token ledger and approval strip below stay unchanged. The
automated evidence stays `npm run audit` (zero off-token values,
D-13) — run it from the repo root. Phase approval is recorded in
the phase SUMMARY (DSGN-02's durable artifact trail).
Captures are regenerated by: npm run capture (D-14 toolchain).
theme/screenshots/ is gitignored (regenerated artifacts) — after
a fresh checkout run `npm run capture`, then serve theme/ and
open this page, e.g.:
npm run basemap:preview --workspace theme
→ http://localhost:8123/gallery.html
CDN-free by construction (D-16): only the token package's built
stylesheet + local captures; no network dependency at all.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ARAHARAH — DSGN-02 Approval Gallery</title>
<!-- token package: Rams tokens + self-hosted Geist / Space Grotesk -->
<link rel="stylesheet" href="dist/rams.css">
<style>
/*
* Page shell — gallery layout only, not component API.
* Every value is a token reference (the audit scans this
* block: it must stay var()-only, zero roundedness, no
* shadows or blur).
*/
body {
background: var(--shell-bg);
color: var(--shell-on-surface);
font: var(--type-body-md);
letter-spacing: var(--type-body-md-tracking);
padding: var(--spacing-margin);
}
main {
display: flex;
flex-direction: column;
gap: var(--spacing-space-lg);
max-width: var(--spacing-gutter-desktop);
margin: 0 auto;
}
main > section {
display: flex;
flex-direction: column;
gap: var(--spacing-space-sm);
}
/* sheet-furniture pattern (plan 01-02): section plates ride the
* shell slots so the register stays legible under both schemes */
main > section > h2.card-header {
color: var(--shell-on-surface);
border-bottom-color: var(--shell-on-surface);
}
.gallery-masthead {
display: flex;
flex-direction: column;
gap: var(--spacing-space-xs);
border-bottom: 2px solid var(--shell-on-surface);
padding-bottom: var(--spacing-space-md);
}
.evidence {
border: 1px solid var(--shell-on-surface);
padding: var(--spacing-space-sm) var(--spacing-space-md);
}
.compare-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-space-sm);
}
.compare-row figure {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-space-xs);
}
.compare-row img,
.full-row img {
width: 100%;
height: auto;
display: block;
border: 1px solid var(--color-matte-black);
background: var(--color-surface);
}
.compare-note {
color: var(--shell-on-surface);
}
.ledger {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--spacing-space-2xl), 1fr));
gap: var(--spacing-space-md) var(--spacing-space-lg);
margin: 0;
}
.ledger > div {
display: flex;
flex-direction: column;
gap: var(--spacing-space-xs);
border-left: 1px solid var(--shell-on-surface);
padding-left: var(--spacing-space-sm);
}
.ledger dd {
margin: 0;
}
.swatch {
display: inline-block;
width: var(--spacing-space-lg);
height: var(--spacing-space-lg);
border: 1px solid var(--color-matte-black);
vertical-align: middle;
margin-right: var(--spacing-space-xs);
}
.approval {
border: 2px solid var(--color-matte-black);
padding: var(--spacing-space-md);
display: flex;
flex-direction: column;
gap: var(--spacing-space-xs);
background: var(--color-soft-beige);
color: var(--color-matte-black);
}
.swatch-signal-orange { background: var(--color-signal-orange); }
.swatch-functional-red { background: var(--color-functional-red); }
.swatch-olive-green { background: var(--color-olive-green); }
.swatch-soft-beige { background: var(--color-soft-beige); }
.swatch-matte-black { background: var(--color-matte-black); }
.swatch-chassis-subtle { background: var(--color-chassis-subtle); }
@media (max-width: 480px) {
.compare-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<!-- Masthead -->
<header class="gallery-masthead">
<p class="text-label-sm">ARAHARAH // TELEMETRY RAMS</p>
<h1 class="text-display-lg-mobile">DSGN-02 APPROVAL GALLERY</h1>
<p class="text-body-md">Rams implementation vs mockups — side-by-side, token by token. Left columns are the mockup reference renders; right columns are the implementation component sheet rendered from the token package.</p>
<div class="evidence">
<p class="text-label-md">AUTOMATED EVIDENCE (D-13)</p>
<p class="text-body-sm">`npm run audit` exits 0 — zero off-token values across every rendered declaration (colors, typography, radius, shadows, blur all derive from theme/src/tokens.css). Re-run it from the repo root; the gallery is the human side of the same gate.</p>
</div>
</header>
<!-- Row 1 — active route guidance -->
<section>
<h2 class="card-header">
<span class="text-label-sm">MOCKUP VS IMPLEMENTATION</span>
<span class="card-ref">ACTIVE ROUTE GUIDANCE</span>
</h2>
<div class="compare-row">
<figure>
<figcaption class="text-label-sm">MOCKUP — REFERENCE RENDER</figcaption>
<img src="screenshots/mockup-active_route_guidance.png" alt="Active route guidance mockup render">
</figure>
<figure>
<figcaption class="text-label-sm">IMPLEMENTATION — COMPONENT SHEET</figcaption>
<img src="screenshots/sheet-full.png" alt="Rams component sheet rendered from the token package">
</figure>
</div>
<p class="compare-note text-body-sm">Compare: maneuver banner + arrow (SEC-05), telemetry card (SEC-04), event chips (SEC-03), headline / telemetry-num typography, buttons (SEC-01).</p>
</section>
<!-- Row 2 — navigate map overview -->
<section>
<h2 class="card-header">
<span class="text-label-sm">MOCKUP VS IMPLEMENTATION</span>
<span class="card-ref">NAVIGATE MAP OVERVIEW</span>
</h2>
<div class="compare-row">
<figure>
<figcaption class="text-label-sm">MOCKUP — REFERENCE RENDER</figcaption>
<img src="screenshots/mockup-navigate_map_overview.png" alt="Navigate map overview mockup render">
</figure>
<figure>
<figcaption class="text-label-sm">IMPLEMENTATION — COMPONENT SHEET</figcaption>
<img src="screenshots/sheet-full.png" alt="Rams component sheet rendered from the token package">
</figure>
</div>
<p class="compare-note text-body-sm">Compare: layer-control toggle (SEC-06), chips / status badges (SEC-03), label typography, inputs (SEC-02). The map canvas itself is the basemap row below.</p>
</section>
<!-- Row 3 — community incident reporter -->
<section>
<h2 class="card-header">
<span class="text-label-sm">MOCKUP VS IMPLEMENTATION</span>
<span class="card-ref">COMMUNITY INCIDENT REPORTER</span>
</h2>
<div class="compare-row">
<figure>
<figcaption class="text-label-sm">MOCKUP — REFERENCE RENDER</figcaption>
<img src="screenshots/mockup-community_incident_reporter.png" alt="Community incident reporter mockup render">
</figure>
<figure>
<figcaption class="text-label-sm">IMPLEMENTATION — COMPONENT SHEET</figcaption>
<img src="screenshots/sheet-full.png" alt="Rams component sheet rendered from the token package">
</figure>
</div>
<p class="compare-note text-body-sm">Compare: input fields (SEC-02), buttons (SEC-01), checkbox (SEC-06), hazard chips (SEC-03), body typography. The reporter's interactive modal flow is intentionally static here (out of scope, deferred).</p>
</section>
<!-- Row 4 — telegram live traffic feed -->
<section>
<h2 class="card-header">
<span class="text-label-sm">MOCKUP VS IMPLEMENTATION</span>
<span class="card-ref">TELEGRAM LIVE TRAFFIC FEED</span>
</h2>
<div class="compare-row">
<figure>
<figcaption class="text-label-sm">MOCKUP — REFERENCE RENDER</figcaption>
<img src="screenshots/mockup-telegram_live_traffic_feed.png" alt="Telegram live traffic feed mockup render">
</figure>
<figure>
<figcaption class="text-label-sm">IMPLEMENTATION — COMPONENT SHEET</figcaption>
<img src="screenshots/sheet-full.png" alt="Rams component sheet rendered from the token package">
</figure>
</div>
<p class="compare-note text-body-sm">Compare: full typography specimen (SEC-00) — display, headline, body, labels, telemetry-num — plus chips (SEC-03), buttons (SEC-01), inputs (SEC-02), telemetry card (SEC-04).</p>
</section>
<!-- Final row — basemap direction -->
<section>
<h2 class="card-header">
<span class="text-label-sm">BASEMAP DIRECTION</span>
<span class="card-ref">RAMS LIBERTY // KL METRO</span>
</h2>
<div class="full-row">
<figure>
<img src="screenshots/basemap-kl.png" alt="Rams Liberty basemap rendering over KL with the reserved signal-orange route vector">
</figure>
</div>
<p class="compare-note text-body-sm">Grayscale / muted land + water grounding, desaturated roads, muted labels — every cartographic color derived from the token package (D-09), labels in Rams-adjacent hosted fonts (D-10). Signal-orange appears nowhere except the route vector placeholder, reserved for Phase 5 (D-08). Confirm: no orange in the cartography, no saturated non-red hues, hierarchy by road weight, not tone.</p>
</section>
<!-- Token ledger — names + rendered specimens only (D-07: no numeric restatement) -->
<section>
<h2 class="card-header">
<span class="text-label-sm">TOKEN LEDGER</span>
<span class="card-ref">NAMES ONLY // D-07</span>
</h2>
<p class="text-body-sm">Typography scale — every name rendered in its own token (the package owns every numeric value):</p>
<dl class="ledger">
<div><dt class="text-label-sm">DISPLAY-LG</dt><dd class="text-display-lg">DISPLAY-LG</dd></div>
<div><dt class="text-label-sm">DISPLAY-LG-MOBILE</dt><dd class="text-display-lg-mobile">DISPLAY-LG-MOBILE</dd></div>
<div><dt class="text-label-sm">HEADLINE-LG</dt><dd class="text-headline-lg">HEADLINE-LG</dd></div>
<div><dt class="text-label-sm">HEADLINE-LG-MOBILE</dt><dd class="text-headline-lg-mobile">HEADLINE-LG-MOBILE</dd></div>
<div><dt class="text-label-sm">HEADLINE-MD</dt><dd class="text-headline-md">HEADLINE-MD</dd></div>
<div><dt class="text-label-sm">HEADLINE-SM</dt><dd class="text-headline-sm">HEADLINE-SM</dd></div>
<div><dt class="text-label-sm">BODY-LG</dt><dd class="text-body-lg">BODY-LG</dd></div>
<div><dt class="text-label-sm">BODY-MD</dt><dd class="text-body-md">BODY-MD</dd></div>
<div><dt class="text-label-sm">BODY-SM</dt><dd class="text-body-sm">BODY-SM</dd></div>
<div><dt class="text-label-sm">LABEL-LG</dt><dd class="text-label-lg">LABEL-LG</dd></div>
<div><dt class="text-label-sm">LABEL-MD</dt><dd class="text-label-md">LABEL-MD</dd></div>
<div><dt class="text-label-sm">LABEL-SM</dt><dd class="text-label-sm">LABEL-SM</dd></div>
<div><dt class="text-label-sm">TELEMETRY-NUM</dt><dd class="text-telemetry-num">18:42</dd></div>
</dl>
<p class="text-body-sm">Hero color tokens — swatches render the tokens themselves:</p>
<dl class="ledger">
<div><dt class="text-label-sm">SIGNAL-ORANGE</dt><dd class="text-label-md"><span class="swatch swatch-signal-orange"></span>SIGNAL-ORANGE</dd></div>
<div><dt class="text-label-sm">FUNCTIONAL-RED</dt><dd class="text-label-md"><span class="swatch swatch-functional-red"></span>FUNCTIONAL-RED</dd></div>
<div><dt class="text-label-sm">OLIVE-GREEN</dt><dd class="text-label-md"><span class="swatch swatch-olive-green"></span>OLIVE-GREEN</dd></div>
<div><dt class="text-label-sm">SOFT-BEIGE</dt><dd class="text-label-md"><span class="swatch swatch-soft-beige"></span>SOFT-BEIGE</dd></div>
<div><dt class="text-label-sm">MATTE-BLACK</dt><dd class="text-label-md"><span class="swatch swatch-matte-black"></span>MATTE-BLACK</dd></div>
<div><dt class="text-label-sm">CHASSIS-SUBTLE</dt><dd class="text-label-md"><span class="swatch swatch-chassis-subtle"></span>CHASSIS-SUBTLE</dd></div>
</dl>
</section>
<!-- Approval strip -->
<section class="approval">
<p class="text-label-md">APPROVAL SCOPE // DSGN-02</p>
<p class="text-body-md">This gate covers the fidelity bar — every component family on the sheet renders from the token package, verified token-by-token against the mockup renders — and the basemap direction (Rams-constant cartography, accent reserved). It does NOT cover interactive flows: incident-report modal flows, map interaction, and animation polish are intentionally out of scope for this phase (deferred per the phase context).</p>
<p class="text-body-sm">Resume signal for the phase checkpoint: "approved" (fidelity bar met, basemap direction good) — or a list of specific screens / components / basemap aspects to fix and iterate before DSGN-02 is recorded.</p>
</section>
</main>
</body>
</html>
+1
View File
@@ -15,6 +15,7 @@
"basemap": "node basemap/build.mjs",
"basemap:preview": "python3 -m http.server 8123",
"audit": "node ./scripts/audit.mjs ./dist/rams.css",
"capture": "node ./scripts/screenshot.mjs",
"test": "node ./scripts/audit.test.mjs"
}
}
+1
View File
@@ -99,6 +99,7 @@ const WRAPPER_AT = new Set([
const IMPLEMENTATION_PAGES = [
'src/sheet.html', // plan 01-02 — component sheet (D-03)
'basemap/index.html', // plan 01-03 — basemap proof page (D-08/D-09)
'gallery.html', // plan 01-04 — DSGN-02 approval gallery (D-12)
];
/* ————— small helpers ————— */
+191
View File
@@ -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);