#!/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); });