01
Averaging a room converges on grey-brown
The mean of every pixel is the wrong summary of a scene. Average enough hues together and the result tends to a desaturated grey-brown whatever the room is doing — a property of averaging over many hues, not a bug in any one implementation. What the page uses instead is the most populous quantized bucket that clears a chroma floor, so a grey wall with one lit lamp reports the lamp. The mean is still drawn, in the last figure above, purely so the two can be compared.
02
Night Shift predicts what this measures
Night Shift and f.lux warm a display toward evening from a clock and a location: a prediction of what the room is probably doing. This page measures it instead. The two are not opposed, and the product contains both — with no camera it falls back to exactly that method, warming from 6500K at solar noon to 2700K after dark, and says which one is running. The difference in kind is that a clock cannot tell a lamp from a window, or a bright office at nine in the evening from a dark one at noon. What it has in exchange is that it always works, asks no permission, and cannot be corrected out by the camera's own white balance — which, per 03, is the failure mode of measuring.
03
The camera corrects out the signal
Auto white balance exists to remove exactly the colour cast this page sets out to measure, and auto exposure does the same to brightness. Where the browser exposes manual control the page takes it; where it does not, which is most mobile browsers and Safari, the reading is relative to whatever the camera settled on at the start of the session rather than absolute. This is the largest single limit on the whole idea and it is not solvable from inside the page.
04
A still room still drifts
Sensor noise pushes pixels across quantization boundaries, so the winning bucket shifts slightly from frame to frame even when nothing in the room moves. Measured on a textured scene held still, the target wandered a mean step of 0.0002–0.0019 and a total spread of 0.0035–0.0067, in Oklab ΔE, across plausible noise amplitudes. Small, but above the threshold at which the filter would have come to rest, so it never did. The theme now holds its target until the room moves 0.01 ΔE away from it. Over 250 still samples the displayed colour changed on 80 frames before that, and 0 after.
05
Re-tuning a transition restarts it
The first version wrote the reading as a CSS target and re-tuned the transition's duration from the room on every sample. A 2200ms transition re-tuned every 200ms restarts eleven times before it can finish, and each restart re-eases from zero velocity. The result reads as jittery and lagging at the same time, which sounds contradictory and is not: it never stops starting, and it never arrives. The settle is now a filter on the sampling clock and CSS only bridges one sample to the next.
06
An exponential filter is distance-invariant
A first-order filter covers the same fraction of any gap in the same time, so it crosses a large distance proportionally faster than a small one. Perceived speed therefore scales with distance: small drifts read as considered, while a light switching off reads as abrupt, from one mechanism with one setting. A velocity cap of 0.22 ΔE per second stretches the large changes. It binds only while the remaining distance is wide, so the small changes that already felt right are untouched by construction.
07
Text is a pole, so it moves in one jump
Contrast picks black or white, never a mid-grey, because inverting a mid-grey yields another mid-grey. That makes the text colour a step function: it changes by the entire range at once, and it changes late, at the moment the background crosses 0.179 luminance — by which point the background is nearly settled and barely moving. Type and ground therefore appeared to run on different clocks. Giving the text its own filter only softens the jump. It now has no clock at all: it is a function of the background currently on screen, crossfaded across a narrow band, so the two are one motion.
08
Most SVG geometry cannot be transitioned
CSS animates cx, cy, r, and a rect's x, y, width and height. It does not animate a line's endpoints, a polyline's points, or a text element's position, and it fails silently rather than erroring. An earlier version of the diagrams transitioned all three: the marker dots eased while their own rules and labels jumped, in every browser. Every moving edge here is now a one-unit rect and every moving label sits in a translated group.
09
Reading the canvas is a stall, not a copy
Pulling pixels back off the GPU costs about the same for twelve pixels as for 1728, because the cost is the pipeline synchronising rather than the bytes moving. Shrinking the capture buys nothing, which is why it stays at 48×36 — small enough to be cheap to process, and no smaller, since smaller would be no cheaper to read.
10
A benchmark that lies about the canvas
Timing getImageData in a loop without redrawing between calls suggests that dropping the willReadFrequently hint makes reads 36× faster. It does not. An unredrawn surface serves a cached snapshot, so the loop measures nothing. Dirtied between reads, the way the page actually works, the hint wins by 2.3×. The wrong version of this measurement is the easy one to take.
11
The expensive part was the arithmetic, not the algorithm
Reading a frame cost 1.23ms and now costs 0.09ms, with every quantity unchanged. Three causes, none of them the method: a pow called three times per pixel where the domain is 256 discrete values and a table is exact; an object allocated per pixel to pass three numbers; and seven separate walks over the buffer, two of which were the same luminance pass run twice.
12
Tabular figures widen the space
In this typeface, turning on tabular figures also widens the space glyph, which blows out word spacing anywhere it touches running text. So tabular is applied only to columns of stacked numerals that have to align, never globally and never to a value containing a space — which rules it out for a string like an rgb() literal.
13 untested
The room barely uses the gamut
Expected, not tested here. Because the camera corrects the cast out (03), the colour that survives is close to neutral, and a wider display gamut would widen a range the measurement was never reaching. If that is right, the useful move is to amplify chroma while holding lightness and hue — at the cost that the rendered colour would no longer be the measured one, which is a trade this page has so far refused.
14
The camera is the demo, not the library
Packaging this as a dependency clarified which half is worth having. The measurement is pure — pixels in, theme out, no DOM, no permission — and a camera is only one way to get pixels, and the most expensive one. A hero image, a video still, an upload or a cover art would all feed the identical function, ask nothing of the user, and work on every device. The camera is what makes the idea legible; it is not what makes it useful.
15 untested
A dark room reads the screen
Expected, not tested here. The front camera faces the person, so in an unlit room the brightest thing in front of it is the display itself. The reading would then partly describe the page's own output — a feedback loop, where the page warms, the reflection warms, and the page warms further. The settle filter and the target threshold both damp it, neither resolves it.
16 untested
Auto-exposure hunting is the likely remaining drift
Expected, not tested here. The threshold in 04 was set against simulated sensor noise. A real camera also hunts: auto exposure and auto white balance re-converge over seconds, which would produce swings both larger and slower than the noise it was sized for. If the page still drifts on real hardware, that is the first place to look, and the threshold rather than the mechanism is what would need raising. Settle times of 3200ms and 520ms would be unaffected.
17
A filter with no first sample charges you for the whole page load
The theme advances by real elapsed time, so that a settle takes the same number of seconds at any sample rate. The first sample of a run has nothing to measure elapsed time against, and measuring it against zero silently means “since the clock started” — every millisecond the page had been open while the permission prompt sat there. Several seconds is many time constants, so the filter covered 99% of the distance in one step: granting the camera snapped the page to the room rather than easing out of the fallback, which is the opposite of what a 3200ms settle promises. The same thing happened on every re-enable, because the teardown reset the motion series but not the sample clock. Both now charge one interval, 100ms.
The bug is ordinary. What is worth recording is that it survived four rounds of fixes to this exact loop, and was found within an hour of the loop being given an interface that could be called without a camera attached. It was never a hard bug — only an unreachable one.
18
A viewBox can quietly falsify a stylesheet
Every rule on this page is one pixel, and the stylesheet says so without qualification. One diagram had been drawing them at 1.6 for months. Each figure is scaled to its grid cell, so a diagram that declared a 200-unit coordinate space where the others declared 320 got a user unit 1.6 times larger — and stroke width and font size are both resolved in user units. Its hairlines and its tick labels were the wrong size against every other figure, from a number that appears nowhere near either.
Nothing could have caught it. The declaration is correct, the stylesheet is correct, and the two are only wrong together. What fixed it was not a check but a shared coordinate space: the eight diagrams now take their width from one constant, so the claim is a property of the system rather than of the care taken in each figure.
19
A wash can animate below the floor of what a screen can show
The field behind the page is painted in the reading’s own text colour at 4.5–7.5% strength, which is the range the rest of the system’s washes live in. Drifting it slowly enough to read as ambient rather than as an animation put it below the floor: over four seconds the composited pixel moved less than one 8-bit step almost everywhere, and two screenshots taken four seconds apart were byte-identical while the shader was demonstrably redrawing four times a second. Opacity and speed are not independent choices. Below a certain alpha there is a speed that is genuinely animating and genuinely invisible, and no amount of looking at the code shows it.
20
A luminosity blend deletes the scene that tests hue
Every image here is luminosity-blended, so imagery carries no colour of its own and the page keeps its promise that colour only ever enters as a measurement. One of the synthetic scenes rotates hue at fixed Oklab lightness and chroma, which is precisely the case a luminance measurement cannot see — and under that blend the tile is therefore almost perfectly still. The caption had promised a rotation the page is incapable of showing. The blend stayed and the caption changed: everything moving in that tile is exactly what the measurement beside it is blind to, which is a better demonstration than the one intended.