tower / commit
feat(tools): a screenshot tour that photographs the app through its UI
README.md | 55 +++ decisions.md | 53 ++- design.md | 10 +- docs/screenshots/desktop-exterior.png | Bin 0 -> 260378 bytes docs/screenshots/desktop-interior-overhead.png | Bin 0 -> 374948 bytes docs/screenshots/desktop-interior.png | Bin 0 -> 446455 bytes docs/screenshots/desktop-map-sightline.png | Bin 0 -> 576206 bytes docs/screenshots/desktop-map.png | Bin 0 -> 435779 bytes docs/screenshots/desktop-reader.png | Bin 0 -> 425728 bytes docs/screenshots/mobile-exterior.png | Bin 0 -> 72930 bytes docs/screenshots/mobile-interior.png | Bin 0 -> 143542 bytes docs/screenshots/mobile-map.png | Bin 0 -> 106948 bytes docs/screenshots/mobile-panel.png | Bin 0 -> 69817 bytes docs/screenshots/overview.png | Bin 0 -> 683709 bytes package-lock.json | 30 ++ package.json | 4 +- tools/screenshots.mjs | 506 +++++++++++++++++++++++++ 17 files changed, 655 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dcf0a5a..e5f08b1 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,60 @@ gets the whole screen and the panel slides in over it, one tap away and one tap gone. Picking a place closes it, because in every view selecting a place is how you travel there — see [decisions.md](decisions.md) D-043. +## Screenshots + +[](docs/screenshots/overview.png) + +`npm run screenshots` drives the real application in a headless browser — clicking +the view buttons, picking places out of the list, dragging to orbit and to look up — +and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI* is +the point: a shot taken this way fails when the UI fails, so a black canvas ends up +in the picture instead of hiding behind a passing unit test. + +| Desktop — 1440×900 | | +|---|---| +| [map](docs/screenshots/desktop-map.png) | the unrolled inner surface, with a place selected | +| [map-sightline](docs/screenshots/desktop-map-sightline.png) | shift-click two points to measure a sightline through the air | +| [exterior](docs/screenshots/desktop-exterior.png) | hull, checkered end caps and the collector, terrain wrapped on as an x-ray | +| [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides | +| [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away | +| [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, places, a detail card and the probe, no editing | + +| Mobile — 390×844 | | +|---|---| +| [map](docs/screenshots/mobile-map.png) | the world with the whole screen, the panel one tap away | +| [exterior](docs/screenshots/mobile-exterior.png) | the spin readout dropped clear of the panel button | +| [interior](docs/screenshots/mobile-interior.png) | the standing readout at a size that fits 390 px | +| [panel](docs/screenshots/mobile-panel.png) | the sidebar as a drawer, opened over the world | + +### Looking before committing + +[`overview.png`](docs/screenshots/overview.png) is a contact sheet of all ten. It +exists to be **looked at, once, before committing** — one image that answers "is the +application still there?". Regenerate, glance at it, then commit. + +`--check` puts a number on the same question. Every shot is compared against the +committed one and the run fails if any of them moved too far: + +``` +npm run screenshots -- --check # fail past 3% divergence on any shot +npm run screenshots -- --threshold 8 # report only, looser bar +npm run screenshots -- --url http://localhost:5173 # use a dev server already up +``` + +The threshold is measured at both ends, not guessed. Seven of the ten shots come out +**byte-identical** between runs — the rain is the only randomised thing in Tower and +it is seeded on the way in, the exterior spin is switched off, and every shot gets a +fresh browser context so no camera leaks into the next. The two exterior shots are +the exception, drifting up to **0.98%** because the orbit control damps toward its +target asymptotically. Against that, hiding both 3D canvases scored **7% to 31%**, +and a view going entirely black scores 12% to 39%. + +**3% is three times the worst noise and less than half the mildest real failure.** +It is a smoke alarm, not a verdict: when it fires, go and look at the sheet. Note +that a view which never worked scores 0% when it breaks further — this catches +regressions, not things that were already broken. + ## Development ``` @@ -44,6 +98,7 @@ npm run dev # http://localhost:5173 npm test # 94 tests; canon figures are pinned to design.md npm run build npm run terrain # regenerate the maps from content/geography.json +npm run screenshots # drive the app and rephotograph docs/screenshots/ ``` Vite + TypeScript, deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the diff --git a/decisions.md b/decisions.md index 58b2b8e..dbefc9a 100644 --- a/decisions.md +++ b/decisions.md @@ -1200,6 +1200,56 @@ the pattern for the same reason the hull carries one of the terrain: sunlight ru along the axis, so a cap is edge-on to every real light and renders as a dark disc exactly when it is being looked at. +## D-042 — Screenshots are taken through the UI, and diffed per shot + +`npm run screenshots` drives the real application in a headless browser and +photographs it: nine views across two viewports, plus a contact sheet of all nine. + +**Through the UI, on purpose.** Every shot clicks the view buttons, picks places out +of the list, and drags to orbit and to look up, rather than posing a renderer +directly. Posing is easier and it defeats the point. A screenshot taken by using the +application fails when the application fails, so a black canvas or a sidebar that +has swallowed the window ends up *in the picture* instead of hiding behind a passing +unit test. The tests already pin the canon numbers; nothing was checking that the +thing still draws. + +**The contact sheet is for a human.** `overview.png` exists to be looked at once, +before committing — one image that answers "is it still there?". It is built by +laying the shots out as an HTML page and screenshotting that, because the browser is +already running and already knows how to lay images out; `tools/png.mjs` only +encodes, and this project has no reason to gain a PNG decoder. + +**Diffed per shot, not per sheet.** The first version measured divergence on the +contact sheet, and that was the wrong unit: rewriting two of the nine panels +outright moved the whole-sheet mean by 0.6%, because a mean over nine panels on a +dark page dilutes everything. Per shot the same change reads in tens of percent. + +**The 3% threshold is measured, at both ends.** Seven of the ten shots come out +byte-identical run to run — the rain is the only randomised thing in Tower and is +seeded on the way in, the exterior spin is switched off, and each shot gets a fresh +browser context so no camera leaks into the next. The two exterior shots are the +exception, drifting up to 0.98% because OrbitControls damps toward its target +asymptotically and lands a hair short of the same place each time. Against that, +hiding both 3D canvases scored 7–31%; a view going entirely black scores 12% to 39%. +3% is three times the worst noise and less than half the mildest real failure. + +It is a smoke alarm, not a verdict: when it fires the answer is to look at the +sheet. And it only catches *regressions* — a view that never worked scores 0% when +it breaks further, which is exactly how mobile-exterior behaved during the sabotage +test that calibrated this, because at the time it was already a black slot. + +**Two browsers, one tour.** The contact sheet gets its own browser instance. In the +one that has just taken the tour — nine WebGL contexts and two Three.js scenes — +`Page.captureScreenshot` on the sheet fails outright with "Unable to capture +screenshot", and the sheet is plain HTML and images, so the only thing wrong with it +is its neighbours. + +**What it found immediately.** The mobile row: the sidebar was a fixed 340 px +against a 390 px viewport, so the world got about fifty pixels and both 3D views +rendered into a black slot. Fixed in D-043, which the same tool then verified — +the six desktop shots moved 0.00% and the three mobile ones moved 10–33%, which is +exactly the shape a layout fix should have. + ## D-043 — On a phone the sidebar is a drawer The layout was one grid, `1fr 340px`, with no media query anywhere. On a 390 px @@ -1215,7 +1265,8 @@ scrolling page and a canvas that eats touches are in permanent conflict. A drawe keeps the canvas full-bleed and the gesture unambiguous. **Below 720 px**, where the panel would otherwise be taking more than half the -window. Above it nothing changes at all. +window. Above it nothing changes at all — the desktop shots come out of the +screenshot tour byte-identical. **Never the full width.** `min(340px, 86vw)` leaves a strip of world showing, which is what tells you the panel is over the map rather than a page you navigated to. diff --git a/design.md b/design.md index 755aad1..6cbdb0f 100644 --- a/design.md +++ b/design.md @@ -531,6 +531,13 @@ src/ scales.ts slider mappings — kept out of views/ so the panel can label the eye-height slider without pulling in Three.js main.ts + +tools/ node-only, never bundled; plain .mjs with no build step + terrain.mjs renders content/geography.json into static/ (§1.11) + png.mjs minimal PNG encoder — zlib is its only dependency + noise.mjs fbm and ridged noise for the terrain relief + screenshots.mjs drives the running application in a headless browser and + photographs it into docs/screenshots/ (D-042) ``` `canon/constants.ts` is the spine. Every derived figure — period, rim speed, land @@ -756,7 +763,8 @@ a moment. Reading the world, not editing it. 7. **Content pipeline and reader mode** — ✅ `content/geography.json` + `npm run terrain` (D-019); the rendered terrain drawn under the map; reader and writer modes (D-011); permalinks (D-010). -8. **Polish** — mobile ✅ (the sidebar is a drawer below 720 px, D-043). +8. **Polish** — mobile ✅ (the sidebar is a drawer below 720 px, D-043) and a + screenshot tour that photographs all ten views through the UI, D-042. Remaining: transitions, performance. All five view phases are built. Phase 2's `canon/` landed with phase 6, since the diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png new file mode 100644 index 0000000..c920665 Binary files /dev/null and b/docs/screenshots/desktop-exterior.png differ diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png new file mode 100644 index 0000000..7798a3a Binary files /dev/null and b/docs/screenshots/desktop-interior-overhead.png differ diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png new file mode 100644 index 0000000..c6044f6 Binary files /dev/null and b/docs/screenshots/desktop-interior.png differ diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png new file mode 100644 index 0000000..c299f16 Binary files /dev/null and b/docs/screenshots/desktop-map-sightline.png differ diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png new file mode 100644 index 0000000..d58935c Binary files /dev/null and b/docs/screenshots/desktop-map.png differ diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png new file mode 100644 index 0000000..c7682e8 Binary files /dev/null and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png new file mode 100644 index 0000000..9d9d465 Binary files /dev/null and b/docs/screenshots/mobile-exterior.png differ diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png new file mode 100644 index 0000000..bcbcab9 Binary files /dev/null and b/docs/screenshots/mobile-interior.png differ diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png new file mode 100644 index 0000000..ab300d8 Binary files /dev/null and b/docs/screenshots/mobile-map.png differ diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png new file mode 100644 index 0000000..f9c4ce6 Binary files /dev/null and b/docs/screenshots/mobile-panel.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000..1a90b57 Binary files /dev/null and b/docs/screenshots/overview.png differ diff --git a/package-lock.json b/package-lock.json index e2d5f3d..c46d17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "devDependencies": { "@types/node": "^26.4.1", "@types/three": "^0.180.0", + "playwright": "^1.63.0", "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^5.0.0" @@ -1210,6 +1211,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index 9233358..9711d67 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "terrain": "node tools/terrain.mjs" + "terrain": "node tools/terrain.mjs", + "screenshots": "node tools/screenshots.mjs" }, "devDependencies": { "@types/node": "^26.4.1", "@types/three": "^0.180.0", + "playwright": "^1.63.0", "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^5.0.0" diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs new file mode 100644 index 0000000..27c8ded --- /dev/null +++ b/tools/screenshots.mjs @@ -0,0 +1,506 @@ +/** + * Screenshot tour. `npm run screenshots` + * + * Drives the real application in a headless browser and photographs it: + * docs/screenshots/<profile>-<shot>.png one file per view, desktop and mobile + * docs/screenshots/overview.png all of them on one contact sheet + * + * Every shot is taken by *using* the map — clicking the view buttons, picking + * places out of the list, dragging to orbit and to look up — never by posing a + * renderer directly. That is the whole point: a screenshot taken through the UI + * fails when the UI fails, so a black canvas, or a sidebar that has eaten the + * window, ends up in the picture instead of hiding behind a passing unit test. + * + * The contact sheet exists to be looked at, once, before committing: one image that + * answers "is the application still there?". `--check` puts a number on that (see + * `divergence`), but the number is a smoke alarm, not a verdict. Look at the sheet. + * + * Determinism is deliberate and shallow — a fixed viewport, a seeded Math.random + * (the rain is the only randomised thing in Tower) and the exterior spin switched + * off. Anything else that moves between runs is a real change worth seeing. + * + * Flags: + * --check exit non-zero if any shot diverged past the threshold + * --threshold <pct> how far a shot may move first (default 3, and calibrated) + * --url <origin> photograph a server already running instead of starting one + */ +import { mkdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import { chromium } from 'playwright' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const OUT = path.join(ROOT, 'docs', 'screenshots') +const OVERVIEW = path.join(OUT, 'overview.png') + +/** + * Above this per-shot mean pixel difference, `--check` stops and asks for human eyes. + * + * Measured, not guessed, at both ends: + * + * noise Seven of the ten shots are byte-identical run to run. The two + * exterior shots are not: OrbitControls damps toward its target + * asymptotically, so the camera lands a hair short of the same place + * each time and the labels and markers shift by a pixel. Observed up + * to 0.98% on mobile-exterior, where the canvas is narrow enough for + * a pixel to count for more. + * failure Hiding both 3D canvases scored 7% (desktop-exterior, mostly black + * sky already) to 31% (desktop-interior). A shot going entirely black + * scores 12% to 39%. + * + * 3% is three times the worst observed noise and less than half the mildest real + * failure. Re-measure both ends before moving it — and note that a view already + * broken scores 0% when it breaks further, which is how mobile-exterior behaved + * before D-043: this catches regressions, not things that never worked. + */ +const DEFAULT_THRESHOLD = 3 + +/** + * Two shapes, because the second one is currently the interesting one: at 390 px + * the fixed 340 px sidebar leaves the world about fifty pixels of window. The + * mobile shots are in here to keep that visible until it is fixed. + */ +const PROFILES = [ + { id: 'desktop', label: 'Desktop — 1440×900', viewport: { width: 1440, height: 900 }, scale: 1, mobile: false }, + { id: 'mobile', label: 'Mobile — 390×844', viewport: { width: 390, height: 844 }, scale: 2, mobile: true }, +] + +// --------------------------------------------------------------------- helpers + +/** Flush a few animation frames, then hold still, so a settling view has settled. */ +async function settle(page, ms = 900) { + await page.evaluate(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => requestAnimationFrame(() => r(null))) + }) + await page.waitForTimeout(ms) +} + +/** The canvas box, for aiming clicks and drags at the world rather than the panel. */ +async function stage(page) { + const box = await page.locator('main').boundingBox() + if (!box) throw new Error('no <main> to aim at') + return { x: box.x, y: box.y, w: box.width, h: box.height } +} + +/** A slow drag, so pointermove handlers read it as a gesture and not as a jump. */ +async function drag(page, [fx, fy], [tx, ty]) { + await page.mouse.move(fx, fy) + await page.mouse.down() + const steps = 24 + for (let i = 1; i <= steps; i++) { + await page.mouse.move(fx + ((tx - fx) * i) / steps, fy + ((ty - fy) * i) / steps) + await page.waitForTimeout(8) + } + await page.mouse.up() +} + +/** Open one of the three views by clicking its button, as a reader would. */ +async function openView(page, view) { + await page.click(`#views button[data-view="${view}"]`) + // Three.js is a deferred import, so the first exterior or interior open is a + // network fetch and a shader compile before there is anything to photograph. + await page.waitForTimeout(view === 'map' ? 400 : 2500) + await settle(page) +} + +/** + * Reach the sidebar, whatever shape it is in. + * + * On a wide screen it is a column and always there. Below 720 px it is a drawer + * that starts closed, so anything in it has to be opened for first — and closed + * again afterwards, or every mobile shot would be a picture of the panel. + */ +async function withPanel(page, fn) { + /** + * Ask the same media query the CSS and the app ask, rather than looking at the + * buttons. A closed drawer is only translated off-screen, so `isVisible()` on + * anything inside it still answers true and then the click times out against + * "element is outside of the viewport". + */ + const drawer = await page.evaluate(() => matchMedia('(max-width: 720px)').matches) + const isOpen = () => + page.evaluate(() => document.getElementById('app').classList.contains('panel-open')) + + if (drawer && !(await isOpen())) { + await page.click('#panel-toggle') + await page.waitForTimeout(300) + } + await fn() + // The app closes the drawer itself when a place is picked, so this is only for + // the cases where it did not — flipping a checkbox, say. + if (drawer && (await isOpen())) { + await page.click('#panel-close') + await page.waitForTimeout(300) + } + await settle(page) +} + +/** Click a place in the World list. In every view this is also how you travel. */ +async function pick(page, name) { + await withPanel(page, () => page.locator('#list li', { hasText: name }).first().click()) +} + +// ----------------------------------------------------------------------- shots + +/** + * The tour. `on` names the profiles a shot is taken in — the sightline probe and + * the overhead look are desktop-only because they are gestures, and the reader + * card is desktop-only because it is the same layout with fewer controls. + */ +const SHOTS = [ + { + id: 'map', + caption: 'Map — the unrolled inner surface, with a place selected', + on: ['desktop', 'mobile'], + run: async (page) => { + await openView(page, 'map') + await pick(page, 'The Capital') + }, + }, + { + id: 'map-sightline', + caption: 'Map — shift-click two points to measure a sightline through the air', + on: ['desktop'], + run: async (page) => { + await openView(page, 'map') + const s = await stage(page) + // Two points well apart in z, so the readout has a long line of sight and a + // transmission worth reading. Shift is held down across both clicks, because + // the probe is a two-click gesture and the map reads the modifier on each. + await page.keyboard.down('Shift') + await page.mouse.click(s.x + s.w * 0.22, s.y + s.h * 0.45) + await page.mouse.click(s.x + s.w * 0.68, s.y + s.h * 0.58) + await page.keyboard.up('Shift') + await settle(page) + }, + }, + { + id: 'exterior', + caption: 'Exterior — hull, checkered end caps and the collector, terrain wrapped on as an x-ray', + on: ['desktop', 'mobile'], + run: async (page) => { + await openView(page, 'exterior') + // Spin off: a turning Tower photographs differently every run, and the value + // of these images is that a difference means something. + await withPanel(page, () => page.uncheck('#f-spin')) + const s = await stage(page) + await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.5], [s.x + s.w * 0.5 - 150, s.y + s.h * 0.5 + 70]) + // The default view sits about 1,200 km out, where Tower is a sliver. Close in + // until the caps and the collector are worth looking at. + for (let i = 0; i < 6; i++) { + await page.mouse.wheel(0, -400) + await page.waitForTimeout(120) + } + // OrbitControls damps at 0.08 per frame, so the camera coasts after a drag. + // Wait it out: a shot taken mid-coast is a different image every run. + await settle(page, 3000) + }, + }, + { + id: 'interior', + caption: 'Interior — standing at The Narrows, the ground curving up on both sides', + on: ['desktop', 'mobile'], + run: async (page) => { + await openView(page, 'interior') + await pick(page, 'The Narrows') + await settle(page, 1400) + }, + }, + { + id: 'interior-overhead', + caption: 'Interior — looking up: the far side 100 km overhead is clearer than ground 100 km away', + on: ['desktop'], + run: async (page) => { + await openView(page, 'interior') + await pick(page, 'Greatwood') + const s = await stage(page) + /** + * Dragging down pitches the view up. The interior turns at 0.0032 rad per + * pixel at the default 60° field, so ~245 px is 45° — high enough to have the + * far side of the world in frame, low enough to keep the ground in it too. + */ + await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.32], [s.x + s.w * 0.5, s.y + s.h * 0.32 + 245]) + await page.check('#f-rain') + await settle(page, 1600) + }, + }, + { + id: 'panel', + caption: 'Mobile — the sidebar as a drawer, opened over the world and one tap from gone', + on: ['mobile'], + run: async (page) => { + await openView(page, 'interior') + await pick(page, 'The Narrows') + await page.click('#panel-toggle') + await page.waitForTimeout(400) + await settle(page) + }, + }, + { + id: 'reader', + caption: 'Reader mode (?reader=1) — terrain, places, a detail card and the probe, no editing', + on: ['desktop'], + run: async (page) => { + await openView(page, 'map') + await pick(page, 'The Spindle') + await settle(page) + }, + }, +] + +/** Reader mode is a query parameter, so it has to be chosen before the page loads. */ +const searchFor = (shot) => (shot.id === 'reader' ? '?reader=1' : '') + +const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`) + +// ------------------------------------------------------------------- the tour + +async function capture(browser, base, profile, shot) { + /** + * A fresh context per shot. The app autosaves the view, the eye height and the + * heading to localStorage, so a shared context would let each shot inherit the + * previous one's camera and quietly stop being reproducible. + */ + const context = await browser.newContext({ + viewport: profile.viewport, + deviceScaleFactor: profile.scale, + isMobile: profile.mobile, + hasTouch: profile.mobile, + }) + /** + * The rain is the only randomised thing in the world; everything else is a pure + * function of the geography and the camera. Seeding it keeps the contact sheet + * comparable between runs. + */ + await context.addInitScript(() => { + let seed = 0x2f6e2b1 + Math.random = () => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 0x100000000 + } + }) + const page = await context.newPage() + const problems = [] + page.on('pageerror', (e) => problems.push(String(e))) + page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) }) + + await page.goto(`${base}/${searchFor(shot)}`, { waitUntil: 'networkidle' }) + await settle(page) + await shot.run(page) + + const file = fileFor(profile.id, shot.id) + await page.screenshot({ path: file, scale: 'css' }) + await context.close() + + const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : '' + console.log(` ${path.relative(ROOT, file)}${note}`) +} + +// -------------------------------------------------------------- contact sheet + +const dataUri = async (file) => `data:image/png;base64,${(await readFile(file)).toString('base64')}` + +/** + * Build the overview: every shot on one page, photographed as one image. + * + * Deliberately undated. Eight of the ten files this tool writes come out + * byte-identical run to run, so regenerating usually costs git nothing at all; + * the exterior shot is the exception, carrying a pixel or two of renderer noise + * that measures 0.00% and still changes the bytes, and the sheet inherits it. + * A date stamp would guarantee a fresh 600 kB blob every day regardless, which + * is a bad trade for a fact git already records. + * + * The browser is already running and already knows how to lay images out, so the + * contact sheet is an HTML page rather than a compositing routine and a PNG decoder + * this project would otherwise have no use for (tools/png.mjs only encodes). + */ +async function buildOverview(browser, cells) { + const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) + const page = await context.newPage() + // The doctype is load-bearing: without it the sheet lays out in quirks mode, the + // frames stop clamping their images, and the page grows tall enough that + // Page.captureScreenshot gives up with "Unable to capture screenshot". + await page.setContent(`<!doctype html> +<style> + :root { color-scheme: dark; } + body { margin: 0; padding: 28px; background: #0b0d12; color: #dde3ee; + font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; } + h1 { font-size: 16px; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 4px; } + p.sub { color: #8892a6; margin: 0 0 22px; font-size: 12px; } + .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } + figure { margin: 0; background: #131722; border: 1px solid #232a3a; border-radius: 8px; + padding: 10px; display: flex; flex-direction: column; } + .frame { height: 300px; display: flex; align-items: center; justify-content: center; + background: #05070d; border-radius: 4px; overflow: hidden; } + img { max-width: 100%; max-height: 100%; display: block; } + figcaption { margin-top: 8px; font-size: 11px; color: #8892a6; } + figcaption b { display: block; color: #7fd4ff; font-weight: 500; letter-spacing: 0.04em; + text-transform: uppercase; font-size: 10px; margin-bottom: 2px; } +</style> +<h1>Tower — screenshot overview</h1> +<p class="sub">${cells.length} views · one glance before committing</p> +<div class="grid"> +${cells.map((c) => ` <figure><div class="frame"><img src="${c.src}" /></div> + <figcaption><b>${c.label.replace('-', ' · ')}</b>${c.caption}</figcaption></figure>`).join('\n')} +</div>`) + await page.waitForTimeout(400) + /** + * Size the window to the sheet and take an ordinary screenshot. `fullPage` is the + * obvious call and it fails outright here: the page carries every shot inline as + * a data URI, and capturing beyond the viewport on that much content returns + * "Unable to capture screenshot" from the protocol rather than an image. + */ + const height = await page.evaluate(() => document.documentElement.scrollHeight) + await page.setViewportSize({ width: 1600, height }) + await page.waitForTimeout(200) + await page.screenshot({ path: OVERVIEW, scale: 'css' }) + await context.close() + console.log(` ${path.relative(ROOT, OVERVIEW)} ${1600}×${height}`) +} + +/** + * Mean absolute difference between two PNGs, as a percentage of full scale. + * + * A crude metric on purpose. It is not trying to be a visual-diff tool; it is + * trying to notice that the interior went black, or that the sidebar swallowed the + * window. Returns null when the two are different sizes — the viewport changed, or + * a shot is new, which is a change the author already knows about. + * + * It is measured per shot rather than on the contact sheet, because the sheet + * dilutes: nine panels on a dark page, and rewriting two of them outright moved + * the whole-sheet mean by 0.6%. Per shot the same change reads in tens of percent, + * which is a number a threshold can actually be set against. + */ +async function divergence(browser, before, after) { + const context = await browser.newContext({ viewport: { width: 400, height: 300 } }) + const page = await context.newPage() + const result = await page.evaluate(async ([a, b]) => { + const load = (src) => new Promise((res, rej) => { + const img = new Image() + img.onload = () => res(img) + img.onerror = () => rej(new Error('bad image')) + img.src = src + }) + const [ia, ib] = await Promise.all([load(a), load(b)]) + if (ia.width !== ib.width || ia.height !== ib.height) return null + const pixels = (img) => { + const c = document.createElement('canvas') + c.width = img.width + c.height = img.height + const ctx = c.getContext('2d') + ctx.drawImage(img, 0, 0) + return ctx.getImageData(0, 0, img.width, img.height).data + } + const pa = pixels(ia) + const pb = pixels(ib) + let sum = 0 + // RGB only: both sheets are fully opaque, and alpha would only dilute the mean. + for (let i = 0; i < pa.length; i += 4) { + sum += Math.abs(pa[i] - pb[i]) + Math.abs(pa[i + 1] - pb[i + 1]) + Math.abs(pa[i + 2] - pb[i + 2]) + } + return ((sum / ((pa.length / 4) * 3)) / 255) * 100 + }, [before, after]) + await context.close() + return result +} + +// ------------------------------------------------------------------------ main + +const argv = process.argv.slice(2) +const check = argv.includes('--check') +const flag = (name) => { + const i = argv.indexOf(name) + return i >= 0 ? argv[i + 1] : undefined +} +const threshold = Number(flag('--threshold') ?? DEFAULT_THRESHOLD) +const givenUrl = flag('--url') + +await mkdir(OUT, { recursive: true }) +/** + * Read every existing shot before anything overwrites it. These are the only + * baseline there is — the run is about to replace them in place. + */ +const previous = new Map() +for (const profile of PROFILES) { + for (const shot of SHOTS) { + if (!shot.on.includes(profile.id)) continue + const file = fileFor(profile.id, shot.id) + const buf = await readFile(file).catch(() => null) + if (buf) previous.set(`${profile.id}-${shot.id}`, `data:image/png;base64,${buf.toString('base64')}`) + } +} + +// Serve the app ourselves unless pointed at one already running. Vite's own API +// means no port guessing and no orphaned child process to reap. +const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } }) +if (server) await server.listen() +const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '') +console.log(`\nTower screenshots — ${base}\n`) + +const cells = [] +try { + const tour = await chromium.launch() + try { + for (const profile of PROFILES) { + console.log(profile.label) + for (const shot of SHOTS) { + if (!shot.on.includes(profile.id)) continue + await capture(tour, base, profile, shot) + cells.push({ + src: await dataUri(fileFor(profile.id, shot.id)), + caption: shot.caption, + label: `${profile.id}-${shot.id}`, + }) + } + } + } finally { + await tour.close() + } + + /** + * A second browser for the paperwork. The tour leaves nine WebGL contexts and + * two Three.js scenes behind it, and in that browser Page.captureScreenshot on + * the sheet fails outright with "Unable to capture screenshot" — the sheet is + * plain HTML and images, so the only thing wrong with it is its neighbours. + */ + const sheet = await chromium.launch() + try { + console.log('Overview') + await buildOverview(sheet, cells) + + if (!previous.size) { + console.log('\nNothing to compare against — this run is the baseline.') + } else { + console.log('\nDivergence from the committed shots') + let worst = 0 + let worstName = '' + for (const cell of cells) { + const before = previous.get(cell.label) + if (!before) { + console.log(` ${cell.label.padEnd(26)} new`) + continue + } + const moved = await divergence(sheet, before, cell.src) + if (moved === null) { + console.log(` ${cell.label.padEnd(26)} resized`) + continue + } + if (moved > worst) { worst = moved; worstName = cell.label } + console.log(` ${cell.label.padEnd(26)} ${moved.toFixed(2)}%${moved > threshold ? ' ←' : ''}`) + } + if (worst > threshold) { + console.log(`\n${worstName} moved ${worst.toFixed(2)}%, over the ${threshold}% threshold.`) + console.log('Open docs/screenshots/overview.png and make sure the UI is still there.') + if (check) process.exitCode = 1 + } else { + console.log(`\nWorst: ${worstName} at ${worst.toFixed(2)}%, under the ${threshold}% threshold.`) + } + } + } finally { + await sheet.close() + } +} finally { + await server?.close() +} +console.log('\nDone. Look at docs/screenshots/overview.png.\n')