/** * Render the built app and write a PNG you can open and judge. * * The defects this exists for cannot be asserted. "The ocean has no specular * response", "the board ends in a hard diamond edge", "the aircraft are * illegible at board scale" are all true of code that typechecks, passes every * test and meets every performance budget. The only instrument that finds them * is a picture, so this makes taking one cheap. * * node scripts/look.mjs [--url ] [--phone] [--at ] * [--wait ] [--click ] [--api ] * * Writes /tmp/tera-look/.png. `--at` pins the clock, because the sun's * position is computed from the real one and a shot taken at 03:00 tells you * nothing about how the water reads at noon. * * This box has an AMD card and no monitor; the ANGLE/Vulkan flags below are what * make Chrome render headless here rather than falling back to a blank canvas. */ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { chromium } from "playwright"; const args = process.argv.slice(2); const name = args[0] ?? "look"; /** * The frames this project keeps taking, by name. * * `node scripts/look.mjs sf-night` and nothing else — no query string to * remember and no timestamp to get wrong. The point is not convenience, it is * that two people asking for "the night board" get the same photograph: an * acceptance criterion that reads "shoot sf-night and look at it" is only worth * writing if `sf-night` means one thing. * * Every field is a default. An explicit flag on the command line still wins, so * a preset is a starting point rather than a cage, and adding one is two lines. */ const PRESETS = { // The fire boards. California is the one with fires on it on a normal day; // SoCal is the one that must be *empty* and say so — see ARCHITECTURE.md §9.2. "fires-california": { url: "/?city=california" }, "fires-socal": { url: "/?city=socal" }, // The LA studio's upper floor. The door lands on the SF studio, so the shot // switches rooms and waits for the swap. "mateo-loft": { url: "/?city=socal&view=office", click: "LA HQ" }, // Night. 04:35Z is 21:35 in Los Angeles: full dark, and well clear of both // twilight edges, so a shot taken a minute late is the same shot. "sf-night": { url: "/?city=sf", at: "2026-08-23T04:35:00Z" }, "sky-night": { url: "/?city=california", at: "2026-08-23T04:35:00Z" }, "socal-night": { url: "/?city=socal", at: "2026-08-23T04:35:00Z" }, // The aeroplane glyph, at the four stand-offs its clamp has to serve: a whole // board, two chapter closeups on that board, and a detailed metro. "glyph-board": { url: "/?city=california" }, "glyph-la": { url: "/?city=california", click: "^LA$" }, "glyph-sf": { url: "/?city=california", click: "^SF$" }, "glyph-bay": { url: "/?city=sf", click: "The Bay" }, // The opening move, landed. `--reduced` collapses it to a cut, which is what // makes an arrival frame reproducible. "hero-california": { url: "/?city=california" }, "hero-socal": { url: "/?city=socal" }, "hero-sf": { url: "/?city=sf" }, }; const preset = PRESETS[name] ?? {}; const flag = (f, d) => { const i = args.indexOf(f); if (i !== -1) return args[i + 1]; return preset[f.replace(/^--/, "")] ?? d; }; const has = (f) => args.includes(f); const OUT = "/tmp/tera-look"; mkdirSync(OUT, { recursive: true }); /** * The port is asked for, not guessed, and the build is then verified. * * This used to draw a random port in 4700–4899 and start `vite preview * --strictPort` on it. When the draw collided with an abandoned preview from an * earlier run — and this box accumulated a hundred and forty-seven of them in * one afternoon — the new preview exited on the strict-port check, `page.goto` * succeeded against the squatter, and Playwright silently photographed somebody * else's dist. Three shots came back byte-identical while the bundle under them * provably changed. A screenshot tool that can photograph the wrong build is * worse than no screenshot tool, because you believe it. * * So: take a port from the kernel rather than from `Math.random`, then read the * URL the preview actually bound out of its own stdout, then fetch `/` and * assert it is the `dist/index.html` sitting on this disk. Any one of the three * would have caught it; all three cost nothing. */ const PORT = await new Promise((resolve, reject) => { const probe = createServer(); probe.once("error", reject); probe.listen(0, "127.0.0.1", () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); }); // `detached`, and vite's own binary rather than `npx`, because of the *other* // half of the orphan story: `npx` is a wrapper, `server.kill()` killed only the // wrapper, and the preview it had spawned went on holding its port forever. A // hundred and forty-seven of them accumulated in one afternoon. Detached gives // the pair a process group, and killing the group at the end kills both. const server = spawn( new URL("../node_modules/.bin/vite", import.meta.url).pathname, ["preview", "--port", String(PORT), "--strictPort"], { detached: true, stdio: ["ignore", "pipe", "pipe"] }, ); const shutdown = () => { try { process.kill(-server.pid, "SIGTERM"); } catch { /* already gone */ } }; process.on("exit", shutdown); const bound = await new Promise((resolve) => { let seen = ""; const settle = setTimeout(() => resolve(null), 30000); const read = (chunk) => { seen += String(chunk); const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen); if (match) { clearTimeout(settle); resolve(Number(match[1])); } }; server.stdout.on("data", read); server.stderr.on("data", read); }); if (bound === null) { console.error(`look: vite preview never announced a URL on ${PORT}; is dist/ built?`); shutdown(); process.exit(1); } if (bound !== PORT) { console.error(`look: preview bound ${bound}, not ${PORT} — refusing to photograph it`); shutdown(); process.exit(1); } // And the served page is this checkout's build, hashed script tag and all. { const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text()); const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8"); const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null; if (bundle(served) === null || bundle(served) !== bundle(onDisk)) { console.error( `look: :${PORT} is serving ${bundle(served)}, dist/ holds ${bundle(onDisk)} — ` + `something else owns that port. Not photographing it.`, ); shutdown(); process.exit(1); } } const phone = has("--phone"); const browser = await chromium.launch({ channel: "chrome", args: [ "--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", ], }); /** * `--reduced` asks the page for `prefers-reduced-motion: reduce`. * * Which is not only an accessibility check. The opening arrival — `arrive()` in * `scene.ts`, and `beginOfficeArrival` in `main.ts` — collapses to a cut under * this preference, so a shot taken with it is the resting frame and nothing * else, whatever the machine's load did to the four and a half seconds before * it. Without it a slow build and a fast one photograph different cameras. */ const context = await browser.newContext({ viewport: phone ? { width: 390, height: 844 } : { width: 1600, height: 1000 }, deviceScaleFactor: 2, timezoneId: "America/Los_Angeles", ...(has("--reduced") ? { reducedMotion: "reduce" } : {}), ...(phone ? { isMobile: true, hasTouch: true, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", } : {}), }); await context.clock.setFixedTime(new Date(flag("--at", "2026-08-21T20:00:00Z"))); const page = await context.newPage(); /** * `--api http://127.0.0.1:8431` photographs the dist against a real server. * * `vite preview` serves the static build and nothing else, so without this every * frame is the **keyless** experience: `/api/v1/health` 404s, `access` resolves * with no `feeds` at all, and the weather, the aircraft, the satellites and the * fires are all off. That is the right default — CONTRACT §0's stranger is * exactly that visitor, and most frames should be shot as they see them — but it * makes the one question a fire board exists to answer unphotographable. * * Proxied through Playwright rather than through a Vite config, for two reasons. * The build is not modified, so what is photographed is byte-identical to what * ships; and the response is *fulfilled* rather than redirected, so the page * sees a same-origin answer and no CORS header has to exist on the API for a * screenshot to work. */ const api = flag("--api", ""); if (api !== "") { const base = api.replace(/\/+$/, ""); await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); try { const response = await route.fetch({ url: `${base}${url.pathname}${url.search}` }); await route.fulfill({ response }); } catch (error) { // A refused upstream must look like a refused upstream, not like a hung // request: the app's own degraded paths are part of what is being judged. console.log(`look: api proxy failed for ${url.pathname} — ${error}`); await route.fulfill({ status: 502, body: "{}", contentType: "application/json" }); } }); } const errors = []; page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, { waitUntil: "networkidle", timeout: 60000, }); await page.waitForTimeout(Number(flag("--wait", "10000"))); // The first-run flow covers the scene it is teaching you about. try { await page.getByText(/^Skip$/).first().click({ timeout: 2500 }); await page.waitForTimeout(1200); } catch { /* already dismissed, or not shown */ } /** * `--click` may be given more than once, and they run in order. * * One click opens a door; two get you somewhere inside it. The LA office's * upper storey is the case that forced this: the first click opens the * building and the second flies to a viewpoint on the floor above, and there is * no single label that does both. */ const clicks = args.flatMap((arg, i) => (arg === "--click" ? [args[i + 1] ?? ""] : [])); if (clicks.length === 0 && typeof preset.click === "string") clicks.push(preset.click); for (const click of clicks) { if (click === "") continue; try { await page.getByText(new RegExp(click, "i")).first().click({ timeout: 5000 }); await page.waitForTimeout(8000); } catch { console.log(`look: could not click ${click}`); } } const path = `${OUT}/${name}.png`; await page.screenshot({ path }); console.log(`look: wrote ${path}`); // Two 404s on a static preview are the zero-config path working: see deploy/STATIC.md. const real = errors.filter((e) => !/404|Failed to load resource/.test(e)); console.log(real.length === 0 ? "look: no console errors" : `look: ERRORS ${JSON.stringify(real)}`); await browser.close(); shutdown(); // Explicit, because the preview's stdout pipes are inherited by a grandchild // and Node will happily wait on them for the rest of the afternoon. process.exit(0);