fuzzyworld

tower / commit

feat(tools): a screenshot tour that photographs the app through its UI

6364748a8878c3fb1f97a272891ce4c7b72db330 · Pavel Safronov · 2026-09-07 18:51:28 -0700 · parents fc11b0646269

feat(tools): a screenshot tour that photographs the app through its UI

`npm run screenshots` drives the real application in a headless browser and
writes docs/screenshots/ — ten views across two viewports, plus a contact
sheet of all ten.

Every shot is taken by *using* the map: clicking the view buttons, picking
places out of the list, shift-clicking two points to measure a sightline,
dragging to orbit and to look up. Posing the renderers directly would be
easier and would defeat 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.

overview.png is the deliverable for a human: one image to look at, once,
before committing. It is built by laying the shots out as an HTML page and
screenshotting that — the browser is already running and already knows how
to lay images out, and tools/png.mjs only encodes.

`--check` puts a number on the same question, per shot rather than on the
sheet: a mean over nine panels on a dark page diluted a change that
rewrote two of them outright down to 0.6%. The 3% threshold is measured at
both ends — seven of the ten shots are byte-identical between runs, the two
exterior ones drift up to 0.98% on orbit damping, and hiding both 3D
canvases scored 7% to 31%. It is a smoke alarm, not a verdict.

Notes for later:
- Two browsers. In the one that has just taken the tour, capturing the
  sheet fails outright with "Unable to capture screenshot".
- Determinism is deliberate and shallow: seeded Math.random (rain is the
  only randomised thing in Tower), spin off, a fresh context per shot.
- playwright is a devDependency with no postinstall, so `npm ci` in CI does
  not download browsers and .gitlab-ci.yml is untouched.

The first thing it found was the mobile layout, fixed in the previous
commit; its three mobile shots moved 10-33% while all six desktop shots
moved 0.00%, which is the shape a layout fix should have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 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
+
+[![All ten views on one sheet](docs/screenshots/overview.png)](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')