PageSourceSearch

https://jerick-1380.github.io/assets/js/main.js?v=202609271451

js jerick-1380.github.io collected 2026-10-03 09:22:53 UTC 65,109 bytes, 1,430 lines download raw bytes

1/* Jerick Shi — site interactions (v2)
2   WebGL nebula background (2D starfield fallback), custom cursor, decode/scramble
3   text, magnetic buttons, spotlight cards, page transitions, scroll spy, hero
4   parallax, research map (force-directed graph), command palette, lightbox,
5   carousel, tilt cards, filters, counters, easter eggs. No dependencies. */
6
7(function () {
8  "use strict";
9
10  var reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
11  var finePointer = window.matchMedia("(pointer: fine)").matches;
12  var mouse = { x: -9999, y: -9999, active: false };
13
14  document.documentElement.classList.add("js");
15
16  window.addEventListener("pointermove", function (e) {
17    mouse.x = e.clientX;
18    mouse.y = e.clientY;
19    mouse.active = true;
20  }, { passive: true });
21  document.addEventListener("pointerleave", function () { mouse.active = false; });
22
23  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
24  function lerp(a, b, t) { return a + (b - a) * t; }
25
26  /* ======================================================================
27     BACKGROUND — WebGL nebula + procedural stars, mouse warp, scroll drift
28     ====================================================================== */
29  var WARP = { level: 0, target: 0 }; // easter egg hook
30
31  var VERT = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
32
33  var FRAG = [
34    "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif",
35    "uniform vec2 u_res;uniform float u_time;uniform vec2 u_mouse;uniform float u_scroll;uniform float u_warp;",
36    "float hash(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}",
37    "float noise(vec2 p){vec2 i=floor(p);vec2 f=fract(p);f=f*f*(3.-2.*f);",
38    " float a=hash(i),b=hash(i+vec2(1,0)),c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1));",
39    " return mix(mix(a,b,f.x),mix(c,d,f.x),f.y);}",
40    "float fbm(vec2 p){float v=0.,a=.5;mat2 m=mat2(1.6,1.2,-1.2,1.6);",
41    " for(int i=0;i<5;i++){v+=a*noise(p);p=m*p;a*=.5;}return v;}",
42    "float stars(vec2 uv,float scale,float t,float bright){",
43    " vec2 g=uv*scale;vec2 id=floor(g);vec2 f=fract(g)-.5;",
44    " float h=hash(id);if(h<.93)return 0.;",
45    " vec2 off=vec2(hash(id+7.1),hash(id+3.7))-.5;f-=off*.7;",
46    " float d=length(f);float tw=.6+.4*sin(t*(1.+h*2.)+h*40.);",
47    " float s=smoothstep(.06,0.,d)*tw*bright;",
48    " s+=smoothstep(.25,0.,d)*.06*tw;return s;}",
49    "void main(){",
50    " vec2 uv=(gl_FragCoord.xy-.5*u_res)/u_res.y;",
51    " vec2 m=(u_mouse-.5*u_res)/u_res.y;",
52    " float t=u_time*.045;",
53    " vec2 d=uv-m;float dist=length(d);",
54    " float infl=exp(-dist*dist*9.);",
55    " vec2 wuv=uv+normalize(d+1e-4)*infl*.07;",
56    " vec2 p=wuv*1.5+vec2(t*.35,-t*.22)+vec2(0.,u_scroll*.00035);",
57    " float q=fbm(p+t*.3);",
58    " float n=fbm(p+q*.9+vec2(1.7,9.2));",
59    " vec3 c1=vec3(.36,.55,1.);vec3 c2=vec3(.62,.42,1.);vec3 c3=vec3(.27,.88,1.);",
60    " vec3 col=mix(c1,c2,smoothstep(.25,.85,n));",
61    " col=mix(col,c3,smoothstep(.6,.95,q)*.6);",
62    " float a=smoothstep(.38,.92,n)*.30;",
63    " vec3 bg=vec3(.016,.024,.05);",
64    " vec3 o=bg+col*a;",
65    " o+=c3*infl*.12;",
66    " float st=0.;",
67    " vec2 suv=uv+vec2(t*.15,u_scroll*.00025);",
68    " st+=stars(suv,18.,u_time,1.);",
69    " st+=stars(suv*1.7+3.1,26.,u_time*1.3,.6);",
70    " st+=stars(uv*.6+vec2(t*.05,u_scroll*.0001),13.,u_time*.7,1.1);",
71    " o+=vec3(.7,.78,1.)*st;",
72    " if(u_warp>0.){",
73    "  float ang=atan(uv.y,uv.x);float r=length(uv);",
74    "  float streak=pow(noise(vec2(ang*40.,r*3.-u_time*8.)),8.)*u_warp*smoothstep(.05,.6,r)*3.;",
75    "  o+=vec3(.5,.7,1.)*streak;o+=c3*u_warp*.06;}",
76    " o+=(hash(gl_FragCoord.xy+u_time)-.5)*.012;",
77    " gl_FragColor=vec4(o,1.);}"
78  ].join("\n");
79
80  function initBackgroundGL(canvas) {
81    var gl = canvas.getContext("webgl", { antialias: false, alpha: false, powerPreference: "low-power" });
82    if (!gl) return false;
83
84    function shader(type, src) {
85      var s = gl.createShader(type);
86      gl.shaderSource(s, src);
87      gl.compileShader(s);
88      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.warn(gl.getShaderInfoLog(s)); return null; }
89      return s;
90    }
91    var vs = shader(gl.VERTEX_SHADER, VERT), fs = shader(gl.FRAGMENT_SHADER, FRAG);
92    if (!vs || !fs) return false;
93    var prog = gl.createProgram();
94    gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
95    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return false;
96    gl.useProgram(prog);
97
98    var buf = gl.createBuffer();
99    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
100    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
101    var loc = gl.getAttribLocation(prog, "p");
102    gl.enableVertexAttribArray(loc);
103    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
104
105    var U = {
106      res: gl.getUniformLocation(prog, "u_res"),
107      time: gl.getUniformLocation(prog, "u_time"),
108      mouse: gl.getUniformLocation(prog, "u_mouse"),
109      scroll: gl.getUniformLocation(prog, "u_scroll"),
110      warp: gl.getUniformLocation(prog, "u_warp")
111    };
112
113    var scale = 0.6; // render at reduced resolution, CSS upscales — cheap on fill rate
114    var w, h, smx = -9999, smy = -9999;
115
116    function resize() {
117      var dpr = Math.min(window.devicePixelRatio || 1, 1.5);
118      w = Math.floor(window.innerWidth * dpr * scale);
119      h = Math.floor(window.innerHeight * dpr * scale);
120      canvas.width = w; canvas.height = h;
121      gl.viewport(0, 0, w, h);
122    }
123
124    var running = true, start = performance.now();
125    function frame(now) {
126      if (!running) return;
127      var t = (now - start) / 1000;
128      // smooth mouse
129      var tx = mouse.active ? mouse.x / window.innerWidth * w : -9999;
130      var ty = mouse.active ? (1 - mouse.y / window.innerHeight) * h : -9999;
131      smx = tx === -9999 ? -9999 : (smx === -9999 ? tx : lerp(smx, tx, 0.08));
132      smy = ty === -9999 ? -9999 : (smy === -9999 ? ty : lerp(smy, ty, 0.08));
133      WARP.level = lerp(WARP.level, WARP.target, 0.05);
134      gl.uniform2f(U.res, w, h);
135      gl.uniform1f(U.time, t * (1 + WARP.level * 6));
136      gl.uniform2f(U.mouse, smx, smy);
137      gl.uniform1f(U.scroll, window.scrollY);
138      gl.uniform1f(U.warp, WARP.level);
139      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
140      if (!reducedMotion) requestAnimationFrame(frame);
141    }
142
143    window.addEventListener("resize", resize);
144    document.addEventListener("visibilitychange", function () {
145      running = !document.hidden;
146      if (running && !reducedMotion) requestAnimationFrame(frame);
147    });
148    resize();
149    requestAnimationFrame(frame);
150    return true;
151  }
152
153  function initBackground2D(canvas) {
154    var ctx = canvas.getContext("2d");
155    var stars = [], w, h, dpr;
156    function resize() {
157      dpr = Math.min(window.devicePixelRatio || 1, 2);
158      w = window.innerWidth; h = window.innerHeight;
159      canvas.width = w * dpr; canvas.height = h * dpr;
160      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
161      var count = Math.min(160, Math.floor((w * h) / 9000));
162      stars = [];
163      for (var i = 0; i < count; i++) {
164        stars.push({ x: Math.random() * w, y: Math.random() * h, r: Math.random() * 1.4 + 0.3,
165          vx: (Math.random() - 0.5) * 0.12, vy: (Math.random() - 0.5) * 0.12, tw: Math.random() * Math.PI * 2 });
166      }
167    }
168    function frame(t) {
169      ctx.clearRect(0, 0, w, h);
170      for (var i = 0; i < stars.length; i++) {
171        var s = stars[i];
172        s.x += s.vx; s.y += s.vy;
173        if (s.x < -10) s.x = w + 10; if (s.x > w + 10) s.x = -10;
174        if (s.y < -10) s.y = h + 10; if (s.y > h + 10) s.y = -10;
175        var twinkle = 0.45 + 0.4 * Math.sin(t / 900 + s.tw);
176        ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
177        ctx.fillStyle = "rgba(155,175,255," + twinkle * 0.7 + ")"; ctx.fill();
178        var dx = s.x - mouse.x, dy = s.y - mouse.y, d = Math.sqrt(dx * dx + dy * dy);
179        if (d < 110) {
180          ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(mouse.x, mouse.y);
181          ctx.strokeStyle = "rgba(91,140,255," + (1 - d / 110) * 0.25 + ")"; ctx.lineWidth = 0.6; ctx.stroke();
182        }
183      }
184      if (!reducedMotion) requestAnimationFrame(frame);
185    }
186    window.addEventListener("resize", resize);
187    resize();
188    requestAnimationFrame(frame);
189  }
190
191  function initBackground() {
192    var canvas = document.getElementById("starfield");
193    if (!canvas) return;
194    var ok = false;
195    try { ok = initBackgroundGL(canvas); } catch (e) { ok = false; }
196    if (!ok) initBackground2D(canvas);
197    if (!reducedMotion) {
198      var grain = document.createElement("div");
199      grain.id = "grain";
200      document.body.appendChild(grain);
201    }
202  }
203
204  /* ======================================================================
205     CURSOR — dot + lagging ring (fine pointers only)
206     ====================================================================== */
207  function initCursor() {
208    if (!finePointer || reducedMotion) return;
209    var dot = document.createElement("div"), ring = document.createElement("div");
210    dot.id = "cur-dot"; ring.id = "cur-ring";
211    document.body.appendChild(dot); document.body.appendChild(ring);
212    document.body.classList.add("has-cursor", "cur-off");
213    var rx = 0, ry = 0, shown = false;
214
215    function tick() {
216      rx = lerp(rx, mouse.x, 0.18); ry = lerp(ry, mouse.y, 0.18);
217      dot.style.transform = "translate(" + mouse.x + "px," + mouse.y + "px) translate(-50%,-50%)";
218      ring.style.transform = "translate(" + rx + "px," + ry + "px) translate(-50%,-50%)";
219      requestAnimationFrame(tick);
220    }
221    requestAnimationFrame(tick);
222
223    window.addEventListener("pointermove", function (e) {
224      if (!shown) { shown = true; rx = e.clientX; ry = e.clientY; document.body.classList.remove("cur-off"); }
225      var el = e.target;
226      var interactive = el.closest && el.closest("a, button, .card, .gallery-img, .dot, .rmap canvas, input, label");
227      document.body.classList.toggle("cur-hover", !!interactive);
228      var isInput = el.closest && el.closest("input, textarea");
229      document.body.classList.toggle("cur-off", !!isInput);
230    }, { passive: true });
231    document.addEventListener("pointerdown", function () { document.body.classList.add("cur-down"); });
232    document.addEventListener("pointerup", function () { document.body.classList.remove("cur-down"); });
233    document.addEventListener("mouseleave", function () { document.body.classList.add("cur-off"); });
234    document.addEventListener("mouseenter", function () { document.body.classList.remove("cur-off"); });
235  }
236
237  /* ======================================================================
238     SCRAMBLE / DECODE TEXT
239     ====================================================================== */
240  var GLYPHS = "!<>-_\\/[]{}—=+*^?#01";
241
242  function scramble(el, opts) {
243    opts = opts || {};
244    if (reducedMotion) return;
245    var nodes = [];
246    (function walk(n) {
247      for (var i = 0; i < n.childNodes.length; i++) {
248        var c = n.childNodes[i];
249        if (c.nodeType === 3 && c.textContent.trim()) nodes.push(c);
250        else if (c.nodeType === 1) walk(c);
251      }
252    })(el);
253    var targets = nodes.map(function (n) {
254      var span = document.createElement("span");
255      n.parentNode.replaceChild(span, n);
256      return { span: span, text: n.textContent };
257    });
258    var dur = opts.duration || 900, start = null;
259    function frame(ts) {
260      if (!start) start = ts;
261      var p = Math.min((ts - start) / dur, 1);
262      targets.forEach(function (t) {
263        var out = "", len = t.text.length;
264        for (var i = 0; i < len; i++) {
265          var ch = t.text[i];
266          var reveal = (i / len) * 0.75 + 0.1; // left-to-right reveal
267          if (ch === " " || p >= reveal) out += ch;
268          else out += '<span class="sc-glyph">' + GLYPHS[Math.floor(Math.random() * GLYPHS.length)] + "</span>";
269        }
270        t.span.innerHTML = out;
271      });
272      if (p < 1) requestAnimationFrame(frame);
273      else targets.forEach(function (t) { t.span.textContent = t.text; });
274    }
275    requestAnimationFrame(frame);
276  }
277
278  function initScramble() {
279    var els = document.querySelectorAll("[data-scramble]");
280    if (!els.length || reducedMotion) return;
281    var io = new IntersectionObserver(function (entries) {
282      entries.forEach(function (en) {
283        if (en.isIntersecting) {
284          scramble(en.target, { duration: +en.target.getAttribute("data-scramble") || 900 });
285          io.unobserve(en.target);
286        }
287      });
288    }, { threshold: 0.3 });
289    els.forEach(function (el) { io.observe(el); });
290  }
291
292  /* ======================================================================
293     MAGNETIC BUTTONS + SPOTLIGHT CARDS + HERO PARALLAX
294     ====================================================================== */
295  function initMagnetic() {
296    if (!finePointer || reducedMotion) return;
297    document.querySelectorAll(".btn, .cmdk-btn, .filter-btn").forEach(function (btn) {
298      btn.addEventListener("pointermove", function (e) {
299        var r = btn.getBoundingClientRect();
300        var dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
301        btn.style.transform = "translate(" + dx * 0.22 + "px," + dy * 0.28 + "px)";
302        btn.style.transition = "transform 0.1s ease-out, box-shadow 0.18s, border-color 0.18s, background 0.18s";
303      });
304      btn.addEventListener("pointerleave", function () {
305        btn.style.transform = "";
306        btn.style.transition = "transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s, border-color 0.18s, background 0.18s";
307      });
308    });
309  }
310
311  function initSpotlight() {
312    if (!finePointer) return;
313    document.addEventListener("pointermove", function (e) {
314      var card = e.target.closest && e.target.closest(".card");
315      if (!card) return;
316      var r = card.getBoundingClientRect();
317      card.style.setProperty("--mx", (e.clientX - r.left) + "px");
318      card.style.setProperty("--my", (e.clientY - r.top) + "px");
319    }, { passive: true });
320  }
321
322  function initHeroParallax() {
323    if (!finePointer || reducedMotion) return;
324    var hero = document.querySelector(".hero");
325    if (!hero) return;
326    var layers = hero.querySelectorAll("[data-depth]");
327    if (!layers.length) return;
328    hero.addEventListener("pointermove", function (e) {
329      var r = hero.getBoundingClientRect();
330      var px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
331      layers.forEach(function (l) {
332        var d = parseFloat(l.getAttribute("data-depth"));
333        l.style.transform = "translate(" + (-px * d * 30) + "px," + (-py * d * 30) + "px)";
334      });
335    });
336    hero.addEventListener("pointerleave", function () {
337      layers.forEach(function (l) { l.style.transform = ""; });
338    });
339  }
340
341  /* ======================================================================
342     PAGE TRANSITIONS
343     ====================================================================== */
344  function initTransitions() {
345    var curtain = document.createElement("div");
346    curtain.id = "curtain";
347    document.body.appendChild(curtain);
348    if (reducedMotion) return;
349
350    document.addEventListener("click", function (e) {
351      var a = e.target.closest && e.target.closest("a[href]");
352      if (!a) return;
353      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
354      if (a.target === "_blank" || a.hasAttribute("download")) return;
355      var href = a.getAttribute("href");
356      if (!href || href[0] === "#" || /^(mailto|tel|javascript):/.test(href)) return;
357      var url;
358      try { url = new URL(a.href, location.href); } catch (err) { return; }
359      if (url.origin !== location.origin) return;
360      if (url.pathname === location.pathname && url.hash) return;
361      if (/\.(pdf|jpg|jpeg|png|zip)$/i.test(url.pathname)) return;
362      e.preventDefault();
363      document.body.classList.add("leaving");
364      setTimeout(function () { location.href = url.href; }, 300);
365    });
366
367    window.addEventListener("pageshow", function (e) {
368      if (e.persisted) document.body.classList.remove("leaving");
369    });
370  }
371
372  /* ======================================================================
373     NAV — scrolled state, hide on scroll down, active link, burger
374     ====================================================================== */
375  function initNav() {
376    var nav = document.querySelector(".nav");
377    var burger = document.querySelector(".nav-burger");
378    var links = document.querySelector(".nav-links");
379    if (burger && links) burger.addEventListener("click", function () { links.classList.toggle("open"); });
380
381    var path = location.pathname.replace(/index\.html$/, "");
382    document.querySelectorAll(".nav-links a").forEach(function (a) {
383      var href = a.getAttribute("href");
384      if (href === "/" ? path === "/" : path.indexOf(href.replace(/\/$/, "")) === 0 && href !== "/") a.classList.add("active");
385    });
386
387    if (!nav) return;
388    var last = window.scrollY, ticking = false;
389    function onScroll() {
390      var y = window.scrollY;
391      nav.classList.toggle("scrolled", y > 24);
392      if (y > 320 && y > last + 4) nav.classList.add("hidden");
393      else if (y < last - 4 || y < 320) nav.classList.remove("hidden");
394      last = y; ticking = false;
395    }
396    window.addEventListener("scroll", function () {
397      if (!ticking) { requestAnimationFrame(onScroll); ticking = true; }
398    }, { passive: true });
399    onScroll();
400  }
401
402  /* ======================================================================
403     SCROLL SPY (homepage sections)
404     ====================================================================== */
405  function initSpy() {
406    var sections = document.querySelectorAll("section.block[id]");
407    if (sections.length < 3) return;
408    var spy = document.createElement("nav");
409    spy.id = "spy";
410    spy.setAttribute("aria-label", "Sections");
411    var items = [];
412    sections.forEach(function (s, i) {
413      var label = (s.querySelector(".eyebrow") || {}).textContent || s.id;
414      label = label.replace(/^\s*\d+\s*/, "").trim();
415      var a = document.createElement("a");
416      a.href = "#" + s.id;
417      a.innerHTML = '<span class="lbl">' + label + "</span><i></i>";
418      spy.appendChild(a);
419      items.push(a);
420    });
421    document.body.appendChild(spy);
422    var io = new IntersectionObserver(function (entries) {
423      entries.forEach(function (en) {
424        if (en.isIntersecting) {
425          items.forEach(function (a, i) { a.classList.toggle("active", sections[i] === en.target); });
426        }
427      });
428    }, { rootMargin: "-40% 0px -50% 0px" });
429    sections.forEach(function (s) { io.observe(s); });
430  }
431
432  /* ======================================================================
433     SCROLL PROGRESS / REVEAL / TYPING / COUNTERS / SKILL BARS / TILT
434     ====================================================================== */
435  function initProgress() {
436    var bar = document.getElementById("progress");
437    if (!bar) return;
438    function update() {
439      var max = document.documentElement.scrollHeight - window.innerHeight;
440      bar.style.width = (max > 0 ? (window.scrollY / max) * 100 : 0) + "%";
441    }
442    window.addEventListener("scroll", update, { passive: true });
443    update();
444  }
445
446  function initReveal() {
447    var els = document.querySelectorAll(".reveal");
448    if (!els.length) return;
449    if (!("IntersectionObserver" in window) || reducedMotion) {
450      els.forEach(function (el) { el.classList.add("visible"); });
451      return;
452    }
453    var io = new IntersectionObserver(function (entries) {
454      entries.forEach(function (en) {
455        if (en.isIntersecting) { en.target.classList.add("visible"); io.unobserve(en.target); }
456      });
457    }, { threshold: 0.12 });
458    els.forEach(function (el) { io.observe(el); });
459  }
460
461  function initTyping() {
462    var el = document.querySelector("[data-type]");
463    if (!el) return;
464    var phrases;
465    try { phrases = JSON.parse(el.getAttribute("data-type")); } catch (e) { return; }
466    var target = el.querySelector(".typed");
467    if (!target || !phrases.length) return;
468    if (reducedMotion) { target.textContent = phrases[0]; return; }
469    var pi = 0, ci = 0, deleting = false;
470    function tick() {
471      var phrase = phrases[pi];
472      if (!deleting) {
473        ci++;
474        target.textContent = phrase.slice(0, ci);
475        if (ci === phrase.length) { deleting = true; setTimeout(tick, 2100); return; }
476        setTimeout(tick, 38 + Math.random() * 40);
477      } else {
478        ci--;
479        target.textContent = phrase.slice(0, ci);
480        if (ci === 0) { deleting = false; pi = (pi + 1) % phrases.length; setTimeout(tick, 350); return; }
481        setTimeout(tick, 18);
482      }
483    }
484    setTimeout(tick, 1100);
485  }
486
487  function initCounters() {
488    var nums = document.querySelectorAll("[data-count]");
489    if (!nums.length) return;
490    function animate(el) {
491      var end = parseFloat(el.getAttribute("data-count"));
492      var suffix = el.getAttribute("data-suffix") || "";
493      var dec = parseInt(el.getAttribute("data-decimals") || "0", 10);
494      function fmt(v) { return (dec ? v.toFixed(dec) : String(Math.round(v))) + suffix; }
495      if (reducedMotion) { el.textContent = fmt(end); return; }
496      var dur = 1400, start = null;
497      function step(ts) {
498        if (!start) start = ts;
499        var p = Math.min((ts - start) / dur, 1);
500        el.textContent = fmt(end * (1 - Math.pow(1 - p, 3)));
501        if (p < 1) requestAnimationFrame(step);
502      }
503      requestAnimationFrame(step);
504    }
505    var io = new IntersectionObserver(function (entries) {
506      entries.forEach(function (en) { if (en.isIntersecting) { animate(en.target); io.unobserve(en.target); } });
507    }, { threshold: 0.4 });
508    nums.forEach(function (el) { io.observe(el); });
509  }
510
511  function initSkillBars() {
512    var fills = document.querySelectorAll(".skill .fill");
513    if (!fills.length) return;
514    var io = new IntersectionObserver(function (entries) {
515      entries.forEach(function (en) {
516        if (en.isIntersecting) { en.target.style.width = en.target.getAttribute("data-w") + "%"; io.unobserve(en.target); }
517      });
518    }, { threshold: 0.4 });
519    fills.forEach(function (el) { io.observe(el); });
520  }
521
522  function initTilt() {
523    if (reducedMotion || !finePointer) return;
524    document.querySelectorAll(".card.tilt, [data-tilt]").forEach(function (card) {
525      card.addEventListener("pointermove", function (e) {
526        var r = card.getBoundingClientRect();
527        var px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
528        card.style.transform = "perspective(800px) rotateY(" + px * 6 + "deg) rotateX(" + -py * 6 + "deg) translateY(-4px)";
529      });
530      card.addEventListener("pointerleave", function () { card.style.transform = ""; });
531    });
532  }
533
534  /* ======================================================================
535     SMALL WIDGETS — abstracts, copy, filters, lightbox, carousel, read time,
536     footer clock, toast
537     ====================================================================== */
538  function initAbstracts() {
539    document.querySelectorAll("[data-toggle]").forEach(function (btn) {
540      btn.addEventListener("click", function () {
541        var target = document.getElementById(btn.getAttribute("data-toggle"));
542        if (!target) return;
543        btn.textContent = target.classList.toggle("open") ? "Hide abstract" : "Abstract";
544      });
545    });
546  }
547
548  function initCopy() {
549    document.querySelectorAll("[data-copy]").forEach(function (btn) {
550      btn.addEventListener("click", function () {
551        var src = document.getElementById(btn.getAttribute("data-copy"));
552        if (!src) return;
553        navigator.clipboard.writeText(src.textContent.trim()).then(function () {
554          var old = btn.textContent;
555          btn.textContent = "Copied ✓";
556          toast("BibTeX copied to clipboard");
557          setTimeout(function () { btn.textContent = old; }, 1600);
558        });
559      });
560    });
561  }
562
563  function initFilters() {
564    var bar = document.querySelector(".filter-bar");
565    if (!bar) return;
566    bar.addEventListener("click", function (e) {
567      var btn = e.target.closest(".filter-btn");
568      if (!btn) return;
569      bar.querySelectorAll(".filter-btn").forEach(function (b) { b.classList.remove("active"); });
570      btn.classList.add("active");
571      var f = btn.getAttribute("data-filter");
572      document.querySelectorAll("[data-tags]").forEach(function (item, i) {
573        var show = f === "all" || item.getAttribute("data-tags").split(" ").indexOf(f) !== -1;
574        item.style.display = show ? "" : "none";
575        if (show && !reducedMotion) {
576          item.style.animation = "none";
577          void item.offsetWidth;
578          item.style.animation = "fade-in 0.4s ease " + (i * 0.04) + "s both";
579        }
580      });
581    });
582  }
583
584  function initLightbox() {
585    var imgs = document.querySelectorAll(".gallery-img");
586    if (!imgs.length) return;
587    var box = document.createElement("div");
588    box.id = "lightbox";
589    box.innerHTML = "<img alt=''>";
590    document.body.appendChild(box);
591    var boxImg = box.querySelector("img");
592    imgs.forEach(function (img) {
593      img.addEventListener("click", function () {
594        boxImg.src = img.getAttribute("data-full") || img.src;
595        box.classList.add("open");
596      });
597    });
598    box.addEventListener("click", function () { box.classList.remove("open"); });
599    document.addEventListener("keydown", function (e) { if (e.key === "Escape") box.classList.remove("open"); });
600  }
601
602  function initCarousels() {
603    document.querySelectorAll(".carousel").forEach(function (car) {
604      var imgs;
605      try { imgs = JSON.parse(car.getAttribute("data-images")); } catch (e) { return; }
606      var frameImg = car.querySelector(".frame img");
607      var dotsWrap = car.querySelector(".dots");
608      var idx = 0, timer;
609      imgs.forEach(function (_, i) {
610        var d = document.createElement("button");
611        d.className = "dot" + (i === 0 ? " active" : "");
612        d.setAttribute("aria-label", "Photo " + (i + 1));
613        d.addEventListener("click", function () { go(i); restart(); });
614        dotsWrap.appendChild(d);
615      });
616      function go(i) {
617        idx = (i + imgs.length) % imgs.length;
618        frameImg.style.opacity = "0";
619        setTimeout(function () { frameImg.src = imgs[idx]; frameImg.style.opacity = "1"; }, 280);
620        dotsWrap.querySelectorAll(".dot").forEach(function (d, j) { d.classList.toggle("active", j === idx); });
621      }
622      function restart() {
623        clearInterval(timer);
624        if (!reducedMotion) timer = setInterval(function () { go(idx + 1); }, 5000);
625      }
626      car.querySelector(".c-prev").addEventListener("click", function () { go(idx - 1); restart(); });
627      car.querySelector(".c-next").addEventListener("click", function () { go(idx + 1); restart(); });
628      car.addEventListener("pointerenter", function () { clearInterval(timer); });
629      car.addEventListener("pointerleave", restart);
630      restart();
631    });
632  }
633
634  function initReadTime() {
635    var body = document.querySelector(".article-body");
636    var eyebrow = document.querySelector(".article header .eyebrow");
637    if (!body || !eyebrow) return;
638    var words = body.textContent.trim().split(/\s+/).length;
639    var mins = Math.max(1, Math.round(words / 220));
640    var span = document.createElement("span");
641    span.className = "readtime";
642    span.textContent = mins + " min read";
643    eyebrow.appendChild(span);
644  }
645
646  /* highlights the current section in the article TOC rail. Uses viewport-relative
647     rects rather than offsetTop: .article is transformed by the page-slide animation,
648     which moves the offsetParent out from under the headings. */
649  function initArticleToc() {
650    var toc = document.querySelector(".article-toc");
651    if (!toc) return;
652    var links = [].slice.call(toc.querySelectorAll("a"));
653    var heads = links.map(function (a) {
654      var id = a.getAttribute("href").slice(1);
655      try { return document.getElementById(decodeURIComponent(id)); } catch (e) { return document.getElementById(id); }
656    });
657    if (!links.length || heads.indexOf(null) !== -1) { toc.style.display = "none"; return; }
658
659    var ticking = false;
660    function update() {
661      ticking = false;
662      var idx = 0;
663      heads.forEach(function (h, i) { if (h.getBoundingClientRect().top <= 140) idx = i; });
664      links.forEach(function (a, i) { a.classList.toggle("active", i === idx); });
665    }
666    update();
667    window.addEventListener("scroll", function () {
668      if (!ticking) { ticking = true; requestAnimationFrame(update); }
669    }, { passive: true });
670  }
671
672  function initClock() {
673    var note = document.querySelector(".foot-note");
674    if (!note) return;
675    var up = document.createElement("span"); up.className = "up"; up.title = "systems nominal";
676    var clock = document.createElement("span"); clock.className = "clock";
677    note.appendChild(up); note.appendChild(clock);
678    function tick() {
679      try {
680        clock.textContent = "BERKELEY " + new Date().toLocaleTimeString("en-US", { timeZone: "America/Los_Angeles", hour12: false });
681      } catch (e) { clock.textContent = new Date().toLocaleTimeString(); }
682    }
683    tick(); setInterval(tick, 1000);
684  }
685
686  var toastEl, toastTimer;
687  function toast(msg) {
688    if (!toastEl) { toastEl = document.createElement("div"); toastEl.id = "toast"; document.body.appendChild(toastEl); }
689    toastEl.textContent = msg;
690    toastEl.classList.add("show");
691    clearTimeout(toastTimer);
692    toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2200);
693  }
694
695  /* ======================================================================
696     EASTER EGGS — Konami hyperdrive
697     ====================================================================== */
698  function initEggs() {
699    var seq = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"];
700    var pos = 0;
701    document.addEventListener("keydown", function (e) {
702      var k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
703      pos = k === seq[pos] ? pos + 1 : (k === seq[0] ? 1 : 0);
704      if (pos === seq.length) {
705        pos = 0;
706        WARP.target = 1;
707        document.body.classList.add("warp");
708        toast("⚡ hyperdrive engaged");
709        setTimeout(function () { WARP.target = 0; document.body.classList.remove("warp"); }, 4500);
710      }
711    });
712  }
713
714  /* ======================================================================
715     RESEARCH MAP — force-directed graph of papers, ideas, people, venues
716     ====================================================================== */
717  var RMAP_DATA = {
718    nodes: [
719      { id: "me", type: "self", label: "Jerick Shi", sub: "multi-agent LLM systems · AI safety", href: "/" },
720
721      { id: "lie", type: "paper", label: "When Agents Lie", sub: "ICML '26 NExT-Game workshop · Best Paper Award", star: true,
722        href: "https://openreview.net/pdf?id=v8nYIkYjY0", ext: true, tip: "Premeditation, persistence, and exploitation in repeated games" },
723      { id: "silence", type: "paper", label: "Strategic Silence", sub: "ICML '26 Failure Modes of Agentic AI 
723workshop",
724        href: "https://openreview.net/pdf?id=ZOdCsExYgi", ext: true, tip: "What game-theoretic benchmarks miss in multi-agent LLMs" },
725      { id: "cheap", type: "paper", label: "Cheap Talk, Empty Promise", sub: "ICLR '26 AI for Mechanism Design workshop",
726        href: "https://arxiv.org/abs/2604.04782", ext: true, tip: "Frontier LLMs break public promises for self-interest" },
727      { id: "tax", type: "paper", label: "Hallucination → Scheming", sub: "ICLR '26 Agents in the Wild workshop",
728        href: "https://arxiv.org/abs/2604.04788", ext: true, tip: "A unified taxonomy and benchmark analysis for LLM deception" },
729      { id: "market", type: "paper", label: "Market-Dependent Communication", sub: "NeurIPS '25 GenAI in Finance workshop",
730        href: "https://doi.org/10.48550/arXiv.2511.13614", ext: true, tip: "Communication in multi-agent alpha generation" },
731      { id: "thesis", type: "paper", label: "Master's Thesis", sub: "CMU-CS-26-105 · The Structure of Deception",
732        href: "/projects/masters-thesis/", tip: "How LLM agents lie, break promises, and exploit trust" },
733      { id: "senior", type: "paper", label: "Senior Thesis", sub: "Multi-agent conversations & LLM forecasting",
734        href: "/projects/senior-thesis/", tip: "Why multi-agent conversations cannot fix LLM forecasting" },
735      { id: "mom", type: "paper", label: "LLMs in Financial Markets", sub: "Meeting of the Minds · First Place",
736        href: "/projects/meeting-minds/", tip: "Predictive power of LLMs on the Fed's Beige Book" },
737
738      { id: "c-decep", type: "concept", label: "deception" },
739      { id: "c-repeat", type: "concept", label: "repeated games" },
740      { id: "c-commit", type: "concept", label: "commitment" },
741      { id: "c-cheap", type: "concept", label: "cheap talk" },
742      { id: "c-bench", type: "concept", label: "benchmarks" },
743      { id: "c-tax", type: "concept", label: "taxonomy" },
744      { id: "c-multi", type: "concept", label: "multi-agent" },
745      { id: "c-game", type: "concept", label: "game theory" },
746      { id: "c-safety", type: "concept", label: "AI safety" },
747      { id: "c-fin", type: "concept", label: "finance" },
748      { id: "c-comm", type: "concept", label: "communication" },
749      { id: "c-forecast", type: "concept", label: "forecasting" },
750      { id: "c-trust", type: "concept", label: "trust & exploitation" },
751
752      { id: "p-conitzer", type: "person", label: "Vincent Conitzer", sub: "CMU · advisor", href: "https://www.cs.cmu.edu/~conitzer/", ext: true },
753      { id: "p-jin", type: "person", label: "Zhijing Jin", sub: "U Toronto · advisor", href: "https://zhijing-jin.com/", ext: true },
754      { id: "p-zhang", type: "person", label: "Terry Zhang", sub: "coauthor" },
755      { id: "p-schoelkopf", type: "person", label: "Bernhard Schölkopf", sub: "MPI-IS · coauthor" },
756      { id: "p-hollifield", type: "person", label: "Burton Hollifield", sub: "CMU Tepper · coauthor" },
757      { id: "p-raghunathan", type: "person", label: "Aditi Raghunathan", sub: "CMU · thesis committee" },
758
759      { id: "v-icml", type: "venue", label: "ICML 2026" },
760      { id: "v-iclr", type: "venue", label: "ICLR 2026" },
761      { id: "v-neurips", type: "venue", label: "NeurIPS 2025" },
762      { id: "v-cmu", type: "venue", label: "CMU" }
763    ],
764    links: [
765      ["me", "lie"], ["me", "silence"], ["me", "cheap"], ["me", "tax"], ["me", "market"], ["me", "thesis"], ["me", "senior"], ["me", "mom"],
766
767      ["lie", "c-decep"], ["lie", "c-repeat"], ["lie", "c-trust"], ["lie", "c-multi"],
768      ["silence", "c-bench"], ["silence", "c-comm"], ["silence", "c-game"], ["silence", "c-multi"],
769      ["cheap", "c-commit"], ["cheap", "c-cheap"], ["cheap", "c-decep"], ["cheap", "c-game"],
770      ["tax", "c-tax"], ["tax", "c-bench"], ["tax", "c-decep"], ["tax", "c-safety"],
771      ["market", "c-fin"], ["market", "c-comm"], ["market", "c-multi"],
772      ["thesis", "c-decep"], ["thesis", "c-repeat"], ["thesis", "c-commit"], ["thesis", "c-trust"], ["thesis", "c-safety"],
773      ["senior", "c-forecast"], ["senior", "c-multi"], ["senior", "c-comm"],
774      ["mom", "c-fin"], ["mom", "c-forecast"],
775
776      ["lie", "p-zhang"], ["lie", "p-schoelkopf"], ["lie", "p-conitzer"], ["lie", "p-jin"],
777      ["silence", "p-zhang"], ["silence", "p-conitzer"], ["silence", "p-jin"],
778      ["cheap", "p-zhang"], ["cheap", "p-jin"], ["cheap", "p-conitzer"],
779      ["tax", "p-zhang"], ["tax", "p-jin"], ["tax", "p-conitzer"],
780      ["market", "p-hollifield"],
781      ["thesis", "p-conitzer"], ["thesis", "p-jin"], ["thesis", "p-raghunathan"],
782
783      ["lie", "v-icml"], ["silence", "v-icml"], ["cheap", "v-iclr"], ["tax", "v-iclr"], ["market", "v-neurips"],
784      ["thesis", "v-cmu"], ["senior", "v-cmu"], ["mom", "v-cmu"]
785    ]
786  };
787
788  var RMAP_STYLE = {
789    self: { color: "#ffffff", glow: "rgba(70,224,255,0.9)", r: 15, label: "always" },
790    paper: { color: "#9d6bff", glow: "rgba(157,107,255,0.9)", r: 9, label: "always" },
791    concept: { color: "#46e0ff", glow: "rgba(70,224,255,0.8)", r: 5.5, label: "wide" },
792    person: { color: "#5b8cff", glow: "rgba(91,140,255,0.8)", r: 6.5, label: "wide" },
793    venue: { color: "#ffd479", glow: "rgba(255,212,121,0.8)", r: 6, label: "wide" }
794  };
795
796  function createResearchMap(host) {
797    var compact = host.classList.contains("compact");
798    var canvas = document.createElement("canvas");
799    host.appendChild(canvas);
800    var ctx = canvas.getContext("2d");
801
802    // legend
803    var legend = document.createElement("div");
804    legend.className = "rmap-legend";
805    var hidden = {};
806    ["paper", "concept", "person", "venue"].forEach(function (t) {
807      var b = document.createElement("button");
808      b.innerHTML = "<i style=\"--c:" + RMAP_STYLE[t].color + "\"></i>" + (t === "person" ? "people" : t + "s");
809      b.addEventListener("click", function () {
810        hidden[t] = !hidden[t];
811        b.classList.toggle("off", hidden[t]);
812        alpha = Math.max(alpha, 0.5);
813      });
814      legend.appendChild(b);
815    });
816    host.appendChild(legend);
817
818    var hint = document.createElement("div");
819    hint.className = "rmap-hint";
820    hint.textContent = finePointer ? "drag nodes · hover to explore · click to open" : "drag nodes · tap to open";
821    host.appendChild(hint);
822
823    var tip = document.createElement("div");
824    tip.className = "rmap-tip";
825    host.appendChild(tip);
826
827    // graph state
828    var nodes = RMAP_DATA.nodes.map(function (n) {
829      return Object.assign({ x: 0, y: 0, vx: 0, vy: 0, phase: Math.random() * Math.PI * 2 }, n, { style: RMAP_STYLE[n.type] });
830    });
831    var byId = {};
832    nodes.forEach(function (n) { byId[n.id] = n; });
833    var links = RMAP_DATA.links.map(function (l) { return { a: byId[l[0]], b: byId[l[1]] }; });
834    var adj = {};
835    links.forEach(function (l) {
836      (adj[l.a.id] = adj[l.a.id] || []).push(l.b.id);
837      (adj[l.b.id] = adj[l.b.id] || []).push(l.a.id);
838    });
839
840    var W = 0, H = 0, dpr = 1, alpha = 1, hover = null, drag = null, pinned = null, externalHighlight = null;
841
842    function visible(n) { return !hidden[n.type]; }
843
844    function resize() {
845      dpr = Math.min(window.devicePixelRatio || 1, 2);
846      var r = host.getBoundingClientRect();
847      W = r.width; H = r.height;
848      canvas.width = W * dpr; canvas.height = H * dpr;
849      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
850      alpha = Math.max(alpha, 0.4);
851    }
852
853    function seed() {
854      var cx = W / 2, cy = H / 2;
855      nodes.forEach(function (n, i) {
856        var ang = (i / nodes.length) * Math.PI * 2, rad = n.type === "self" ? 0 : n.type === "paper" ? Math.min(W, H) * 0.18 : Math.min(W, H) * 0.36;
857        n.x = cx + Math.cos(ang) * rad + (Math.random() - 0.5) * 20;
858        n.y = cy + Math.sin(ang) * rad + (Math.random() - 0.5) * 20;
859      });
860    }
861
862    function restLength(l) {
863      var t = l.a.type + "-" + l.b.type;
864      var base = Math.min(W, H);
865      if (t === "self-paper") return base * (compact ? 0.30 : 0.26);
866      if (l.a.type === "paper" && l.b.type === "concept") return base * (compact ? 0.22 : 0.19);
867      return base * (compact ? 0.24 : 0.21);
868    }
869
870    function step() {
871      var i, j, n, m, dx, dy, d2, d, f;
872      var k = Math.min(W, H) * (compact ? 9 : 11);
873      for (i = 0; i < nodes.length; i++) {
874        n = nodes[i];
875        if (!visible(n)) continue;
876        for (j = i + 1; j < nodes.length; j++) {
877          m = nodes[j];
878          if (!visible(m)) continue;
879          dx = m.x - n.x; dy = m.y - n.y;
880          d2 = dx * dx + dy * dy + 0.01;
881          if (d2 > 250000) continue;
882          f = (k / d2) * alpha;
883          d = Math.sqrt(d2);
884          dx /= d; dy /= d;
885          n.vx -= dx * f; n.vy -= dy * f;
886          m.vx += dx * f; m.vy += dy * f;
887        }
888      }
889      links.forEach(function (l) {
890        if (!visible(l.a) || !visible(l.b)) return;
891        dx = l.b.x - l.a.x; dy = l.b.y - l.a.y;
892        d = Math.sqrt(dx * dx + dy * dy) + 0.01;
893        f = (d - restLength(l)) * 0.012 * alpha;
894        dx /= d; dy /= d;
895        l.a.vx += dx * f; l.a.vy += dy * f;
896        l.b.vx -= dx * f; l.b.vy -= dy * f;
897      });
898      var cx = W / 2, cy = H / 2, t = performance.now() / 1000;
899      nodes.forEach(function (n) {
900        if (!visible(n)) return;
901        if (n === drag) { n.vx = n.vy = 0; return; }
902        // gravity is weaker along the wider axis so the graph fills the box
903        var gx = n.type === "self" ? 0.03 : (W >= H ? 0.0012 : 0.003);
904        var gy = n.type === "self" ? 0.03 : (W >= H ? 0.003 : 0.0012);
905        n.vx += (cx - n.x) * gx * alpha;
906        n.vy += (cy - n.y) * gy * alpha;
907        // gentle idle drift so the map never looks frozen
908        if (!reducedMotion) { n.vx += Math.cos(t * 0.6 + n.phase) * 0.012; n.vy += Math.sin(t * 0.5 + n.phase) * 0.012; }
909        n.vx *= 0.82; n.vy *= 0.82;
910        n.x += n.vx; n.y += n.vy;
911        n.x = clamp(n.x, 30, W - 30); n.y = clamp(n.y, 40, H - 44);
912      });
913      alpha = Math.max(alpha * 0.985, 0.06);
914    }
915
916    function focusSet() {
917      var f = hover || pinned || externalHighlight;
918      if (!f) return null;
919      var set = {}; set[f.id] = true;
920      (adj[f.id] || []).forEach(function (id) { set[id] = true; });
921      return set;
922    }
923
924    function draw() {
925      ctx.clearRect(0, 0, W, H);
926      var focus = focusSet();
927      var showAllLabels = W > 700;
928
929      // links
930      links.forEach(function (l) {
931        if (!visible(l.a) || !visible(l.b)) return;
932        var lit = focus && focus[l.a.id] && focus[l.b.id] && (l.a === (hover || pinned || externalHighlight) || l.b === (hover || pinned || externalHighlight));
933        ctx.beginPath();
934        ctx.moveTo(l.a.x, l.a.y); ctx.lineTo(l.b.x, l.b.y);
935        if (lit) {
936          var g = ctx.createLinearGradient(l.a.x, l.a.y, l.b.x, l.b.y);
937          g.addColorStop(0, l.a.style.color); g.addColorStop(1, l.b.style.color);
938          ctx.strokeStyle = g; ctx.lineWidth = 1.4; ctx.globalAlpha = 0.9;
939        } else {
940          ctx.strokeStyle = "rgba(139,156,255,1)"; ctx.lineWidth = 0.7;
940 ctx.globalAlpha = focus ? 0.06 : 0.16;
941        }
942        ctx.stroke();
943      });
944      ctx.globalAlpha = 1;
945
946      // nodes
947      nodes.forEach(function (n) {
948        if (!visible(n)) return;
949        var dim = focus && !focus[n.id];
950        var isFocus = n === hover || n === pinned || n === externalHighlight;
951        var r = n.style.r * (isFocus ? 1.35 : 1) * (n.star ? 1.15 : 1);
952        ctx.globalAlpha = dim ? 0.18 : 1;
953        // glow
954        var g = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, r * 3.2);
955        g.addColorStop(0, n.style.glow); g.addColorStop(1, "rgba(0,0,0,0)");
956        ctx.fillStyle = g; ctx.globalAlpha = (dim ? 0.05 : isFocus ? 0.6 : 0.28);
957        ctx.beginPath(); ctx.arc(n.x, n.y, r * 3.2, 0, Math.PI * 2); ctx.fill();
958        ctx.globalAlpha = dim ? 0.2 : 1;
959        // body
960        ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
961        ctx.fillStyle = n.type === "self" ? "#0a0f1e" : n.style.color;
962        ctx.fill();
963        ctx.lineWidth = n.type === "self" ? 2 : 1;
964        ctx.strokeStyle = n.type === "self" ? "#46e0ff" : "rgba(255,255,255,0.35)";
965        ctx.stroke();
966        if (n.star) {
967          ctx.fillStyle = "#ffd479"; ctx.font = "9px " + "JetBrains Mono, monospace";
968          ctx.textAlign = "center"; ctx.textBaseline = "middle";
969          ctx.fillText("★", n.x, n.y + 0.5);
970        }
971        // label
972        var wantLabel = n.style.label === "always" || showAllLabels || isFocus || (focus && focus[n.id]);
973        if (wantLabel) {
974          var big = n.type === "self" || n.type === "paper";
975          ctx.font = (big ? "600 " : "") + (big ? 12 : 10.5) + "px " + (big ? "Space Grotesk, Helvetica Neue, sans-serif" : "JetBrains Mono, monospace");
976          ctx.textAlign = "center"; ctx.textBaseline = "top";
977          var ty = n.y + r + 5;
978          var half = ctx.measureText(n.label).width / 2 + 4;
979          var lx = clamp(n.x, half, W - half);
980          ctx.lineWidth = 3; ctx.strokeStyle = "rgba(4,6,13,0.85)"; ctx.lineJoin = "round";
981          ctx.strokeText(n.label, lx, ty);
982          ctx.fillStyle = big ? "#e8ecf8" : (dim ? "#6b7490" : "#9aa3bc");
983          if (isFocus) ctx.fillStyle = n.style.color === "#ffffff" ? "#46e0ff" : n.style.color;
984          ctx.fillText(n.label, lx, ty);
985        }
986        ctx.globalAlpha = 1;
987      });
988    }
989
990    var running = false;
991    function loop() {
992      if (!running) return;
993      step(); draw();
994      requestAnimationFrame(loop);
995    }
996
997    // interaction
998    function pick(x, y) {
999      var best = null, bd = 1e9;
1000      nodes.forEach(function (n) {
1001        if (!visible(n)) return;
1002        var dx = n.x - x, dy = n.y - y, d = dx * dx + dy * dy, rr = (n.style.r + 10);
1003        if (d < rr * rr && d < bd) { bd = d; best = n; }
1004      });
1005      return best;
1006    }
1007
1008    function local(e) {
1009      var r = canvas.getBoundingClientRect();
1010      return { x: e.clientX - r.left, y: e.clientY - r.top };
1011    }
1012
1013    function showTip(n, x, y) {
1014      if (!n || n.type === "concept" || n.type === "venue") { tip.classList.remove("show"); return; }
1015      var kind = n.type === "self" ? "you are here" : n.type;
1016      tip.innerHTML = '<span class="k">' + kind + (n.star ? " · ★ best paper" : "") + "</span><b>" + n.label + "</b>" +
1017        (n.tip ? '<div style="font-size:0.8rem;">' + n.tip + "</div>" : "") +
1018        (n.sub ? '<div style="font-size:0.74rem; color:var(--faint); margin-top:0.25rem;">' + n.sub + "</div>" : "") +
1019        (n.href && n.type !== "self" ? '<span class="go">click to open →</span>' : "");
1020      var tw = 280;
1021      var left = clamp(x + 18, 8, W - tw - 8), top = y + 18;
1022      if (top > H - 140) top = y - 120;
1023      tip.style.left = left + "px"; tip.style.top = top + "px";
1024      tip.classList.add("show");
1025    }
1026
1027    var downAt = null, moved = false;
1028    canvas.addEventListener("pointerdown", function (e) {
1029      var p = local(e);
1030      var n = pick(p.x, p.y);
1031      downAt = p; moved = false;
1032      if (n) { drag = n; canvas.classList.add("dragging"); canvas.setPointerCapture(e.pointerId); alpha = Math.max(alpha, 0.3); }
1033    });
1034    canvas.addEventListener("pointermove", function (e) {
1035      var p = local(e);
1036      if (drag) {
1037        drag.x = clamp(p.x, 30, W - 30); drag.y = clamp(p.y, 40, H - 44);
1038        if (downAt && Math.hypot(p.x - downAt.x, p.y - downAt.y) > 4) moved = true;
1039        alpha = Math.max(alpha, 0.25);
1040        showTip(drag, p.x, p.y);
1041        return;
1042      }
1043      var n = pick(p.x, p.y);
1044      if (n !== hover) { hover = n; alpha = Math.max(alpha, 0.12); }
1045      canvas.style.cursor = n ? "pointer" : "";
1046      showTip(n, p.x, p.y);
1047    });
1048    function release(e) {
1049      if (drag) {
1050        var n = drag;
1051        drag = null; canvas.classList.remove("dragging");
1052        try { canvas.releasePointerCapture(e.pointerId); } catch (err) {}
1053        if (!moved) {
1054          if (n.href && n.type !== "self") {
1055            if (n.ext) window.open(n.href, "_blank", "noopener");
1056            else { document.body.classList.add("leaving"); setTimeout(function () { location.href = n.href; }, reducedMotion ? 0 : 300); }
1057          } else {
1058            pinned = pinned === n ? null : n;
1059          }
1060        }
1061      }
1062      downAt = null;
1063    }
1064    canvas.addEventListener("pointerup", release);
1065    canvas.addEventListener("pointercancel", release);
1066    canvas.addEventListener("pointerleave", function () { hover = null; tip.classList.remove("show"); });
1067
1068    // start when visible
1069    var started = false;
1070    var io = new IntersectionObserver(function (entries) {
1071      entries.forEach(function (en) {
1072        if (en.isIntersecting) {
1073          if (!started) {
1074            started = true;
1075            resize(); seed();
1076            alpha = 1;
1077            for (var i = 0; i < 260; i++) step(); // settle before first paint
1078            alpha = 0.25;
1079          }
1080          if (!running) { running = true; requestAnimationFrame(loop); }
1081        } else {
1082          running = false;
1083        }
1084      });
1085    }, { threshold: 0.05 });
1086    io.observe(host);
1087    window.addEventListener("resize", function () { if (started) resize(); });
1088
1089    return {
1090      highlight: function (id) { externalHighlight = id ? byId[id] || null : null; alpha = Math.max(alpha, 0.1); }
1091    };
1092  }
1093
1094  function initResearchMaps() {
1095    var maps = [];
1096    document.querySelectorAll("[data-research-map]").forEach(function (host) {
1097      host.classList.add("rmap");
1098      if (host.getAttribute("data-research-map") === "compact") host.classList.add("compact");
1099      maps.push(createResearchMap(host));
1100    });
1101    if (!maps.length) return;
1102    // paper cards can highlight their node on hover
1103    document.querySelectorAll("[data-node]").forEach(function (el) {
1104      el.addEventListener("pointerenter", function () { maps.forEach(function (m) { m.highlight(el.getAttribute("data-node")); }); });
1105      el.addEventListener("pointerleave", function () { maps.forEach(function (m) { m.highlight(null); }); });
1106    });
1107  }
1108
1109  /* ======================================================================
1110     COMMAND PALETTE
1111     ====================================================================== */
1112  var CMD_ITEMS = [
1113    { label: "Home / About", href: "/", k: "go" },
1114    { label: "Research & Publications", href: "/publications/", k: "go" },
1115    { label: "Research Map (interactive)", href: "/publications/#map", k: "go" },
1116    { label: "Projects", href: "/projects/", k: "go" },
1117    { label: "Teaching", href: "/teaching/", k: "go" },
1118    { label: "Blog", href: "/blog/", k: "go" },
1119    { label: "Hobbies", href: "/hobbies/", k: "go" },
1120    { label: "Bookshelf", href: "/books/", k: "go" },
1121    { label: "CV", href: "/cv/", k: "go" },
1122    { label: "Download CV (PDF)", href: "/assets/pdf/Shi_Jerick_CV.pdf", k: "pdf" },
1123    { label: "Master's Thesis (PDF)", href: "/assets/pdf/masters_thesis.pdf", k: "pdf" },
1124    { label: "When Agents Lie — Best Paper, ICML '26 WS (PDF)", href: "https://openreview.net/pdf?id=v8nYIkYjY0", k: "pdf" },
1125    { label: "Strategic Silence in Multi-Agent LLMs (PDF)", href: "https://openreview.net/pdf?id=ZOdCsExYgi", k: "pdf" },
1126    { label: "Thesis Defense (YouTube)", href: "https://youtu.be/Z3Q9AkriPxg", k: "link" },
1127    { label: "GitHub — Jerick-1380", href: "https://github.com/Jerick-1380", k: "link" },
1128    { label: "LinkedIn", href: "https://www.linkedin.com/in/jerick-shi-293773216", k: "link" },
1129    { label: "Google Scholar", href: "https://scholar.google.com/citations?user=6wj2mTQAAAAJ", k: "link" },
1130    { label: "Flickr Portfolio", href: "https://flickr.com/photos/203834484@N07/", k: "link" },
1131    { label: "Email — [email protected]", href: "mailto:[email protected]", k: "link" },
1132    { label: "Quant Engine — join waitlist", href: "https://quant-engine.com/", k: "link" },
1133    { label: "Engage hyperdrive", href: "#warp", k: "fun" }
1134  ];
1135
1136  /* ======================================================================
1137     BOOKSHELF — cover wall with genre filter, search, and FLIP reordering.
1138     Ratings and genres are Jerick's own; covers and years come from Open
1139     Library. Add a book here and the page, stats and filters all follow.
1140     ====================================================================== */
1141  var BOOK_GENRES = {
1142    productivity: "Productivity",
1143    finance: "Finance",
1144    psychology: "Psychology",
1145    cs: "Computer Science",
1146    freediving: "Freediving"
1147  };
1148
1149  var BOOKS = [
1150    { t: "Atomic Habits", a: "James Clear", y: 2018, g: "productivity", r: 5, c: "atomic-habits" },
1151    { t: "Deep Work", a: "Cal Newport", y: 2016, g: "productivity", r: 5, c: "deep-work" },
1152    { t: "The Defining Decade", a: "Meg Jay", y: 2012, g: "productivity", r: 5, c: "the-defining-decade" },
1153    { t: "Make Time", a: "Jake Knapp & John Zeratsky", y: 2018, g: "productivity", r: 4, c: "make-time" },
1154    { t: "How to Be a Gentleman", a: "John Bridges", y: 2001, g: "productivity", r: 4, c: "how-to-be-a-gentleman" },
1155    { t: "The 12 Week Year", a: "Brian P. Moran", y: 2013, g: "productivity", r: 3, c: "the-12-week-year" },
1156    { t: "Feel-Good Productivity", a: "Ali Abdaal", y: 2023, g: "productivity", r: 3, c: "feel-good-productivity" },
1157    { t: "Indistractable", a: "Nir Eyal", y: 2019, g: "productivity", r: 3, c: "indistractable" },
1158    { t: "No More Mr. Nice Guy", a: "Robert Glover", y: 2001, g: "psychology", r: 5, c: "no-more-mr-nice-guy" },
1159    { t: "Surrounded by Idiots", a: "Thomas Erikson", y: 2014, g: "psychology", r: 4, c: "surrounded-by-idiots" },
1160    { t: "How to Win Friends and Influence People", a: "Dale Carnegie", y: 1936, g: "psychology", r: 4, c: "how-to-win-friends-and-influence-people" },
1161    { t: "Million Dollar Weekend", a: "Noah Kagan", y: 2024, g: "finance", r: 4, c: "million-dollar-weekend" },
1162    { t: "The Millionaire Fastlane", a: "MJ DeMarco", y: 2011, g: "finance", r: 4, c: "the-millionaire-fastlane" },
1163    { t: "The Complete TurtleTrader", a: "Michael W. Covel", y: 2007, g: "finance", r: 4, c: "the-complete-turtletrader" },
1164    { t: "The 4-Hour Workweek", a: "Timothy Ferriss", y: 2007, g: "finance", r: 3, c: "the-4-hour-workweek" },
1165    { t: "The Psychology of Money", a: "Morgan Housel", y: 2020, g: "finance", r: 3, c: "the-psychology-of-money" },
1166    { t: "Life 3.0", a: "Max Tegmark", y: 2017, g: "cs", r: 4, c: "life-3-0" },
1167    { t: "The Precipice", a: "Toby Ord", y: 2020, g: "cs", r: 3, c: "the-precipice" },
1168    { t: "Deep", a: "James Nestor", y: 2014, g: "freediving", r: 4, c: "deep" },
1169    { t: "One Breath", a: "Adam Skolnick", y: 2016, g: "freediving", r: 3, c: "one-breath" }
1170  ];
1171
1172  function bookStars(n) {
1173    var s = "";
1174    for (var i = 1; i <= 5; i++) s += i <= n ? "★" : "☆";
1175    return s;
1176  }
1177
1178  function esc(s) {
1179    return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1180  }
1181
1182  /* FLIP: measure, mutate, then animate each survivor from its old box */
1183  function flipReorder(items, mutate) {
1184    if (reducedMotion || !items.length || !items[0].el.animate) { mutate(); return; }
1185    var before = [];
1186    items.forEach(function (it) {
1187      before.push(it.el.style.display === "none" ? null : it.el.getBoundingClientRect());
1188    });
1189    mutate();
1190    items.forEach(function (it, i) {
1191      if (it.el.style.display === "none") return;
1192      var a = before[i];
1193      if (!a) {
1194        it.el.animate([{ opacity: 0, transform: "scale(0.94)" }, { opacity: 1, transform: "none" }],
1195          { duration: 300, easing: "ease-out" });
1196        return;
1197      }
1198      var b = it.el.getBoundingClientRect();
1199      var dx = a.left - b.left, dy = a.top - b.top;
1200      if (!dx && !dy) return;
1201      it.el.animate([{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
1202        { duration: 420, easing: "cubic-bezier(0.22, 1, 0.36, 1)" });
1203    });
1204  }
1205
1206  function initBookshelf() {
1207    var host = document.querySelector("[data-bookshelf]");
1208    if (!host) return;
1209
1210    var total = BOOKS.length;
1211    var genreKeys = Object.keys(BOOK_GENRES);
1212    var sum = 0, top = 0;
1213    BOOKS.forEach(function (b) { sum += b.r; if (b.r === 5) top++; });
1214    var avg = (sum / total).toFixed(1);
1215
1216    host.innerHTML =
1217      '<div class="stats cols-4 reveal">' +
1218        '<div class="stat"><div class="num" data-count="' + total + '">0</div><div class="lbl">books</div></div>' +
1219        '<div class="stat"><div class="num" data-count="' + genreKeys.length + '">0</div><div class="lbl">genres</div></div>' +
1220        '<div class="stat"><div class="num" data-count="' + avg + '" data-decimals="1">0</div><div class="lbl">average rating</div></div>' +
1221        '<div class="stat"><div class="num" data-count="' + top + '">0</div><div class="lbl">five-star reads</div></div>' +
1222      '</div>' +
1223      '<div class="shelf-controls">' +
1224        '<div class="filter-bar shelf-filters">' +
1225          '<button class="filter-btn active" data-genre="all">All</button>' +
1226          genreKeys.map(function (k) {
1227            return '<button class="filter-btn" data-genre="' + k + '">' + BOOK_GENRES[k] + '</button>';
1228          }).join("") +
1229        '</div>' +
1230        '<input class="shelf-search" type="search" placeholder="Search title or author…" aria-label="Search books">' +
1231      '</div>' +
1232      '<div class="shelf-count mono"></div>' +
1233      '<div class="shelf-grid"></div>' +
1234      '<p class="shelf-empty mono">// no books match that</p>';
1235
1236    var grid = host.querySelector(".shelf-grid");
1237    var countEl = host.querySelector(".shelf-count");
1238    var emptyEl = host.querySelector(".shelf-empty");
1239    var search = host.querySelector(".shelf-search");
1240
1241    var items = BOOKS.map(function (b) {
1242      var el = document.createElement("div");
1243      el.className = "book";
1244      el.setAttribute("data-tilt", "");
1245      el.setAttribute("tabindex", "0");
1246      el.setAttribute("role", "button");
1247      el.setAttribute("aria-label", b.t + " by " + b.a + ", rated " + b.r + " of 5");
1248      el.innerHTML =
1249        '<img src="/assets/img/book_covers/' + b.c + '.jpg" alt="' + esc(b.t) + ' cover" loading="lazy">' +
1250        '<div class="book-ov">' +
1251          '<div class="t">' + esc(b.t) + '</div>' +
1252          '<div class="a">' + esc(b.a) + '</div>' +
1253          '<div class="r"><span class="on">' + bookStars(b.r).slice(0, b.r) + '</span>' +
1254            bookStars(b.r).slice(b.r) + '</div>' +
1255        '</div>';
1256      grid.appendChild(el);
1257      return { el: el, b: b, q: (b.t + " " + b.a + " " + BOOK_GENRES[b.g]).toLowerCase() };
1258    });
1259
1260    /* detail panel — appended to <body>, never inside <main>, which is
1261       transformed by page-slide and would capture position:fixed */
1262    var modal = document.createElement("div");
1263    modal.className = "book-modal";
1264    modal.innerHTML = '<div class="book-modal-panel" role="dialog" aria-modal="true"></div>';
1265    document.body.appendChild(modal);
1266    var panel = modal.querySelector(".book-modal-panel");
1267
1268    function openBook(b) {
1269      panel.innerHTML =
1270        '<button class="book-close" aria-label="Close">✕</button>' +
1271        '<img src="/assets/img/book_covers/' + b.c + '.jpg" alt="' + esc(b.t) + ' cover">' +
1272        '<div class="book-meta">' +
1273          '<span class="chip purple">' + BOOK_GENRES[b.g] + '</span>' +
1274          '<h3>' + esc(b.t) + '</h3>' +
1275          '<p class="muted">' + esc(b.a) + ' · ' + b.y + '</p>' +
1276          '<div class="book-rating"><span class="on">' + bookStars(b.r).slice(0, b.r) + '</span>' +
1277            bookStars(b.r).slice(b.r) + '<span class="n">' + b.r + '/5</span></div>' +
1278          '<a class="btn btn-sm btn-ghost" target="_blank" rel="noopener" href="https://openlibrary.org/search?q=' +
1279            encodeURIComponent(b.t + " " + b.a) + '">Find the book</a>' +
1280        '</div>';
1281      modal.classList.add("open");
1282    }
1283    function closeBook() { modal.classList.remove("open"); }
1284
1285    modal.addEventListener("click", function (e) {
1286      if (e.target === modal || e.target.closest(".book-close")) closeBook();
1287    });
1288    document.addEventListener("keydown", function (e) {
1289      if (e.key === "Escape") closeBook();
1290    });
1291    items.forEach(function (it) {
1292      it.el.addEventListener("click", function () { openBook(it.b); });
1293      it.el.addEventListener("keydown", function (e) {
1294        if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openBook(it.b); }
1295      });
1296    });
1297
1298    /* anything on the page can open a book's panel: data-open-book="<cover-slug>" */
1299    document.querySelectorAll("[data-open-book]").forEach(function (el) {
1300      var slug = el.getAttribute("data-open-book"), match = null;
1301      BOOKS.forEach(function (b) { if (b.c === slug) match = b; });
1302      if (!match) return;
1303      el.addEventListener("click", function (e) { e.preventDefault(); openBook(match); });
1304    });
1305
1306    var genre = "all", query = "";
1307    function apply() {
1308      var shown = 0;
1309      flipReorder(items, function () {
1310        items.forEach(function (it) {
1311          var ok = (genre === "all" || it.b.g === genre) && (!query || it.q.indexOf(query) !== -1);
1312          it.el.style.display = ok ? "" : "none";
1313          if (ok) shown++;
1314        });
1315      });
1316      countEl.textContent = shown === total
1317        ? "// showing all " + total + " books"
1318        : "// showing " + shown + " of " + total;
1319      emptyEl.style.display = shown ? "none" : "block";
1320    }
1321
1322    host.querySelector(".shelf-filters").addEventListener("click", function (e) {
1323      var btn = e.target.closest(".filter-btn");
1324      if (!btn) return;
1325      host.querySelectorAll(".shelf-filters .filter-btn").forEach(function (b) { b.classList.remove("active"); });
1326      btn.classList.add("active");
1327      genre = btn.getAttribute("data-genre");
1328      apply();
1329    });
1330    search.addEventListener("input", function () {
1331      query = search.value.toLowerCase().trim();
1332      apply();
1333    });
1334
1335    apply();
1336  }
1337
1338  function initCmdk() {
1339    var overlay = document.createElement("div");
1340    overlay.id = "cmdk";
1341    overlay.innerHTML =
1342      '<div class="cmdk-panel" role="dialog" aria-label="Command palette">' +
1343      '<input type="text" placeholder="Type to search — pages, papers, links…" aria-label="Search">' +
1344      '<div class="cmdk-list"></div></div>';
1345    document.body.appendChild(overlay);
1346
1347    var input = overlay.querySelector("input");
1348    var list = overlay.querySelector(".cmdk-list");
1349    var sel = 0, filtered = CMD_ITEMS;
1350
1351    function render() {
1352      if (!filtered.length) { list.innerHTML = '<div class="cmdk-empty">no results_</div>'; return; }
1353      list.innerHTML = filtered.map(function (it, i) {
1354        return '<div class="cmdk-item' + (i === sel ? " sel" : "") + '" data-i="' + i + '">' +
1355          '<span class="k">' + it.k + "</span><span>" + it.label + "</span></div>";
1356      }).join("");
1357      var s = list.querySelector(".sel");
1358      if (s && s.scrollIntoView) s.scrollIntoView({ block: "nearest" });
1359    }
1360    function open() { overlay.classList.add("open"); input.value = ""; filtered = CMD_ITEMS; sel = 0; render(); input.focus(); }
1361    function close() { overlay.classList.remove("open"); }
1362    function go(item) {
1363      close();
1364      if (item.href === "#warp") {
1365        WARP.target = 1; document.body.classList.add("warp"); toast("⚡ hyperdrive engaged");
1366        setTimeout(function () { WARP.target = 0; document.body.classList.remove("warp"); }, 4500);
1367        return;
1368      }
1369      if (/^https?:|^mailto:/.test(item.href)) window.open(item.href, item.href.indexOf("mailto:") === 0 ? "_self" : "_blank");
1370      else if (reducedMotion) location.href = item.href;
1371      else { document.body.classList.add("leaving"); setTimeout(function () { location.href = item.href; }, 300); }
1372    }
1373
1374    input.addEventListener("input", function () {
1375      var q = input.value.toLowerCase().trim();
1376      filtered = CMD_ITEMS.filter(function (it) { return it.label.toLowerCase().indexOf(q) !== -1; });
1377      sel = 0; render();
1378    });
1379    input.addEventListener("keydown", function (e) {
1380      if (e.key === "ArrowDown") { sel = Math.min(sel + 1, filtered.length - 1); render(); e.preventDefault(); }
1381      else if (e.key === "ArrowUp") { sel = Math.max(sel - 1, 0); render(); e.preventDefault(); }
1382      else if (e.key === "Enter" && filtered[sel]) go(filtered[sel]);
1383    });
1384    list.addEventListener("click", function (e) {
1385      var item = e.target.closest(".cmdk-item");
1386      if (item) go(filtered[+item.getAttribute("data-i")]);
1387    });
1388    overlay.addEventListener("click", function (e) { if (e.target === overlay) close(); });
1389    document.addEventListener("keydown", function (e) {
1390      var typing = /input|textarea/i.test(document.activeElement && document.activeElement.tagName);
1391      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); overlay.classList.contains("open") ? close() : open(); }
1392      else if (e.key === "/" && !typing) { e.preventDefault(); open(); }
1393      else if (e.key === "Escape") close();
1394    });
1395    document.querySelectorAll(".cmdk-btn").forEach(function (b) { b.addEventListener("click", open); });
1396  }
1397
1398  /* ======================================================================
1399     BOOT
1400     ====================================================================== */
1401  document.addEventListener("DOMContentLoaded", function () {
1402    initBackground();
1403    initTransitions();
1404    initCursor();
1405    initNav();
1406    initProgress();
1407    initBookshelf();   // renders before the reveal/counter/tilt observers attach
1408    initReveal();
1409    initScramble();
1410    initTyping();
1411    initCounters();
1412    initSkillBars();
1413    initTilt();
1414    initMagnetic();
1415    initSpotlight();
1416    initHeroParallax();
1417    initSpy();
1418    initAbstracts();
1419    initCopy();
1420    initFilters();
1421    initLightbox();
1422    initCarousels();
1423    initReadTime();
1424    initArticleToc();
1425    initClock();
1426    initResearchMaps();
1427    initCmdk();
1428    initEggs();
1429  });
1430})();

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.