/* ============== App ============== */ const { useState, useEffect, useRef } = React; const projects = JSON.parse(document.getElementById("projects-data").textContent); /* ----- Interactive hero carousel (drag + auto-roll + focus scaling) ----- */ function HeroCarousel() { const trackRef = React.useRef(null); const viewportRef = React.useRef(null); // duplicate list for seamless looping const items = [...projects, ...projects, ...projects]; const baseLen = projects.length; React.useEffect(() => { const vp = viewportRef.current; const track = trackRef.current; if (!vp || !track) return; let x = 0; // current translate let target = 0; // eased target (for drag) let dragging = false; let startX = 0; let startTarget = 0; let velocity = 0.45; // auto-scroll px per frame let lastPointerX = 0; let paused = false; let raf; // start one-third in so we can scroll both directions function unit() { return track.scrollWidth / 3; } x = -unit(); target = x; function applyFocus() { const vpRect = vp.getBoundingClientRect(); const center = vpRect.left + vpRect.width / 2; track.querySelectorAll(".hc-card").forEach((card) => { const r = card.getBoundingClientRect(); const cardCenter = r.left + r.width / 2; const dist = Math.abs(center - cardCenter) / vpRect.width; const k = Math.max(0, 1 - dist * 1.6); const scale = 0.86 + k * 0.14; const op = 0.45 + k * 0.55; card.style.transform = `scale(${scale.toFixed(3)})`; card.style.opacity = op.toFixed(3); card.style.setProperty("--focus", k.toFixed(3)); }); } function frame() { if (!dragging && !paused) target -= velocity; // wrap const u = unit(); if (target <= -u * 2) target += u; if (target >= 0) target -= u; x += (target - x) * (dragging ? 0.35 : 0.12); track.style.transform = `translate3d(${x.toFixed(2)}px,0,0)`; applyFocus(); raf = requestAnimationFrame(frame); } raf = requestAnimationFrame(frame); // pointer drag function down(e) { dragging = true; vp.dataset.moved = "0"; vp.classList.add("is-dragging"); startX = e.clientX ?? (e.touches && e.touches[0].clientX) ?? 0; lastPointerX = startX; startTarget = target; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); } function move(e) { const cx = e.clientX ?? 0; if (Math.abs(cx - startX) > 6) vp.dataset.moved = "1"; target = startTarget + (cx - startX); velocity = Math.max(0.2, Math.min(2.5, (lastPointerX - cx) * 0.3)) || 0.45; lastPointerX = cx; } function up() { dragging = false; vp.classList.remove("is-dragging"); if (velocity < 0.2 || isNaN(velocity)) velocity = 0.45; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); // clear the moved flag shortly after so the click handler can read it first setTimeout(() => { vp.dataset.moved = "0"; }, 50); } vp.addEventListener("pointerdown", down); // pause on hover vp.addEventListener("mouseenter", () => { paused = true; }); vp.addEventListener("mouseleave", () => { paused = false; }); // arrow buttons const prevBtn = document.getElementById("hc-prev"); const nextBtn = document.getElementById("hc-next"); const step = () => (track.querySelector(".hc-card")?.getBoundingClientRect().width || 320) + 20; const onPrev = () => { target += step(); }; const onNext = () => { target -= step(); }; prevBtn && prevBtn.addEventListener("click", onPrev); nextBtn && nextBtn.addEventListener("click", onNext); return () => { cancelAnimationFrame(raf); vp.removeEventListener("pointerdown", down); prevBtn && prevBtn.removeEventListener("click", onPrev); nextBtn && nextBtn.removeEventListener("click", onNext); }; }, []); return (
{items.map((p, i) => ( { if (viewportRef.current?.dataset.moved === "1") e.preventDefault(); }}>
{p.client} {String((i % baseLen) + 1).padStart(2, "0")}
{p.subtitle.replace(/<[^>]+>/g, "")} {p.tags.slice(0, 2).join(" · ")}
View case
))}
); } const heroCarouselEl = document.getElementById("hero-carousel"); if (heroCarouselEl) ReactDOM.createRoot(heroCarouselEl).render(); /* ----- Hero word rotator ----- */ (function rotator() { const words = ["next tap.", "next click.", "next return.", "next yes.", "long haul."]; let i = 0; const slot = document.querySelector("#rotator .slot"); if (!slot) return; setInterval(() => { i = (i + 1) % words.length; slot.style.transition = "transform .55s cubic-bezier(.6,.0,.3,1), opacity .35s ease"; slot.style.transform = "translateY(-110%)"; slot.style.opacity = "0"; setTimeout(() => { slot.textContent = words[i]; slot.style.transition = "none"; slot.style.transform = "translateY(110%)"; slot.style.opacity = "0"; // next frame requestAnimationFrame(() => { slot.style.transition = "transform .55s cubic-bezier(.2,.7,.2,1), opacity .35s ease"; slot.style.transform = "translateY(0)"; slot.style.opacity = "1"; }); }, 550); }, 2800); })(); /* ----- Featured sticky stack ----- */ function FeaturedStack() { return ( <> {projects.map((p, idx) => { // Each card is sticky and stacks. We translate each subsequent card to its own offset for the "fan" effect. const stickyTop = 88 + idx * 18; return (
{p.client}
{p.subtitle.replace(/<[^>]+>/g, "")}

{p.title.replace(/<[^>]+>/g, "")}

{p.desc.replace(/<[^>]+>/g, "")}

View case study
); })} ); } const stackEl = document.getElementById("feat-stack"); if (stackEl) ReactDOM.createRoot(stackEl).render(); /* ============== Custom dot cursor ============== */ (function dotCursor() { const dot = document.getElementById("dot-cursor"); if (!dot) return; let tx = -100, ty = -100, cx = -100, cy = -100; let rafId; function tick() { cx += (tx - cx) * 0.22; cy += (ty - cy) * 0.22; dot.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`; rafId = requestAnimationFrame(tick); } tick(); window.addEventListener("mousemove", (e) => { tx = e.clientX; ty = e.clientY; }); // Hover detection: scale up when over project surfaces function bind() { document.querySelectorAll("[data-cursor='view']").forEach((el) => { if (el.__cursorBound) return; el.__cursorBound = true; el.addEventListener("mouseenter", () => dot.classList.add("is-hover")); el.addEventListener("mouseleave", () => dot.classList.remove("is-hover")); }); } bind(); setTimeout(bind, 300); setTimeout(bind, 1000); setTimeout(bind, 2200); })(); /* ============== Mobile menu ============== */ (function mobileMenu() { const btn = document.getElementById("mobile-toggle"); const close = document.getElementById("mobile-close"); const sheet = document.getElementById("mobile-sheet"); if (!btn || !sheet) return; function open() { sheet.classList.add("is-open"); btn.setAttribute("aria-expanded", "true"); document.body.style.overflow = "hidden"; } function shut() { sheet.classList.remove("is-open"); btn.setAttribute("aria-expanded", "false"); document.body.style.overflow = ""; } btn.addEventListener("click", open); close && close.addEventListener("click", shut); sheet.querySelectorAll("a").forEach((a) => a.addEventListener("click", shut)); window.addEventListener("keydown", (e) => { if (e.key === "Escape") shut(); }); })(); /* ============== Contact form: submit + CAPTCHA ============== */ (function contactForm() { const form = document.getElementById("contact-form"); if (!form) return; const success = document.getElementById("form-success"); const qEl = document.getElementById("cf-captcha-q"); const capIn = document.getElementById("cf-captcha"); // Simple, accessible math CAPTCHA — regenerated on load and after each submit. let answer = 0; function newCaptcha() { const a = Math.floor(Math.random() * 8) + 1; const b = Math.floor(Math.random() * 8) + 1; answer = a + b; if (qEl) qEl.textContent = "what is " + a + " + " + b + "?"; if (capIn) capIn.value = ""; } newCaptcha(); form.addEventListener("submit", (e) => { e.preventDefault(); const name = form.querySelector("#cf-name").value.trim(); const email = form.querySelector("#cf-email").value.trim(); const message = form.querySelector("#cf-project").value.trim(); const cap = capIn ? capIn.value.trim() : ""; if (!name || !email || !message) { success.textContent = "Please fill the required fields ✱"; success.style.color = "var(--cream)"; return; } if (parseInt(cap, 10) !== answer) { success.textContent = "That answer isn't right — give the sum another try."; success.style.color = "var(--cream)"; newCaptcha(); return; } success.textContent = "Thanks — your message is on its way. I'll reply within a working day. ✦"; success.style.color = "var(--accent)"; form.reset(); newCaptcha(); }); })(); /* ============== Scroll progress bar ============== */ (function scrollProgress() { const bar = document.createElement("div"); bar.className = "scroll-progress"; document.body.appendChild(bar); function update() { const h = document.documentElement; const max = h.scrollHeight - h.clientHeight; const p = max > 0 ? h.scrollTop / max : 0; bar.style.setProperty("--p", p.toFixed(4)); } update(); window.addEventListener("scroll", update, { passive: true }); window.addEventListener("resize", update); })(); /* ============== Topbar scroll state ============== */ const topbar = document.getElementById("topbar"); function onScroll() { if (!topbar) return; if (window.scrollY > 12) topbar.classList.add("is-scrolled"); else topbar.classList.remove("is-scrolled"); } onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); /* ============== Reveal on scroll (JS-gated) ============== */ const prefersReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!prefersReduced && "IntersectionObserver" in window) { document.documentElement.classList.add("js-reveal"); } const io = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); } }); }, { threshold: 0.06, rootMargin: "0px 0px -10% 0px" } ); function revealAboveFold(el) { const r = el.getBoundingClientRect(); return r.top < (window.innerHeight || 800) * 0.95; } function attachReveal() { document.querySelectorAll(".reveal:not(.is-bound)").forEach((el) => { el.classList.add("is-bound"); if (revealAboveFold(el)) el.classList.add("is-in"); else io.observe(el); }); } attachReveal(); requestAnimationFrame(attachReveal); setTimeout(attachReveal, 200); setTimeout(attachReveal, 800); setTimeout(attachReveal, 1800); // Hard failsafe setTimeout(() => { document.querySelectorAll(".reveal:not(.is-in)").forEach((el) => el.classList.add("is-in")); }, 2500); /* ============== Nav active section ============== */ const sections = ["work", "about", "contact"]; const navAs = sections.map((id) => document.querySelector(`.nav-links a[href="#${id}"]`)); const sectionEls = sections.map((id) => document.getElementById(id)); window.addEventListener("scroll", () => { const mid = window.scrollY + window.innerHeight * 0.35; let activeIdx = -1; sectionEls.forEach((s, i) => { if (!s) return; if (s.offsetTop <= mid) activeIdx = i; }); navAs.forEach((a, i) => { if (!a) return; if (i === activeIdx) a.classList.add("is-active"); else a.classList.remove("is-active"); }); }, { passive: true });