diff --git a/index.html b/index.html index 3a36c8c..8f790ab 100644 --- a/index.html +++ b/index.html @@ -438,6 +438,25 @@ overscroll-behavior: contain; 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 { margin: var(--s2) 0 var(--s1); font-size: 9px; diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts index 52e1cb4..69b9348 100644 --- a/src/engine/scenekit.ts +++ b/src/engine/scenekit.ts @@ -864,10 +864,31 @@ void main() { */ 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 - // simply *not brighter* than the horizon it sits under. - color = mix( color, uHorizon * 0.82, clamp( - height * 5.0, 0.0, 1.0 ) ); + /* + * Below the horizon there is no sky, and what is painted there has one job: + * be *not brighter* than the horizon it sits under, for the office floors and + * 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. diff --git a/src/test/render/skyHorizonSeam.test.ts b/src/test/render/skyHorizonSeam.test.ts new file mode 100644 index 0000000..3b06058 --- /dev/null +++ b/src/test/render/skyHorizonSeam.test.ts @@ -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`); + } + }); +}); diff --git a/src/test/ui/mount.test.ts b/src/test/ui/mount.test.ts index 16fe40a..c0ced87 100644 --- a/src/test/ui/mount.test.ts +++ b/src/test/ui/mount.test.ts @@ -6,7 +6,7 @@ import type { DeviceCommand, DeviceDeclaration } from "../../devices/types.ts"; import type { ControlMode } from "../../play/controlMode.ts"; import { chromeState } 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 { FakeDocument, type FakeElement, el, fakeStorage } from "./fakeDom.ts"; @@ -515,3 +515,68 @@ describe("mountChrome", () => { 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(); + 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)); + }); +}); diff --git a/src/test/ui/stylesheet.test.ts b/src/test/ui/stylesheet.test.ts index bd3de96..2dcd06a 100644 --- a/src/test/ui/stylesheet.test.ts +++ b/src/test/ui/stylesheet.test.ts @@ -233,3 +233,51 @@ describe("the stylesheet", () => { 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"); + } + }); +}); diff --git a/src/ui/mount.ts b/src/ui/mount.ts index 47b0ae2..1eda7e5 100644 --- a/src/ui/mount.ts +++ b/src/ui/mount.ts @@ -158,6 +158,47 @@ const TOUCH_EDGES: Readonly> = { * Bind the chrome to a document (or to any subtree, which is what makes this * 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; + 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( root: Document | HTMLElement, handlers: ChromeMountOptions, @@ -277,6 +318,13 @@ export function mountChrome( // ---- 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", () => { handlers.onTogglePanel?.(!(latest?.panelOpen ?? true)); }); @@ -592,6 +640,7 @@ export function mountChrome( rows.push(button); } placeNav.replaceChildren(...rows); + markOverflow(placeNav as HTMLElement); } /** @@ -616,6 +665,7 @@ export function mountChrome( (child) => child.getAttribute?.("data-place") === litPlace.key, ); (row as HTMLElement | undefined)?.scrollIntoView?.({ block: "nearest" }); + markOverflow(placeNav as HTMLElement); } setHidden(chapterSection, !state.chaptersVisible);