1
0

A plan view in the corner, a night you can actually see, and three kinds of visitor

The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:53:30 -07:00
parent 47faec9f9d
commit 5bc7258753
24 changed files with 3982 additions and 211 deletions
+209 -60
View File
@@ -400,6 +400,16 @@ export const DEFAULT_MOONLIGHT: MoonlightOptions = {
// night on a screen someone is looking at in a lit room, not the photon
// count. At the honest value the map is a black rectangle, which is the bug
// this exists to fix.
//
// Left where it was when the moonless floor came up under it, and that is a
// decision rather than an oversight. The key is already three quarters of the
// light in a full-moon frame, so raising it to keep the gap would have been
// raising the one term that is nearest to overshooting into a blue-graded day;
// the gap is defended in `applyNight` instead, on the fill, where there was
// room. What did have to be checked is that moonrise is still an *event* —
// see the sanity checks at the foot of this file, where a full moon 43° up is
// seven times the key of a moonless night and better than twice its ground
// luminance on screen.
intensity: 1.15,
color: 0x9db4e8,
skyLift: 0.85,
@@ -484,11 +494,71 @@ const DEFAULT_SHADOW_FLOOR_DEG = 7;
const NIGHT_FLOOR_TOP = 0x090e1c;
const NIGHT_FLOOR_HORIZON = 0x16203a;
/** The same sky with a full moon in it. */
/**
* The same floor, for the light that lands on the ground.
*
* `NIGHT_FLOOR_TOP` above makes the argument for the sky and then only fixes
* the sky, which is exactly half the job — and the half that hides the other
* half, because a lifted sky behind a black landmass reads as a working render
* of an empty ocean. What it was actually producing at -18° was terrain at
* #000004 under a sky at #16203a: the coastline gone, the hills gone, water and
* land the same colour, and nothing left in the frame but the lit windows and
* the freeway threads floating in it.
*
* A photometrically correct answer here really is close to zero. A moonless
* night is a few thousandths of a lux of airglow and starlight, against a hundred
* thousand at noon, and any honest ratio lands under one code value. But three
* things make zero the wrong number to render:
*
* - **The display has no room underneath.** Everything from 1/1000 of white
* down to nothing shares the bottom two or three code values of an 8-bit
* sRGB ramp. A correctly exposed night does not come out dim, it comes out
* quantised to black, and no amount of squinting recovers a coastline that
* was rounded to #000.
* - **Nobody is dark-adapted.** The eye that can read a moonless landscape has
* spent forty minutes getting there. The eye looking at this has a lit room
* behind it and a white browser chrome around it, and its black point is
* several stops above the screen's.
* - **This is a map.** It is looked at from eighty kilometres up, from outside
* the atmosphere it is depicting, by someone who wants to know where the bay
* is. A view that goes correctly blank at 3 a.m. is not a night mode, it is
* an outage — and it is reported as one.
*
* So these are the same kind of lie as `DEFAULT_MOONLIGHT.intensity`: not the
* light there is, but the light a moonless night *looks like* it has once you
* are standing in it. Held deliberately low enough that the city's own lit
* windows stay the brightest thing in the frame by a factor of four or five,
* which is the one relationship that makes it read as night rather than as a
* blue-graded day.
*
* Split five ways rather than folded into one brightness because the *ratio*
* between them is what stops the result looking like fog. Ambient is
* unshaped — every surface gets the same number whichever way it faces — so a
* night lit by ambient alone is flat, and flat and dark is fog, not darkness.
* The hemisphere carries most of it instead, sky term well above ground term, so
* a roof is lighter than a wall; and the keyframe table's token sidelight
* survives at full strength on a moonless night (see `applyNight`) so the hills
* still have a lit side and a dark one.
*/
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
const NIGHT_FLOOR_AMBIENT = 0x47557f;
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
/**
* The same sky, and the same fill, with a full moon in it.
*
* These moved up when the floor did, and they had to: a floor raised to meet the
* moon has deleted the moon, and `moonPosition` is four hundred lines of Meeus
* that would then be decorative. The gap is the point — a moonlit night has to
* arrive as an event, four to five times the moonless floor in linear light, and
* with a *direction* in it that the floor by construction does not have.
*/
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x2b3b60;
const MOONLIT_AMBIENT = 0x3f4c76;
const MOONLIT_HEMI_SKY = 0x40597f;
const MOONLIT_AMBIENT = 0x546490;
/**
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
@@ -557,40 +627,52 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
// either; it is a token sidelight standing in for moonlight and city
// glow, because a scene lit by hemisphere alone has no silhouettes in it
// and reads as a bug rather than as darkness.
//
// The three night stops used to be an order of magnitude below this, and
// the reason they were wrong is instructive: they were read off a
// photograph of a night sky, which is a picture of the *sky* and says
// nothing about the ground under it. Multiplied out, `hemiSky` at 0x121a30
// times 0.25 came to about four thousandths of the fill at noon, which is
// roughly honest and rendered San Francisco as #000004. What is here now
// is the ground reading the eye reports — the coastline findable, the
// hills with a lit side, the bay darker than the land around it — with the
// sky stops left where they were, because those were never the problem.
// See `NIGHT_FLOOR_HEMI_SKY`, which is what actually holds this up: these
// rows sit just under the floor and the floor is what binds.
elevation: -18,
skyTop: 0x05070f,
skyHorizon: 0x0b1120,
sunColor: 0x2e3c66,
sunIntensity: 0.05,
hemiSky: 0x121a30,
hemiGround: 0x080a10,
hemiIntensity: 0.25,
ambientColor: 0x28304a,
ambientIntensity: 0.1,
sunColor: 0x44558a,
sunIntensity: 0.16,
hemiSky: 0x2f447e,
hemiGround: 0x1b2234,
hemiIntensity: 0.55,
ambientColor: 0x414e78,
ambientIntensity: 0.16,
},
{
elevation: -12,
skyTop: 0x080d1e,
skyHorizon: 0x141d38,
sunColor: 0x3d4a76,
sunIntensity: 0.07,
hemiSky: 0x18223c,
hemiGround: 0x0a0d16,
hemiIntensity: 0.28,
ambientColor: 0x2c3552,
ambientIntensity: 0.11,
sunColor: 0x51629b,
sunIntensity: 0.19,
hemiSky: 0x32477d,
hemiGround: 0x1d2437,
hemiIntensity: 0.57,
ambientColor: 0x424f7a,
ambientIntensity: 0.17,
},
{
elevation: -6,
skyTop: 0x101a3a,
skyHorizon: 0x2b3560,
sunColor: 0x5b5d8e,
sunIntensity: 0.12,
hemiSky: 0x22304f,
hemiGround: 0x121520,
hemiIntensity: 0.35,
ambientColor: 0x38406a,
ambientIntensity: 0.14,
sunColor: 0x66699a,
sunIntensity: 0.26,
hemiSky: 0x3c558c,
hemiGround: 0x23293c,
hemiIntensity: 0.6,
ambientColor: 0x485389,
ambientIntensity: 0.19,
},
{
// The sun on the horizon. Warm at the bottom, cold at the top, and the
@@ -766,7 +848,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
}
fogFar = Math.max(fogFar, floorFar);
const fogColor = applyObscuration(rig, obscuration, day, condition);
const fogColor = applyObscuration(rig, obscuration, day, night, condition);
// A clear day keeps the near plane it was given; anything shorter holds the
// ratio instead, because fog that starts where the clear day's did and ends
@@ -943,15 +1025,25 @@ function applyNight(
// silhouettes in it and reads as a bug. There is a real moon now, so the
// stand-in gets out of its way — not entirely, because something still has
// to hold the shape of the city up on an overcast night at new moon.
rig.sunIntensity *= 1 - 0.85 * night;
//
// Weighted by how much moon there actually is, and not, as it was, by how
// much night there is. Those are the same number only on the nights the
// moon happens to be up, and the difference is the whole bug: `moonRig`
// correctly returns nothing for a moon below the horizon, so on the common
// case — which is most of every month, and *every* night before moonrise —
// this line was removing 85% of the only directional light in the scene in
// favour of a moon that was not there. The hills lost their lit side and
// the frame went flat, on exactly the nights that needed the stand-in most.
const present = clamp(moon.glow, 0, 1);
rig.sunIntensity *= 1 - 0.85 * night * present;
const glow = clamp(moon.glow * options.skyLift, 0, 1);
rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
rig.hemiIntensity *= 1 + 0.5 * glow;
rig.ambientIntensity *= 1 + 0.45 * glow;
rig.hemiIntensity *= 1 + 0.7 * glow;
rig.ambientIntensity *= 1 + 0.6 * glow;
}
// Starlight, airglow, and the sodium of everywhere else bouncing off whatever
@@ -965,6 +1057,32 @@ function applyNight(
// the frame without having to know this ran.
rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);
// And the same again for the light that lands on the ground, which is the
// half the sky floor above was always missing. Colours per channel through
// `atLeast` for the reason that function documents — the floor is a blue, not
// a brightness — and the two intensities by plain maximum, because both terms
// are colour *times* intensity and flooring only one of them can be undone by
// the other. Both ramp in on `night`, so this is a floor that arrives through
// civil twilight rather than a step that switches on at some elevation.
//
// A moonlit night is already well above all five of these and passes through
// untouched, which is the whole reason the moon's own lift went up when this
// went in. See `MOONLIT_HEMI_SKY`.
rig.hemiSky = mixHex(rig.hemiSky, atLeast(rig.hemiSky, NIGHT_FLOOR_HEMI_SKY), night);
rig.hemiGround = mixHex(rig.hemiGround, atLeast(rig.hemiGround, NIGHT_FLOOR_HEMI_GROUND), night);
const ambientFloor = atLeast(rig.ambientColor, NIGHT_FLOOR_AMBIENT);
rig.ambientColor = mixHex(rig.ambientColor, ambientFloor, night);
rig.hemiIntensity = lerp(
rig.hemiIntensity,
Math.max(rig.hemiIntensity, NIGHT_FLOOR_HEMI_INTENSITY),
night,
);
rig.ambientIntensity = lerp(
rig.ambientIntensity,
Math.max(rig.ambientIntensity, NIGHT_FLOOR_AMBIENT_INTENSITY),
night,
);
}
/**
@@ -1215,11 +1333,27 @@ function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null)
* `cityDaylight()` commits to and the physically honest answer — distant haze
* is lit by the sky it sits in front of, so it goes warm at sunset along with
* everything else rather than staying a neutral grey.
*
* **The convergence is a daytime effect and only a daytime effect**, which is
* what `night` is for. Fog is bright because the sun is in it, so pulling the
* fill toward the fog colour is a *brightening* and a flattening — the two
* things that make an overcast noon look like an overcast noon. Run the same
* mixes at 3 a.m., where the fog colour is a near-black derived from a night
* sky, and they are pure subtraction: San Francisco's August marine layer is at
* its thickest at three in the morning, and it was quietly taking 63% of the
* ground fill and all of its colour straight back out again — after
* `applyNight` had finished, so the night floor could not see it happen and had
* no chance to defend the frame. Which is a fair description of what a fog does
* to a photograph and a terrible description of what it does to a city, where
* the deck is lit from *underneath* by everything that is still switched on.
* The sky still converges at night; it should, since a foggy night has no stars
* in it. The ground no longer does.
*/
function applyObscuration(
rig: Rig,
obscuration: number,
day: number,
night: number,
condition: SkyCondition,
): number {
let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day);
@@ -1233,10 +1367,11 @@ function applyObscuration(
rig.sunIntensity *= 1 - 0.88 * obscuration;
rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration);
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration);
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration);
const lit = 1 - night;
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration * lit);
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration * lit);
rig.hemiIntensity *= 1 + 0.12 * obscuration * day;
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration);
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration * lit);
rig.ambientIntensity *= 1 + 0.45 * obscuration * day;
return fogColor;
@@ -1387,12 +1522,14 @@ function wrapSigned(x: number, period: number): number {
* back a clear golden morning or it is not a model of anything — October is
* the month San Francisco is warm and cloudless and every visitor is
* surprised by it.
* - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
* ambient 0.12, and the light direction's `y` pinned at 0.122, which is
* sin 7° — the shadow floor, keeping what is left of the token night
* sidelight from shining up through the ground. The key is a thousandth
* - **03:00 PDT** (-23.7°, with the moon down): key 0.045, hemisphere 0.78,
* ambient 0.22, and the light direction's `y` pinned at 0.122, which is
* sin 7° — the shadow floor, keeping the token night sidelight from shining
* up through the ground. The hemisphere and the ambient are the night
* floor's own two numbers to the digit, which is the floor doing exactly the
* job it is there for; the key is down to 0.045 from the table's 0.16
* because the marine layer is at its thickest at 3 a.m. in June and takes
* 88% of it; the sky is a fog grey rather than a night blue for the same
* 88% of it, and the sky is a fog grey rather than a night blue for the same
* reason, which is right — a foggy night has no stars in it either.
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
* the palette's own. Out of season, the layer is not there.
@@ -1416,42 +1553,54 @@ function wrapSigned(x: number, period: number): number {
* *comes from* when nobody was asked; it is not what makes obscuration look
* like anything.
*
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
* Tromsø on 5 January, at -2.98°, returns sun 0.37 against a twilight-blue sky
* and nothing non-finite anywhere, which is the polar-night path through
* `solar.ts` arriving here intact.
*
* **At night, San Francisco with no weather and no marine layer**, so that the
* moon can be read on its own:
*
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour
* #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A
* seventh of the sun's noon intensity against nearly half of its fill,
* which is a soft directional key with shadows you can find and not trip
* over — on a sky that is unmistakably night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008
* of the table's own sidelight colour, and the sky lands on #090e1c over
* #16203a — the floor, exactly. That is the darkest frame this file can
* produce, and it is the point: a genuinely correct night is `#000` and
* `#000` is a bug report.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of
* the full moon's key from half its disc and a tenth of its altitude,
* which is the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 1.174, colour
* #9cb3e6, hemisphere 1.12 of #3a5188, ambient 0.29 of #4d5c87, sky #101b39
* over #2a385b. Half the sun's noon intensity, which is a preposterous
* number and the one that puts a soft directional key on the city with
* shadows you can find and not trip over — on a sky that is unmistakably
* night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.160 of
* the table's own sidelight colour #44558a, hemisphere 0.78 of #354c88 over
* #1f2740, ambient 0.22 of #47557f, and the sky at #090e1c over #16203a.
* Every one of those five is the floor to the digit. That is the darkest
* frame this file can produce, and it is the point: a genuinely correct
* night is `#000` and `#000` is a bug report. Rendered, the Bay Area board
* comes out at about y23 on the land against y15 on the bay and y29 on the
* sky, with downtown's windows peaking past y130 — dark, but a dark you can
* find a coastline in.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.397. A third of the
* full moon's key from half its disc and a tenth of its altitude, which is
* the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.610 and the
* sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
* all of the colour, and August is when it would.
* - **The same night reported overcast**: key 0.032 — no shadows at all —
* all of the colour, and August is when it would. The *fill* it no longer
* takes; see `applyObscuration`.
* - **The same night reported overcast**: key 0.131 — no shadows at all —
* with the fill barely down, because a cloud deck over a full moon is a
* softbox rather than a lid.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050,
* colour #2e3c66, the table's token sidelight left in charge. The night
* floor under the sky still applies, because that one is not about the
* moon.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.160,
* colour #44558a, the table's token sidelight left in charge — which is now
* the same frame the moon-below-the-horizon case produces, and should be.
* Both floors still apply, because neither of them is about the moon.
*
* The gap between the second of those and the first is the one relationship
* this file is tuned around: seven times the key, and on screen a Bay Area board
* that goes from about y23 on the land to about y53. Moonrise is an event you
* can watch happen, which is the whole justification for `moonPosition` being
* four hundred lines of Meeus rather than a constant.
*
* The 28 August 2026 dusk is worth watching as a sequence, because it is the
* configuration `combineKey` exists for — a full moon rising as the sun sets,
* the two of them opposite each other in the sky. At sun +0.8° the key is 0.663
* and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between
* them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing
* the two of them opposite each other in the sky. At sun +0.5° the key is 0.592
* and #ce805b from the west; at -3.7° it is 0.328 and #8e6f87 from between
* them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing
* across the city over about half an hour, which is not an artefact — it is
* what actually happens, and on the one night a month it happens on.
*/