scroll.js

  1// Instant anchor scroll - no smooth scrolling for better UX on long pages.
  2// `behavior: 'instant'` explicitly overrides any CSS `scroll-behavior: smooth`
  3// from a stylesheet we don't own; `behavior: 'auto'` would defer to CSS.
  4export function initAnchorScroll() {
  5	document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
  6		anchor.addEventListener("click", (e) => {
  7			e.preventDefault();
  8			const target = document.querySelector(anchor.getAttribute("href"));
  9			if (target) {
 10				const offset = 40;
 11				const targetPosition = target.getBoundingClientRect().top + window.scrollY - offset;
 12				window.scrollTo({ top: targetPosition, behavior: 'instant' });
 13			}
 14		});
 15	});
 16}
 17
 18export function initHashTracking() {
 19	const sections = document.querySelectorAll('section[id]');
 20	if (!sections.length) return;
 21
 22	let currentHash = window.location.hash.slice(1) || '';
 23	let ticking = false;
 24
 25	function updateHash() {
 26		// Don't override command deep links while user is in the commands section
 27		if (currentHash.startsWith('cmd-')) {
 28			const cmdEl = document.getElementById(currentHash);
 29			if (cmdEl) {
 30				const rect = cmdEl.getBoundingClientRect();
 31				// Only clear the cmd hash if user scrolled well away from commands section
 32				if (rect.top > window.innerHeight * 2 || rect.bottom < -window.innerHeight) {
 33					currentHash = '';
 34				} else {
 35					ticking = false;
 36					return;
 37				}
 38			}
 39		}
 40
 41		const scrollY = window.scrollY;
 42		const viewportHeight = window.innerHeight;
 43		const triggerPoint = scrollY + viewportHeight * 0.3;
 44
 45		let activeSection = '';
 46
 47		sections.forEach(section => {
 48			const rect = section.getBoundingClientRect();
 49			const sectionTop = scrollY + rect.top;
 50			const sectionBottom = sectionTop + rect.height;
 51
 52			if (triggerPoint >= sectionTop && triggerPoint < sectionBottom) {
 53				activeSection = section.id;
 54			}
 55		});
 56
 57		// Don't set #hero — it's the default state, no hash needed
 58		if (activeSection === 'hero') activeSection = '';
 59
 60		if (activeSection !== currentHash) {
 61			currentHash = activeSection;
 62			if (activeSection) {
 63				history.replaceState(null, '', `#${activeSection}`);
 64			} else {
 65				history.replaceState(null, '', window.location.pathname);
 66			}
 67		}
 68
 69		ticking = false;
 70	}
 71
 72	window.addEventListener('scroll', () => {
 73		if (!ticking) {
 74			requestAnimationFrame(updateHash);
 75			ticking = true;
 76		}
 77	}, { passive: true });
 78
 79	// Handle initial hash on page load — instant jump, retried on
 80	// fonts.ready and window `load`. A fixed setTimeout is unreliable
 81	// because async-loaded display fonts reflow the page by hundreds of
 82	// pixels when they swap in; computing target position before that
 83	// lands the user several sections above the right spot.
 84	if (window.location.hash) {
 85		const hash = window.location.hash.slice(1);
 86		const target = document.getElementById(hash);
 87		if (target) {
 88			currentHash = hash;
 89			let clicked = false;
 90			const jump = () => {
 91				const offset = 40;
 92				const targetPosition = target.getBoundingClientRect().top + window.scrollY - offset;
 93				window.scrollTo({ top: targetPosition, behavior: 'instant' });
 94				if (!clicked && hash.startsWith('cmd-') && target.classList.contains('manual-entry')) {
 95					target.click();
 96					clicked = true;
 97				}
 98			};
 99			jump();
100			if (document.fonts?.ready) document.fonts.ready.then(jump).catch(() => {});
101			window.addEventListener('load', jump, { once: true });
102		}
103	} else {
104		// No hash — don't set one on initial load
105	}
106}
107