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:
@@ -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
|
||||
* 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(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user