Make the light screenshot actually light

The light inbox screenshot in the README was not light, and had not been
since it was first taken -- the pair showed the dark theme twice.

The app was never at fault: update() calls applyTheme() synchronously
and the CSS flips --bg to #f6f8fa as it should. The capture was.
Swapping the theme under setDeviceMetricsOverride produces a mixed
frame, the panes that re-rendered in the new theme and the rest of the
chrome still in the old one, while the DOM and computed styles insist
the whole page is light. Clicking the app's own toggle, setting the
attribute, pinning it against applyTheme with a MutationObserver,
installing that pin before the document loads, nudging the viewport and
forcing a full reflow all left the frame mixed.

Chrome launched at --window-size, with the emulation layer never
touched, renders it correctly. That is docs/screenshots-light.mjs, and
inbox-light.jpg is now genuinely light.

assertTheme() stays: without it the script wrote a dark screenshot under
a light caption and reported success, which is how this survived
unnoticed. The header says which shots are taken elsewhere and why.
This commit is contained in:
2026-08-25 10:39:44 -07:00
parent ea55b1b2aa
commit 00868f053c
3 changed files with 158 additions and 23 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* The light inbox shot, with no Emulation.setDeviceMetricsOverride at all --
* the window is simply launched at the size we want. The emulation layer is the
* prime suspect for the mixed-theme frames every other approach produced.
*/
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
const OUT = process.argv[2] ?? ".";
const PORT = 9334;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check",
"--window-size=1420,790", "--force-device-scale-factor=1",
"--user-data-dir=/tmp/claude-light-profile", "about:blank",
], { stdio: "ignore" });
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
const version = await json("/json/version");
let id = 1; const pending = new Map();
const ws = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { resolve, reject } = pending.get(x.id); pending.delete(x.id); x.error ? reject(new Error(JSON.stringify(x.error))) : resolve(x.result); } };
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const i = id++; pending.set(i, { resolve, reject }); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
const cmd = (m, p) => send(m, p, sessionId);
await cmd("Page.enable"); await cmd("Runtime.enable");
const evaluate = async (expression) => {
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
return r.result.value;
};
const waitFor = async (expr, what, ms = 20000) => {
const end = Date.now() + ms;
while (Date.now() < end) { if (await evaluate(`!!(${expr})`)) return; await sleep(200); }
throw new Error(`timed out waiting for ${what}`);
};
try {
await cmd("Page.navigate", { url: "http://localhost:5173/" });
await sleep(1500);
console.log("viewport:", await evaluate(`window.innerWidth + 'x' + window.innerHeight`));
await evaluate(`
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
window.__btn = (t, r=document) => [...r.querySelectorAll('button')].find(b => b.textContent.trim() === t);
`);
await evaluate(`(() => {
const i = [...document.querySelectorAll('input')];
window.__set(i.find(x => x.type === 'text' || x.type === 'email'), '[email protected]');
window.__set(document.querySelector('input[type=password]'), 'demo');
window.__btn('Sign in').click();
})()`);
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
await sleep(1500);
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
await sleep(1500);
// The app's own control, the way a user switches theme.
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => /light mode/i.test(x.getAttribute('aria-label') || x.title || ''));
if (b) b.click(); else document.documentElement.dataset.theme = 'light';
})()`);
await sleep(2000);
const bg = await evaluate(`getComputedStyle(document.body).backgroundColor`);
const topbar = await evaluate(`getComputedStyle(document.querySelector('.topbar')).backgroundColor`);
console.log("body:", bg, "topbar:", topbar);
if (parseInt(bg.match(/\d+/)[0], 10) < 200) throw new Error("page is not rendering light");
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/inbox-light.jpg`, Buffer.from(data, "base64"));
console.log("wrote inbox-light.jpg");
} finally { ws.close(); chrome.kill(); }
+83 -23
View File
@@ -1,21 +1,35 @@
/** /**
* Regenerates the README screenshots from the mock server. * Regenerates most of the README screenshots from the mock server.
* *
* Run the mock stack first (`npm run dev:mock`), then: * Drives headless Chrome over CDP, so the viewport is exactly the size the
* images already use rather than whatever a window happens to be.
*
* npm run dev:mock # in another terminal
* node docs/screenshots.mjs docs/screenshots * node docs/screenshots.mjs docs/screenshots
* node docs/screenshots-light.mjs docs/screenshots
* *
* Restart the mock before a run: the filters shot creates rules, and a second * Restart the mock before a run. The filters shot creates rules, so a second
* run against the same mock would show them twice. * run against the same mock shows them twice.
* *
* The mobile shot is not taken here. Run at the tail of this sequence it would * Two shots are deliberately not taken here:
* not render the message list at 500px within the wait, and chasing that down
* was not worth it for a screenshot -- take it with a short run of its own.
* *
* Drives headless Chrome over CDP rather than the extension, so the viewport is * - **mobile**, because at the tail of this sequence the app would not render
* exactly the size the existing images use (1420x703, mobile 500x703) instead of * the message list at 500px within the wait. A short run of its own is
* whatever the window happens to be. * reliable, and it is a screenshot, not a mystery worth solving.
* *
* Usage: node shots.mjs <out-dir> (mock stack must be up on :5173) * - **inbox-light**, because of setDeviceMetricsOverride. Swapping the theme
* under the emulation layer captures a *mixed* frame: the panes that
* re-rendered come out light while the rest of the chrome stays dark, with
* the DOM and computed styles insisting the whole page is light. The app is
* not at fault -- update() calls applyTheme() synchronously and the CSS does
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
* CSS-variable change touches while metrics are overridden. Launching Chrome
* at --window-size and never calling setDeviceMetricsOverride renders it
* correctly, which is what docs/screenshots-light.mjs does.
*
* assertTheme() stays either way: without it this script wrote a dark
* screenshot under a light caption and reported success, and that is how the
* README came to show the same theme twice for months.
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { writeFile, mkdir } from "node:fs/promises"; import { writeFile, mkdir } from "node:fs/promises";
@@ -65,8 +79,26 @@ const cmd = (m, p) => send(m, p, sessionId);
await cmd("Page.enable"); await cmd("Page.enable");
await cmd("Runtime.enable"); await cmd("Runtime.enable");
const metrics = (width, height, mobile = false) => let current = { width: 1420, height: 703, mobile: false };
cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile }); const metrics = (width, height, mobile = false) => {
current = { width, height, mobile };
return cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });
};
/**
* Forces the whole page to repaint.
*
* Headless only repaints the layers that changed, and a theme swap changes CSS
* variables rather than any single element — so the capture came back with the
* message pane in the new theme and the rest of the app in the old one. Nudging
* the viewport by a pixel and back invalidates everything.
*/
const repaint = async () => {
// Detaching and reattaching the body invalidates every layer; nudging the
// viewport did not, and the capture kept coming back with mixed themes.
await evaluate(`(() => { const b = document.body; b.style.display = 'none'; void b.offsetHeight; b.style.display = ''; })()`);
await sleep(500);
};
const go = async (url) => { await cmd("Page.navigate", { url }); await sleep(1200); }; const go = async (url) => { await cmd("Page.navigate", { url }); await sleep(1200); };
const evaluate = async (expression) => { const evaluate = async (expression) => {
@@ -83,6 +115,37 @@ const waitFor = async (jsExpr, what, ms = 15000) => {
} }
throw new Error(`timed out waiting for ${what}`); throw new Error(`timed out waiting for ${what}`);
}; };
/**
* Pins the theme, because setting it once is not enough.
*
* The app re-runs applyTheme() from its own setting whenever the settings store
* stirs, and that overwrote a plain attribute set during the settle before the
* capture — twice, silently, producing a "light" screenshot of the dark theme.
* A MutationObserver puts it back faster than anything can take it away.
*
* The check is the rendered background colour: the attribute is what lied.
*/
const themeTest = (want) => want === "light"
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
: "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) < 60";
const setTheme = async (want) => {
await evaluate(`(() => {
const html = document.documentElement;
const want = ${JSON.stringify(want)};
if (window.__themePin) window.__themePin.disconnect();
window.__themePin = new MutationObserver(() => { if (html.dataset.theme !== want) html.dataset.theme = want; });
window.__themePin.observe(html, { attributes: true, attributeFilter: ['data-theme'] });
html.dataset.theme = want;
})()`);
await waitFor(themeTest(want), `the ${want} theme to actually render`);
await repaint();
};
/** Refuses to write the file unless the page still looks the way it should. */
const assertTheme = async (want) => {
if (!(await evaluate(themeTest(want)))) throw new Error(`page is not rendering the ${want} theme at capture time`);
};
const shot = async (name) => { const shot = async (name) => {
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 }); const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/${name}`, Buffer.from(data, "base64")); await writeFile(`${OUT}/${name}`, Buffer.from(data, "base64"));
@@ -138,13 +201,8 @@ try {
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`); await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
await sleep(800); await sleep(800);
// --- same inbox in the light theme --- // (inbox-light is captured by docs/screenshots-light.mjs -- see the header)
await evaluate(`(() => { const b = [...document.querySelectorAll('button')].find(x => /light mode/i.test(x.getAttribute('aria-label')||x.title||'')); if (b) b.click(); })()`);
await sleep(1200);
await shot("inbox-light.jpg");
// back to dark for the rest
await evaluate(`(() => { const b = [...document.querySelectorAll('button')].find(x => /dark mode/i.test(x.getAttribute('aria-label')||x.title||'')); if (b) b.click(); })()`);
await sleep(900);
// --- calendar --- // --- calendar ---
await go("http://localhost:5173/calendar"); await go("http://localhost:5173/calendar");
@@ -160,10 +218,12 @@ try {
await waitFor("document.querySelector('[class*=contact]')", "the contact list"); await waitFor("document.querySelector('[class*=contact]')", "the contact list");
// Open someone, so the detail pane is not an empty "Select a contact". // Open someone, so the detail pane is not an empty "Select a contact".
await evaluate(`(() => { await evaluate(`(() => {
const row = [...document.querySelectorAll('[class*=contact-row], [class*=contact-item], li, div')] const hit = [...document.querySelectorAll('div, li, button, a')]
.find(e => /ada@example\.org/.test(e.textContent || '') && e.querySelector('*') === null || /Ada Lovelace/.test((e.textContent||'').slice(0,40))); .filter(e => (e.textContent || '').trim().startsWith('Ada Lovelace'))
if (row) row.click(); .sort((a, b) => a.textContent.length - b.textContent.length)[0];
if (hit) (hit.closest('li, button, a, [class*=row], [class*=item]') || hit).click();
})()`); })()`);
await waitFor("!/Select a contact/.test(document.body.innerText)", "the contact detail pane", 8000);
await sleep(1800); await sleep(1800);
await shot("contacts.jpg"); await shot("contacts.jpg");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB