PageSourceSearch

https://srinidhibhat.netlify.app/js/main.js

js srinidhibhat.netlify.app collected 2026-10-03 11:26:30 UTC 36,507 bytes, 785 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &amp; 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.