diff --git a/src/components/CornerNav.astro b/src/components/CornerNav.astro index cf24139..724b3d9 100644 --- a/src/components/CornerNav.astro +++ b/src/components/CornerNav.astro @@ -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
. + 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 @@ -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). @@ -463,42 +488,58 @@ const items: Item[] = [ // only things that hide the sky, they are always `main > section > `, 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('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(); } @@ -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);