Take the screenshots again, on today's build

The ones in the README were captured on 23 August, before the 2.0 QA
work: no drag handles on the filter rules, none of the calendar or
Sieve fixes, and the read-receipt line in the composer missing.

Same sizes as before, 1420x703 and 500x703 for the phone, so the table
lays out unchanged. The filters shot now carries four rules that say
something -- list-id, sender, subject, a wildcard match, each filing
somewhere different -- instead of four blank ones, and the calendar is
in the month view its caption has always claimed. A contact is open in
the contacts shot rather than an empty "select a contact".

The script that took them is committed alongside, so the next person
does not have to work out how to drive the mock: headless Chrome over
CDP, which is also how the viewport comes out at exactly the size the
old images used.
This commit is contained in:
2026-08-25 10:16:14 -07:00
parent db6afb72ff
commit a027b312c5
9 changed files with 212 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
/**
* Regenerates the README screenshots from the mock server.
*
* Run the mock stack first (`npm run dev:mock`), then:
* node docs/screenshots.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.
*
* 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.
*
* 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.
*
* Usage: node shots.mjs <out-dir> (mock stack must be up on :5173)
*/
import { spawn } from "node:child_process";
import { writeFile, mkdir } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
const OUT = process.argv[2];
if (!OUT) { console.error("usage: node shots.mjs <out-dir>"); process.exit(2); }
await mkdir(OUT, { recursive: true });
const PORT = 9333;
const chrome = spawn("google-chrome-stable", [
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
`--user-data-dir=/tmp/claude-shots-profile`, "about:blank",
], { stdio: "ignore" });
const json = async (path) => {
for (let i = 0; i < 60; i++) {
try { return await (await fetch(`http://127.0.0.1:${PORT}${path}`)).json(); }
catch { await sleep(250); }
}
throw new Error("Chrome did not come up");
};
const version = await json("/json/version");
let nextId = 1;
const pending = new Map();
const ws = new WebSocket(version.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
}
};
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, 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 metrics = (width, height, mobile = false) =>
cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });
const go = async (url) => { await cmd("Page.navigate", { url }); await sleep(1200); };
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;
};
/** Polls a predicate inside the page until it is true, or gives up loudly. */
const waitFor = async (jsExpr, what, ms = 15000) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await evaluate(`!!(${jsExpr})`)) return;
await sleep(200);
}
throw new Error(`timed out waiting for ${what}`);
};
const shot = async (name) => {
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
await writeFile(`${OUT}/${name}`, Buffer.from(data, "base64"));
console.log(" wrote", name);
};
// Helpers injected into the page: React-controlled inputs need the native setter.
const HELPERS = `
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
window.__btn = (txt, root=document) => [...root.querySelectorAll('button')].find(b => b.textContent.trim() === txt);
window.__click = (sel) => { const el = document.querySelector(sel); if (el) el.click(); return !!el; };
window.__sel = (el, v) => { el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); };
`;
try {
console.log("chrome:", version.Browser);
// --- login (taller, as the existing shot is) ---
await metrics(1420, 759);
await go("http://localhost:5173/");
await evaluate(HELPERS);
await sleep(600);
await shot("login.jpg");
// --- sign in (a fresh profile prefills nothing, so both fields) ---
await evaluate(`(() => {
const inputs = [...document.querySelectorAll('input')];
const user = inputs.find(i => i.type === 'text' || i.type === 'email');
const pw = document.querySelector('input[type=password]');
window.__set(user, '[email protected]');
window.__set(pw, 'demo');
window.__btn('Sign in').click();
})()`);
await waitFor("document.querySelector('.msg-row') || document.querySelector('.nav-item')", "the app after sign-in");
await sleep(1500);
// --- inbox, dark, with a conversation open ---
await metrics(1420, 703);
await go("http://localhost:5173/mail");
await evaluate(HELPERS);
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
await sleep(1800);
await shot("inbox-dark.jpg");
// --- the reply composer, still on the dark theme ---
await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(x => /^reply$/i.test(x.getAttribute('aria-label')||'') || /^reply$/i.test(x.textContent.trim()));
if (b) b.click();
})()`);
await sleep(1800);
await shot("compose.jpg");
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);
// --- calendar ---
await go("http://localhost:5173/calendar");
await waitFor("document.querySelector('.cal-grid, .calendar, [class*=cal]')", "the calendar");
await evaluate(HELPERS);
// The README caption promises the month view.
await evaluate(`(() => { const b = window.__btn('Month'); if (b) b.click(); })()`);
await sleep(1800);
await shot("calendar.jpg");
// --- contacts ---
await go("http://localhost:5173/contacts");
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();
})()`);
await sleep(1800);
await shot("contacts.jpg");
// --- filters, with rules that actually say something ---
await go("http://localhost:5173/settings/filters");
await evaluate(HELPERS);
await waitFor("[...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'New rule')", "the filters editor");
await evaluate(`(async () => {
const wait = (ms=350) => new Promise(r => setTimeout(r, ms));
const rules = [
{ name: 'Newsletters', field: 'list-id', op: 'exists', value: '', folder: 'Newsletters' },
{ name: 'From the boss', field: 'from', op: 'contains', value: '[email protected]', folder: 'Work' },
{ name: 'Receipts', field: 'subject', op: 'contains', value: 'invoice', folder: 'Archive' },
{ name: 'Build failures',field: 'subject', op: 'matches', value: '*FAILED*', folder: 'Work' },
];
for (const r of rules) {
window.__btn('New rule').click(); await wait();
const d = document.querySelector('.dialog');
window.__set(d.querySelector('input.input'), r.name); await wait(120);
const row = d.querySelector('.rule-row');
const sels = row.querySelectorAll('select');
window.__sel(sels[0], r.field); await wait(120);
const sels2 = d.querySelector('.rule-row').querySelectorAll('select');
if (sels2[1]) { window.__sel(sels2[1], r.op); await wait(120); }
const val = [...d.querySelector('.rule-row').querySelectorAll('input.input')].pop();
if (val && r.value) { window.__set(val, r.value); await wait(120); }
const arow = d.querySelector('.rule-row.actions');
const asels = arow.querySelectorAll('select');
if (asels[1]) { window.__sel(asels[1], r.folder); await wait(120); }
window.__btn('Done', d).click(); await wait();
}
const save = window.__btn('Save filters'); if (save && !save.disabled) save.click();
await wait(1500);
// Clear the "Filters saved" toast so it does not sit over a rule.
document.querySelectorAll('.toast, [class*=toast]').forEach(t => t.remove());
})()`);
await sleep(1200);
await shot("filters.jpg");
// (mobile is captured separately by shots-mobile.mjs)
console.log("done");
} finally {
ws.close();
chrome.kill();
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 54 KiB