docs: a README with a face, in English and Portuguese; decode every HTML entity in previews

The README opens like a product page: the mark, a line on what it is, the
links, the language switch and a picture of the real interface running on an
invented mailbox, in dark and light and in both languages. scripts/screenshots
holds that invented mailbox and the script that photographs it, so the pictures
can be redone. Previews now decode any HTML entity through the browser rather
than a short list, so a Portuguese or marketing preview no longer prints
ç or ·.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-23 07:55:12 -03:00
1 parent 8226c76d7a
commit 1e06491019
16 files changed
+679 -5

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// Photographs the webmail running against the invented mailbox of
// stub-server.mjs, for the README. Needs puppeteer-core and a Chrome:
//
// CHROME_PATH=/usr/bin/google-chrome node scripts/screenshots/capture.mjs
//
// BASE is where the webmail is listening (default http://localhost:3100).
import puppeteer from "puppeteer-core";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const out = path.join(here, "..", "..", "docs", "img");
const base = process.env.BASE ?? "http://localhost:3100";
const stub = process.env.STUB ?? "http://127.0.0.1:4001";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// The webmail only checks that a session cookie looks like one; the stub
// accepts anything, so a token with the right claims is enough.
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const session = `${b64({ alg: "HS256" })}.${b64({ aud: "lambdamail:user", purpose: "session", surface: "user", exp: Math.floor(Date.now() / 1000) + 3600 })}.stub`;
const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH, headless: true, args: ["--no-sandbox"] });
async function shoot({ name, theme, locale, width = 1440, height = 900, steps }) {
// Every picture starts from the mailbox as it began: opening a message marks it read.
await fetch(`${stub}/__reset`);
const page = await browser.newPage();
await page.setViewport({ width, height, deviceScaleFactor: 1 });
const host = new URL(base).hostname;
await page.setCookie(
{ name: "lm_user_session", value: session, domain: host, path: "/" },
{ name: "lm_locale", value: locale, domain: host, path: "/" },
);
await page.evaluateOnNewDocument((t) => localStorage.setItem("lm_theme", t), theme);
await page.goto(`${base}/user/mail/inbox`, { waitUntil: "domcontentloaded", timeout: 120000 });
await page.waitForSelector("button.lm-row", { timeout: 120000 });
// Next's development badge is not part of the product.
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" });
await sleep(1500);
const open = async (re) => {
for (const row of await page.$$("button.lm-row")) {
if (re.test(await row.evaluate((e) => e.textContent))) { await row.click(); return; }
}
throw new Error(`no row matches ${re}`);
};
await steps(page, open);
await page.screenshot({ path: path.join(out, `${name}.png`) });
await page.close();
console.log("wrote", name);
}
const launch = async (page, open) => { await open(/Launch checklist/); await sleep(3500); };
const digest = async (page, open) => { await open(/weekly digest/); await sleep(3500); };
const menu = async (page, open) => {
await open(/Launch checklist/); await sleep(3000);
await page.keyboard.down("Control"); await page.keyboard.press("k"); await page.keyboard.up("Control");
await sleep(700);
};
for (const [locale, tag] of [["en", "en"], ["pt-BR", "pt"]]) {
await shoot({ name: `webmail-dark-${tag}`, theme: "dark", locale, steps: launch });
await shoot({ name: `webmail-light-${tag}`, theme: "light", locale, steps: launch });
await shoot({ name: `menu-dark-${tag}`, theme: "dark", locale, steps: menu });
await shoot({ name: `designed-light-${tag}`, theme: "light", locale, steps: digest });
}
await browser.close();