diff --git a/static/js/citations.js b/static/js/citations.js deleted file mode 100644 index 70c93d2..0000000 --- a/static/js/citations.js +++ /dev/null @@ -1,86 +0,0 @@ -/* citations.js — hover tooltip for inline citation markers. - On hover of a .cite-marker, reads the matching bibliography entry from - the DOM and shows it in a floating tooltip. On click, follows the href - to jump to the bibliography section. Phase 3 popups.js can supersede this. */ - -(function () { - 'use strict'; - - let activeTooltip = null; - let hideTimer = null; - - function makeTooltip(html) { - const el = document.createElement('div'); - el.className = 'cite-tooltip'; - el.innerHTML = html; - el.addEventListener('mouseenter', () => clearTimeout(hideTimer)); - el.addEventListener('mouseleave', scheduleHide); - return el; - } - - function positionTooltip(tooltip, anchor) { - document.body.appendChild(tooltip); - const aRect = anchor.getBoundingClientRect(); - const tRect = tooltip.getBoundingClientRect(); - - let left = aRect.left + window.scrollX; - let top = aRect.top + window.scrollY - tRect.height - 10; - - // Keep horizontally within viewport with margin - const maxLeft = window.innerWidth - tRect.width - 12; - left = Math.max(8, Math.min(left, maxLeft)); - - // Flip below anchor if not enough room above - if (top < window.scrollY + 8) { - top = aRect.bottom + window.scrollY + 10; - } - - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - } - - function scheduleHide() { - hideTimer = setTimeout(() => { - if (activeTooltip) { - activeTooltip.remove(); - activeTooltip = null; - } - }, 180); - } - - function getRefHtml(refEl) { - // Strip the [N] number span, return the remaining innerHTML - const clone = refEl.cloneNode(true); - const num = clone.querySelector('.ref-num'); - if (num) num.remove(); - return clone.innerHTML.trim(); - } - - function init() { - document.querySelectorAll('.cite-marker').forEach(marker => { - const link = marker.querySelector('a.cite-link'); - if (!link) return; - - const href = link.getAttribute('href'); - if (!href || !href.startsWith('#')) return; - - const refEl = document.getElementById(href.slice(1)); - if (!refEl) return; - - marker.addEventListener('mouseenter', () => { - clearTimeout(hideTimer); - if (activeTooltip) { activeTooltip.remove(); } - activeTooltip = makeTooltip(getRefHtml(refEl)); - positionTooltip(activeTooltip, marker); - }); - - marker.addEventListener('mouseleave', scheduleHide); - }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); diff --git a/static/js/lightbox.js b/static/js/lightbox.js index 4889831..ae5a503 100644 --- a/static/js/lightbox.js +++ b/static/js/lightbox.js @@ -165,7 +165,12 @@ var images = document.querySelectorAll('img[data-lightbox]'); images.forEach(function (el) { - el.addEventListener('click', function () { + // Keyboard activation: the trigger acts as a button, and the + // tabindex also lets close() return focus to it. + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + + function activate() { // Look for a sibling figcaption in the parent figure var figcaptionText = ''; var parent = el.parentElement; @@ -176,6 +181,14 @@ } } open(el.src, el.alt, figcaptionText, el); + } + + el.addEventListener('click', activate); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activate(); + } }); }); @@ -199,11 +212,42 @@ setInfoVisible(!overlay.classList.contains('is-info-visible')); }); - // Escape closes; "i" toggles info panel (darkroom only). + /* Focus trap for the overlay: cycle Tab/Shift+Tab through the + focusable controls inside the lightbox so keyboard users + cannot tab out into the obscured page background. Same + approach as gallery.js's trapTab; the [hidden] exclusion + covers infoBtn, which is hidden outside darkroom mode. */ + function trapTab(e) { + var focusable = Array.from(overlay.querySelectorAll( + 'button:not([disabled]):not([hidden]), [tabindex]:not([tabindex="-1"])' + )); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + var active = document.activeElement; + if (e.shiftKey) { + if (active === first || !overlay.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !overlay.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + } + + // Escape closes; Tab is trapped; "i" toggles info panel (darkroom only). document.addEventListener('keydown', function (e) { if (!overlay.classList.contains('is-open')) return; if (e.key === 'Escape') { close(); + } else if (e.key === 'Tab') { + trapTab(e); } else if ((e.key === 'i' || e.key === 'I') && overlay.classList.contains('darkroom') && !infoBtn.hidden) { diff --git a/static/js/nav.js b/static/js/nav.js index da35627..4834134 100644 --- a/static/js/nav.js +++ b/static/js/nav.js @@ -17,17 +17,23 @@ const toggle = document.querySelector('.nav-portal-toggle'); if (!portals || !toggle) return; + // safeStorage (utils.js, loaded synchronously before us) so a + // storage-blocked context can't throw before the click listener + // below binds; guarded like theme.js in case utils.js itself + // failed to load. + const store = window.lnUtils && window.lnUtils.safeStorage; + function setOpen(open) { portals.classList.toggle('is-open', open); toggle.setAttribute('aria-expanded', String(open)); // Rotate arrow indicator if present. const arrow = toggle.querySelector('.nav-portal-arrow'); if (arrow) arrow.textContent = open ? '▲' : '▼'; - localStorage.setItem(STORAGE_KEY, open ? '1' : '0'); + if (store) store.set(STORAGE_KEY, open ? '1' : '0'); } // Restore persisted state; default is collapsed. - const stored = localStorage.getItem(STORAGE_KEY); + const stored = store ? store.get(STORAGE_KEY) : null; setOpen(stored === '1'); toggle.addEventListener('click', function () { diff --git a/static/js/popups.js b/static/js/popups.js index a5b863c..d5489ce 100644 --- a/static/js/popups.js +++ b/static/js/popups.js @@ -472,7 +472,12 @@ if (!match) return Promise.resolve(null); var ctx = { match: match, href: href }; - var url = p.url(ctx); + /* p.url runs synchronously (before the .catch below attaches) and + can throw — e.g. decodeURIComponent on a malformed percent + sequence in the link path. Treat a throw as "no popup". */ + var url; + try { url = p.url(ctx); } + catch (e) { return Promise.resolve(null); } var fetcher = p.fetchType === 'xml' ? fetchXml : fetchJson; return fetcher(url, p.fetchInit).then(function (data) { @@ -951,10 +956,10 @@ var agoDays = daysBetween(start, today); /* "~" prefix when we've rounded to a unit larger than days. */ var span = humanDuration(spanDays, true); - var ago = humanAgo(agoDays); + var ago = humanAgo(agoDays); /* '' when start is in the future */ lines.push( ''); if (commits && /^\d+$/.test(commits)) { var n = parseInt(commits, 10); @@ -965,11 +970,17 @@ } } else { var days = daysBetween(start, today); - lines.push( - ''); + var ago2 = humanAgo(days); /* '' when the date is in the future */ + if (ago2) { + lines.push( + ''); + } } + /* Nothing renderable (e.g. a lone future date): no popup. */ + if (!lines.length) return Promise.resolve(null); + return Promise.resolve(''); } @@ -981,9 +992,10 @@ return isNaN(d.getTime()) ? null : d; } - /* Whole-day difference between two Dates, floored (never negative). */ + /* Whole-day difference b − a, floored. Negative when b precedes a, + so callers can detect future dates instead of mislabelling them. */ function daysBetween(a, b) { - var ms = Math.abs(b.getTime() - a.getTime()); + var ms = b.getTime() - a.getTime(); return Math.floor(ms / 86400000); } @@ -1005,9 +1017,12 @@ return (approx ? '~' : '') + y + ' year' + (y === 1 ? '' : 's'); } - /* Past-tense phrasing for a date N days in the past. */ + /* Past-tense phrasing for a date N days in the past. Returns '' for + future dates (negative N) — mirror now.js — so callers render + nothing rather than a false "N days ago". */ function humanAgo(days) { - if (days <= 0) return 'today'; + if (days < 0) return ''; /* future / clock skew */ + if (days === 0) return 'today'; if (days === 1) return 'yesterday'; if (days < 14) return days + ' days ago'; return humanDuration(days, true) + ' ago'; diff --git a/static/js/search.js b/static/js/search.js index fdebba4..2859c70 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -7,11 +7,18 @@ 'use strict'; window.addEventListener('DOMContentLoaded', function () { - var ui = new PagefindUI({ - element: '#search', - showImages: false, - excerptLength: 30, - }); + /* If the Pagefind bundle failed to load (e.g. 404), skip only the + Pagefind setup — the rest of this handler must still run. */ + var ui = null; + if (typeof PagefindUI === 'undefined') { + console.warn('search.js: PagefindUI not loaded — keyword search disabled.'); + } else { + ui = new PagefindUI({ + element: '#search', + showImages: false, + excerptLength: 30, + }); + } /* Timing instrumentation ------------------------------------------ */ var timingEl = document.getElementById('search-timing'); @@ -46,7 +53,7 @@ /* Pre-fill from URL parameter and trigger the search -------------- */ var params = new URLSearchParams(window.location.search); var q = params.get('q'); - if (q) { + if (q && ui) { startTime = performance.now(); ui.triggerSearch(q); } diff --git a/static/js/semantic-search.js b/static/js/semantic-search.js index 78a9db1..d82f318 100644 --- a/static/js/semantic-search.js +++ b/static/js/semantic-search.js @@ -39,10 +39,17 @@ Index loading — fetch once, lazily ------------------------------------------------------------------ */ + /* In-flight promise so concurrent first searches share a single + index fetch (mirrors loadModelPromise below). Without this guard, + two rapid keystrokes would each fetch semantic-index.bin and + semantic-meta.json before the first resolves. */ + var loadIndexPromise = null; + function loadIndex() { if (indexReady) return Promise.resolve(); + if (loadIndexPromise) return loadIndexPromise; - return Promise.all([ + loadIndexPromise = Promise.all([ fetch('/data/semantic-index.bin').then(function (r) { if (!r.ok) throw new Error('semantic-index.bin not found'); return r.arrayBuffer(); @@ -54,8 +61,23 @@ ]).then(function (results) { vectors = new Float32Array(results[0]); meta = results[1]; + /* Consistency check: a stale CDN-cached bin/json pair would + otherwise produce NaN scores and silently garbage ranking. */ + if (vectors.length !== meta.length * DIM) { + console.error('semantic-search: index/meta size mismatch (' + + vectors.length + ' floats vs ' + meta.length + ' × ' + DIM + ')'); + vectors = null; + meta = null; + throw new Error('semantic index not available: index/meta size mismatch'); + } indexReady = true; + }).catch(function (err) { + /* Allow a retry on the next call instead of caching the + failed promise forever. */ + loadIndexPromise = null; + throw err; }); + return loadIndexPromise; } /* ------------------------------------------------------------------ @@ -114,14 +136,23 @@ }); } + /* Generation token: each runSearch call invalidates all still-in-flight + predecessors, so a stale (earlier) query's results can never render + after a newer query's. */ + var searchGeneration = 0; + function runSearch(query) { + var gen = ++searchGeneration; + query = query.trim(); if (!query) { clearResults(); return; } setStatus('Searching…'); var indexPromise = loadIndex().catch(function (err) { - setStatus('Semantic index not available — run make build first.'); + if (gen === searchGeneration) { + setStatus('Semantic index not available — run make build first.'); + } throw err; }); var modelPromise = loadModel(); @@ -130,12 +161,14 @@ var pipe = results[1]; return pipe(query, { pooling: 'mean', normalize: true }); }).then(function (output) { + if (gen !== searchGeneration) return; /* superseded by a newer query */ var queryVec = output.data; /* Float32Array, length 384 */ var scores = cosineSims(queryVec); var hits = topK(scores); renderResults(hits); setStatus(hits.length ? '' : 'No results found.'); }).catch(function (err) { + if (gen !== searchGeneration) return; /* superseded by a newer query */ if (err.message && err.message.indexOf('not available') === -1) { setStatus('Search error — see console for details.'); console.error('semantic-search:', err);