fuzzyworld

tower / files / tools / screenshots.mjs

screenshots.mjs

Blame: the commit that last changed each line · file · history

6364748a 2026-09-08 Pavel Safronov1/**
2 * Screenshot tour. `npm run screenshots`
3 *
4 * Drives the real application in a headless browser and photographs it:
5 * docs/screenshots/<profile>-<shot>.png one file per view, desktop and mobile
6 * docs/screenshots/overview.png all of them on one contact sheet
7 *
8 * Every shot is taken by *using* the map — clicking the view buttons, picking
9 * places out of the list, dragging to orbit and to look up — never by posing a
10 * renderer directly. That is the whole point: a screenshot taken through the UI
11 * fails when the UI fails, so a black canvas, or a sidebar that has eaten the
12 * window, ends up in the picture instead of hiding behind a passing unit test.
13 *
14 * The contact sheet exists to be looked at, once, before committing: one image that
15 * answers "is the application still there?". `--check` puts a number on that (see
16 * `divergence`), but the number is a smoke alarm, not a verdict. Look at the sheet.
17 *
18 * Determinism is deliberate and shallow — a fixed viewport, a seeded Math.random
e27aaf20 2026-09-08 Pavel Safronov19 * (nothing in Tower is stochastic since D-049) and the exterior spin switched
6364748a 2026-09-08 Pavel Safronov20 * off. Anything else that moves between runs is a real change worth seeing.
21 *
22 * Flags:
23 * --check exit non-zero if any shot diverged past the threshold
24 * --threshold <pct> how far a shot may move first (default 3, and calibrated)
25 * --url <origin> photograph a server already running instead of starting one
26 */
27import { mkdir, readFile } from 'node:fs/promises'
28import path from 'node:path'
29import { fileURLToPath } from 'node:url'
30import { createServer } from 'vite'
3cc2b5bd 2026-10-04 Pavel Safronov31import { launch, seedRandom, settle, warmWorld } from './browser.mjs'
6364748a 2026-09-08 Pavel Safronov32
33const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
34const OUT = path.join(ROOT, 'docs', 'screenshots')
35const OVERVIEW = path.join(OUT, 'overview.png')
36
37/**
38 * Above this per-shot mean pixel difference, `--check` stops and asks for human eyes.
39 *
40 * Measured, not guessed, at both ends:
41 *
42 * noise Seven of the ten shots are byte-identical run to run. The two
43 * exterior shots are not: OrbitControls damps toward its target
44 * asymptotically, so the camera lands a hair short of the same place
45 * each time and the labels and markers shift by a pixel. Observed up
46 * to 0.98% on mobile-exterior, where the canvas is narrow enough for
47 * a pixel to count for more.
48 * failure Hiding both 3D canvases scored 7% (desktop-exterior, mostly black
49 * sky already) to 31% (desktop-interior). A shot going entirely black
50 * scores 12% to 39%.
51 *
52 * 3% is three times the worst observed noise and less than half the mildest real
53 * failure. Re-measure both ends before moving it — and note that a view already
54 * broken scores 0% when it breaks further, which is how mobile-exterior behaved
55 * before D-043: this catches regressions, not things that never worked.
56 */
57const DEFAULT_THRESHOLD = 3
58
18958d3d 2026-09-08 Pavel Safronov59/** How many shots to photograph at once. See the pool in main() for why four. */
60const CONCURRENCY = 4
61
6364748a 2026-09-08 Pavel Safronov62/**
63 * Two shapes, because the second one is currently the interesting one: at 390 px
64 * the fixed 340 px sidebar leaves the world about fifty pixels of window. The
65 * mobile shots are in here to keep that visible until it is fixed.
66 */
67const PROFILES = [
68 { id: 'desktop', label: 'Desktop — 1440×900', viewport: { width: 1440, height: 900 }, scale: 1, mobile: false },
69 { id: 'mobile', label: 'Mobile — 390×844', viewport: { width: 390, height: 844 }, scale: 2, mobile: true },
70]
71
72// --------------------------------------------------------------------- helpers
73
74
75/** The canvas box, for aiming clicks and drags at the world rather than the panel. */
76async function stage(page) {
77 const box = await page.locator('main').boundingBox()
78 if (!box) throw new Error('no <main> to aim at')
79 return { x: box.x, y: box.y, w: box.width, h: box.height }
80}
81
82/** A slow drag, so pointermove handlers read it as a gesture and not as a jump. */
83async function drag(page, [fx, fy], [tx, ty]) {
84 await page.mouse.move(fx, fy)
85 await page.mouse.down()
86 const steps = 24
87 for (let i = 1; i <= steps; i++) {
88 await page.mouse.move(fx + ((tx - fx) * i) / steps, fy + ((ty - fy) * i) / steps)
89 await page.waitForTimeout(8)
90 }
91 await page.mouse.up()
92}
93
4699e6cb 2026-09-08 Pavel Safronov94/**
95 * Open one of the three views by clicking its button, as a reader would.
96 *
97 * Waits for the view to exist rather than for a guess at how long it takes. Three
98 * is a deferred import, so both 3D views appear some hundreds of milliseconds
99 * after the click — and a drag begun before the renderer has attached its pointer
100 * handlers is silently half-swallowed. That is not hypothetical: it is how
101 * interior-overhead came out pitched +13° when its drag is sized for +45°, and it
102 * had been doing so quietly for several runs behind a 2.5 s timeout that was
103 * usually but not always long enough.
104 *
105 * Both readouts are rendered only once their view is live, so a non-empty one is
106 * the signal — no app change needed to expose it.
107 */
6364748a 2026-09-08 Pavel Safronov108async function openView(page, view) {
109 await page.click(`#views button[data-view="${view}"]`)
4699e6cb 2026-09-08 Pavel Safronov110 if (view !== 'map') {
111 await page.waitForFunction(
112 (sel) => (document.querySelector(sel)?.textContent ?? '').length > 0,
113 view === 'exterior' ? '#spin' : '#standing',
114 { timeout: 30_000 })
115 }
116 await page.waitForTimeout(view === 'map' ? 400 : 700)
6364748a 2026-09-08 Pavel Safronov117 await settle(page)
118}
119
120/**
121 * Reach the sidebar, whatever shape it is in.
122 *
123 * On a wide screen it is a column and always there. Below 720 px it is a drawer
124 * that starts closed, so anything in it has to be opened for first — and closed
125 * again afterwards, or every mobile shot would be a picture of the panel.
126 */
127async function withPanel(page, fn) {
128 /**
129 * Ask the same media query the CSS and the app ask, rather than looking at the
130 * buttons. A closed drawer is only translated off-screen, so `isVisible()` on
131 * anything inside it still answers true and then the click times out against
132 * "element is outside of the viewport".
133 */
134 const drawer = await page.evaluate(() => matchMedia('(max-width: 720px)').matches)
135 const isOpen = () =>
136 page.evaluate(() => document.getElementById('app').classList.contains('panel-open'))
137
138 if (drawer && !(await isOpen())) {
139 await page.click('#panel-toggle')
140 await page.waitForTimeout(300)
141 }
142 await fn()
143 // The app closes the drawer itself when a place is picked, so this is only for
144 // the cases where it did not — flipping a checkbox, say.
145 if (drawer && (await isOpen())) {
146 await page.click('#panel-close')
147 await page.waitForTimeout(300)
148 }
149 await settle(page)
150}
151
152/** Click a place in the World list. In every view this is also how you travel. */
153async function pick(page, name) {
154 await withPanel(page, () => page.locator('#list li', { hasText: name }).first().click())
155}
156
157// ----------------------------------------------------------------------- shots
158
159/**
160 * The tour. `on` names the profiles a shot is taken in — the sightline probe and
161 * the overhead look are desktop-only because they are gestures, and the reader
162 * card is desktop-only because it is the same layout with fewer controls.
163 */
164const SHOTS = [
165 {
166 id: 'map',
167 caption: 'Map — the unrolled inner surface, with a place selected',
168 on: ['desktop', 'mobile'],
169 run: async (page) => {
170 await openView(page, 'map')
171 await pick(page, 'The Capital')
172 },
173 },
174 {
175 id: 'map-sightline',
176 caption: 'Map — shift-click two points to measure a sightline through the air',
177 on: ['desktop'],
178 run: async (page) => {
179 await openView(page, 'map')
180 const s = await stage(page)
181 // Two points well apart in z, so the readout has a long line of sight and a
182 // transmission worth reading. Shift is held down across both clicks, because
183 // the probe is a two-click gesture and the map reads the modifier on each.
184 await page.keyboard.down('Shift')
185 await page.mouse.click(s.x + s.w * 0.22, s.y + s.h * 0.45)
186 await page.mouse.click(s.x + s.w * 0.68, s.y + s.h * 0.58)
187 await page.keyboard.up('Shift')
188 await settle(page)
189 },
190 },
191 {
192 id: 'exterior',
4699e6cb 2026-09-08 Pavel Safronov193 caption: 'Exterior, x-ray hull — the inner surface projected out, with the caps and the collector',
6364748a 2026-09-08 Pavel Safronov194 on: ['desktop', 'mobile'],
195 run: async (page) => {
196 await openView(page, 'exterior')
197 // Spin off: a turning Tower photographs differently every run, and the value
198 // of these images is that a difference means something.
199 await withPanel(page, () => page.uncheck('#f-spin'))
4699e6cb 2026-09-08 Pavel Safronov200 await withPanel(page, () => page.selectOption('#f-ext-view', 'xray'))
6364748a 2026-09-08 Pavel Safronov201 const s = await stage(page)
202 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])
203 // The default view sits about 1,200 km out, where Tower is a sliver. Close in
204 // until the caps and the collector are worth looking at.
205 for (let i = 0; i < 6; i++) {
206 await page.mouse.wheel(0, -400)
207 await page.waitForTimeout(120)
208 }
209 // OrbitControls damps at 0.08 per frame, so the camera coasts after a drag.
210 // Wait it out: a shot taken mid-coast is a different image every run.
211 await settle(page, 3000)
212 },
213 },
4699e6cb 2026-09-08 Pavel Safronov214 {
215 id: 'exterior-true',
216 caption: 'Exterior, true hull — bare skin, and the land visible in through the far cap window',
217 on: ['desktop'],
218 run: async (page) => {
219 await openView(page, 'exterior')
220 await withPanel(page, () => page.uncheck('#f-spin'))
221 await withPanel(page, () => page.selectOption('#f-ext-view', 'true'))
222 // Swing round to the far cap: the window is the whole point of this mode and
223 // it is at one specific end of a 540 km object.
224 await pick(page, 'The Far Cap')
225 const s = await stage(page)
226 await page.mouse.move(s.x + s.w * 0.5, s.y + s.h * 0.5)
227 for (let i = 0; i < 3; i++) {
228 await page.mouse.wheel(0, -400)
229 await page.waitForTimeout(120)
230 }
231 await settle(page, 3000)
232 },
233 },
6364748a 2026-09-08 Pavel Safronov234 {
235 id: 'interior',
236 caption: 'Interior — standing at The Narrows, the ground curving up on both sides',
237 on: ['desktop', 'mobile'],
238 run: async (page) => {
239 await openView(page, 'interior')
240 await pick(page, 'The Narrows')
241 await settle(page, 1400)
242 },
243 },
99a2821b 2026-09-29 Pavel Safronov244 // The scenic tour's legs (src/tour/), each held at one moment. The legs are worked out
245 // from the world, so these follow it as it is edited; a test checks every id. A held leg is
246 // planned from a fixed seed, so it is the same flight on every run. Each is at least twelve
247 // seconds long, so every moment here is inside it.
248 ...[
249 ['falls', 8, 'a helicopter circling the Long Water\'s 680 m fall, leaning antispinward as it drops'],
250 ['falls-glide', 7, 'a paraglider past the same fall'],
251 ['ship', 9, 'following a ship round the Sunward Sea'],
252 ['squall', 9, 'a ship caught out in a squall Tower gave no warning of'],
253 ['summit', 8, 'an airship off Snowcrown, the highest summit in Tower'],
254 ['ridge', 7, 'a paraglider ridge-soaring on a summit\'s flank'],
255 ['thermal', 9, 'a glider climbing in a thermal over the Dry Reach'],
256 ['mesas', 10, 'a helicopter past the mesas of the Dry Reach'],
257 ['giants', 10, 'a helicopter under the giants of the Tall Wood'],
258 ['canopy', 8, 'a light aircraft low over Greatwood'],
259 ['deep', 9, 'a seaplane out from the Capital over The Deep, the far shore climbing the sky'],
260 ['girdle', 8, 'an airliner over the Girdle, flying antispinward, and lighter for it'],
261 ['climb', 11, 'a giant airship climbing out of the Capital: the world curls up overhead'],
262 ['sun-circuit', 8, 'a giant airship round the sun, outside the beam'],
263 ['far-cap', 9, 'toward the far cap by night, the one sky that turns'],
42b4554e 2026-09-29 Pavel Safronov264 ['moonlight', 6, 'a seaplane over The Deep by the light of Tower\'s moon'],
99a2821b 2026-09-29 Pavel Safronov265 ['train-narrows', 6, 'a train on the Axial Line through The Narrows, on its viaduct'],
266 ['train-ring', 6, 'a train on the Uplands Ring, round the world'],
267 ].map(([scene, t, caption]) => ({
268 id: `tour-${scene}`, caption: `Scenic tour — ${caption}`, on: ['desktop'], scene,
a088e5d5 2026-09-17 Pavel Safronov269 run: async (page) => {
270 await openView(page, 'interior')
99a2821b 2026-09-29 Pavel Safronov271 const title = await page.evaluate(([id, at]) => window.tower.showScene(id, at), [scene, t])
272 if (!title) throw new Error(`no leg ${scene} in this world`)
a088e5d5 2026-09-17 Pavel Safronov273 await settle(page, 1400)
274 },
99a2821b 2026-09-29 Pavel Safronov275 })),
276 ...[
277 ['grand', 5, 'Tower, its collector and the sun in one frame'],
278 ['docking', 12, 'a spacecraft docking on the shell, the stars wheeling past'],
279 ['far-cap-peek', 12, 'looking in through the far cap at the world inside'],
280 ['hull-skim', 7, 'skimming the hull while Tower turns under the ship'],
281 ].map(([scene, t, caption]) => ({
282 id: `tour-${scene}`, caption: `Scenic tour, outside — ${caption}`, on: ['desktop'], scene,
a088e5d5 2026-09-17 Pavel Safronov283 run: async (page) => {
99a2821b 2026-09-29 Pavel Safronov284 await openView(page, 'exterior')
285 const title = await page.evaluate(([id, at]) => window.towerOutside.showScene(id, at), [scene, t])
286 if (!title) throw new Error(`no leg ${scene} outside`)
80bcc97a 2026-09-17 Pavel Safronov287 await settle(page, 1400)
288 },
99a2821b 2026-09-29 Pavel Safronov289 })),
6364748a 2026-09-08 Pavel Safronov290 {
291 id: 'interior-overhead',
292 caption: 'Interior — looking up: the far side 100 km overhead is clearer than ground 100 km away',
293 on: ['desktop'],
294 run: async (page) => {
295 await openView(page, 'interior')
00ceeedb 2026-09-27 Pavel Safronov296 /**
297 * From open ground, not from Greatwood: since the forest is trees (D-071), standing
298 * in it and looking up shows a canopy, which is honest and is not what this shot is
299 * about — the claim being photographed is about the air, not about the wood.
300 */
301 await pick(page, 'The Capital')
6364748a 2026-09-08 Pavel Safronov302 const s = await stage(page)
303 /**
304 * Dragging down pitches the view up. The interior turns at 0.0032 rad per
4699e6cb 2026-09-08 Pavel Safronov305 * pixel at the default 60° field, so 164 px is 30°.
306 *
307 * It was 245 px / 45° until the far cap became a window. At 45° the frame is
308 * almost all haze — which is honest, and a poor picture of the claim in the
309 * caption. 30° keeps the ground's edge curving up on one side and the cap on
310 * the other, which is what the shot is actually for.
6364748a 2026-09-08 Pavel Safronov311 */
4699e6cb 2026-09-08 Pavel Safronov312 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 + 164])
6364748a 2026-09-08 Pavel Safronov313 await settle(page, 1600)
314 },
315 },
316 {
317 id: 'panel',
318 caption: 'Mobile — the sidebar as a drawer, opened over the world and one tap from gone',
319 on: ['mobile'],
320 run: async (page) => {
321 await openView(page, 'interior')
322 await pick(page, 'The Narrows')
323 await page.click('#panel-toggle')
324 await page.waitForTimeout(400)
325 await settle(page)
326 },
327 },
4699e6cb 2026-09-08 Pavel Safronov328 {
329 id: 'interior-night',
330 caption: 'Interior — the far cap window at night: stars, and the beam banked to a thousandth',
331 on: ['desktop'],
332 run: async (page) => {
333 await openView(page, 'interior')
334 // The Capital is 270 km from the far cap, which is far enough to see the
335 // whole 100 km window and near enough that some of the sky survives the air.
336 await pick(page, 'The Capital')
59c12827 2026-09-28 Pavel Safronov337 await withPanel(page, () => setTime(page, 23 * 60))
4699e6cb 2026-09-08 Pavel Safronov338 await settle(page, 1600)
339 },
340 },
567fe8ad 2026-09-08 Pavel Safronov341 {
342 id: 'interior-suncap',
343 caption: 'Interior — the sun cap window: the collector\'s black disc, and stars round the edge of it',
344 on: ['desktop'],
345 run: async (page) => {
346 await openView(page, 'interior')
347 await pick(page, 'The Capital')
59c12827 2026-09-28 Pavel Safronov348 await withPanel(page, () => setTime(page, 23 * 60))
567fe8ad 2026-09-08 Pavel Safronov349 // Turn to face sunward. 246 px is 45° at the default field, and the drag has
350 // to stay inside the viewport, so it goes round in four passes rather than
351 // one 982 px sweep that would run off the edge and drop the pointer capture.
352 const s = await stage(page)
353 for (let i = 0; i < 4; i++) {
354 await drag(page, [s.x + s.w * 0.8, s.y + s.h * 0.5], [s.x + s.w * 0.8 - 246, s.y + s.h * 0.5])
355 await page.waitForTimeout(150)
356 }
357 await settle(page, 1600)
358 },
359 },
2bb3e0de 2026-09-08 Pavel Safronov360 {
59c12827 2026-09-28 Pavel Safronov361 id: 'interior-morning',
362 caption: 'Interior — 06:30: the sun just out from the sun cap and coming, the rod lit only behind it',
2bb3e0de 2026-09-08 Pavel Safronov363 on: ['desktop'],
364 run: async (page) => {
365 await openView(page, 'interior')
366 await pick(page, 'The Capital')
59c12827 2026-09-28 Pavel Safronov367 // Day 0 is the equinox: sunrise 05:44, so by 06:30 the sun is 34 km out from the
368 // sun cap and 236 km sunward of The Capital, 12° up (D-074).
369 await withPanel(page, () => setTime(page, 6 * 60 + 30))
2bb3e0de 2026-09-08 Pavel Safronov370 const s = await stage(page)
371 for (let i = 0; i < 4; i++) {
372 await drag(page, [s.x + s.w * 0.8, s.y + s.h * 0.5], [s.x + s.w * 0.8 - 246, s.y + s.h * 0.5])
373 await page.waitForTimeout(150)
374 }
375 await settle(page, 1600)
376 },
377 },
6364748a 2026-09-08 Pavel Safronov378 {
9d5a061c 2026-09-27 Pavel Safronov379 id: 'detail',
380 caption: 'A place selected — its detail card, with what the world made of it',
6364748a 2026-09-08 Pavel Safronov381 on: ['desktop'],
382 run: async (page) => {
383 await openView(page, 'map')
9d5a061c 2026-09-27 Pavel Safronov384 await pick(page, 'The Dry Reach')
6364748a 2026-09-08 Pavel Safronov385 await settle(page)
386 },
387 },
388]
389
59c12827 2026-09-28 Pavel Safronov390/** Set the world clock's time of day, in minutes, the way a reader would: with the slider. */
391async function setTime(page, minutes) {
392 await page.locator('#f-time').fill(String(minutes))
393 await page.locator('#f-time').dispatchEvent('input')
394}
395
80bcc97a 2026-09-17 Pavel Safronov396/**
397 * Every page asks for two terrain workers and the compiled-world cache. Several pages run
398 * at once, and each opening a full pool of six and compiling the whole world — erosion
399 * and all — was a laptop running hot for a minute. With the cache warmed once (below),
400 * no shot compiles at all.
401 */
de3d60d6 2026-09-29 Pavel Safronov402// And the clock held at noon on day 0: left alone it opens at Pisa's time now, and runs.
1d447a1a 2026-09-28 Pavel Safronov403const TOUR_PARAMS = 'workers=2&cache=1&clock=12'
9d5a061c 2026-09-27 Pavel Safronov404const searchFor = () => `?${TOUR_PARAMS}`
80bcc97a 2026-09-17 Pavel Safronov405/** The storage every shot's fresh context starts from: the warm cache, and nothing else. */
406let warmStorage = undefined
6364748a 2026-09-08 Pavel Safronov407
408const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`)
409
410// ------------------------------------------------------------------- the tour
411
412async function capture(browser, base, profile, shot) {
18958d3d 2026-09-08 Pavel Safronov413 const t0 = Date.now()
6364748a 2026-09-08 Pavel Safronov414 /**
415 * A fresh context per shot. The app autosaves the view, the eye height and the
416 * heading to localStorage, so a shared context would let each shot inherit the
417 * previous one's camera and quietly stop being reproducible.
418 */
419 const context = await browser.newContext({
80bcc97a 2026-09-17 Pavel Safronov420 storageState: warmStorage,
6364748a 2026-09-08 Pavel Safronov421 viewport: profile.viewport,
422 deviceScaleFactor: profile.scale,
423 isMobile: profile.mobile,
424 hasTouch: profile.mobile,
425 })
3cc2b5bd 2026-10-04 Pavel Safronov426 await seedRandom(context)
6364748a 2026-09-08 Pavel Safronov427 const page = await context.newPage()
428 const problems = []
429 page.on('pageerror', (e) => problems.push(String(e)))
430 page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) })
431
432 await page.goto(`${base}/${searchFor(shot)}`, { waitUntil: 'networkidle' })
3cc2b5bd 2026-10-04 Pavel Safronov433 // The tour runs on a dev server, where the author has a button for saving a photograph
434 // (D-099). Readers do not, and these are pictures of what readers get.
435 await page.addStyleTag({ content: '#photo-row { display: none !important; }' })
6364748a 2026-09-08 Pavel Safronov436 await settle(page)
437 await shot.run(page)
438
80bcc97a 2026-09-17 Pavel Safronov439 // A shot that compiled the world itself means the warm cache did not reach it.
440 const compiled = await page.evaluate(() => window.towerTerrain && !window.towerTerrain.lastFromCache)
441 if (compiled) problems.push('compiled the world itself: the warm cache was missed')
442
6364748a 2026-09-08 Pavel Safronov443 const file = fileFor(profile.id, shot.id)
444 await page.screenshot({ path: file, scale: 'css' })
445 await context.close()
446
447 const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : ''
18958d3d 2026-09-08 Pavel Safronov448 console.log(` ${path.relative(ROOT, file)} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`)
6364748a 2026-09-08 Pavel Safronov449}
450
451// -------------------------------------------------------------- contact sheet
452
453const dataUri = async (file) => `data:image/png;base64,${(await readFile(file)).toString('base64')}`
454
455/**
456 * Build the overview: every shot on one page, photographed as one image.
457 *
458 * Deliberately undated. Eight of the ten files this tool writes come out
459 * byte-identical run to run, so regenerating usually costs git nothing at all;
460 * the exterior shot is the exception, carrying a pixel or two of renderer noise
461 * that measures 0.00% and still changes the bytes, and the sheet inherits it.
462 * A date stamp would guarantee a fresh 600 kB blob every day regardless, which
463 * is a bad trade for a fact git already records.
464 *
465 * The browser is already running and already knows how to lay images out, so the
466 * contact sheet is an HTML page rather than a compositing routine and a PNG decoder
467 * this project would otherwise have no use for (tools/png.mjs only encodes).
468 */
469async function buildOverview(browser, cells) {
470 const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
471 const page = await context.newPage()
472 // The doctype is load-bearing: without it the sheet lays out in quirks mode, the
473 // frames stop clamping their images, and the page grows tall enough that
474 // Page.captureScreenshot gives up with "Unable to capture screenshot".
475 await page.setContent(`<!doctype html>
476<style>
477 :root { color-scheme: dark; }
478 body { margin: 0; padding: 28px; background: #0b0d12; color: #dde3ee;
479 font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
480 h1 { font-size: 16px; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 4px; }
481 p.sub { color: #8892a6; margin: 0 0 22px; font-size: 12px; }
482 .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
483 figure { margin: 0; background: #131722; border: 1px solid #232a3a; border-radius: 8px;
484 padding: 10px; display: flex; flex-direction: column; }
485 .frame { height: 300px; display: flex; align-items: center; justify-content: center;
486 background: #05070d; border-radius: 4px; overflow: hidden; }
487 img { max-width: 100%; max-height: 100%; display: block; }
488 figcaption { margin-top: 8px; font-size: 11px; color: #8892a6; }
489 figcaption b { display: block; color: #7fd4ff; font-weight: 500; letter-spacing: 0.04em;
490 text-transform: uppercase; font-size: 10px; margin-bottom: 2px; }
491</style>
492<h1>Tower — screenshot overview</h1>
493<p class="sub">${cells.length} views · one glance before committing</p>
494<div class="grid">
495${cells.map((c) => ` <figure><div class="frame"><img src="${c.src}" /></div>
496 <figcaption><b>${c.label.replace('-', ' · ')}</b>${c.caption}</figcaption></figure>`).join('\n')}
497</div>`)
498 await page.waitForTimeout(400)
499 /**
500 * Size the window to the sheet and take an ordinary screenshot. `fullPage` is the
501 * obvious call and it fails outright here: the page carries every shot inline as
502 * a data URI, and capturing beyond the viewport on that much content returns
503 * "Unable to capture screenshot" from the protocol rather than an image.
504 */
505 const height = await page.evaluate(() => document.documentElement.scrollHeight)
506 await page.setViewportSize({ width: 1600, height })
507 await page.waitForTimeout(200)
508 await page.screenshot({ path: OVERVIEW, scale: 'css' })
509 await context.close()
510 console.log(` ${path.relative(ROOT, OVERVIEW)} ${1600}×${height}`)
511}
512
513/**
514 * Mean absolute difference between two PNGs, as a percentage of full scale.
515 *
516 * A crude metric on purpose. It is not trying to be a visual-diff tool; it is
517 * trying to notice that the interior went black, or that the sidebar swallowed the
518 * window. Returns null when the two are different sizes — the viewport changed, or
519 * a shot is new, which is a change the author already knows about.
520 *
521 * It is measured per shot rather than on the contact sheet, because the sheet
522 * dilutes: nine panels on a dark page, and rewriting two of them outright moved
523 * the whole-sheet mean by 0.6%. Per shot the same change reads in tens of percent,
524 * which is a number a threshold can actually be set against.
525 */
526async function divergence(browser, before, after) {
527 const context = await browser.newContext({ viewport: { width: 400, height: 300 } })
528 const page = await context.newPage()
529 const result = await page.evaluate(async ([a, b]) => {
530 const load = (src) => new Promise((res, rej) => {
531 const img = new Image()
532 img.onload = () => res(img)
533 img.onerror = () => rej(new Error('bad image'))
534 img.src = src
535 })
536 const [ia, ib] = await Promise.all([load(a), load(b)])
537 if (ia.width !== ib.width || ia.height !== ib.height) return null
538 const pixels = (img) => {
539 const c = document.createElement('canvas')
540 c.width = img.width
541 c.height = img.height
542 const ctx = c.getContext('2d')
543 ctx.drawImage(img, 0, 0)
544 return ctx.getImageData(0, 0, img.width, img.height).data
545 }
546 const pa = pixels(ia)
547 const pb = pixels(ib)
548 let sum = 0
549 // RGB only: both sheets are fully opaque, and alpha would only dilute the mean.
550 for (let i = 0; i < pa.length; i += 4) {
551 sum += Math.abs(pa[i] - pb[i]) + Math.abs(pa[i + 1] - pb[i + 1]) + Math.abs(pa[i + 2] - pb[i + 2])
552 }
553 return ((sum / ((pa.length / 4) * 3)) / 255) * 100
554 }, [before, after])
555 await context.close()
556 return result
557}
558
559// ------------------------------------------------------------------------ main
560
561const argv = process.argv.slice(2)
562const check = argv.includes('--check')
563const flag = (name) => {
564 const i = argv.indexOf(name)
565 return i >= 0 ? argv[i + 1] : undefined
566}
567const threshold = Number(flag('--threshold') ?? DEFAULT_THRESHOLD)
568const givenUrl = flag('--url')
51c39d8b 2026-09-17 Pavel Safronov569/**
570 * `--only tour-storm,interior` retakes just those shots (id prefixes) and leaves the rest
571 * of the sheet alone. For working on one view: the whole tour is 23 shots and three
572 * minutes of somebody's fans.
573 */
574const only = (flag('--only') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
575const wanted = (shot) => only.length === 0 || only.some((o) => shot.id.startsWith(o))
6364748a 2026-09-08 Pavel Safronov576
577await mkdir(OUT, { recursive: true })
578/**
579 * Read every existing shot before anything overwrites it. These are the only
580 * baseline there is — the run is about to replace them in place.
581 */
582const previous = new Map()
583for (const profile of PROFILES) {
584 for (const shot of SHOTS) {
51c39d8b 2026-09-17 Pavel Safronov585 if (!shot.on.includes(profile.id) || !wanted(shot)) continue
6364748a 2026-09-08 Pavel Safronov586 const file = fileFor(profile.id, shot.id)
587 const buf = await readFile(file).catch(() => null)
588 if (buf) previous.set(`${profile.id}-${shot.id}`, `data:image/png;base64,${buf.toString('base64')}`)
589 }
590}
591
592// Serve the app ourselves unless pointed at one already running. Vite's own API
593// means no port guessing and no orphaned child process to reap.
594const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } })
595if (server) await server.listen()
596const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '')
597console.log(`\nTower screenshots — ${base}\n`)
598
599const cells = []
600try {
18958d3d 2026-09-08 Pavel Safronov601 const tour = await launch()
6364748a 2026-09-08 Pavel Safronov602 try {
18958d3d 2026-09-08 Pavel Safronov603 /**
604 * Every shot is independent — its own context, its own page, its own copy of
605 * the world — so they run several at a time. On the GPU a shot is mostly
606 * waiting: for the module graph, for a shader compile, for a view to settle.
607 * Serial, that idle time is the whole run; four at once fills it.
608 *
609 * Four rather than more because each shot holds a WebGL context and its own
376f2ce6 2026-09-16 Pavel Safronov610 * pool of terrain workers generating the world, and because past that the machine starts
18958d3d 2026-09-08 Pavel Safronov611 * competing with itself for the GPU and the settle timings get noisier — which
612 * matters here more than speed does, since a shot that settles differently
613 * between runs shows up as divergence rather than as a slow run.
614 */
80bcc97a 2026-09-17 Pavel Safronov615 /**
616 * Compile the world once, in a page of its own, and hand every shot the IndexedDB it
617 * leaves behind. localStorage is emptied first: it holds the view and the camera, and
618 * a shot inheriting those would stop being reproducible.
619 */
3cc2b5bd 2026-10-04 Pavel Safronov620 warmStorage = await warmWorld(tour, base, TOUR_PARAMS)
18958d3d 2026-09-08 Pavel Safronov621 const jobs = []
6364748a 2026-09-08 Pavel Safronov622 for (const profile of PROFILES) {
623 for (const shot of SHOTS) {
51c39d8b 2026-09-17 Pavel Safronov624 if (shot.on.includes(profile.id) && wanted(shot)) jobs.push({ profile, shot })
18958d3d 2026-09-08 Pavel Safronov625 }
626 }
627 const results = new Array(jobs.length)
628 let next = 0
629 const worker = async () => {
630 for (let i = next++; i < jobs.length; i = next++) {
631 const { profile, shot } = jobs[i]
6364748a 2026-09-08 Pavel Safronov632 await capture(tour, base, profile, shot)
18958d3d 2026-09-08 Pavel Safronov633 results[i] = {
6364748a 2026-09-08 Pavel Safronov634 src: await dataUri(fileFor(profile.id, shot.id)),
635 caption: shot.caption,
636 label: `${profile.id}-${shot.id}`,
18958d3d 2026-09-08 Pavel Safronov637 }
6364748a 2026-09-08 Pavel Safronov638 }
639 }
18958d3d 2026-09-08 Pavel Safronov640 await Promise.all(Array.from({ length: CONCURRENCY }, worker))
641 // The sheet reads in profile order, not in whatever order the pool finished.
642 cells.push(...results)
6364748a 2026-09-08 Pavel Safronov643 } finally {
644 await tour.close()
645 }
646
647 /**
648 * A second browser for the paperwork. The tour leaves nine WebGL contexts and
649 * two Three.js scenes behind it, and in that browser Page.captureScreenshot on
650 * the sheet fails outright with "Unable to capture screenshot" — the sheet is
651 * plain HTML and images, so the only thing wrong with it is its neighbours.
652 */
18958d3d 2026-09-08 Pavel Safronov653 const sheet = await launch()
6364748a 2026-09-08 Pavel Safronov654 try {
51c39d8b 2026-09-17 Pavel Safronov655 // The sheet is every shot together, so it is only rebuilt when every shot was taken.
656 if (only.length) {
657 console.log(`Only ${only.join(', ')}: the overview sheet is left as it was.`)
658 } else {
659 console.log('Overview')
660 await buildOverview(sheet, cells)
661 }
6364748a 2026-09-08 Pavel Safronov662
663 if (!previous.size) {
664 console.log('\nNothing to compare against — this run is the baseline.')
665 } else {
666 console.log('\nDivergence from the committed shots')
667 let worst = 0
668 let worstName = ''
669 for (const cell of cells) {
670 const before = previous.get(cell.label)
671 if (!before) {
672 console.log(` ${cell.label.padEnd(26)} new`)
673 continue
674 }
675 const moved = await divergence(sheet, before, cell.src)
676 if (moved === null) {
677 console.log(` ${cell.label.padEnd(26)} resized`)
678 continue
679 }
680 if (moved > worst) { worst = moved; worstName = cell.label }
681 console.log(` ${cell.label.padEnd(26)} ${moved.toFixed(2)}%${moved > threshold ? ' ←' : ''}`)
682 }
683 if (worst > threshold) {
684 console.log(`\n${worstName} moved ${worst.toFixed(2)}%, over the ${threshold}% threshold.`)
685 console.log('Open docs/screenshots/overview.png and make sure the UI is still there.')
686 if (check) process.exitCode = 1
687 } else {
688 console.log(`\nWorst: ${worstName} at ${worst.toFixed(2)}%, under the ${threshold}% threshold.`)
689 }
690 }
691 } finally {
692 await sheet.close()
693 }
694} finally {
695 await server?.close()
696}
697console.log('\nDone. Look at docs/screenshots/overview.png.\n')