/* ════════════════════════════════════════════════════════════════════════ Minimal interactions. MOTION_INTENSITY 3: hover + a single subtle reveal. No custom cursor, no particle canvas, no typewriter, no scroll listeners. ════════════════════════════════════════════════════════════════════════ */ /* ─── Navbar border on scroll (IntersectionObserver, not a scroll listener) */ (function () { const nav = document.getElementById('navbar'); const sentinel = document.getElementById('nav-sentinel'); if (!nav || !sentinel) return; const io = new IntersectionObserver( ([entry]) => nav.classList.toggle('scrolled', !entry.isIntersecting), { threshold: 0 } ); io.observe(sentinel); })(); /* ─── Scrollspy: highlight the nav link for the section in view ───────── */ (function () { const links = Array.from(document.querySelectorAll('#navbar a[href*="#"]')); if (!links.length || !('IntersectionObserver' in window)) return; const map = {}; links.forEach((a) => { const id = (a.getAttribute('href') || '').split('#')[1]; if (id) (map[id] = map[id] || []).push(a); }); const sections = Object.keys(map) .map((id) => document.getElementById(id)) .filter(Boolean); if (!sections.length) return; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (!e.isIntersecting) return; links.forEach((a) => a.classList.remove('active')); (map[e.target.id] || []).forEach((a) => a.classList.add('active')); }); }, { rootMargin: '-45% 0px -50% 0px', threshold: 0 }); sections.forEach((s) => io.observe(s)); })(); /* ─── Mobile menu toggle ─────────────────────────────────────────────── */ (function () { const btn = document.getElementById('menu-btn'); const menu = document.getElementById('mobile-menu'); if (!btn || !menu) return; btn.addEventListener('click', () => { const hidden = menu.classList.toggle('hidden'); btn.setAttribute('aria-expanded', String(!hidden)); }); menu.querySelectorAll('a').forEach(a => a.addEventListener('click', () => menu.classList.add('hidden'))); })(); /* ─── Scroll reveal (one subtle entry per element, then unobserve) ────── */ (function () { const els = document.querySelectorAll('.reveal'); if (!els.length) return; if (!('IntersectionObserver' in window)) { els.forEach(el => el.classList.add('visible')); return; } const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' }); els.forEach(el => io.observe(el)); })(); /* ─── Contact form (AJAX, JSON → /contact) ───────────────────────────── */ (function () { const form = document.getElementById('contact-form'); if (!form) return; const status = document.getElementById('contact-status'); form.addEventListener('submit', async (e) => { e.preventDefault(); const data = Object.fromEntries(new FormData(form)); const btn = form.querySelector('[type="submit"]'); if (btn) btn.disabled = true; const setStatus = (msg, cls) => { if (status) { status.textContent = msg; status.className = 'mt-1 text-sm ' + cls; } }; try { const res = await fetch('/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (res.ok) { setStatus(form.dataset.successMsg || 'Sent.', 'text-emerald-600'); form.reset(); } else { setStatus(form.dataset.errorMsg || 'Something went wrong.', 'text-red-600'); } } catch { setStatus(form.dataset.errorMsg || 'Network error.', 'text-red-600'); } finally { if (btn) btn.disabled = false; } }); })();