/* ============== 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 (
);
}
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.subtitle.replace(/<[^>]+>/g, "")}
{p.title.replace(/<[^>]+>/g, "")}
{p.desc.replace(/<[^>]+>/g, "")}
);
})}
>
);
}
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 });