Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 64 additions & 24 deletions src/components/CornerNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,15 @@ const items: Item[] = [
const mode = nav.dataset.navMode === 'light' ? 'light' : 'scroll';

let onScroll: (() => void) | null = null;
// Hoisted to the teardown's scope, not the branch's: the re-measure listener and the content observer must
// be removable, or every View Transition would stack another observer on the same <main>.
let onResize: (() => void) | null = null;
let zoneRO: ResizeObserver | null = null;
if (mode === 'light' || !descent) {
nav.dataset.zone = 'light';
} else {
// document-y behind the nav's vertical center (fixed near the top edge)
const probe = () => (nav.getBoundingClientRect().top + nav.getBoundingClientRect().bottom) / 2;
// (The old per-frame `probe()` is gone: it read the nav's rect TWICE on every frame to find a value that
// cannot change while scrolling, since the nav is position:fixed. It is now navCenterY, measured once.)

/**
* Relative luminance of the first OPAQUE thing actually painted behind the nav, or null if the sky is
Expand All @@ -447,10 +451,31 @@ const items: Item[] = [
* and it keeps working when panels move or new ones are added. elementsFromPoint (plural) is used
* because the nav itself is the topmost thing at that point; its own subtree is skipped.
*/
const backdropLuminance = (): number | null => {
// ── MEASURED ONCE, NOT PER FRAME. This is the fix for a regression I shipped: the owner reported the
// slide transitions had become "almost like sluggish" in prod, and this function was why.
//
// It used to run inside the scroll handler, i.e. on EVERY animation frame of every scroll — and a deck
// slide transition is a ~500ms smooth scroll, so every frame of it. Each call did a fresh
// querySelectorAll and then interleaved getBoundingClientRect() with getComputedStyle() per element,
// which is the textbook layout-thrash pattern: each style read forces the layout the previous rect read
// just invalidated. The handler it replaced did one offsetHeight and two rect reads.
//
// Nothing it measures actually changes while you scroll. A panel's DOCUMENT-space top and bottom and its
// background colour are fixed; only scrollY moves. So the measurement is hoisted into measureBackdrops()
// and the per-frame path becomes pure arithmetic over a cached array — no DOM reads at all.
type Band = { top: number; bottom: number; lum: number };
let bands: Band[] = [];
let navCenterY = 0;
let descentH = 0;
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };

const measureBackdrops = (): void => {
const nr = nav.getBoundingClientRect();
const y = (nr.top + nr.bottom) / 2;
navCenterY = (nr.top + nr.bottom) / 2; // the nav is position:fixed, so this is scroll-invariant
descentH = descent.offsetHeight;
const vw = window.innerWidth;
const sy = window.scrollY;
const next: Band[] = [];
// GEOMETRY, NOT HIT-TESTING. document.elementsFromPoint looked like the obvious tool and is the wrong
// one: it SKIPS every element with pointer-events: none, which is precisely what all of this site's
// decorative sky layers are (the fluid-sky canvas, the atmosphere washes, the hero legibility band).
Expand All @@ -463,42 +488,58 @@ const items: Item[] = [
// only things that hide the sky, they are always `main > section > <block>`, and their boxes are
// ordinary geometry that no pointer-events value can hide. Anything else means the sky is what shows,
// and the descent-fraction proxy below is the right model for a sky whose colour varies with depth.
// Read every candidate's geometry and colour ONCE, in document coordinates. The rect reads and the
// style reads still interleave here, but this runs on init and on resize rather than 60 times a second.
for (const el of document.querySelectorAll<HTMLElement>('main > section > *')) {
const r = el.getBoundingClientRect();
if (r.top > y || r.bottom < y) continue; // not on the nav's line
if (r.width < vw * 0.6) continue; // not full-bleed: a card, not a panel
const parts = /rgba?\(([^)]+)\)/.exec(getComputedStyle(el).backgroundColor);
if (!parts) continue;
const [cr, cg, cb, ca = 1] = parts[1].split(',').map((s) => parseFloat(s));
if (!(ca >= 0.9)) continue; // translucent: the sky still reads through
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * lin(cr) + 0.7152 * lin(cg) + 0.0722 * lin(cb);
next.push({
top: r.top + sy,
bottom: r.bottom + sy,
lum: 0.2126 * lin(cr) + 0.7152 * lin(cg) + 0.0722 * lin(cb),
});
}
return null;
bands = next;
};

const update = () => {
// An opaque backdrop answers the question directly; the gradient fraction is only the fallback for
// when the sky itself is what shows through. 0.35 sits between the paper tones (~0.85) and the dusk/
// ink ones (~0.02-0.2), so mid-grey dusk still chooses paper text as it did before.
const lumBehind = backdropLuminance();
if (lumBehind !== null) {
nav.dataset.zone = lumBehind > 0.35 ? 'light' : 'dark';
return;
// THE WHOLE PER-FRAME PATH, and it touches the DOM exactly once (scrollY, which is free — it does not
// force layout). An opaque backdrop answers the question directly; the gradient fraction is the
// fallback for when the sky itself is what shows through. 0.35 sits between the paper tones (~0.85)
// and the dusk/ink ones (~0.02-0.2), so mid-grey dusk still chooses paper text as it did before.
const docY = window.scrollY + navCenterY;
for (const b of bands) {
if (docY >= b.top && docY <= b.bottom) {
nav.dataset.zone = b.lum > 0.35 ? 'light' : 'dark';
return;
}
}
const h = descent.offsetHeight;
if (!h) return;
const frac = (window.scrollY + probe()) / h;
nav.dataset.zone = frac > DARK_FROM ? 'dark' : 'light';
if (!descentH) return;
nav.dataset.zone = docY / descentH > DARK_FROM ? 'dark' : 'light';
};

measureBackdrops();
let ticking = false;
onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { update(); ticking = false; });
};
// RESIZE RE-MEASURES; SCROLL DOES NOT. Previously both events ran the same handler, which is how the
// measurement ended up in the scroll path in the first place.
onResize = () => { measureBackdrops(); onScroll?.(); };
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
window.addEventListener('resize', onResize, { passive: true });
// The document also changes height WITHOUT a resize — the terrain canvas and images settle after first
// paint, and a deck slide can grow. Stale bands would leave the nav in the wrong zone, so watch the
// content box; this fires rarely and never during a scroll.
zoneRO = new ResizeObserver(() => measureBackdrops());
const mainEl = document.querySelector('main');
if (mainEl) zoneRO.observe(mainEl);
update();
}

Expand Down Expand Up @@ -601,10 +642,9 @@ const items: Item[] = [
document.addEventListener('keydown', onDocKey);

teardown = () => {
if (onScroll) {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
}
if (onScroll) window.removeEventListener('scroll', onScroll);
if (onResize) window.removeEventListener('resize', onResize);
zoneRO?.disconnect();
toggle?.removeEventListener('click', onToggle);
menu?.removeEventListener('click', onMenu);
document.removeEventListener('pointerdown', onDocPointer);
Expand Down
Loading