CALASTRA is a house that does not exist, on a cape that does. The page has one input — how far you have scrolled — and turns it into a time of day between 06:00 and 22:00. Everything else on the screen is computed from where the sun is at that moment: how bright the light is, what colour it is, how long a shadow it throws, how far it reaches into the arcade, when the lamps come up.
Nothing on that page is tuned by eye. Change the latitude and the whole thing relights.
Move it yourself
This is the same model, cut down to the part worth reading. Drag the hour and watch every number follow from one quantity — the sun's altitude.
The gnomon in the picture is one metre tall, so the shadow it throws is the number under 1 m casts — the same figure the real page prints beside its steps.
1 · The scroll is a clock
The only thing the page reads from the browser is a scroll offset. It maps to the hours of one day, and that hour is the single input to everything below.
const DAY_FROM = 6, DAY_TO = 22; // 06:00 to 22:00
function hourFromScroll(y, top, range) {
const p = Math.min(1, Math.max(0, (y - top) / range));
return DAY_FROM + p * (DAY_TO - DAY_FROM);
}
A reader who scrolls is already expecting something to move. Tying the movement to a physical quantity rather than to an animation timeline means they can stop anywhere and the page is still consistent with itself.
2 · The hour is a sun
Solar position from the NOAA equations: the fractional year gives the declination and the equation of time, the hour gives the hour angle, and the two produce altitude and azimuth. Roughly twenty lines, no dependencies.
function position(hour) {
const { eqTime, decl } = orbit(SITE.day); // NOAA, from the fractional year
const trueSolar = hour * 60 + eqTime + 4 * SITE.lon - 60 * SITE.tz;
const ha = (trueSolar / 4 - 180) * RAD; // hour angle
const lat = SITE.lat * RAD;
const cosZ = Math.sin(lat) * Math.sin(decl)
+ Math.cos(lat) * Math.cos(decl) * Math.cos(ha);
return { altitude: 90 - Math.acos(cosZ) / RAD, /* azimuth… */ };
}
This is the step you can check against something outside your own code, and you should. At 43.1907° N on 21 June the model puts sunrise at 06:17, solar noon at 13:39 with the sun 70.26° up, and sunset at 21:02. The theoretical maximum altitude at that latitude on that date is 90 − 43.19 + 23.44 = 70.25°. If your noon does not land on that number, the rest of the page is decoration.
3 · The altitude is an amount of air
Low sun means a long path through the atmosphere. Kasten & Young (1989) give the relative optical air mass, and that one number drives both the brightness and the colour.
const airMass = (alt) => alt <= 0 ? Infinity
: 1 / (Math.sin(alt * RAD) + 0.50572 * Math.pow(alt + 6.07995, -1.6364));
function illuminance(alt) { // lux on a horizontal surface
if (alt <= 0) return 400 * Math.pow((alt + 6) / 6, 3); // civil twilight
const m = airMass(alt);
return 128000 * Math.exp(-0.21 * m) * Math.sin(alt * RAD) // direct beam
+ 1000 * Math.pow(Math.sin(alt * RAD), 0.6); // sky
}
128 000 lx is the clear-sky beam at air mass zero. Everything after it is extinction: the sun at 16° through 3.5 air masses is not "dimmer sunlight", it is a different light.
4 · The air is a colour
The long path scatters the blue out first, which is the whole reason evening looks like evening. Map the air mass to a colour temperature, then put that temperature on the Planckian locus (Kim et al., 1999) and convert the chromaticity to sRGB.
const kelvin = (alt) => alt <= 0 ? 1900
: clamp(6500 - 1950 * Math.log(airMass(alt)), 1900, 6100);
// Planckian locus → xy → sRGB, normalised so the hue survives any temperature
const { x, y } = planckXY(kelvin(altitude));
element.style.background = xyToCss(x, y);
At 17:30 the model says 5 602 K; the page prints 5,600 K on the beam. That is not a colour anyone picked. It is what the equations return, and it is why the page never looks graded.
5 · The sun is a shadow
The last step is trigonometry, and it is the one readers believe fastest because they can check it against a real afternoon.
const shadow = (alt, height = 1) => height / Math.tan(alt * RAD);
// the arcade faces 208°, its openings are 3.2 m high, the colonnade is 4.1 m deep
function sunlitDepth(alt, az) {
const off = Math.abs(((az - 208 + 540) % 360) - 180); // 0 = straight on
if (off >= 90 || alt <= 0) return 0; // behind the wall
const reach = 3.2 / Math.tan(alt * RAD) * Math.cos(off * RAD);
return Math.min(reach, 4.1); // it stops at the back wall
}
At 07:42 the sun is 16.27° up and a one-metre stick throws 3.43 m. Under the arcade at the same hour the answer is 0.00 m — the light cannot get in at all until 14:12, because the openings face 208° and the sun is still in the east. A sentence like that is only worth printing if a function produced it.
What it costs
The interesting part of the bill: the physics is the cheapest thing on the page.
| Part | Size | Note |
|---|---|---|
The whole model (js/sun.js) | 7.4 KB | 168 lines, no dependencies |
| The page | 56.6 KB | one self-contained HTML file |
| The films | 96 MB | nine clips, cross-faded by the hour |
One full sample — position, air mass, illuminance, colour, shadow, lamps, the lot — measured at 0.56 µs on an M1 Pro. That is about 28 600 complete recomputations inside a single 16 ms frame. The model is never what makes a page like this heavy; the video is.
Because a sample is that cheap, there is no reason to cache it, tween it or run it on a worker. Recompute the whole state from the scroll position every frame and the page can never drift out of agreement with itself.
What went wrong
The mechanism above was finished and correct for two days before anyone noticed the page was dead.
The hero and each chapter scrub a <video> by scroll — the same scroll that moves
the sun. Chrome will not seek a video served over HTTP unless the server answers Range
requests. It does not throw. video.currentTime = t is silently ignored,
video.seekable is empty, and the element sits on frame 0 for ever. The numbers kept
moving, the light kept changing, and the films looked like carefully chosen stills.
It survived every visual check, twice — once on the development server and once inside the capture
pipeline, which had its own SimpleHTTPRequestHandler. A 576-frame take (45 minutes) and a
4K render (17 minutes) were produced with frozen films before it was caught. Measured on the same
three frames after the fix: the mean pixel difference between two frames of one section went from
5 to 43, and the share of pixels that changed went from 22 % to 82 %.
// the page's own readiness check now refuses to start without it
const dead = [...document.querySelectorAll('video')].filter(
(v) => !v.seekable.length || v.seekable.end(0) < v.duration - 0.2);
if (dead.length) throw new Error(
'films cannot seek — the server must answer HTTP Range: '
+ dead.map((v) => v.currentSrc.split('/').pop()).join(' '));
A scrubbed page that is broken still looks plausible. Any check that cannot tell a still from a frozen video will pass it. Diff two frames from inside the section before you touch the choreography.
See it running
The page itself is on this site. Scroll it slowly: the clock in the corner is the only thing telling you what the input is, and everything else is downstream of it.
Solar position: NOAA Global Monitoring Laboratory solar calculator equations. Air mass: Kasten & Young, Revised optical air mass tables and approximation formula, Applied Optics 28 (1989). Planckian locus: Kim et al., Design of advanced color temperature control system for HDTV applications (1999).