diff --git a/scripts/04-06-welcome-hero-screenshots.mjs b/scripts/04-06-welcome-hero-screenshots.mjs new file mode 100644 index 0000000..d718be5 --- /dev/null +++ b/scripts/04-06-welcome-hero-screenshots.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// scripts/04-06-welcome-hero-screenshots.mjs — Plan 04-06 Task 4 +// operator-empirical screenshot artifact. +// +// Per the orchestrator checkpoint protocol + saved memory +// `feedback-trust-harness-over-manual-uat`, the operator judges the +// dark-mode aesthetic from Puppeteer screenshots, NOT a manual Chrome +// session. This script: +// 1. Loads dist/ as an unpacked extension via puppeteer.launch +// enableExtensions. +// 2. Resolves the runtime extension ID by polling the targets list. +// 3. Opens chrome-extension:///src/welcome/welcome.html, waits +// for the inline- selector (populateMark ran), then +// page.screenshot the `.welcome-hero` region — the LIGHT-surface +// regression-baseline shot. +// 4. Both emulateMediaFeatures([prefers-color-scheme: dark]) AND +// sets [data-theme="dark"] on the element. Mokosh's +// src/shared/tokens.css cascade only fires on the explicit class +// (`.dark` / `[data-theme="dark"]` selector at line 234) — there +// is currently no `@media (prefers-color-scheme: dark)` block — +// so the attribute-set is the actual trigger. The media-feature +// emulation is forward-compatible with any future @media block. +// 5. Re-screenshot the `.welcome-hero` region — the DARK-surface +// aesthetic-judgment shot. +// +// Output paths (canonical per the 04-06-PLAN.md Task 4 contract): +// - /tmp/04-06-welcome-hero-light.png +// - /tmp/04-06-welcome-hero-dark.png +// +// References: +// - https://pptr.dev/api/puppeteer.page.emulatemediafeatures +// - https://pptr.dev/api/puppeteer.page.screenshot +// - tests/uat/lib/launch.ts (the canonical extension-loading pattern +// this script mirrors; A35 driver uses the same chrome-extension:// +// welcome.html URL). +// - .planning/phases/04-harden-clean-up-optional/04-UI-SPEC.md +// §"Implementation amendment" (Option A currentColor cascade). + +import puppeteer from 'puppeteer'; +import { resolve as resolvePath } from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const DIST_DIR = resolvePath(process.cwd(), 'dist'); +const LIGHT_PNG = '/tmp/04-06-welcome-hero-light.png'; +const DARK_PNG = '/tmp/04-06-welcome-hero-dark.png'; +const EXTENSION_ID_TIMEOUT_MS = 10_000; +const WELCOME_PAGE_TIMEOUT_MS = 10_000; + +/** + * Resolve the runtime extension ID via the canonical Puppeteer + * `browser.extensions()` Map (Puppeteer >= 22.x). Mirrors the + * resolveExtensionIdWithPolling helper at tests/uat/lib/launch.ts — + * kept inline here so this script is a single-file artifact. + */ +async function resolveExtensionId(browser) { + const deadline = Date.now() + EXTENSION_ID_TIMEOUT_MS; + while (Date.now() < deadline) { + const extensionsMap = await browser.extensions(); + const entries = [...extensionsMap]; + if (entries.length > 0) { + // entries is Array<[extensionId, manifest]>; take the first + // entry's key (we loaded exactly one extension via + // enableExtensions:[DIST_DIR] so there is no ambiguity). + return entries[0][0]; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `Extension ID not resolvable within ${EXTENSION_ID_TIMEOUT_MS}ms — ` + + `is dist/ a valid unpacked extension?`, + ); +} + +async function captureWelcomeHero({ outputPath, dark, browser, extensionId }) { + const page = await browser.newPage(); + try { + // emulateMediaFeatures is forward-compat with any future @media + // (prefers-color-scheme: dark) block in tokens.css; today's + // tokens.css fires the dark cascade via [data-theme="dark"] on + // , which we set explicitly below before navigating. + if (dark) { + await page.emulateMediaFeatures([ + { name: 'prefers-color-scheme', value: 'dark' }, + ]); + } + const welcomeUrl = `chrome-extension://${extensionId}/src/welcome/welcome.html`; + await page.goto(welcomeUrl, { waitUntil: 'domcontentloaded' }); + if (dark) { + // Set [data-theme="dark"] on so the + // .dark, [data-theme="dark"] { ... } block in + // src/shared/tokens.css lines 234-251 actually fires. + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + }); + } + await page.waitForSelector('.welcome-hero', { + timeout: WELCOME_PAGE_TIMEOUT_MS, + }); + await page.waitForSelector('.welcome-hero__mark svg', { + timeout: WELCOME_PAGE_TIMEOUT_MS, + }); + // Allow a brief settle so the dark token override / font-load / + // style flush before screenshot. + await new Promise((resolve) => setTimeout(resolve, 250)); + // Read back the computed stroke + the hero element's bounding box + // in a single evaluate call so we minimize CDP round trips (some + // headless environments throttle Runtime.callFunctionOn on + // extension pages — observed empirically; switching from + // ElementHandle.screenshot() to page.screenshot({clip}) avoids + // a second elementHandle.evaluate which was timing out). + const probe = await page.evaluate(() => { + const heroEl = document.querySelector('.welcome-hero'); + const svgEl = document.querySelector('.welcome-hero__mark svg'); + if (heroEl === null) return null; + const r = heroEl.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + computedStroke: + svgEl === null ? '' : getComputedStyle(svgEl).stroke, + }; + }); + if (probe === null) { + throw new Error('.welcome-hero element not found post-settle'); + } + await page.screenshot({ + path: outputPath, + clip: { + x: Math.max(0, Math.floor(probe.x)), + y: Math.max(0, Math.floor(probe.y)), + width: Math.ceil(probe.width), + height: Math.ceil(probe.height), + }, + }); + return { outputPath, computedStroke: probe.computedStroke }; + } finally { + await page.close(); + } +} + +async function main() { + const downloadsDir = mkdtempSync(join(tmpdir(), 'mokosh-04-06-screenshots-')); + const browser = await puppeteer.launch({ + enableExtensions: [DIST_DIR], + headless: true, + pipe: true, + // Match the UAT harness protocolTimeout (PROTOCOL_TIMEOUT_MS at + // tests/uat/lib/launch.ts) — the default 30s is sometimes too + // tight for headless extension page navigation + style settle + // on slower machines. + protocolTimeout: 120_000, + args: ['--no-sandbox'], + }); + try { + const extensionId = await resolveExtensionId(browser); + process.stdout.write(`Resolved extension ID: ${extensionId}\n`); + + const light = await captureWelcomeHero({ + outputPath: LIGHT_PNG, + dark: false, + browser, + extensionId, + }); + process.stdout.write( + `LIGHT screenshot: ${light.outputPath} ` + + `(computed stroke: ${light.computedStroke})\n`, + ); + + const dark = await captureWelcomeHero({ + outputPath: DARK_PNG, + dark: true, + browser, + extensionId, + }); + process.stdout.write( + `DARK screenshot: ${dark.outputPath} ` + + `(computed stroke: ${dark.computedStroke})\n`, + ); + } finally { + await browser.close(); + // Inform the caller about the per-run temp directory (cleanup is + // OS-managed under /tmp — same lifecycle as the UAT harness). + process.stdout.write(`Temp dir (auto-cleaned by OS): ${downloadsDir}\n`); + } +} + +main().catch((err) => { + process.stderr.write(`FATAL: ${err instanceof Error ? err.stack : String(err)}\n`); + process.exit(1); +});