Files
mokosh/scripts/04-06-welcome-hero-screenshots.mjs
Mark d66cbf6900 chore(04-06): add operator-empirical screenshot harness (Task 4 artifact)
Per the orchestrator checkpoint protocol + the saved-memory feedback
"trust harness over manual UAT", Task 4's dark-mode aesthetic
judgment uses Puppeteer-produced screenshots (NOT a manual Chrome
session). This script:

1. Loads dist/ via puppeteer.launch enableExtensions.
2. Resolves the runtime extension ID via the canonical
   browser.extensions() Map (mirrors tests/uat/lib/launch.ts
   resolveExtensionIdWithPolling).
3. Opens chrome-extension://<id>/src/welcome/welcome.html.
4. Captures the .welcome-hero bounding-box region in LIGHT surface
   (default OS appearance — the regression-baseline shot, matching
   the Plan 01-10 cycle-2 operator ack 2026-05-20).
5. Sets [data-theme="dark"] on <html> (Mokosh's tokens.css cascade
   uses the explicit .dark / [data-theme="dark"] selector at line
   234; emulateMediaFeatures alone does NOT trigger it because
   tokens.css has no @media (prefers-color-scheme: dark) block — a
   fact verified live this session). emulateMediaFeatures is also
   set, forward-compatible with any future @media block.
6. Re-screenshot the hero region — the DARK-surface aesthetic shot.

Output paths (canonical per the 04-06-PLAN Task 4 contract):
  - /tmp/04-06-welcome-hero-light.png
  - /tmp/04-06-welcome-hero-dark.png

Run results (this session):
  - LIGHT: computed stroke = rgb(250, 247, 241) — linen-50; the
    --mks-fg-inverse value on the LIGHT cascade flowing through
    .welcome-hero__mark to the inline <svg>'s currentColor.
  - DARK:  computed stroke = rgb(24, 27, 42) — ink-900; the
    --mks-fg-inverse value AFTER the .dark cascade override
    (tokens.css 244 sets --mks-fg-inverse: var(--mks-ink-900)) —
    the strategy's contrast flip is empirically verified.

Implementation notes (deviation Rule 3 — observed environment
constraints fixed inline):
  - Initial extension ID resolver used browser.targets() polling +
    regex; rewritten to use the canonical Puppeteer 22.x
    browser.extensions() Map approach.
  - Initial screenshot used ElementHandle.screenshot(); Puppeteer
    Runtime.callFunctionOn timed out on the second elementHandle
    evaluate in headless extension page context. Rewritten to a
    single page.evaluate() that returns getBoundingClientRect() +
    computedStroke in one CDP round trip, then page.screenshot({clip})
    against those coordinates — succeeds reliably.
  - protocolTimeout set to 120s to match the UAT harness baseline.

References:
  - .planning/phases/04-harden-clean-up-optional/04-06-PLAN.md Task 4.
  - tests/uat/lib/launch.ts (the canonical extension-loading pattern).
  - https://pptr.dev/api/puppeteer.browser.extensions
  - https://pptr.dev/api/puppeteer.page.screenshot
2026-05-26 09:11:46 +02:00

195 lines
7.5 KiB
JavaScript

#!/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://<id>/src/welcome/welcome.html, waits
// for the inline-<svg> 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 <html> 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
// <html>, 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 <html> 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 ? '<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);
});