Build note · This site
The browser that says everything is broken
The preview pane I build in reports its document as hidden. A hidden document never fires IntersectionObserver. Every element on this page that reveals on scroll therefore looks broken in that pane, whether it is broken or not.
This is a note about not trusting my own eyes. Most of what is on this site appears when you scroll to it: seventy one elements fade up, two videos attach their source, and a megabyte of 3D viewer arrives only if somebody reaches the ant. All of that hangs off one browser API, and in the window I do most of my work in, that API never fires.
01 The bug that was not a bug
An embedded preview that is not on screen sets document.visibilityState to hidden. That is correct behaviour and it is also, for my purposes, a trap: the browser will not run an IntersectionObserver callback for a document it considers hidden, because nothing can be intersecting a viewport nobody is looking at.
The result is that the page loads, renders, and then sits there with every reveal still in its starting state. It looks exactly like a page whose reveal code is broken. I spent real time fixing code that was already working before I understood what I was looking at.
02 Drive a real browser instead
The fix is to stop looking at the preview and start driving a browser that actually composites. Headless Edge over the Chrome DevTools Protocol renders for real, so the observers fire and the measurements mean something. It takes about forty lines to open a socket to it and evaluate anything you like in the page.
const proc = spawn(EDGE, ['--headless=new', `--remote-debugging-port=${PORT}`,
'--remote-allow-origins=*', '--window-size=1440,900', '--hide-scrollbars', 'about:blank']);
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
const send = (method, params = {}) => new Promise(res => {
const i = ++id; pending.set(i, res);
ws.send(JSON.stringify({ id: i, method, params }));
});
From there a check is just a question asked of the live page. How many of the seventy one reveal elements have actually revealed. How many videos are still holding their source back. How many kilobytes have crossed the network so far.
AT LOAD {"visibility":"visible","riseRevealed":0,"riseTotal":71,"videosPending":2,"kb":17}
AFTER SCROLL {"videosPending":0,"mp4s":["walk-planted.mp4 612KB"],"kb":1019}
CONSOLE ERRORS: none
03 Measure the thing, not a screenshot of it
A screenshot proves a frame existed. It does not prove the frame was right, and it says nothing at all about what happens over time. When I added a lattice to the hero that bends toward the pointer, the question was not whether it looked correct in one capture. It was whether it draws when the pointer moves, whether it stops when the pointer stops, and whether it leaves the processor alone once you have scrolled past it.
Those are all countable. Wrap requestAnimationFrame before the page loads and you have a frame counter. Wrap clearRect and you have a draw counter. Read the canvas back with getImageData and checksum it, and you can tell whether two moments in time are the same picture.
{ "idleFramesAtLoad": 0,
"framesWhileWarping": 139,
"idleFramesAfterSettle": 0,
"framesWhenScrolledPast": 0,
"transparentGround": true }
Zero, one hundred and thirty nine, zero, zero. That is the whole behavioural contract in four numbers, and none of them are available by looking.
04 Three ways the harness lied to me
A measurement rig is code, so it has bugs, and its bugs are worse than ordinary bugs because they arrive wearing the costume of evidence.
The first pixel read is not stable. Reading the canvas immediately after paint returned a slightly different checksum than reading it a moment later, with an identical count of inked pixels. It made a static frame look like an animating one. The fix is a throwaway read before the one you keep, which is an embarrassing sentence to write and a real property of the platform.
A narrow window is not a phone. Emulation.setDeviceMetricsOverride with mobile: true resizes the viewport and does not change the hover and pointer media features. So a 390 pixel capture still reports a fine pointer, still takes hover, and still runs every desktop code path. My phone screenshots were desktop screenshots in a narrow coat until I set those features explicitly.
await send('Emulation.setEmulatedMedia', { features: [
{ name: 'hover', value: 'none' },
{ name: 'pointer', value: 'coarse' },
]});
One browser cannot always do two runs. Navigating twice in the same process, on a page carrying WebGL, left the renderer unable to answer at all: every evaluation came back undefined and every test after the first reported a failure that had nothing to do with the code. Each run gets its own process now. That is slower and it is not ambiguous.
05 What it still cannot tell me
None of this has an opinion about whether the thing looks good. It will confirm that a lattice bends toward the pointer, at what frame cost, and that it stops when it should. It will not tell me the violet is too bright behind the paragraph, which it was, and which I only found by putting the capture next to the copy and reading it.
So the rig answers "is it doing what I said" and I still have to answer "is that the right thing". The value is in not confusing the two, and in never again spending an afternoon fixing code that was working because a preview window told me it was not.