1/* ============================================================ 2 SRINIDHI BHAT â Portfolio interactions 3 GSAP + ScrollTrigger + Lenis (progressive enhancement) 4 ============================================================ */ 5(function () { 6 'use strict'; 7 8 /* ---------- Render data-driven content (js/site-data.js + js/cases-data.js) ---------- 9 Runs first so every later block (cursor binding, scroll reveals, the 10 collage marquee, the case viewer, hover previews) sees real DOM instead 11 of hand-written HTML. Add new projects/posters by editing site-data.js â 12 nothing here needs to change. */ 13 (function () { 14 var SITE = window.SITE_DATA || { work: [], vibe: [], posters: [] }; 15 var PAGES = window.CASE_PAGES || {}; 16 17 function esc(s) { 18 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 19 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 20 }); 21 } 22 23 // site-data.js keeps pointing at the original .png/.jpg you dropped in â 24 // tools/optimize_images.py generates a .webp (and a smaller `-t.webp` for 25 // the Craft Wall) next to it, and we swap the extension here. Keeps the 26 // "add an object, done" authoring flow while shipping ~85% fewer bytes. 27 // If the derivative is missing (script not run yet), onerror falls back 28 // to the original so nothing ever renders broken. 29 function webp(src, suffix) { 30 return String(src).replace(/\.(png|jpe?g)$/i, (suffix || '') + '.webp'); 31 } 32 function fallback(src) { 33 return ' onerror="this.onerror=null;this.src=\'' + esc(src) + '\'"'; 34 } 35 36 // Merge hand-written card metadata with generated per-slug page data 37 // into the single lookup the case viewer / hover preview read from. 38 window.CASE_DATA = {}; 39 (SITE.work || []).forEach(function (w) { 40 window.CASE_DATA[w.slug] = Object.assign({}, w, PAGES[w.slug]); 41 }); 42 43 var cardsEl = document.getElementById('cards'); 44 if (cardsEl && SITE.work && SITE.work.length) { 45 cardsEl.innerHTML = SITE.work.map(function (w) { 46 return '<a class="card" href="' + esc(w.pdf) + '" target="_blank" rel="noopener" data-case="' + esc(w.slug) + '" data-cursor="view">' + 47 '<div class="card-media"><img src="' + esc(webp(w.thumb)) + '" alt="' + esc(w.title) + '"' + 48 ' width="1000" height="562" loading="lazy" decoding="async"' + fallback(w.thumb) + '></div>' + 49 '<div class="card-body">' + 50 '<span class="card-tags">' + esc(w.tags) + '</span>' + 51 '<h3>' + esc(w.title) + '</h3>' + 52 '<p class="card-blurb">' + esc(w.blurb) + '</p>' + 53 '<span class="card-cta">View â</span>' + 54 '</div>' + 55 '</a>'; 56 }).join(''); 57 } 58 59 var vibeEl = document.getElementById('vibeCards'); 60 if (vibeEl && SITE.vibe && SITE.vibe.length) { 61 vibeEl.innerHTML = SITE.vibe.map(function (v) { 62 var statusCls = v.status === 'building' ? ' is-building' : ''; 63 var chips = (v.stack || []).map(function (s) { return '<span class="vcard-chip">' + esc(s) + '</span>'; }).join(''); 64 return '<a class="vcard" href="' + esc(v.href) + '" target="_blank" rel="noopener" data-cursor="view">' + 65 '<div class="vcard-bar">' + 66 '<span class="vcard-dot"></span><span class="vcard-dot"></span><span class="vcard-dot"></span>' + 67 '<span class="vcard-path">' + esc(v.path) + '</span>' + 68 '<span class="vcard-status' + statusCls + '">' + esc(v.status) + '</span>' + 69 '</div>' + 70 '<div class="vcard-body">' + 71 '<p class="vcard-cmd"><span class="prompt">$</span>' + esc(v.cmd) + '</p>' + 72 '<h3 class="vcard-name">' + esc(v.name) + '</h3>' + 73 '<p class="vcard-desc">' + esc(v.desc) + '</p>' + 74 '<span class="vcard-stack">' + chips + '</span>' + 75 '<span class="vcard-cta">' + esc(v.cta) + ' <span aria-hidden="true">â</span></span>' + 76 '</div>' + 77 '</a>'; 78 }).join(''); 79 } 80 81 var rowEls = document.querySelectorAll('.collage-row'); 82 if (rowEls.length && SITE.posters && SITE.posters.length) { 83 var sets = Array.prototype.map.call(rowEls, function (row) { return row.querySelector('.collage-set'); });
84 sets.forEach(function (set) { if (set) set.innerHTML = ''; }); 85 var counts = sets.map(function () { return 0; }); 86 SITE.posters.forEach(function (p) { 87 var rowIdx = (p.row >= 1 && p.row <= sets.length) ? p.row - 1 88 : counts.indexOf(Math.min.apply(null, counts)); // no row given -> balance automatically 89 var set = sets[rowIdx]; 90 if (!set) return; 91 counts[rowIdx]++; 92 // The wall only ever shows these at <=260px tall, so it gets the small 93 // `-t.webp`; data-full points the lightbox at the full-size file, which 94 // is fetched only once someone actually opens one. 95 // A <button>, not a <figure>: this opens the lightbox, so it has to be 96 // reachable and operable from the keyboard like any other control. 97 set.insertAdjacentHTML('beforeend', 98 '<button class="gi" type="button" aria-label="View ' + esc(p.alt || 'poster') + ' full size">' + 99 '<img src="' + esc(webp(p.src, '-t')) + '"' + 100 ' data-full="' + esc(webp(p.src)) + '"' + 101 ' alt="' + esc(p.alt || 'Poster') + '"' + 102 ' loading="lazy" decoding="async"' + fallback(p.src) + '></button>'); 103 }); 104 } 105 })(); 106 107 var hasGSAP = typeof gsap !== 'undefined'; 108 var hasST = hasGSAP && typeof ScrollTrigger !== 'undefined'; 109 var staticMode = /[?&]static/.test(location.search); 110 var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches || staticMode; 111 var isTouch = window.matchMedia('(hover: none), (pointer: coarse)').matches; 112 var isMobile = window.matchMedia('(max-width: 900px)').matches; 113 114 if (hasST) { 115 gsap.registerPlugin(ScrollTrigger); 116 // Mobile in-app browsers (Instagram, etc.) resize the viewport as their 117 // chrome collapses/expands mid-scroll; without this, ScrollTrigger's 118 // auto-refresh on that resize desyncs scrub positions (e.g. hero 119 // parallax stuck offset after scrolling down then back up). 120 ScrollTrigger.config({ ignoreMobileResize: true }); 121 } 122 123 /* ---------- Smooth scroll (Lenis) ---------- */ 124 var lenis = null; 125 if (typeof Lenis !== 'undefined' && !reduceMotion && !isTouch) { 126 lenis = new Lenis({ duration: 1.1, smoothWheel: true }); 127 (function raf(t) { lenis.raf(t); requestAnimationFrame(raf); })(); 128 if (hasST) lenis.on('scroll', ScrollTrigger.update); 129 } 130 131 document.querySelectorAll('a[href^="#"]').forEach(function (a) { 132 a.addEventListener('click', function (e) { 133 var id = a.getAttribute('href'); 134 var target = id.length > 1 && document.querySelector(id); 135 if (!target) return; 136 e.preventDefault(); 137 closeMenu(); 138 if (lenis) lenis.scrollTo(target, { offset: 0 }); 139 else target.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth' }); 140 // preventDefault also cancels the focus move the browser would normally 141 // do for an in-page link, which would strand a keyboard user's focus at 142 // the top of the page â including anyone using the skip link. Sections 143 // aren't focusable by default, so make the target programmatically 144 // focusable just long enough to receive it. 145 if (!target.hasAttribute('tabindex')) { 146 target.setAttribute('tabindex', '-1'); 147 target.addEventListener('blur', function once() { 148 target.removeAttribute('tabindex'); 149 target.removeEventListener('blur', once); 150 }); 151 } 152 target.focus({ preventScroll: true }); 153 }); 154 }); 155 156 /* ---------- Back-to-top button ---------- */ 157 var backToTop = document.getElementById('backToTop'); 158 if (backToTop) { 159 var toggleBackToTop = function () { 160 backToTop.classList.toggle('is-visible', window.scrollY > window.innerHeight * 0.6); 161 }; 162 window.addEventListener('scroll', toggleBackToTop, { passive: true }); 163 toggleBackToTop(); 164 } 165 166 /* ---------- Word-wrap for split reveals (preserves <br>, <em>) ---------- */ 167 function wrapWords(el) { 168 Array.prototype.slice.call(el.childNodes).forEach(function (node) { 169 if (node.nodeType === 3) {
170 var parts = node.textContent.split(/(\s+)/).filter(Boolean); 171 var frag = document.createDocumentFragment(); 172 parts.forEach(function (part) { 173 if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(' ')); return; } 174 // The heading fonts use very tight line-heights (as low as 0.9) for 175 // display sizing, which leaves no room for serif overshoot, italic 176 // swashes, or ascenders/descenders once each word is boxed into its 177 // own overflow:hidden mask for the reveal animation. Padding the 178 // mask out (and pulling it back in with an equal negative margin, 179 // so layout/line-wrapping is unaffected) gives glyphs breathing 180 // room before the box clips them. 181 var wm = document.createElement('span'); wm.className = 'wm'; 182 wm.style.display = 'inline-block'; wm.style.overflow = 'hidden'; wm.style.verticalAlign = 'top'; 183 wm.style.padding = '0.3em 0.2em'; wm.style.margin = '-0.3em -0.2em'; 184 var w = document.createElement('span'); w.className = 'w'; 185 w.style.display = 'inline-block'; w.style.lineHeight = '1.2'; w.textContent = part; 186 wm.appendChild(w); frag.appendChild(wm); 187 }); 188 el.replaceChild(frag, node); 189 } else if (node.nodeType === 1 && node.nodeName !== 'BR') { 190 wrapWords(node); 191 } 192 }); 193 } 194 document.querySelectorAll('[data-split]').forEach(wrapWords); 195 196 /* ---------- Preloader ---------- */ 197 var preloader = document.getElementById('preloader'); 198 var preloaderDone = false; 199 function killPreloader() { 200 if (preloaderDone) return; 201 preloaderDone = true; 202 if (window.__killPreloaderFailsafe) clearTimeout(window.__killPreloaderFailsafe); 203 if (preloader) preloader.style.display = 'none'; 204 document.body.style.overflow = ''; 205 heroIntro(); 206 } 207 setTimeout(killPreloader, 4500); // failsafe (background tabs suspend rAF) 208 209 var nameEl = document.getElementById('preloaderName'); 210 if (nameEl) { 211 nameEl.innerHTML = nameEl.textContent.split('').map(function (c) { 212 return c === ' ' ? ' ' : '<span class="pl-w" style="display:inline-block">' + c + '</span>'; 213 }).join(''); 214 } 215 216 if (preloader && hasGSAP && !reduceMotion && !document.hidden) { 217 document.body.style.overflow = 'hidden'; 218 var count = { v: 0 }; 219 var countEl = document.getElementById('preloaderCount'); 220 var fill = document.getElementById('preloaderFill'); 221 var tl = gsap.timeline({ onComplete: killPreloader }); 222 tl.from('.pl-w', { yPercent: 120, opacity: 0, duration: 0.7, stagger: 0.03, ease: 'expo.out' }, 0); 223 if (fill) tl.to(fill, { scaleX: 1, duration: 1.5, ease: 'power2.inOut' }, 0.1); 224 tl.to(count, { 225 v: 100, duration: 1.5, ease: 'power2.inOut', 226 onUpdate: function () { if (countEl) countEl.textContent = ('0' + Math.round(count.v)).slice(-2); } 227 }, 0.1); 228 tl.to('.pl-w', { yPercent: -120, opacity: 0, duration: 0.55, stagger: 0.02, ease: 'expo.in' }, '+=0.15'); 229 tl.to(preloader, { clipPath: 'inset(0 0 100% 0)', duration: 0.8, ease: 'expo.inOut' }, '-=0.3'); 230 231 // The counter used to run to 100 on a fixed ~2.4s timeline no matter how 232 // fast the page was actually ready â which, now that the payload is a 233 // fraction of what it was, made the preloader the slowest thing on the 234 // site. It's a progress indicator, so let it track real progress: once 235 // load fires, ease the timeline up to 3x and let it finish. The ramp keeps 236 // it feeling like an animation rather than a jump cut, and the 4.5s 237 // failsafe above still covers a load event that never arrives. 238 var sped = false; 239 function raceToEnd() { 240 if (sped) return; 241 sped = true; 242 gsap.to(tl, { timeScale: 3, duration: 0.25, ease: 'power2.in' }); 243 } 244 if (document.readyState === 'complete') raceToEnd(); 245 else window.addEventListener('load', raceToEnd, { once: true }); 246 } else { 247 killPreloader(); 248 } 249 250 /* ---------- Hero intro ---------- */ 251 function heroIntro() { 252 if (!hasGSAP || reduceMotion || document.hidden) return; 253 var tl = gsap.timeline(); 254 tl.from('.hero-eyebrow, .hero-name .hn-1, .hero-name .hn-2, .hero-role', { 255 yPercent: 100, opacity: 0, duration: 1.1, stagger: 0.09, ease: 'expo.out' 256 }, 0.05); 257 tl.from('.hero-figure', { opacity: 0, scale: 0.92, yPercent: 4, duration: 1.3, ease: 'expo.out' }, 0.35); 258 tl.from('.hero-bio, .hero-seal, .hero-foot, .nav', { 259 opacity: 0, y: 22, duration: 0.9, stagger: 0.08, ease: 'power3.out', clearProps: 'opacity,transform' 260 }, 0.7); 261 } 262 263 /* ---------- Custom cursor ---------- */ 264 var cursor = document.getElementById('cursor'); 265 var cursorLabel = document.getElementById('cursorLabel'); 266 if (cursor && !isTouch && hasGSAP) { 267 // Only now hide the native cursor. Doing it from CSS alone would leave a 268 // pointerless page if GSAP failed to load or this branch never ran. 269 document.documentElement.classList.add('has-cursor'); 270 var cx = gsap.quickTo(cursor, 'x', { duration: 0.18, ease: 'power3.out' }); 271 var cy = gsap.quickTo(cursor, 'y', { duration: 0.18, ease: 'power3.out' }); 272 window.addEventListener('mousemove', function (e) { cursor.style.opacity = '1'; cx(e.clientX); cy(e.clientY); }); 273 // Over text fields the caret matters more than the dot: give the native 274 // I-beam back and get the custom cursor out of the way. 275 document.querySelectorAll('input, textarea, select').forEach(function (el) { 276 el.addEventListener('mouseenter', function () { cursor.classList.add('is-hidden'); }); 277 el.addEventListener('mouseleave', function () { cursor.classList.remove('is-hidden'); }); 278 }); 279 function bindCursor(sel, cls, label) { 280 document.querySelectorAll(sel).forEach(function (el) { 281 el.addEventListener('mouseenter', function () { cursor.classList.add(cls); if (label) cursorLabel.textContent = label; }); 282 el.addEventListener('mouseleave', function () { cursor.classList.remove(cls); if (label) cursorLabel.textContent = ''; }); 283 }); 284 } 285 bindCursor('[data-cursor="hover"], .nav-links a, .contact-links a', 'is-hover', ''); 286 bindCursor('[data-cursor="view"]', 'is-view', 'View â'); 287 bindCursor('.gi', 'is-view', 'Open'); 288 } 289 290 /* ---------- Mobile menu ---------- */ 291 var burger = document.getElementById('navBurger'); 292 var menu = document.getElementById('mobileMenu'); 293 function closeMenu() { 294 if (!menu) return; 295 menu.classList.remove('is-open'); 296 menu.setAttribute('aria-hidden', 'true'); 297 if (burger) { 298 burger.setAttribute('aria-expanded', 'false'); 299 burger.children[0].style.transform = ''; 300 burger.children[1].style.transform = ''; 301 } 302 } 303 if (burger && menu) { 304 burger.addEventListener('click', function () { 305 var open = menu.classList.toggle('is-open'); 306 menu.setAttribute('aria-hidden', String(!open)); 307 burger.setAttribute('aria-expanded', String(open)); 308 burger.children[0].style.transform = open ? 'translateY(4px) rotate(45deg)' : ''; 309 burger.children[1].style.transform = open ? 'translateY(-4px) rotate(-45deg)' : ''; 310 }); 311 } 312 313 /* ---------- Theme toggle (light default, dark opt-in, persisted) ---------- */ 314 (function () { 315 var toggle = document.getElementById('themeToggle'); 316 if (!toggle) return; 317 var metaTheme = document.querySelector('meta[name="theme-color"]'); 318 var COLORS = { light: '#f6f4ef', dark: '#0b1020' }; 319 function apply(theme) { 320 if (theme === 'dark') document.documentElement.setAttribute('data-theme', 'dark'); 321 else document.documentElement.removeAttribute('data-theme'); 322 toggle.setAttribute('aria-label', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 323 if (metaTheme) metaTheme.setAttribute('content', COLORS[theme]); 324 } 325 function current() { 326 return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; 327 } 328 apply(current()); 329 toggle.addEventListener('click', function () { 330 var next = current() === 'dark' ? 'light' : 'dark'; 331 apply(next);
332 try { localStorage.setItem('theme', next); } catch (e) {} 333 }); 334 })(); 335 336 /* ---------- Scroll reveals ---------- */ 337 if (hasST && !reduceMotion) { 338 document.querySelectorAll('[data-split]').forEach(function (el) { 339 gsap.from(el.querySelectorAll('.w'), { 340 yPercent: 115, duration: 0.9, stagger: 0.045, ease: 'expo.out', 341 scrollTrigger: { trigger: el, start: 'top 88%' } 342 }); 343 }); 344 document.querySelectorAll('[data-reveal]').forEach(function (el) { 345 gsap.fromTo(el, { opacity: 0, y: 30 }, 346 { opacity: 1, y: 0, duration: 1, ease: 'power3.out', scrollTrigger: { trigger: el, start: 'top 90%' } }); 347 }); 348 // Cards: staggered load reveal 349 gsap.utils.toArray('.card').forEach(function (card, i) { 350 gsap.fromTo(card, 351 { opacity: 0, y: 64, scale: 0.97 }, 352 { opacity: 1, y: 0, scale: 1, duration: 0.9, ease: 'power3.out', 353 delay: (i % 2) * 0.08, 354 scrollTrigger: { trigger: card, start: 'top 92%' } }); 355 }); 356 // count-up 357 document.querySelectorAll('[data-count]').forEach(function (el) { 358 var target = parseInt(el.getAttribute('data-count'), 10); 359 var obj = { v: 0 }; 360 gsap.to(obj, { 361 v: target, duration: 1.6, ease: 'power2.out', 362 scrollTrigger: { trigger: el, start: 'top 90%' }, 363 onUpdate: function () { el.textContent = Math.round(obj.v); } 364 }); 365 }); 366 // hero parallax (desktop only) 367 if (!isMobile) { 368 gsap.to('.hero-figure', { yPercent: 10, ease: 'none', scrollTrigger: { trigger: '.hero', start: 'top top', end: 'bottom top', scrub: true } }); 369 gsap.to('.hero-orb-1', { yPercent: 22, ease: 'none', scrollTrigger: { trigger: '.hero', start: 'top top', end: 'bottom top', scrub: true } }); 370 gsap.to('.hero-orb-2', { yPercent: -16, ease: 'none', scrollTrigger: { trigger: '.hero', start: 'top top', end: 'bottom top', scrub: true } }); 371 gsap.to('.hero-mesh', { yPercent: 8, ease: 'none', scrollTrigger: { trigger: '.hero', start: 'top top', end: 'bottom top', scrub: true } }); 372 } 373 } else { 374 document.querySelectorAll('[data-reveal]').forEach(function (el) { el.style.opacity = 1; }); 375 document.querySelectorAll('[data-count]').forEach(function (el) { el.textContent = el.getAttribute('data-count'); }); 376 } 377 378 /* ---------- Seamless infinite marquee ---------- 379 Shared by the gallery collage rows and the testimonials strip. 380 Measures a single set's width only AFTER its images have real 381 dimensions, then clones enough copies to cover 2Ã viewport so the 382 modulo wrap is invisible â no jerky jump, scrolls forever. */ 383 function initMarquee(row, set) { 384 if (!set) return; 385 386 var gapPx = parseFloat(getComputedStyle(set).gap) || 0; 387 var track = document.createElement('div'); 388 track.style.display = 'flex'; 389 track.style.gap = gapPx + 'px'; 390 track.style.width = 'max-content'; 391 track.style.willChange = 'transform'; 392 row.appendChild(track); 393 track.appendChild(set); 394 395 var dir = parseFloat(row.getAttribute('data-dir')) || 1; // 1 = leftward, -1 = rightward 396 var pxPerSec = 60; 397 var speedAttr = parseFloat(row.getAttribute('data-speed')); 398 if (speedAttr) pxPerSec = 2400 / speedAttr; 399 400 var setWidth = 0, x = 0, last = 0, paused = false, started = false; 401 402 function ensureFill() { 403 var need = window.innerWidth * 2 + setWidth; 404 while (track.getBoundingClientRect().width < need && track.children.length < 48) { 405 var clone = set.cloneNode(true); 406 // Each poster is a <button> so the lightbox is keyboard-operable, which 407 // means every duplicate the marquee makes to fill the row would other- 408 // wise be another tab stop â up to 48 copies per row, three rows, all 409 // announcing the same posters. Only the original set stays reachable. 410 clone.setAttribute('aria-hidden', 'true'); 411 clone.querySelectorAll('.gi').forEach(function (b) { b.tabIndex = -1; }); 412 track.appendChild(clone); 413 } 414 } 415 function tryStart() { 416 if (started || reduceMotion) return; 417 setWidth = set.getBoundingClientRect().width + gapPx; 418 if (setWidth <= gapPx + 10) return; // images not measured yet 419 ensureFill(); 420 started = true; 421 x = dir > 0 ? 0 : -setWidth; 422 last = performance.now(); 423 requestAnimationFrame(tick); 424 } 425 function tick(now) { 426 var dt = Math.min(0.05, (now - last) / 1000); 427 last = now; 428 if (!paused && setWidth > 0) { 429 x -= pxPerSec * dt * dir; 430 if (x <= -setWidth) x += setWidth; 431 else if (x >= 0) x -= setWidth; 432 track.style.transform = 'translate3d(' + x.toFixed(2) + 'px,0,0)'; 433 } 434 requestAnimationFrame(tick); 435 } 436 437 row.addEventListener('mouseenter', function () { paused = true; }); 438 row.addEventListener('mouseleave', function () { paused = false; }); 439 440 // The posters are `loading="lazy"` and sit ~7000px down the page, so they 441 // have no width at page load and measuring now would size the track from 442 // nothing. Wait until the row is near the viewport (which is also what 443 // triggers the lazy fetch), then poll until the images report real widths. 444 // An earlier version polled on a fixed 8s timer from page load â that 445 // expired long before a scrolling visitor ever reached the wall. 446 var poll = null; 447 function beginWatching() { 448 if (poll || started) return; 449 set.querySelectorAll('img').forEach(function (im) { 450 if (im.complete) return; 451 im.addEventListener('load', tryStart, { once: true }); 452 });
453 tryStart(); 454 poll = setInterval(function () { 455 if (started) { clearInterval(poll); poll = null; } 456 else tryStart(); 457 }, 300); 458 } 459 460 if ('IntersectionObserver' in window) { 461 var io = new IntersectionObserver(function (entries) { 462 if (!entries[0].isIntersecting) return; 463 io.disconnect(); 464 beginWatching(); 465 }, { rootMargin: '600px 0px' }); 466 io.observe(row); 467 } else { 468 beginWatching(); 469 } 470 471 var rt; 472 window.addEventListener('resize', function () { 473 clearTimeout(rt); 474 rt = setTimeout(function () { 475 if (!started) { tryStart(); return; } 476 setWidth = set.getBoundingClientRect().width + gapPx; 477 ensureFill(); 478 }, 150); 479 }); 480 } 481 482 document.querySelectorAll('.collage-row').forEach(function (row) { 483 initMarquee(row, row.querySelector('.collage-set')); 484 }); 485 486 /* ---------- Focus trap (shared by the lightbox and the case viewer) ---------- 487 Both overlays declare aria-modal="true", which tells assistive tech the rest 488 of the page is inert â but that's a promise the browser doesn't keep on its 489 own. Without this, Tab walks straight out of the dialog and into the work 490 cards behind it while the overlay still covers the screen. */ 491 var FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'; 492 function trapFocus(container) { 493 return function (e) { 494 if (e.key !== 'Tab') return; 495 var items = Array.prototype.filter.call( 496 container.querySelectorAll(FOCUSABLE), 497 function (el) { return el.offsetParent !== null || el === document.activeElement; } 498 ); 499 if (!items.length) return; 500 var first = items[0], last = items[items.length - 1]; 501 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 502 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 503 }; 504 } 505 506 /* ---------- Lightbox ---------- */ 507 var lightbox = document.getElementById('lightbox'); 508 var lightboxImg = document.getElementById('lightboxImg'); 509 var lightboxClose = document.getElementById('lightboxClose'); 510 if (lightbox) { 511 var lbTrap = trapFocus(lightbox); 512 var lbLastFocus = null; 513 document.addEventListener('click', function (e) { 514 var fig = e.target.closest && e.target.closest('.gi'); 515 if (!fig) return; 516 var img = fig.querySelector('img'); 517 if (!img) return; 518 // The wall renders a downscaled thumb; open the full-size file here. 519 lightboxImg.src = img.getAttribute('data-full') || img.currentSrc || img.src; 520 lightboxImg.alt = img.alt || 'Artwork enlarged'; 521 lightbox.classList.add('is-open'); 522 lightbox.setAttribute('aria-hidden', 'false'); 523 if (lenis) lenis.stop(); 524 lbLastFocus = document.activeElement; 525 document.addEventListener('keydown', lbTrap); 526 lightboxClose.focus(); 527 }); 528 function closeLightbox() { 529 if (!lightbox.classList.contains('is-open')) return; 530 lightbox.classList.remove('is-open'); 531 lightbox.setAttribute('aria-hidden', 'true'); 532 if (lenis) lenis.start(); 533 document.removeEventListener('keydown', lbTrap); 534 if (lbLastFocus && lbLastFocus.focus) lbLastFocus.focus(); 535 } 536 lightboxClose.addEventListener('click', closeLightbox); 537 lightbox.addEventListener('click', function (e) { if (e.target === lightbox) closeLightbox(); }); 538 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeLightbox(); }); 539 } 540 541 /* ---------- Case viewer (native screens, no PDF download) ---------- */ 542 (function () { 543 var DATA = window.CASE_DATA || {}; 544 var cv = document.getElementById('caseViewer'); 545 if (!cv) return; 546 var scroll = document.getElementById('cvScroll'); 547 var titleEl = document.getElementById('cvTitle'); 548 var tagsEl = document.getElementById('cvTags'); 549 var pdfEl = document.getElementById('cvPdf'); 550 var protoEl = document.getElementById('cvProto'); 551 var closeEl = document.getElementById('cvClose'); 552 var progressEl = document.getElementById('cvProgress'); 553 var fillEl = document.getElementById('cvBarFill'); 554 var total = 1, lastFocus = null; 555 var cvTrap = trapFocus(cv); 556 function pad(n) { return ('0' + n).slice(-2); } 557 558 function build(c) { 559 var stage = document.createElement('div'); 560 stage.className = 'cv-stage ' + (c.layout === 'page' ? 'is-page' : 'is-deck');
561 c.pages.forEach(function (p, i) { 562 var img = document.createElement('img'); 563 img.src = p.src; img.width = p.w; img.height = p.h; 564 img.loading = i < 2 ? 'eager' : 'lazy'; 565 img.decoding = 'async'; 566 img.alt = c.title + ' â screen ' + (i + 1); 567 if (c.layout === 'page') { stage.appendChild(img); } 568 else { var f = document.createElement('figure'); f.className = 'cv-slide'; f.appendChild(img); stage.appendChild(f); } 569 }); 570 scroll.innerHTML = ''; 571 scroll.appendChild(stage); 572 } 573 function updateProgress() { 574 var max = scroll.scrollHeight - scroll.clientHeight; 575 var frac = max > 0 ? Math.min(1, Math.max(0, scroll.scrollTop / max)) : 0; 576 fillEl.style.width = (frac * 100).toFixed(1) + '%'; 577 var cur = Math.min(total, Math.floor(frac * (total - 1) + 0.5) + 1); 578 progressEl.textContent = pad(cur) + ' / ' + pad(total); 579 } 580 function openCase(slug) { 581 var c = DATA[slug]; 582 if (!c) return false; 583 titleEl.textContent = c.title; 584 tagsEl.textContent = c.tags; 585 pdfEl.href = c.pdf; 586 if (protoEl) { 587 if (c.prototypeUrl) { protoEl.href = c.prototypeUrl; protoEl.hidden = false; } 588 else { protoEl.hidden = true; protoEl.removeAttribute('href'); } 589 } 590 total = c.pages.length; 591 build(c); 592 lastFocus = document.activeElement; 593 cv.classList.add('is-open'); 594 cv.setAttribute('aria-hidden', 'false'); 595 document.body.style.overflow = 'hidden'; 596 if (lenis) lenis.stop(); 597 scroll.scrollTop = 0; 598 updateProgress(); 599 document.addEventListener('keydown', cvTrap); 600 closeEl.focus(); 601 return true; 602 } 603 function closeCase() { 604 cv.classList.remove('is-open'); 605 cv.setAttribute('aria-hidden', 'true'); 606 document.body.style.overflow = ''; 607 if (lenis) lenis.start(); 608 document.removeEventListener('keydown', cvTrap); 609 if (lastFocus && lastFocus.focus) lastFocus.focus(); 610 } 611 scroll.addEventListener('scroll', updateProgress, { passive: true }); 612 closeEl.addEventListener('click', closeCase); 613 document.addEventListener('keydown', function (e) { 614 if (e.key === 'Escape' && cv.classList.contains('is-open')) closeCase(); 615 }); 616 document.querySelectorAll('.card[data-case]').forEach(function (card) { 617 card.addEventListener('click', function (e) { 618 var slug = card.getAttribute('data-case'); 619 if (DATA[slug]) { e.preventDefault(); openCase(slug); } 620 // no data for slug -> fall through to href (PDF) as graceful fallback 621 }); 622 }); 623 })(); 624 625 /* ---------- Work view switcher (Selected Work <-> vibe-coded) ---------- */ 626 (function () { 627 var tabs = document.querySelectorAll('.work-tab'); 628 if (!tabs.length) return; 629 var panels = { work: document.getElementById('cards'), vibe: document.getElementById('vibeCards') }; 630 var kickerEl = document.getElementById('workKicker'); 631 var titleEl = document.getElementById('workTitle'); 632 var noteEl = document.getElementById('workNote'); 633 var COPY = { 634 work: { 635 kicker: '( Selected work â 01 / 10 )', 636 title: 'Selected <em>Work</em>', 637 note: 'Ten case studies across product design, landing pages & brand concepts. Tap any card to view the full screens â right here, no downloads. â' 638 }, 639 vibe: { 640 kicker: '( Side projects · shipped after midnight )', 641 title: "Sh*t I've <em>Vibe Coded</em>", 642 note: 'Seventeen things I built solo with an AI pair-programmer and way too much coffee. Live links, zero polish guarantees. â' 643 } 644 }; 645 var current = 'work'; 646 function activate(name) { 647 if (name === current || !panels[name]) return; 648 var from = panels[current], to = panels[name]; 649 current = name;
650 tabs.forEach(function (t) { 651 var on = t.getAttribute('data-tab') === name; 652 t.classList.toggle('is-active', on); 653 t.setAttribute('aria-selected', String(on)); 654 }); 655 if (kickerEl) kickerEl.innerHTML = COPY[name].kicker; 656 if (titleEl) titleEl.innerHTML = COPY[name].title; 657 if (noteEl) noteEl.innerHTML = COPY[name].note; 658 from.classList.add('is-entering'); 659 setTimeout(function () { 660 from.hidden = true; 661 to.hidden = false; 662 to.classList.add('is-entering'); 663 void to.offsetWidth; // force reflow so removing the class below actually transitions 664 to.classList.remove('is-entering'); 665 // the two panels are very different heights (10 cards vs 4), so the 666 // section height jumps instantly here -- re-anchor the viewport to a 667 // consistent spot on the section instead of leaving the scroll 668 // position wherever it happened to be relative to the OLD height 669 var section = document.getElementById('work'); 670 var anchor = section.getBoundingClientRect().top + window.scrollY - 90; 671 window.scrollTo({ top: Math.max(0, anchor), behavior: reduceMotion ? 'auto' : 'smooth' }); 672 // every section below (experience, gallery, ...) just shifted with 673 // the height change, but their reveal-on-scroll triggers keep the 674 // stale pixel offsets from page load -- refresh so ScrollTrigger 675 // re-measures against the new layout, or those reveals fire at the 676 // wrong scroll position (reads as a dead gap before the next section) 677 if (hasST) ScrollTrigger.refresh(); 678 }, 220); 679 }
680 tabs.forEach(function (t) { 681 t.addEventListener('click', function () { activate(t.getAttribute('data-tab')); }); 682 }); 683 })(); 684 685 /* ---------- Card hover preview (peek at first 3 case-study pages) ---------- */ 686 (function () { 687 var preview = document.getElementById('cardPreview'); 688 if (!preview || isTouch) return; 689 var DATA = window.CASE_DATA || {}; 690 var moveX = hasGSAP ? gsap.quickTo(preview, 'x', { duration: 0.35, ease: 'power3.out' }) : null; 691 var moveY = hasGSAP ? gsap.quickTo(preview, 'y', { duration: 0.35, ease: 'power3.out' }) : null; 692 function move(e) { 693 if (moveX && moveY) { moveX(e.clientX); moveY(e.clientY); } 694 else { preview.style.transform = 'translate(' + e.clientX + 'px,' + e.clientY + 'px)'; } 695 } 696 document.querySelectorAll('.card[data-case]').forEach(function (card) { 697 var slug = card.getAttribute('data-case'); 698 var c = DATA[slug]; 699 if (!c || !c.pages || !c.pages.length) return; 700 card.addEventListener('mouseenter', function (e) { 701 preview.innerHTML = c.pages.slice(0, 3).map(function (p) { 702 return '<div class="card-preview-item"><img src="' + p.src + '" alt="" loading="lazy"></div>'; 703 }).join(''); 704 preview.classList.add('is-visible'); 705 move(e); 706 }); 707 card.addEventListener('mousemove', move); 708 card.addEventListener('mouseleave', function () { preview.classList.remove('is-visible'); }); 709 }); 710 })(); 711 712 /* ---------- Contact form (Netlify AJAX submit) ---------- */ 713 (function () { 714 var form = document.querySelector('.contact-form'); 715 if (!form) return; 716 var statusEl = document.getElementById('cfStatus'); 717 var toastEl = document.getElementById('cfToast'); 718 var btn = form.querySelector('.cf-submit'); 719 var btnTxt = form.querySelector('.cf-submit-txt'); 720 var toastTimer = null; 721 form.addEventListener('submit', function (e) { 722 e.preventDefault(); 723 var hp = form.querySelector('[name="bot-field"]'); 724 if (hp && hp.value) return; // honeypot tripped 725 if (typeof form.reportValidity === 'function' && !form.reportValidity()) return; 726 var body = new URLSearchParams(new FormData(form)).toString(); 727 if (btn) btn.disabled = true; 728 if (btnTxt) btnTxt.textContent = 'Sendingâ¦'; 729 statusEl.className = 'cf-status'; 730 statusEl.textContent = ''; 731 fetch('/', { 732 method: 'POST', 733 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 734 body: body 735 }).then(function (res) { 736 if (!res.ok) throw new Error('status ' + res.status); 737 form.reset(); 738 if (btn) btn.disabled = false; 739 if (btnTxt) btnTxt.textContent = 'Send it over'; 740 if (toastEl) { 741 clearTimeout(toastTimer); 742 toastEl.classList.add('is-visible'); 743 toastEl.setAttribute('aria-hidden', 'false'); 744 toastTimer = setTimeout(function () { 745 toastEl.classList.remove('is-visible'); 746 toastEl.setAttribute('aria-hidden', 'true'); 747 }, 5000); 748 } 749 }).catch(function () { 750 if (btn) btn.disabled = false; 751 if (btnTxt) btnTxt.textContent = 'Send it over'; 752 statusEl.className = 'cf-status is-err'; 753 statusEl.textContent = 'Something went wrong â please email [email protected] instead.'; 754 }); 755 }); 756 })(); 757 758 /* ---------- Magnetic ---------- */ 759 if (!isTouch && hasGSAP && !reduceMotion) { 760 document.querySelectorAll('[data-magnetic]').forEach(function (el) { 761 var mx = gsap.quickTo(el, 'x', { duration: 0.4, ease: 'power3.out' }); 762 var my = gsap.quickTo(el, 'y', { duration: 0.4, ease: 'power3.out' }); 763 el.addEventListener('mousemove', function (e) { 764 var r = el.getBoundingClientRect(); 765 mx((e.clientX - (r.left + r.width / 2)) * 0.22); 766 my((e.clientY - (r.top + r.height / 2)) * 0.22); 767 }); 768 el.addEventListener('mouseleave', function () { mx(0); my(0); }); 769 }); 770 } 771 772 /* ---------- Clocks (IST) ---------- */ 773 function fmt() { 774 try { 775 return new Intl.DateTimeFormat('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Kolkata' }).format(new Date()); 776 } catch (e) { return '--:--'; } 777 } 778 function tick() { 779 var t = fmt(); 780 var a = document.getElementById('localTime'); if (a) a.textContent = t; 781 var b = document.getElementById('heroTime'); if (b) b.textContent = t; 782 } 783 tick(); 784 setInterval(tick, 30000); 785})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.