From 05230598dda158a4f4c47509099a13ff4300d480 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sat, 27 Jun 2026 18:50:28 +0200 Subject: [PATCH] fix(site): scroll-spy highlights the last guide section at page bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "On this page" rail used an IntersectionObserver with a narrow `-20% 0px -65% 0px` band: a heading only went active while inside the top 20–35% of the viewport. Headings near the page bottom never scroll high enough to enter that band, so the final section(s) never activated — at the very bottom the rail stayed stuck on whichever heading was last in the band (often the first). Replace it with a scroll-position computation: the active heading is the last one whose top has crossed a line ~28% down the viewport, and the last heading is forced once scrolled to the page bottom. rAF-throttled scroll/resize listeners. Verified via Playwright at top / middle / bottom — correct heading active in each, exactly one active at a time. --- .../en/guide/[part]/[chapter]/index.astro | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/site/src/pages/en/guide/[part]/[chapter]/index.astro b/site/src/pages/en/guide/[part]/[chapter]/index.astro index f9df1179..69cc9fa2 100644 --- a/site/src/pages/en/guide/[part]/[chapter]/index.astro +++ b/site/src/pages/en/guide/[part]/[chapter]/index.astro @@ -194,28 +194,43 @@ const sidebarParts = GUIDE_PARTS.map(part => ({ } if (map.size === 0) return; + const headings = [...map.keys()]; + if (headings.length === 0) return; + const setActive = (link) => { - links.forEach(node => node.setAttribute('data-active', 'false')); - link.setAttribute('data-active', 'true'); + links.forEach(node => node.setAttribute('data-active', String(node === link))); }; - const observer = new IntersectionObserver( - entries => { - const visible = entries - .filter(entry => entry.isIntersecting) - .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - const top = visible[0]; - if (!top) return; - const link = map.get(top.target); - if (link) setActive(link); - }, - { rootMargin: '-20% 0px -65% 0px', threshold: [0, 1] }, - ); - - for (const heading of map.keys()) observer.observe(heading); + // Active = the last heading whose top has crossed a line ~28% down the + // viewport. A rootMargin "band" can't do this: headings near the page + // bottom never scroll high enough to enter the band, so the final + // section(s) would never activate. At the very bottom we force the last + // heading, since it can sit below the line with nothing left to scroll. + const LINE = 0.28; + const update = () => { + const line = window.innerHeight * LINE; + let active = headings[0]; + for (const heading of headings) { + if (heading.getBoundingClientRect().top <= line) active = heading; + else break; + } + const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4; + if (atBottom) active = headings[headings.length - 1]; + setActive(map.get(active)); + }; - const firstLink = links[0]; - if (firstLink instanceof HTMLAnchorElement) setActive(firstLink); + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + update(); + }); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll, { passive: true }); + update(); })();