dd882287df
deploy / deploy (push) Successful in 6m30s
Redesign-preserve pass on the light editorial theme (dials 7/5/3): - Hero: live availability status, accent value-prop, role line, social row, staggered entrance - Motion (all motivated, reduced-motion safe): CSS scroll-driven reading progress bar, scrollspy nav with animated underline, CTA/blog arrow nudges, service hover accent rule, portfolio cover scale, card lift - Shared multi-column footer across home + blog (brand, nav, contact, social) - Fix anchor scroll offset under the fixed navbar (scroll-margin-top) - Wire real social: LinkedIn, Instagram, email (code.soroush@gmail.com) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
4.2 KiB
JavaScript
103 lines
4.2 KiB
JavaScript
/* ════════════════════════════════════════════════════════════════════════
|
|
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;
|
|
}
|
|
});
|
|
})();
|