1
0

fix: the sky meets the sea, and the places rail admits it scrolls

Two defects a photograph found and no gate could.

**The sky seam.** A phone held upright drew a hard horizontal step across the
top 10.5% of the frame — a flat duller slab above a warm gradient, meeting in
one pixel. Painting the sky dome red proved the slab was the dome itself and
everything below the line was sea; so this was never missing geometry, it was
two halves of one join disagreeing about a colour.

The sea is a flat plane, so however wide it is made the camera's own far plane
cuts it at an elevation *below* the horizontal — about 12 degrees in a plan
view of the state. atmosphere.ts has fogged it to exactly the horizon colour by
then, which is the entire mechanism by which water dissolves into sky instead
of ending in an edge. The dome at that same angle has to be the same number,
and it was not: the below-horizon darkening ramped to its full 0.82 over the
first 11.5 degrees. The sea arrived at the horizon colour and met a sky already
a fifth darker. Measured 155,107,99 against 127,88,81 across one pixel.

Holding the horizon's own colour for the first 30 degrees costs nothing the
darkening was for — a floor or an ocean covers that band whenever there is one,
and where there is not, matching is the whole point. Measured after: the worst
single-row step anywhere in the sky falls from 15/255 to 8, and the 8 is water
texture rather than a line.

render/skyHorizonSeam.test.ts parses the shader text, which is a weaker
instrument than a frame and the strongest one available with no GL context. It
asserts the one number the picture proved wrong — how far down the dome still
paints the horizon colour — and it caught a real off-by-a-hair while being
written: 0.42 is sin(24.8 deg), just inside the 25 deg the sea can reach. It
also guards the backtick-in-a-template-literal trap that has now broken this
same shader three times.

**The places rail.** Its cap is min(22rem, 42vh) and rows are variable height,
so it always landed mid-row, and a row sliced through its own text reads as a
button that failed to draw rather than as more list. markOverflow writes
data-overflow and the stylesheet fades whichever end has list behind it.

Every measurement in it is guarded, because ui/mount.ts is deliberately tested
with no DOM at all: no geometry must mean no attribute, not data-overflow=NaN.
A pixel of slack at each end, because fractional layout reports a fully-scrolled
list a fraction short and a fade that never switches off dims a row over
nothing. stylesheet.test.ts asserts the two files still speak the same four
words — nothing else connects them and the failure is silent.

1,664 + 295 tests, all gates, budget 779,789 tri / 150 draws / p95 16.7 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 06:42:10 -07:00
parent 4bd8481be1
commit 2ff11a385e
6 changed files with 318 additions and 5 deletions
+19
View File
@@ -438,6 +438,25 @@
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
} }
/* The clipped end fades, so a row the cap slices through reads as the
edge of a scroll rather than as a button that failed to draw. Set from
`markOverflow` in ui/mount.ts on rebuild and on the rail's own scroll;
with no attribute (no layout measured yet) there is no mask at all,
which is the correct answer for a list that does not overflow. */
.places[data-overflow="bottom"] {
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2.25rem), transparent 100%);
mask-image: linear-gradient(to bottom, #000 calc(100% - 2.25rem), transparent 100%);
}
.places[data-overflow="top"] {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 2.25rem);
mask-image: linear-gradient(to bottom, transparent 0, #000 2.25rem);
}
.places[data-overflow="both"] {
-webkit-mask-image: linear-gradient(
to bottom, transparent 0, #000 2.25rem, #000 calc(100% - 2.25rem), transparent 100%);
mask-image: linear-gradient(
to bottom, transparent 0, #000 2.25rem, #000 calc(100% - 2.25rem), transparent 100%);
}
.places__region { .places__region {
margin: var(--s2) 0 var(--s1); margin: var(--s2) 0 var(--s1);
font-size: 9px; font-size: 9px;
+25 -4
View File
@@ -864,10 +864,31 @@ void main() {
*/ */
vec3 color = mix( uHorizon, uTop, pow( clamp( height, 0.0, 1.0 ), 0.42 ) ); vec3 color = mix( uHorizon, uTop, pow( clamp( height, 0.0, 1.0 ), 0.42 ) );
// Below the horizon there is no sky. The ocean covers this in the city and /*
// the ground plane covers it in a sited office, so what it has to be is * Below the horizon there is no sky, and what is painted there has one job:
// simply *not brighter* than the horizon it sits under. * be *not brighter* than the horizon it sits under, for the office floors and
color = mix( color, uHorizon * 0.82, clamp( - height * 5.0, 0.0, 1.0 ) ); * ocean that normally cover it.
*
* The band immediately under the horizontal is the exception, and it is the
* only part of this a board ever shows. The sea is a flat plane, so however
* wide it is made the camera's own far plane cuts it off at an elevation some
* degrees *below* the horizontal — around 12 degrees in a plan view of the
* state. atmosphere.ts fogs it to exactly the horizon colour by the time it
* gets there, which is what makes the water dissolve into sky rather than end
* in an edge. So whatever the dome paints at that same angle is the other half
* of that join, and it has to be the same number.
*
* It was not: the darkening used to ramp in over the first 11.5 degrees, so
* the sea arrived at the horizon colour and met a sky already at 0.82 of it.
* That is a hard step of a fifth of the brightness, drawn all the way across
* the frame — 1.7% of a desktop window and **10.5% of a phone held upright**,
* where the camera pitches down far enough to put the whole band in shot.
*
* Holding the horizon's own colour for the first 30 degrees costs nothing that
* the darkening was for: a floor or an ocean covers that band whenever there
* is one, and where there is not, matching is the entire point.
*/
color = mix( color, uHorizon * 0.82, smoothstep( 0.50, 0.98, - height ) );
/* /*
* Two glows, one warm quarter of sky. * Two glows, one warm quarter of sky.
+110
View File
@@ -0,0 +1,110 @@
/**
* The join between the sea and the sky, asserted where it is decided.
*
* ## The defect
*
* A phone held upright, on the California board, drew a hard horizontal step
* across the top **10.5%** of the frame: a flat, duller slab above a warm
* gradient, meeting in one pixel. It survived every gate in the repo. It is a
* shader constant, so no typecheck sees it; nothing here compiles GLSL, so no
* test saw it; and it is above the terrain, so the performance budget and the
* pixel-regression baselines were both unmoved.
*
* ## What causes it
*
* The sea is a flat plane. However wide it is made — and `SEA_SPAN_MULTIPLE`
* puts its rim nine board spans out precisely so it never ends in a visible
* edge — a camera standing off above it has its own **far plane** cut the water
* at an elevation some degrees *below* the horizontal. In a plan view of the
* state that angle is around twelve degrees. `atmosphere.ts` has fogged the
* water to exactly the horizon colour by the time it gets there, which is the
* whole mechanism by which water dissolves into sky instead of ending.
*
* So the dome, at that same angle, is the other half of that join. It has to be
* painting the same number. It was not: the below-horizon darkening ramped to
* its full 0.82 over the first 11.5 degrees, so the sea arrived at the horizon
* colour and met a sky already a fifth darker.
*
* ## What is asserted
*
* The shader's own text, parsed. That is a weaker instrument than a rendered
* frame and it is the strongest one available without a GL context, so what it
* checks is the one number a picture proved wrong: how far below the horizontal
* the dome still paints the horizon's own colour. A future edit is free to
* change the curve, the depth, or the colour — it is not free to start
* darkening inside the band where the sea is still being drawn.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
const source = readFileSync(new URL("../../engine/scenekit.ts", import.meta.url), "utf8");
/**
* The lowest elevation, as a sine, at which a board can still be drawing sea.
*
* The camera may retreat two spans from the scene origin, and the sea's rim is
* nine spans out, so the shallowest sight-line to the far water is about
* `atan(2 / 9)` = 12.5 degrees below the horizontal. Rounded out to 25 degrees,
* because the number that matters is the one a *fog* far plane produces rather
* than the geometry's rim, and the far plane moves with the stand-off.
*/
const SEA_MAY_REACH_SINE = Math.sin((25 * Math.PI) / 180);
describe("the sky below the horizon", () => {
/** The one line that decides it, isolated so a failure names the right thing. */
const line = /color = mix\( color, uHorizon \* [\d.]+, ([^;]+)\);/.exec(source)?.[1]?.trim();
it("still exists and is still a blend toward a darker horizon", () => {
assert.notEqual(line, undefined, "the below-horizon term is gone from the sky shader");
const factor = Number(/uHorizon \* ([\d.]+)/.exec(source)?.[1]);
assert.ok(factor > 0 && factor <= 1, `below-horizon sky must not be brighter: ${factor}`);
});
it("holds the horizon's own colour through every angle the sea can reach", () => {
const edges = /smoothstep\(\s*([\d.]+),\s*([\d.]+),\s*- ?height\s*\)/.exec(line ?? "");
assert.notEqual(
edges,
null,
`the below-horizon ramp is no longer a smoothstep in -height: ${line}`,
);
const start = Number(edges![1]);
assert.ok(
start > SEA_MAY_REACH_SINE,
`the sky starts darkening ${((Math.asin(start) * 180) / Math.PI).toFixed(1)}deg below ` +
`the horizontal, inside the ${((Math.asin(SEA_MAY_REACH_SINE) * 180) / Math.PI).toFixed(0)}deg ` +
"band where the sea is still drawn — that is the seam",
);
});
/**
* The fix it replaced, named so it cannot be reintroduced as a tidy-up. A
* `clamp(-height * k, 0, 1)` is a perfectly ordinary way to write this and it
* is the exact shape that shipped the seam: at any k large enough to look
* like a horizon it is fully dark before the sea has finished.
*/
it("does not go back to a clamp that saturates in the first few degrees", () => {
assert.ok(
!/clamp\(\s*- ?height \* [\d.]+/.test(source),
"the below-horizon term is a clamp again; see this suite's header",
);
});
});
/**
* Not a shader assertion, and here because this file is where the trap is
* documented: the sky shader is a **template literal**, and a backtick anywhere
* inside it — including in a comment quoting a filename — terminates it and
* turns the rest of the GLSL into JavaScript. It has cost this repo three
* builds. `tsc` does catch it, so this is a faster and clearer message rather
* than a new guarantee.
*/
describe("the sky shader's source text", () => {
it("carries no backtick inside the shader template literals", () => {
const shaders = [...source.matchAll(/(vertexShader|fragmentShader): `([\s\S]*?)`,\n/g)];
assert.ok(shaders.length >= 2, "the sky shaders are no longer inline template literals");
for (const [, which, body] of shaders) {
assert.ok(!(body ?? "").includes("`"), `a backtick inside ${which} ends the literal early`);
}
});
});
+66 -1
View File
@@ -6,7 +6,7 @@ import type { DeviceCommand, DeviceDeclaration } from "../../devices/types.ts";
import type { ControlMode } from "../../play/controlMode.ts"; import type { ControlMode } from "../../play/controlMode.ts";
import { chromeState } from "../../ui/chromeState.ts"; import { chromeState } from "../../ui/chromeState.ts";
import type { ChromeInputs } from "../../ui/chromeState.ts"; import type { ChromeInputs } from "../../ui/chromeState.ts";
import { mountChrome } from "../../ui/mount.ts"; import { markOverflow, mountChrome } from "../../ui/mount.ts";
import type { ChromeMountOptions } from "../../ui/mount.ts"; import type { ChromeMountOptions } from "../../ui/mount.ts";
import { FakeDocument, type FakeElement, el, fakeStorage } from "./fakeDom.ts"; import { FakeDocument, type FakeElement, el, fakeStorage } from "./fakeDom.ts";
@@ -515,3 +515,68 @@ describe("mountChrome", () => {
chrome.dispose(); chrome.dispose();
}); });
}); });
describe("the places rail's overflow edge", () => {
/** The bare minimum a real element offers: three numbers and an attribute bag. */
function rail(scrollTop: number, clientHeight: number, scrollHeight: number) {
const attributes = new Map<string, string>();
return {
scrollTop,
clientHeight,
scrollHeight,
attributes,
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
};
}
function edgeOf(scrollTop: number, clientHeight: number, scrollHeight: number) {
const node = rail(scrollTop, clientHeight, scrollHeight);
markOverflow(node as unknown as HTMLElement);
return node.attributes.get("data-overflow");
}
it("says nothing is clipped when the list fits", () => {
assert.equal(edgeOf(0, 400, 400), "none");
});
it("fades the bottom when there are rows below the cap", () => {
assert.equal(edgeOf(0, 352, 900), "bottom");
});
it("fades the top once the reader has scrolled past the first row", () => {
assert.equal(edgeOf(548, 352, 900), "top");
});
it("fades both ends in the middle of a long list", () => {
assert.equal(edgeOf(200, 352, 900), "both");
});
/**
* The bug this guards: fractional layout means a list scrolled fully down
* reports a scrollTop a fraction of a pixel short, and a `<` with no slack
* leaves the bottom permanently dimmed over nothing.
*/
it("treats a sub-pixel shortfall at either end as arrival", () => {
assert.equal(edgeOf(0.4, 352, 352.4), "none");
assert.equal(edgeOf(547.6, 352.2, 900), "top");
});
/**
* This module is tested with no DOM, and half the ways of running it — the
* `FakeElement` harness, a node without layout, an element measured before
* first paint — have no geometry to report. That must set no attribute at
* all, rather than `data-overflow="NaN"` matching no rule and masking nothing
* while looking like a live value in the inspector.
*/
it("sets nothing at all when there is no geometry to read", () => {
const node = rail(Number.NaN, Number.NaN, Number.NaN);
markOverflow(node as unknown as HTMLElement);
assert.equal(node.attributes.has("data-overflow"), false);
const bare = { setAttribute() {} } as unknown as HTMLElement;
assert.doesNotThrow(() => markOverflow(bare));
assert.doesNotThrow(() => markOverflow(null));
});
});
+48
View File
@@ -233,3 +233,51 @@ describe("the stylesheet", () => {
assert.ok(DEVICE_PANEL_CSS.includes("@media (prefers-reduced-motion: reduce)")); assert.ok(DEVICE_PANEL_CSS.includes("@media (prefers-reduced-motion: reduce)"));
}); });
}); });
/**
* The places rail's fade is a two-file mechanism: `markOverflow` in
* `ui/mount.ts` writes `data-overflow`, and the stylesheet decides what that
* looks like. Nothing else connects them, so either half can be renamed without
* the other noticing and the failure is silent — a list that clips a row and
* offers no sign it scrolls, which is exactly the fault the fade was added to
* fix. This asserts the two halves still speak the same four words.
*/
describe("the places rail's overflow fade", () => {
const mount = read("src/ui/mount.ts");
/** Every value `markOverflow` can assign, read out of the code itself. */
const emitted = ["both", "top", "bottom", "none"];
it("emits exactly the values this test knows about", () => {
const line = /const edge =[\s\S]*?;/.exec(mount)?.[0] ?? "";
assert.notEqual(line, "", "markOverflow no longer computes an `edge`");
for (const value of emitted) {
assert.ok(line.includes(`"${value}"`), `markOverflow stopped emitting ${value}`);
}
const quoted = [...line.matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
assert.deepEqual(new Set(quoted), new Set(emitted), "markOverflow emits a new value");
});
it("masks every clipped state and leaves the unclipped one alone", () => {
for (const value of ["top", "bottom", "both"]) {
assert.ok(
css.includes(`.places[data-overflow="${value}"]`),
`no fade rule for data-overflow="${value}"`,
);
}
assert.ok(
!css.includes('.places[data-overflow="none"]'),
"a list that is not clipped must not be masked",
);
});
it("keeps the unprefixed mask alongside the -webkit- one", () => {
const rules = css.split(".places[data-overflow=").slice(1);
assert.equal(rules.length, 3);
for (const rule of rules) {
const body = rule.slice(0, rule.indexOf("}"));
assert.ok(body.includes("-webkit-mask-image:"), "missing the prefixed mask");
assert.ok(/[^-]mask-image:/.test(body), "missing the standard mask");
}
});
});
+50
View File
@@ -158,6 +158,47 @@ const TOUCH_EDGES: Readonly<Record<string, PlayEdgeControl>> = {
* Bind the chrome to a document (or to any subtree, which is what makes this * Bind the chrome to a document (or to any subtree, which is what makes this
* testable against a fake DOM) and return the applier. * testable against a fake DOM) and return the applier.
*/ */
/**
* Tell the places rail whether it is clipping rows, and which end.
*
* The rail is capped at `min(22rem, 42vh)` because twenty-four rungs is taller
* than a phone. A cap on a list of variable-height rows lands mid-row, and a
* row sliced through its own text reads as a rendering fault rather than as an
* invitation to scroll. The stylesheet answers that with a fade at whichever
* end has more list behind it, so the half-row is visibly the *edge of a
* scroll* and not a broken button.
*
* Every measurement is guarded, because this module is deliberately tested with
* no DOM at all — `ui/mount.test.ts` drives it through a `FakeElement` that has
* no layout and therefore no `scrollHeight`. A missing number means "nothing is
* known about the geometry", which must produce no attribute rather than
* `data-overflow="NaN"`.
*/
export function markOverflow(el: HTMLElement | null): void {
if (el === null) return;
const { scrollTop, clientHeight, scrollHeight } = el as unknown as Record<string, unknown>;
if (
typeof scrollTop !== "number" ||
typeof clientHeight !== "number" ||
typeof scrollHeight !== "number" ||
!Number.isFinite(scrollTop) ||
!Number.isFinite(clientHeight) ||
!Number.isFinite(scrollHeight)
) {
return;
}
// A pixel of slack. Fractional layout means a list scrolled fully to the
// bottom routinely reports scrollTop + clientHeight one part in a thousand
// short of scrollHeight, and a fade that never switches off is a fade that
// permanently dims a row nothing is hiding.
const SLACK = 1;
const above = scrollTop > SLACK;
const below = scrollTop + clientHeight < scrollHeight - SLACK;
const edge = above && below ? "both" : above ? "top" : below ? "bottom" : "none";
el.setAttribute?.("data-overflow", edge);
}
export function mountChrome( export function mountChrome(
root: Document | HTMLElement, root: Document | HTMLElement,
handlers: ChromeMountOptions, handlers: ChromeMountOptions,
@@ -277,6 +318,13 @@ export function mountChrome(
// ---- Static wiring ----------------------------------------------------- // ---- Static wiring -----------------------------------------------------
// The rail's own scroll is the other thing that moves its edges. `apply`
// cannot see it: the list is rebuilt only when a rung's identity or highlight
// changes, and a reader dragging the column changes neither.
on(placeNav, "scroll", () => {
markOverflow(placeNav as HTMLElement);
});
on(panelToggle, "click", () => { on(panelToggle, "click", () => {
handlers.onTogglePanel?.(!(latest?.panelOpen ?? true)); handlers.onTogglePanel?.(!(latest?.panelOpen ?? true));
}); });
@@ -592,6 +640,7 @@ export function mountChrome(
rows.push(button); rows.push(button);
} }
placeNav.replaceChildren(...rows); placeNav.replaceChildren(...rows);
markOverflow(placeNav as HTMLElement);
} }
/** /**
@@ -616,6 +665,7 @@ export function mountChrome(
(child) => child.getAttribute?.("data-place") === litPlace.key, (child) => child.getAttribute?.("data-place") === litPlace.key,
); );
(row as HTMLElement | undefined)?.scrollIntoView?.({ block: "nearest" }); (row as HTMLElement | undefined)?.scrollIntoView?.({ block: "nearest" });
markOverflow(placeNav as HTMLElement);
} }
setHidden(chapterSection, !state.chaptersVisible); setHidden(chapterSection, !state.chaptersVisible);