PageSourceSearch

https://drstarkim.com/assets/js/main.js

js drstarkim.com collected 2026-10-06 16:44:35 UTC 49,696 bytes, 1,050 lines download raw bytes

1/* =========================================================================
2   STAR KIM — site engine (no framework, no build step)
3   ========================================================================= */
4(function () {
5  "use strict";
6  var SK = window.SK || {};
7  var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
9  /* ---------- tiny helpers ---------- */
10  function $(sel, ctx) { return (ctx || document).querySelector(sel); }
11  function $all(sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); }
12  function el(html) { var t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; }
13  function esc(s) {
14    return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
15      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
16    });
17  }
18
19  /* five-point star path helpers (viewBox 0 0 24 24, centred) */
20  var STAR24 = "M12 2 L14.35 8.76 L21.51 8.91 L15.8 13.24 L17.88 20.09 L12 16 L6.12 20.09 L8.2 13.24 L2.49 8.91 L9.65 8.76 Z";
21
22  /* ---------- branded placeholder image (data URI): shooting star on navy sky ---------- */
23  function placeholder(label, w, h) {
24    w = w || 800; h = h || 600;
25    var cx = w / 2, cy = h / 2 - (label ? 14 : 0);
26    var svg =
27      '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">' +
28      '<defs>' +
29      '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">' +
30      '<stop offset="0" stop-color="#132239"/><stop offset="1" stop-color="#0A1424"/></linearGradient>' +
31      '<linearGradient id="trail" x1="0" y1="1" x2="1" y2="0">' +
32      '<stop offset="0" stop-color="#E9B857" stop-opacity="0"/><stop offset="1" stop-color="#F6E4B8"/></linearGradient>' +
33      '<radialGradient id="glow"><stop offset="0" stop-color="#3C64A5" stop-opacity="0.5"/><stop offset="1" stop-color="#3C64A5" stop-opacity="0"/></radialGradient>' +
34      '</defs>' +
35      '<rect width="100%" height="100%" fill="url(#sky)"/>' +
36      '<circle cx="' + (cx + 60) + '" cy="' + (cy - 30) + '" r="150" fill="url(#glow)"/>' +
37      dust(w, h) +
38      '<g transform="translate(' + (cx - 51) + ',' + (cy - 58) + ') scale(1.6)" opacity="0.9">' +
39      '<path d="M18 39 C 13 32 15 23 21 18 C 22 22 25 22 25 18 C 24 10 28 5 30 0 C 33 4 34 8 33 13 C 36 9 39 12 39 17 C 43 14 46 16 46 22 C 48 27 48 34 44 39 C 39 43 33 44 27 43 C 23 43 20 41 18 39 Z" fill="#E5402E"/>' +
40      '<path d="M32 23 L37.9 39.9 L55.8 40.3 L41.5 51.1 L46.7 68.2 L32 58 L17.3 68.2 L22.5 51.1 L8.2 40.3 L26.1 39.9 Z" fill="#E9B857"/>' +
41      '</g>' +
42      (label ? '<text x="50%" y="' + (h / 2 + 58) + '" fill="#7F8DA6" font-family="Inter, sans-serif" font-size="13" text-anchor="middle">' + esc(label) + '</text>' : "") +
43      '</svg>';
44    return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
45  }
46  function dust(w, h) {
47    var s = "", i, x, y, r;
48    var seed = 7;
49    function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
50    for (i = 0; i < 60; i++) {
51      x = Math.round(rnd() * w); y = Math.round(rnd() * h); r = rnd() < 0.12 ? 1.6 : 0.8;
52      s += '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" fill="#EAF2FF" opacity="' + (0.22 + rnd() * 0.5).toFixed(2) + '"/>';
53    }
54    return s;
55  }
56
57  /* ---------- navy-sky starfield (progressive enhancement, decorative) ---------- */
58  function initStarfield() {
59    $all(".starfield").forEach(function (field) {
60      var count = parseInt(field.dataset.stars || "44", 10);
61      var brights = parseInt(field.dataset.bright || "5", 10);
62      var colors = ["#EAF2FF", "#F6E4B8", "#8FB6E8"];
63      var seed = 42;
64      function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
65      var html = "";
66      for (var i = 0; i < count; i++) {
67        var big = i < brights;
68        var x = (rnd() * 100).toFixed(2), y = (rnd() * 100).toFixed(2);
69        var tw = (3 + rnd() * 5).toFixed(1), d = (rnd() * 6).toFixed(1);
70        var c = colors[Math.floor(rnd() * colors.length)];
71        if (big) {
72          var sz = (10 + rnd() * 9).toFixed(1);
73          html += '<svg class="star star--bright" viewBox="0 0 24 24" aria-hidden="true" style="left:' + x + '%;top:' + y +
74            '%;width:' + sz + 'px;height:' + sz + 'px;color:' + c + ';--o:.95;--tw:' + tw + 's;--d:-' + d + 's">' +
75            '<path d="' + STAR24 + '" fill="currentColor"/></svg>';
76        } else {
77          var s = (1 + rnd() * 1.7).toFixed(1);
78          var o = (0.3 + rnd() * 0.5).toFixed(2);
79          html += '<i class="star" style="left:' + x + '%;top:' + y + '%;width:' + s + 'px;height:' + s +
80            'px;background:' + c + ';--o:' + o + ';--tw:' + tw + 's;--d:-' + d + 's"></i>';
81        }
82      }
83      field.insertAdjacentHTML("beforeend", html);
84      if (field.dataset.shooting === "true" && !reduceMotion) {
85        var sh = document.createElement("span");
86        sh.className = "shooting-star";
87        sh.style.setProperty("--top", (6 + rnd() * 22) + "%");
88        sh.style.setProperty("--delay", (2 + rnd() * 4).toFixed(1) + "s");
89        field.appendChild(sh);
90      }
91    });
92  }
93  function smartImg(src, alt, label) {
94    var im = new Image();
95    im.loading = "lazy";
96    im.decoding = "async";
97    im.alt = alt || "";
98    // attach the error handler BEFORE setting src, or a fast 404 fires it first
99    im.addEventListener("error", function () {
100      if (im.dataset.fallback) return;
101      im.dataset.fallback = "1";
102      im.src = placeholder(label || alt || "");
103    });
104    im.src = src;
105    return im;
106  }
107
108  /* build a safe URL to a file inside an album folder (folder + file may contain
109     spaces, "-", etc. — encode each path segment) */
110  function albumSrc(id, file) {
111    return "assets/img/albums/" + encodeURIComponent(id) + "/" + encodeURIComponent(file || "");
112  }
113  function albumMeta(al) {
114    return [al.date, al.location].filter(Boolean).join(" · ");
115  }
116  function pad2(n) { return (n < 10 ? "0" : "") + n; }
117
118  /* Turn each SK.albums entry — a plain "MMDDYY - Event Name" string, or an
119     object — into { id, title, date, sort, photos? } and sort newest-first. */
120  function normAlbums() {
121    var MON = ["", "January", "February", "March", "April", "May", "June", "July",
122      "August", "September", "October", "November", "December"];
123    var list = (SK.albums || []).map(function (raw) {
124      var a = typeof raw === "string" ? { id: raw } : (function () {
125        var o = {}; for (var k in raw) o[k] = raw[k]; return o;
126      })();
127      var m = /^(\d{2})(\d{2})(\d{2})\s*-\s*(.+)$/.exec(a.id || "");
128      if (m) {
129        if (a.title == null) a.title = m[4].trim();
130        if (a.date == null) a.date = MON[+m[1]] + " " + (+m[2]) + ", 20" + m[3];
131        a.sort = "20" + m[3] + m[1] + m[2];
132      } else {
133        if (a.title == null) a.title = a.id || "";
134        a.sort = a.sort || "0";
135      }
136      return a;
137    });
138    list.sort(function (x, y) { return (y.sort || "").localeCompare(x.sort || ""); });
139    return list;
140  }
141
142  /* Walk 01.jpg, 02.jpg, 03.jpg … in a folder, calling onPhoto(file) for each
143     that exists, then onDone() at the first gap. */
144  function discoverPhotos(id, onPhoto, onDone) {
145    var i = 1, MAX = 400;
146    (function step() {
147      var file = pad2(i) + ".jpg";
148      var probe = new Image();
149      probe.onload = function () { onPhoto(file, i); i++; if (i <= MAX) step(); else onDone(); };
150      probe.onerror = function () { onDone(); };
151      probe.src = albumSrc(id, file);
152    })();
153  }
154
155  /* ---------- icons ---------- */
156  var ICON = {
157    arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
158    arrowUpRight: '<path d="M7 17 17 7M8 7h9v9"/>',
159    back: '<path d="M19 12H5M11 18l-6-6 6-6"/>',
160    play: '<path d="M8 5v14l11-7z" fill="currentColor" stroke="none"/>',
161    close: '<path d="M6 6l12 12M18 6 6 18"/>',
162    prev: '<path d="M15 18l-6-6 6-6"/>',
163    next: '<path d="M9 6l6 6-6 6"/>',
164    menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
165    mail: '<path d="M3 6h18v12H3z"/><path d="m3 7 9 6 9-6"/>',
166    scholar: '<path d="M12 3 1 9l11 6 9-4.9V17h2V9L12 3z"/><path d="M5 13v4c0 1.7 3.1 3 7 3s7-1.3 7-3v-4"/>',
167    linkedin: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 10v7M8 7v0M12 17v-4a2 2 0 0 1 4 0v4M12 12v5" stroke-linecap="round"/>',
168    github: '<path d="M9 19c-4 1.4-4-2.2-6-2.8M15 21v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.6 11.6 0 0 0-6 0C7.3 3.6 6.3 3.9 6.3 3.9a4.3 4.3 0 0 0-.1 3.2A4.6 4.6 0 0 0 5 10.3c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/>',
169    youtube: '<rect x="2" y="5" width="20" height="14" rx="3"/><path d="m10 9 5 3-5 3z" fill="currentColor" stroke="none"/>',
170    orcid: '<circle cx="12" cy="12" r="9"/><path d="M12 8v8M12 8v0" stroke-linecap="round"/><path d="M12 8h1.5a4 4 0 0 1 0 8H12"/>',
171    instagram: '<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/>',
172    site: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 2.5 15.3 0 18M12 3c-2.5 2.7-2.5 15.3 0 18"/>'
173  };
174  function svgIcon(name, cls) {
175    return '<svg class="' + (cls || "") + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (ICON[name] || "") + '</svg>';
176  }
177  // { name, href } -> a linked name (no icon)
178  function creditLink(c) {
179    if (!c || !c.name) return "";
180    return c.href
181      ? '<a class="credit-link" href="' + esc(c.href) + '" target="_blank" rel="noopener">' + esc(c.name) + '</a>'
182      : esc(c.name);
183  }
184
185  /* ---------- nav ---------- */
186  function initNav() {
187    var toggle = $(".nav-toggle");
188    var nav = $("#primary-nav");
189    if (toggle && nav) {
190      toggle.addEventListener("click", function () {
191        var open = nav.getAttribute("data-open") === "true";
192        nav.setAttribute("data-open", String(!open));
193        toggle.setAttribute("aria-expanded", String(!open));
194      });
195      $all("a", nav).forEach(function (a) {
196        a.addEventListener("click", function () {
197          nav.setAttribute("data-open", "false");
198          toggle.setAttribute("aria-expanded", "false");
199        });
200      });
201    }
202    // mark current page
203    var here = location.pathname.split("/").pop() || "index.html";
204    $all("#primary-nav a[data-page]").forEach(function (a) {
205      if (a.getAttribute("data-page") === here || (here === "album.html" && a.getAttribute("data-page") === "media.html")) {
206        a.setAttribute("aria-current", "page");
207      }
208    });
209  }
210
211  /* ---------- reveal on scroll ---------- */
212  function initReveal() {
213    var items = $all(".reveal");
214    if (reduceMotion || !("IntersectionObserver" in window)) {
215      items.forEach(function (i) { i.classList.add("is-in"); });
216      return;
217    }
218    document.documentElement.classList.add("reveal-ready");
219    var io = new IntersectionObserver(function (entries) {
220      entries.forEach(function (e) {
221        if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); }
222      });
223    }, { rootMargin: "0px 0px -8% 0px", threshold: 0.12 });
224    items.forEach(function (i) { io.observe(i); });
225  }
226
227  function initYear() {
228    $all("[data-year]").forEach(function (n) { n.textContent = new Date().getFullYear(); });
229  }
230
231  /* ---------- social links (footer + hero) ---------- */
232  function renderSocial() {
233    if (!SK.site) return;
234    var links = (SK.site.social || []).map(function (s) {
235      return '<a href="' + esc(s.href) + '" target="_blank" rel="noopener noreferrer" aria-label="' +
236        esc(s.label) + '" title="' + esc(s.label) + '">' + svgIcon(s.icon || "arrowUpRight") + '</a>';
237    }).join("");
238    $all("[data-render='social']").forEach(function (mount) { mount.innerHTML = links; });
239  }
240  function renderEmail() {
241    $all("[data-email]").forEach(function (n) {
242      if (!SK.site) return;
243      var m = SK.site.email;
244      if (n.tagName === "A") { n.href = "mailto:" + m; if (n.dataset.email === "text") n.textContent = m; }
245      else n.textContent = m;
246    });
247  }
248
249  /* ---------- HOME ---------- */
250  function renderIntro() {
251    var m = $("[data-render='site-intro']");
252    if (m && SK.site && SK.site.intro) m.innerHTML = "<p>" + SK.site.intro + "</p>";
253  }
254
255  /* home-page panels: a short preview of each section, drawn from that section's data */
256  function miniItem(title, meta) {
257    return '<li>' +
258      (title ? '<span class="mini__t">' + esc(title) + '</span>' : "") +
259      (meta ? '<span class="mini__m">' + esc(meta) + '</span>' : "") +
260      '</li>';
261  }
262  function fillMini(sel, html) {
263    var m = $(sel);
264    if (m) m.innerHTML = html; // mount is already the <ul class="mini">
265  }
266
267  function renderHomePanels() {
268    // Research — the focus areas
269    if (SK.areas) {
270      fillMini("[data-render='home-research']", SK.areas.slice(0, 4).map(function (a) {
271        return miniItem(a.title, a.short || a.blurb.split(". ")[0]);
272      }).join(""));
273    }
274    // Publications — 3 most recent
275    if (SK.publications) {
276      var pubs = SK.publications.slice().sort(function (x, y) { return y.year - x.year; }).slice(0, 3);
277      fillMini("[data-render='home-publications']", pubs.map(function (p) {
278        var kind = (p.kind || "").charAt(0).toUpperCase() + (p.kind || "").slice(1);
279        return miniItem(p.title, (p.month ? p.month + " " : "") + p.year + " · " + kind);
280      }).join(""));
281    }
282    // Media — 2 most recent
283    if (SK.media) {
284      fillMini("[data-render='home-media']", SK.media.slice(0, 2).map(function (m) {
285        return miniItem(m.title, m.source + (m.date ? " · " + m.date : ""));
286      }).join(""));
287    }
288    // Talks — next upcoming, else most recent
289    if (SK.talks) {
290      var up = SK.talks.filter(function (t) { return t.status === "upcoming"; });
291      var list = (up.length ? up : SK.talks.filter(function (t) { return t.status === "past"; })).slice(0, 2);
292      fillMini("[data-render='home-talks']", list.map(function (t) {
293        var titles = t.titles || (t.title ? [t.title] : []);
294        var sub = titles.length > 1
295          ? titles.length + " presentations"
296          : (titles[0] ? (typeof titles[0] === "string" ? titles[0] : titles[0].title) : "");
297        return miniItem(t.event, t.when + (sub ? " · " + sub : "") + (t.place ? " · " + t.place : ""));
298      }).join(""));
299    }
300    // Galleries — 3 album covers
301    var gm = $("[data-render='home-galleries']");
302    if (gm && SK.albums) {
303      gm.innerHTML = '<div class="mini-albums"></div>';
304      var row = gm.firstChild;
305      normAlbums().slice(0, 3).forEach(function (al) {
306        var a = el('<a href="album.html?album=' + encodeURIComponent(al.id) + '"><span class="mini-albums__cover"></span><span class="mini-albums__t">' + esc(al.title) + '</span></a>');
307        a.querySelector(".mini-albums__cover").appendChild(
308          smartImg(albumSrc(al.id, al.cover || (al.photos && al.photos[0]) || "01.jpg"), al.title, al.title)
309        );
310        row.appendChild(a);
311      });
312    }
313  }
314
315  function renderHome() {
316    // detailed area cards (research.html)
317    var a = $("[data-render='home-areas']");
318    if (a && SK.areas) {
319      a.innerHTML = "";
320      SK.areas.forEach(function (area, i) {
321        a.appendChild(el(
322          '<article class="cell reveal">' +
323          '<div class="cell__no">' + String(i + 1).padStart(2, "0") + '</div>' +
324          '<h3>' + esc(area.title) + '</h3>' +
325          '<p>' + esc(area.blurb) + '</p>' +
326          '<ul>' + (area.techniques || area.tags || []).map(function (t) { return '<li>' + esc(t) + '</li>'; }).join("") + '</ul>' +
327          '</article>'
328        ));
329      });
330    }
331    // compact focus list (home)
332    var fl = $("[data-render='home-focus']");
333    if (fl && SK.areas) {
334      fl.innerHTML = "";
335      SK.areas.forEach(function (area) {
336        fl.appendChild(el(
337          '<li><strong>' + esc(area.title) + '</strong><span>' + esc(area.short || area.blurb) + '</span></li>'
338        ));
339      });
340    }
341    var f = $("[data-render='home-featured']");
342    if (f && SK.projects) {
343      f.innerHTML = "";
344      SK.projects.filter(function (p) { return p.flagship; }
344).forEach(function (p) {
345        f.appendChild(el(
346          '<li><a class="feat__row" href="research.html#' + esc(p.id) + '">' +
347          '<span class="feat__text">' +
348          '<span class="feat__t">' + esc(p.title) + '</span>' +
349          '<span class="feat__d">' + esc(p.card || p.kicker || "") + '</span>' +
350          '</span>' +
351          '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>' +
352          '</a></li>'
353        ));
354      });
355    }
356  }
357
358  /* ---------- WORK ---------- */
359  /* ---------- project demo media (tiny thumb) ----------
360     youtube -> opens the video on YouTube in a new tab
361     image   -> opens the lightbox                                        */
362  function projectDemo(p) {
363    if (!p.youtube && !p.image) return null;
364    var isVideo = !!p.youtube;
365    var thumb = p.youtube ? "https://i.ytimg.com/vi/" + p.youtube + "/hqdefault.jpg"
366      : [].concat(p.image)[0];
367    var label = isVideo ? "Watch demo on YouTube" : "View images";
368    var inner =
369      '<span class="project__demo-thumb">' +
370      (isVideo ? '<span class="project__demo-play">' + svgIcon("play") + '</span>' : '') +
371      '</span>' +
372      '<span class="project__demo-label">' + label +
373      (isVideo ? ' ' + svgIcon("arrowUpRight") : '') + '</span>';
374    var node = isVideo
375      ? el('<a class="project__demo" href="https://www.youtube.com/watch?v=' + esc(p.youtube) +
376          '" target="_blank" rel="noopener">' + inner + '</a>')
377      : el('<button class="project__demo" type="button" aria-label="View project images">' + inner + '</button>');
378    node.querySelector(".project__demo-thumb").insertBefore(
379      smartImg(thumb, p.title, p.title), node.querySelector(".project__demo-thumb").firstChild);
380    if (!isVideo) node.addEventListener("click", function () {
381      var imgs = [].concat(p.image);
382      openLightbox(imgs.map(function (s) { return { src: s, caption: p.title }; }), 0);
383    });
384    return node;
385  }
386
387  function renderWork() {
388    var mount = $("[data-render='work']");
389    var list = SK.projects || SK.areas;
390    if (!mount || !list) return;
391    var ordered = list.slice().sort(function (x, y) { return (y.flagship ? 1 : 0) - (x.flagship ? 1 : 0); });
392    mount.innerHTML = "";
393    function blurbHtml(b) {
394      if (Array.isArray(b)) {
395        return '<p>' + esc(b[0]) + '</p>' +
396          (b.length > 1 ? '<ul class="project__list">' +
397            b.slice(1).map(function (x) { return '<li>' + esc(x) + '</li>'; }).join("") + '</ul>' : "");
398      }
399      return '<p>' + esc(b) + '</p>';
400    }
401    ordered.forEach(function (p, i) {
402      var hasRole = p.contributions && p.contributions.length;
403      var node = el(
404        '<article class="project reveal"' + (p.id ? ' id="' + esc(p.id) + '"' : '') + '>' +
405        '<div class="project__head">' +
406        '<div class="project__index">' + String(i + 1).padStart(2, "0") + (p.year ? ' / ' + esc(p.year) : '') + '</div>' +
407        (p.kicker ? '<span class="project__flag">' + esc(p.kicker) + '</span>' : '') +
408        '<h3>' + esc(p.title) + '</h3>' +
409        '<div class="project__body">' + blurbHtml(p.summary || p.blurb) +
410        (p.collaborator ? '<p class="project__collab">Clinical collaborator: ' + creditLink(p.collaborator) + '</p>' : "") +
411        (hasRole ? '<p style="color:var(--text-3);font-size:var(--fs-sub);margin-bottom:6px">My role</p>' +
412          '<ul style="margin:0 0 4px;padding-left:18px;color:var(--text-2);font-size:var(--fs-sub)">' +
413          p.contributions.map(function (c) { return '<li style="margin-bottom:4px">' + esc(c) + '</li>'; }).join("") + '</ul>' : '') +
414        (p.links && p.links.length ? '<div style="margin-top:12px;display:flex;gap:16px">' + p.links.map(function (l) { return '<a class="link" href="' + esc(l.href) + '">' + esc(l.label) + '</a>'; }).join("") + '</div>' : "") +
415        ((p.techniques || p.tags || []).length
416          ? '<p class="project__tech-label">Techniques</p><div class="project__tags">' +
417            (p.techniques || p.tags).map(function (t) { return '<span>' + esc(t) + '</span>'; }).join("") + '</div>'
418          : "") +
419        '</div></div>' +
420        '</article>'
421      );
422
423      var body = node.querySelector(".project__body");
424      var demo = projectDemo(p);
425      if (demo) body.appendChild(demo);
426
427      var team = p.id ? peopleFor(p.id) : [];
428      if (team.length) {
429        var row = el('<div class="project__team"><p class="project__team-label">People</p><div class="person-row"></div></div>');
430        var strip = row.querySelector(".person-row");
431        team.forEach(function (m) { strip.appendChild(memberPhoto(m)); });
432        node.querySelector(".project__head").appendChild(row);
433      } else if (p.recruiting) {
434        var rec = el('<p class="project__recruiting">We are actively looking for students to join this project. <a class="link" href="contact.html">Get in touch</a>.</p>');
435        node.querySelector(".project__head").appendChild(rec);
436      }
437      mount.appendChild(node);
438    });
439    if (location.hash) {
440      var t = document.getElementById(location.hash.slice(1));
441      if (t) t.scrollIntoView();
442    }
443  }
444
445  /* ---------- FUNDING (Research page) ---------- */
446  function renderGrants() {
447    var mount = $("[data-render='grants']");
448    if (!mount || !SK.grants || !SK.grants.length) return;
449    mount.innerHTML = SK.grants.map(function (g) {
450      var meta = [g.role, g.funder, g.period, g.amount].filter(Boolean).join(" · ");
451      return '<article class="grant">' +
452        '<h3>' + esc(g.title) + (g.internal ? ' <span class="grant__tag">internal</span>' : '') + '</h3>' +
453        '<p class="grant__meta">' + esc(meta) + '</p>' +
454        (g.project ? '<p class="grant__project">' + esc(g.project) + '</p>' : '') +
455        '</article>';
456    }).join("");
457    var host = mount.closest("section");
458    if (host) host.hidden = false;
459  }
460
461  /* ---------- ABOUT ---------- */
462  function renderAbout() {
463    if (!SK.about) return;
464    var bio = $("[data-render='bio']");
465    if (bio) bio.innerHTML = SK.about.bio.map(function (p) {
466      return Array.isArray(p)
467        ? '<ul class="bio-list">' + p.map(function (x) { return '<li>' + esc(x) + '</li>'; }).join("") + '</ul>'
468        : '<p>' + esc(p) + '</p>';
469    }).join("");
470
471    // `note` may be a string or an array of strings; either way it renders as a
472    // bullet list so every entry looks consistent.
473    function noteBlock(note) {
474      if (!note) return "";
475      return '<ul class="tl-notes">' +
476        [].concat(note).map(function (n) { return '<li>' + esc(n) + '</li>'; }).join("") + '</ul>';
477    }
478
479    fillTimeline("[data-render='education']", SK.about.education, function (d) {
480      return '<span class="yr">' + esc(d.yr) + '</span><h3>' + esc(d.title) + '</h3><span class="org">' + esc(d.org) + '</span>' + noteBlock(d.note);
481    });
482    fillTimeline("[data-render='experience']", SK.about.experience, function (d) {
483      return '<span class="yr">' + esc(d.yr) + '</span><h3>' + esc(d.title) + '</h3><span class="org">' + esc(d.org) + '</span>' + noteBlock(d.note);
484    });
485
486    var rec = $("[data-render='recognition']");
487    if (rec) {
488      rec.innerHTML = "";
489      (SK.about.recognition || []).forEach(function (r) {
490        rec.appendChild(el('<li style="padding:14px 0;border-bottom:1px solid var(--hairline);display:flex;gap:16px"><span style="color:var(--gold);flex:none;font-family:var(--font-display)">' + esc(r.yr) + '</span><span style="color:var(--text-2)">' + esc(r.text) + '</span></li>'));
491      });
492    }
493    var st = $("[data-render='about-stats']");
494    if (st) {
495      st.innerHTML = "";
496      (SK.about.stats || []).forEach(function (s) {
497        st.appendChild(el('<div><dt>' + esc(s.n) + '</dt><dd>' + esc(s.label) + '</dd></div>'));
498      });
499    }
500  }
501  function fillTimeline(sel, list, tpl) {
502    var m = $(sel);
503    if (!m || !list) return;
504    m.innerHTML = "";
505    list.forEach(function (d) { m.appendChild(el('<div class="tl-item reveal">' + tpl(d) + '</div>')); });
506  }
507
508  /* ---------- PUBLICATIONS ---------- */
509  var PUB_MON = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12 };
510  function pubKey(p) { return (p.year || 0) * 100 + (PUB_MON[String(p.month || "").toLowerCase()] || 0); }
511  function renderPublications() {
512    var mount = $("[data-render='publications']");
513    if (!mount || !SK.publications) return;
514    var list = SK.publications.slice().sort(function (a, b) { return pubKey(b) - pubKey(a); });
515
516    // summary counts
517    var summary = $("[data-render='pub-summary']");
518    if (summary) {
519      var order = ["journal", "conference", "abstract", "chapter", "preprint", "patent"];
520      var labels = {
521        journal: "Journal articles", conference: "Conference papers", abstract: "Abstracts",
522        chapter: "Book chapters", preprint: "Preprints", patent: "Patents"
523      };
524      var counts = {};
525      list.forEach(function (p) { counts[p.kind] = (counts[p.kind] || 0) + 1; });
526      var cells = order.filter(function (k) { return counts[k]; }).map(function (k) {
527        var n = counts[k], lbl = counts[k] === 1 ? labels[k].replace(/s$/, "") : labels[k];
528        return '<div class="pub-summary__stat"><span class="pub-summary__n">' + n +
529          '</span><span class="pub-summary__l">' + esc(lbl) + '</span></div>';
530      });
531      if (SK.inPrep && SK.inPrep.length) {
532        cells.push('<div class="pub-summary__stat"><span class="pub-summary__n">' + SK.inPrep.length +
533          '</span><span class="pub-summary__l">In preparation</span></div>');
534      }
535      summary.innerHTML = cells.join("");
536    }
537    var KIND_ORDER = ["journal", "conference", "abstract", "chapter", "preprint", "patent"];
538    var present = list.map(function (p) { return p.kind; });
539    var kinds = ["all"].concat(KIND_ORDER.filter(function (k) { return present.indexOf(k) !== -1; }));
540    var filterBar = $("[data-render='pub-filters']");
541    var active = "all";
542
543    if (filterBar) {
544      filterBar.innerHTML = "";
545      kinds.forEach(function (k) {
546        var b = el('<button type="button" aria-pressed="' + (k === "all") + '">' + esc(k === "all" ? "All" : k.charAt(0).toUpperCase() + k.slice(1)) + '</button>');
547        b.addEventListener("click", function () {
548          active = k;
549          $all("button", filterBar).forEach(function (x) { x.setAttribute("aria-pressed", String(x === b)); });
550          draw();
551        });
552        filterBar.appendChild(b);
553      });
554    }
555    function boldName(s) {
556      return esc(s).replace(/(Kim,?\s*B\.?|B\.?\s?Kim|S\.?\s?Kim)/g, "<strong style='color:var(--text)'>$1</strong>");
557    }
558    function draw() {
559      mount.innerHTML = "";
560      var shown = list.filter(function (p) { return active === "all" || p.kind === active; });
561      shown.forEach(function (p) {
562        var links = p.links || [];
563        var doi = links.filter(function (l) { return /doi/i.test(l.label); })[0];
564        var rest = links.filter(function (l) { return !/doi/i.test(l.label); });
565        mount.appendChild(el(
566          '<article class="pub">' +
567          '<div class="pub__top"><span class="pub__yr">' + esc((p.month ? p.month + " " : "") + p.year) + '</span>' +
568          '<span class="pub__kind">' + esc(p.kind) + '</span></div>' +
569          '<h3>' + esc(p.title) +
570          (doi ? ' <a class="pub__doi" href="' + esc(doi.href) + '" target="_blank" rel="noopener">(DOI)</a>' : '') + '</h3>' +
571          '<div class="pub__authors">' + boldName(p.authors) + '</div>' +
572          '<div class="pub__venue">' + esc(p.venue) + '</div>' +
573          (rest.length ? '<div class="pub__links">' + rest.map(function (l) { return '<a href="' + esc(l.href) + '"' + (l.href.charAt(0) === "#" ? "" : ' target="_blank" rel="noopener"') + '>' + esc(l.label) + ' ' + svgIcon("arrowUpRight") + '</a>'; }).join("") + '</div>' : "") +
574          '</article>'
575        ));
576      });
577    }
578    draw();
579
580    // "In preparation" — its own section, no year/venue
581    var prepMount = $("[data-render='publications-inprep']");
582    if (prepMount && SK.inPrep && SK.inPrep.length) {
583      prepMount.innerHTML = SK.inPrep.map(function (p) {
584        return '<article class="pub pub--prep">' +
585          '<h3>' + esc(p.title) + '</h3>' +
586          '<div class="pub__authors">' + boldName(p.authors) + '</div>' +
587          '<div class="pub__venue">Manuscript in preparation</div>' +
588          '</article>';
589      }).join("");
590      var host = prepMount.closest("section");
591      if (host) host.hidden = false;
592    }
593  }
594
595  /* ---------- MEDIA ---------- */
596  function renderMedia() {
597    var mount = $("[data-render='media']");
598    if (!mount || !SK.media) return;
599    mount.innerHTML = "";
600    SK.media.forEach(function (m, i) {
601      if (m.type === "link") {
602        mount.appendChild(el(
603          '<a class="video-card" href="' + esc(m.href) + '" target="_blank" rel="noopener">' +
604          '<div class="video-card__frame ticked"><div class="video-card__play"><span>' + svgIcon("arrowUpRight") + '</span></div></div>' +
605          '<h3>' + esc(m.title) + '</h3><p>' + esc(m.note) + '</p>' +
606          '<span class="src">' + esc(m.source) + ' · ' + esc(m.date) + '</span></a>'
607        ));
608        return;
609      }
610      var id = m.type === "youtube" ? m.youtube : m.vimeo;
611      var card = el(
612        '<article class="video-card">' +
613        '<button class="video-card__frame ticked" type="button" aria-label="Play: ' + esc(m.title) + '">' +
614        '<div class="video-card__play"><span>' + svgIcon("play") + '</span></div></button>' +
615        '<h3>' + esc(m.title) + '</h3><p>' + esc(m.note) + '</p>' +
616        (m.credit ? '<p class="video-card__credit">With ' + creditLink(m.credit) + '</p>' : "") +
617        '<span class="src">' + esc(m.source) + ' · ' + esc(m.date) + '</span></article>'
618      );
619      var frame = card.querySelector(".video-card__frame");
620      var thumbSrc = m.thumb
621        ? m.thumb
622        : (m.type === "youtube" && id ? "https://i.ytimg.com/vi/" + id + "/hqdefault.jpg" : placeholder(m.title));
623      frame.insertBefore(smartImg(thumbSrc, m.title, "Video thumbnail"), frame.firstChild);
624      frame.addEventListener("click", function () {
625        if (!id) { alert("Add the video ID in data/media.js to enable playback."); return; }
626        var src = m.type === "youtube"
627          ? "https://www.youtube-nocookie.com/embed/" + id + "?autoplay=1&rel=0"
628          : "https://player.vimeo.com/video/" + id + "?autoplay=1";
629        var wrap = el('<div class="embed-frame ticked"><iframe src="' + src + '" title="' + esc(m.title) + '" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>');
630        frame.replaceWith(wrap);
631      });
632      mount.appendChild(card);
633    });
634  }
635
636  /* ---------- GALLERIES (album grid, on media.html) ---------- */
637  function renderGalleries() {
638    var mount = $("[data-render='albums']");
639    if (!mount || !SK.albums) return;
640    mount.innerHTML = "";
641    normAlbums().forEach(function (al) {
642      var card = el(
643        '<a class="album-card reveal" href="album.html?album=' + encodeURIComponent(al.id) + '">' +
644        '<span class="album-card__count" hidden></span>' +
645        '<div class="album-card__cover"></div>' +
646        '<div class="album-card__body"><h3>' + esc(al.title) + '</h3>' +
647        '<div class="meta">' + esc(albumMeta(al)) + '</div></div>' +
648        '</a>'
649      );
650      card.querySelector(".album-card__cover").appendChild(
651        smartImg(albumSrc(al.id, al.cover || (al.photos && al.photos[0]) || "01.jpg"), al.title + " — cover", al.title)
652      );
653      mount.appendChild(card);
654
655      var badge = card.querySelector(".album-card__count");
656      function setCount(n) {
657        if (!n) return;
658        badge.textContent = n + (n === 1 ? " photo" : " photos");
659        badge.hidden = false;
660      }
661      if (al.photos && al.photos.length) setCount(al.photos.length);
662      else { var n = 0; discoverPhotos(al.id, function () { n++; }, function () { setCount(n); }); }
663    });
664  }
665
666  /* ---------- ALBUM (detail) ---------- */
667  function renderAlbum() {
668    var mount = $("[data-render='album']");
669    if (!mount || !SK.albums) return;
670    var want = new URLSearchParams(location.search).get("album");
671    var al = normAlbums().filter(function (a) { return a.id === want; })[0];
672    if (!al) {
673      mount.innerHTML = '<p class="lead">Album not found. <a class="link" href="media.html">Back to all albums</a>.</p>';
674      return;
675    }
676    document.title = al.title + " — Star Kim";
677    $("[data-album-title]") && ($("[data-album-title]").textContent = al.title);
678    $("[data-album-meta]") && ($("[data-album-meta]").textContent = albumMeta(al));
679
680    if (al.note || al.credit) {
681      mount.appendChild(el('<p class="album-note">' +
682        (al.note ? esc(al.note) + " " : "") + creditLink(al.credit) + '</p>'));
683    }
684
685    var grid = el('<div class="photo-grid"></div>');
686    mount.appendChild(grid);
687    var photos = [];   // { src, caption } — grows as photos are found
688
689    function addPhoto(file, caption) {
690      var idx = photos.length;
691      photos.push({ src: albumSrc(al.id, file), caption: caption || "" });
692      var b = el('<button type="button" aria-label="Open photo ' + (idx + 1) +
693        (caption ? ": " + esc(caption) : "") + '"></button>');
694      b.appendChild(smartImg(photos[idx].src, caption || (al.title + " photo " + (idx + 1)), al.title));
695      b.addEventListener("click", function () { openLightbox(photos, idx); });
696      grid.appendChild(b);
697    }
698
699    if (al.photos && al.photos.length) {
700      // explicit list (supports { src, caption })
701      al.photos.forEach(function (p) {
702        var o = typeof p === "string" ? { src: p } : p;
703        addPhoto(o.src, o.caption);
704      });
705    } else {
706      // auto-discover 01.jpg, 02.jpg, …
707      discoverPhotos(al.id, function (file) { addPhoto(file); }, function () {
708        if (!photos.length) grid.innerHTML =
709          '<p class="lead">No photos in this album yet.</p>';
710      });
711    }
712  }
713
714  /* ---------- LIGHTBOX ---------- */
715  var lb;
716  function ensureLightbox() {
717    if (lb) return lb;
718    lb = el(
719      '<div class="lightbox" hidden role="dialog" aria-modal="true" aria-label="Photo viewer">' +
720      '<button class="lightbox__btn lightbox__close" type="button" aria-label="Close">' + svgIcon("close") + '</button>' +
721      '<div class="lightbox__stage"><img alt=""></div>' +
722      '<div class="lightbox__bar">' +
723      '<button class="lightbox__btn" type="button" data-dir="-1" aria-label="Previous photo">' + svgIcon("prev") + '</button>' +
724      '<span class="lightbox__caption"></span>' +
725      '<button class="lightbox__btn" type="button" data-dir="1" aria-label="Next photo">' + svgIcon("next") + '</button>' +
726      '</div></div>'
727    );
728    document.body.appendChild(lb);
729    return lb;
730  }
731  var lbState = { list: [], idx: 0, lastFocus: null };
732  function openLightbox(list, idx) {
733    ensureLightbox();
734    lbState.list = list; lbState.idx = idx; lbState.lastFocus = document.activeElement;
735    lb.hidden = false;
736    document.body.style.overflow = "hidden";
737    drawLb();
738    lb.querySelector(".lightbox__close").focus();
739    lb.addEventListener("click", onLbClick);
740    document.addEventListener("keydown", onLbKey);
741  }
742  function closeLightbox() {
743    lb.hidden = true;
744    document.body.style.overflow = "";
745    lb.removeEventListener("click", onLbClick);
746    document.removeEventListener("keydown", onLbKey);
747    if (lbState.lastFocus) lbState.lastFocus.focus();
748  }
749  function stepLb(d) {
750    lbState.idx = (lbState.idx + d + lbState.list.length) % lbState.list.length;
751    drawLb();
752  }
753  function drawLb() {
754    var cur = lbState.list[lbState.idx];
755    var img = lb.querySelector(".lightbox__stage img");
756    img.src = cur.src; img.alt = cur.caption || "";
757    img.onerror = function () { img.onerror = null; img.src = placeholder(cur.caption || "Photo"); };
758    lb.querySelector(".lightbox__caption").textContent =
759      (lbState.idx + 1) + " / " + lbState.list.length + (cur.caption ? "  —  " + cur.caption : "");
760  }
761  function onLbClick(e) {
762    if (e.target.closest(".lightbox__close") || e.target === lb) return closeLightbox();
763    var dir = e.target.closest("[data-dir]");
764    if (dir) stepLb(parseInt(dir.getAttribute("data-dir"), 10));
765  }
766  function onLbKey(e) {
767    if (e.key === "Escape") closeLightbox();
768    else if (e.key === "ArrowLeft") stepLb(-1);
769    else if (e.key === "ArrowRight") stepLb(1);
770    else if (e.key === "Tab") {
771      var f = $all("button", lb);
772      var first = f[0], last = f[f.length - 1];
773      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
774      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
775    }
776  }
777
778  /* ---------- PERSON PANEL (a lab member's outcomes) ---------- */
779  var MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
780  var SEASONS = { winter: 1, spring: 4, summer: 7, fall: 10, autumn: 10 };
781  function hlSort(date) {
782    var d = String(date || "").toLowerCase();
783    var ym = d.match(/(\d{4})/);
784    var year = ym ? +ym[1] : 0;
785    var mon = 0;
786    var iso = d.match(/\d{4}-(\d{1,2})/);
787    if (iso) mon = +iso[1];
788    if (!mon) for (var i = 0; i < 12; i++) if (d.indexOf(MONTHS[i]) > -1) { mon = i + 1; break; }
789    if (!mon) Object.keys(SEASONS).forEach(function (s) { if (d.indexOf(s) > -1) mon = SEASONS[s]; });
790    return year * 100 + mon;
791  }
792  function hlItem(h) {
793    var body = h.href
794      ? '<a class="mh__link" href="' + esc(h.href) + '" target="_blank" rel="noopener">' + esc(h.text || "") + '</a>'
795      : esc(h.text || "");
796    return '<li class="mh">' +
797      '<span class="mh__date">' + esc(h.date || "") + '</span>' +
798      (h.type ? '<span class="mh__type">' + esc(h.type) + '</span>' : "") +
799      '<span class="mh__text">' + body + '</span></li>';
800  }
801  var pm, pmLast;
802  function ensurePersonModal() {
803    if (pm) return pm;
804    pm = el(
805      '<div class="pmodal" hidden role="dialog" aria-modal="true" aria-labelledby="pmodal-name">' +
806      '<div class="pmodal__card">' +
807      '<button class="pmodal__close" type="button" aria-label="Close">' + svgIcon("close") + '</button>' +
808      '<div class="pmodal__head">' +
809      '<div class="pmodal__photo"></div>' +
810      '<div><h3 id="pmodal-name" class="pmodal__name"></h3>' +
811      '<p class="pmodal__role"></p>' +
812      '<p class="pmodal__meta"></p></div>' +
813      '</div>' +
814      '<ul class="pmodal__list"></ul>' +
815      '</div></div>'
816    );
817    document.body.appendChild(pm);
818    pm.addEventListener("click", function (e) {
819      if (e.target === pm || e.target.closest(".pmodal__close")) closePerson();
820    });
821    return pm;
822  }
823  function onPmKey(e) { if (e.key === "Escape") closePerson(); }
824  function openPerson(m) {
825    ensurePersonModal();
826    pmLast = document.activeElement;
827    var ph = pm.querySelector(".pmodal__photo");
828    ph.innerHTML = "";
829    ph.appendChild(smartImg(m.photo || avatarURI(m.name), m.name || "", m.name));
830    pm.querySelector(".pmodal__name").textContent = m.name || "";
831    pm.querySelector(".pmodal__role").textContent =
832      [m.role, m.major, m.honors ? "Honors" : ""].filter(Boolean).join(" · ");
833    pm.querySelector(".pmodal__meta").textContent =
834      m.since ? "In the lab since " + m.since : "";
835    var hl = (m.highlights || []).slice().sort(function (a, b) { return hlSort(b.date) - hlSort(a.date); });
836    pm.querySelector(".pmodal__list").innerHTML = hl.length
837      ? hl.map(hlItem).join("")
838      : '<li class="mh"><span class="mh__text">Milestones will be listed here.</span></li>';
839    pm.hidden = false;
840    document.body.style.overflow = "hidden";
841    pm.querySelector(".pmodal__close").focus();
842    document.addEventListener("keydown", onPmKey);
843  }
844  function closePerson() {
845    pm.hidden = true;
846    document.body.style.overflow = "";
847    document.removeEventListener("keydown", onPmKey);
848    if (pmLast) pmLast.focus();
849  }
850  // A photo button for one member; opens their outcomes panel on click.
851  function memberPhoto(m) {
852    var btn = el(
853      '<button class="person-photo" type="button" aria-label="' +
854      esc(m.name || "Lab member") + ' — view outcomes"><span class="person-photo__ring"></span>' +
855      '<span class="person-photo__name">' + esc(m.name || "") + '</span></button>'
856    );
857    var ring = btn.querySelector(".person-photo__ring");
858    var img = new Image();
859    img.loading = "lazy"; img.decoding = "async"; img.alt = m.name || "";
860    img.addEventListener("error", function () {
861      if (img.dataset.fb) return;
862      img.dataset.fb = "1";
863      img.src = avatarURI(m.name);
864    });
865    img.src = m.photo || avatarURI(m.name);
866    ring.appendChild(img);
867    btn.addEventListener("click", function () { openPerson(m); });
868    return btn;
869  }
870  function peopleFor(areaId) {
871    var out = [];
872    (SK.people || []).forEach(function (g) {
873      if (g.projectId === areaId) out = out.concat(g.members || []);
874    });
875    return out;
876  }
877
878  /* ---------- TALKS ---------- */
879  function talkSort(when) {
880    var s = String(when || ""), low = s.toLowerCase();
881    var year = +((s.match(/(\d{4})/) || [])[1] || 0);
882    var MON = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
883    var mon = 0;
884    for (var i = 0; i < 12; i++) if (low.indexOf(MON[i]) > -1) { mon = i + 1; break; }
885    var day = +((s.match(/\b(\d{1,2})\b/) || [])[1] || 0);
886    return year * 10000 + mon * 100 + day;
887  }
888  function renderTalks() {
889    if (!SK.talks) return;
890    var up = $("[data-render='talks-upcoming']");
891    var past = $("[data-render='talks-past']");
892    var byDateAsc = function (a, b) { return talkSort(a.when) - talkSort(b.when); };
893    var byDateDesc = function (a, b) { return talkSort(b.when) - talkSort(a.when); };
894    function row(t) {
895      var titles = t.titles || (t.title ? [t.title] : []);
896      var titlesHtml = titles.length
897        ? '<ul class="trip__titles">' + titles.map(function (x) {
898            return '<li>' + esc(typeof x === "string" ? x : x.title) + '</li>';
899          }).join("") + '</ul>'
900        : "";
901      return el(
902        '<article class="trip reveal">' +
903        '<div><span class="trip__when">' + esc(t.when) + '</span>' +
904        (t.place ? '<br><span class="trip__place">' + esc(t.place) + '</span>' : "") +
905        '<br><span class="trip__status" data-s="' + esc(t.status) + '">' + (t.status === "upcoming" ? "Upcoming" : "Past") + '</span></div>' +
906        '<div><h3>' + esc(t.event) + '</h3>' +
907        titlesHtml +
908        (t.note ? '<p class="trip__note">' + esc(t.note) + '</p>' : "") +
909        (t.tags && t.tags.length ? '<div class="trip__tags">' + t.tags.map(function (x) { return '<span>' + esc(x) + '</span>'; }).join("") + '</div>' : "") +
910        '</div></article>'
911      );
912    }
913    // upcoming: soonest first · past: most recent first
914    if (up) { up.innerHTML = ""; SK.talks.filter(function (t) { return t.status === "upcoming"; }).sort(byDateAsc).forEach(function (t) { up.appendChild(row(t)); }); }
915    if (past) { past.innerHTML = ""; SK.talks.filter(function (t) { return t.status === "past"; }).sort(byDateDesc).forEach(function (t) { past.appendChild(row(t)); }); }
916  }
917
918  /* ---------- PEOPLE (lab members, grouped by project) ---------- */
919  function avatarURI(name) {
920    var parts = String(name || "?").trim().split(/\s+/);
921    var initials = ((parts[0] || "")[0] || "") + ((parts[parts.length - 1] || "")[0] || "");
922    var svg =
923      '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">' +
924      '<rect width="240" height="240" fill="#17273F"/>' +
925      '<text x="120" y="132" fill="#E9B857" font-family="Space Grotesk, Inter, sans-serif" ' +
926      'font-size="92" font-weight="600" text-anchor="middle">' + esc(initials.toUpperCase()) + '</text></svg>';
927    return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
928  }
929
930  function renderPeople() {
931    var mount = $("[data-render='people']");
932    if (!mount || !SK.people) return;
933    var areaIds = (SK.areas || []).map(function (a) { return a.id; });
934    // Groups tied to a research area render as photos under that area
935    // (see renderWork). Here we only show the leftover groups, e.g. Alumni.
936    var groups = SK.people.filter(function (g) { return areaIds.indexOf(g.projectId) === -1; });
937    mount.innerHTML = "";
938    if (!groups.length) {
939      var host = mount.closest("section");
940      if (host) host.hidden = true;
941      return;
942    }
943    groups.forEach(function (group) {
944      var section = el(
945        '<div class="people-group">' +
946        '<h3 class="people-group__title">' + esc(group.project || "Lab") + '</h3>' +
947        '<div class="person-row person-row--wrap"></div>' +
948        '</div>'
949      );
950      var strip = section.querySelector(".person-row");
951      (group.members || []).forEach(function (m) {
952        var wrap = el('<div class="person-cell"></div>');
953        wrap.appendChild(memberPhoto(m));
954        if (m.role) wrap.appendChild(el('<p class="person-cell__role">' + esc(m.role) + '</p>'));
955        strip.appendChild(wrap);
956      });
957      mount.appendChild(section);
958    });
959  }
960
961  /* ---------- CONTACT FORM ---------- */
962  function initContactForm() {
963    var form = $("#contact-form");
964    if (!form) return;
965    var status = $("#form-status");
966    var fields = $all("[data-required]", form);
967
968    // FormSubmit redirects back here with ?sent=1 after a successful send.
969    if (/[?&]sent=1\b/.test(location.search)) {
970      status.className = "form-status form-status--ok";
971      status.hidden = false;
972      status.textContent = "Thanks — your message has been sent. Dr. Kim will reply to the ones she can help with.";
973      history.replaceState(null, "", location.pathname);
974    }
975
976    form.addEventListener("submit", function (e) {
977      var errors = [];
978      fields.forEach(function (f) {
979        var val = (f.value || "").trim();
980        var bad = !val || (f.type === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(val));
981        setFieldError(f, bad);
982        if (bad) errors.push(f);
983      });
984
985      if (errors.length) {
986        e.preventDefault();
987        status.className = "form-status";
988        status.setAttribute("role", "alert");
989        status.hidden = false;
990        status.innerHTML = "<strong>Please check a few fields.</strong><br>" +
991          errors.map(function (f) {
992            var lbl = form.querySelector("label[for='" + f.id + "']");
993            return '<a href="#' + f.id + '">' + esc(lbl ? lbl.textContent : f.name) + '</a>';
994          }).join(" &nbsp;·&nbsp; ");
995        status.focus();
996        return;
997      }
998      // On the live site, let the form POST natively to its action (FormSubmit),
999      // which emails the submission. On localhost / file:// there is no useful
1000      // endpoint, so fall back to the visitor's mail client instead.
1001      var host = location.hostname;
1002      var isLocal = location.protocol === "file:" || host === "" ||
1003        host === "localhost" || /^127\./.test(host) || host === "0.0.0.0";
1004      var action = form.getAttribute("action") || "";
1005      if (/^https?:\/\//i.test(action) && !isLocal) return; // native submit
1006
1007      e.preventDefault();
1008      var to = (window.SK && SK.site && SK.site.email) || "";
1009      var subject = encodeURIComponent("[Website] " + ($("#c-topic", form).value || "Enquiry") + " — " + $("#c-name", form).value);
1010      var body = encodeURIComponent(
1011        $("#c-message", form).value + "\n\n—\n" + $("#c-name", form).value + "\n" + $("#c-email", form).value +
1012        ($("#c-org", form).value ? "\n" + $("#c-org", form).value : "")
1013      );
1014      window.location.href = "mailto:" + to + "?subject=" + subject + "&body=" + body;
1015      status.className = "form-status form-status--ok";
1016      status.hidden = false;
1017      status.innerHTML = "<strong>Opening your email app…</strong><br>If nothing happens, write to <a data-email='text' href='#'></a> directly.";
1018      renderEmail();
1019    });
1020
1021    fields.forEach(function (f) {
1022      f.addEventListener("blur", function () {
1023        if (f.getAttribute("aria-invalid") === "true") {
1024          var val = (f.value || "").trim();
1025          var bad = !val || (f.type === "email" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(val));
1026          setFieldError(f, bad);
1027        }
1028      });
1029    });
1030  }
1031  function setFieldError(f, bad) {
1032    var msg = document.getElementById(f.id + "-err");
1033    f.setAttribute("aria-invalid", String(!!bad));
1034    if (msg) msg.textContent = bad
1035      ? (f.type === "email" ? "Enter a valid email address." : "This field is required.")
1036      : "";
1037  }
1038
1039  /* ---------- boot ---------- */
1040  function safe(fn) { try { fn(); } catch (e) { if (window.console) console.error("[SK]", fn.name, e); } }
1041
1042  document.addEventListener("DOMContentLoaded", function () {
1043    [initNav, initYear, initStarfield, renderSocial, renderEmail, renderIntro, renderHomePanels,
1044     renderHome, renderWork, renderAbout, renderPublications, renderMedia, renderGalleries,
1045     renderAlbum, renderTalks, renderPeople, renderGrants, initContactForm].forEach(safe);
1046    // reveal runs last; hidden state is opt-in via .reveal-ready so a failure here
1047    // cannot leave content invisible
1048    safe(initReveal);
1049  });
1050})();

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.