diff --git a/docs/screenshots-light.mjs b/docs/screenshots-light.mjs new file mode 100644 index 0000000..3b3e401 --- /dev/null +++ b/docs/screenshots-light.mjs @@ -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'), 'demo@example.com'); + 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(); } diff --git a/docs/screenshots.mjs b/docs/screenshots.mjs index 23e9534..f583d56 100644 --- a/docs/screenshots.mjs +++ b/docs/screenshots.mjs @@ -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-light.mjs docs/screenshots * - * Restart the mock before a run: the filters shot creates rules, and a second - * run against the same mock would show them twice. + * Restart the mock before a run. The filters shot creates rules, so a second + * run against the same mock shows them twice. * - * The mobile shot is not taken here. Run at the tail of this sequence it would - * 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. + * Two shots are deliberately not taken here: * - * Drives headless Chrome over CDP rather than the extension, so the viewport is - * exactly the size the existing images use (1420x703, mobile 500x703) instead of - * whatever the window happens to be. + * - **mobile**, because at the tail of this sequence the app would not render + * the message list at 500px within the wait. A short run of its own is + * reliable, and it is a screenshot, not a mystery worth solving. * - * Usage: node shots.mjs (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 { writeFile, mkdir } from "node:fs/promises"; @@ -65,8 +79,26 @@ const cmd = (m, p) => send(m, p, sessionId); await cmd("Page.enable"); await cmd("Runtime.enable"); -const metrics = (width, height, mobile = false) => - cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile }); +let current = { width: 1420, height: 703, mobile: false }; +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 evaluate = async (expression) => { @@ -83,6 +115,37 @@ const waitFor = async (jsExpr, what, ms = 15000) => { } 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 { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 }); 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 sleep(800); - // --- same inbox in the light theme --- - 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); + // (inbox-light is captured by docs/screenshots-light.mjs -- see the header) + // --- calendar --- await go("http://localhost:5173/calendar"); @@ -160,10 +218,12 @@ try { await waitFor("document.querySelector('[class*=contact]')", "the contact list"); // Open someone, so the detail pane is not an empty "Select a contact". await evaluate(`(() => { - const row = [...document.querySelectorAll('[class*=contact-row], [class*=contact-item], li, div')] - .find(e => /ada@example\.org/.test(e.textContent || '') && e.querySelector('*') === null || /Ada Lovelace/.test((e.textContent||'').slice(0,40))); - if (row) row.click(); + const hit = [...document.querySelectorAll('div, li, button, a')] + .filter(e => (e.textContent || '').trim().startsWith('Ada Lovelace')) + .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 shot("contacts.jpg"); diff --git a/docs/screenshots/inbox-light.jpg b/docs/screenshots/inbox-light.jpg index 824f436..9fa5ee8 100644 Binary files a/docs/screenshots/inbox-light.jpg and b/docs/screenshots/inbox-light.jpg differ