Frontend: search races, lightbox a11y, popup edge cases

- semantic-search.js: generation token prevents stale results from
  rendering over newer queries; in-flight dedup on the index fetch;
  index/meta size consistency check fails loudly instead of NaN
  ranking (AUDIT §5.5)
- lightbox.js: triggers keyboard-activatable (role=button, tabindex,
  Enter/Space); Tab trapped inside the aria-modal overlay, modeled on
  gallery.js (§5.6)
- nav.js: portal toggle persists via guarded safeStorage so
  storage-blocked contexts can't kill the toggle (§5.7)
- popups.js: provider url() throws (malformed percent-encoding) are
  treated as no-popup; future dates render nothing instead of
  "N days ago" (§5.7)
- search.js: missing PagefindUI degrades to a console warning instead
  of aborting the whole handler (§5.7)
- citations.js: deleted — dead code superseded by popups.js (§5.7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 09:43:25 -04:00
parent c17c203747
commit caa113e036
6 changed files with 127 additions and 108 deletions

View File

@ -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();
}
})();

View File

@ -165,7 +165,12 @@
var images = document.querySelectorAll('img[data-lightbox]'); var images = document.querySelectorAll('img[data-lightbox]');
images.forEach(function (el) { 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 // Look for a sibling figcaption in the parent figure
var figcaptionText = ''; var figcaptionText = '';
var parent = el.parentElement; var parent = el.parentElement;
@ -176,6 +181,14 @@
} }
} }
open(el.src, el.alt, figcaptionText, el); 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')); 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) { document.addEventListener('keydown', function (e) {
if (!overlay.classList.contains('is-open')) return; if (!overlay.classList.contains('is-open')) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
close(); close();
} else if (e.key === 'Tab') {
trapTab(e);
} else if ((e.key === 'i' || e.key === 'I') } else if ((e.key === 'i' || e.key === 'I')
&& overlay.classList.contains('darkroom') && overlay.classList.contains('darkroom')
&& !infoBtn.hidden) { && !infoBtn.hidden) {

View File

@ -17,17 +17,23 @@
const toggle = document.querySelector('.nav-portal-toggle'); const toggle = document.querySelector('.nav-portal-toggle');
if (!portals || !toggle) return; 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) { function setOpen(open) {
portals.classList.toggle('is-open', open); portals.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open)); toggle.setAttribute('aria-expanded', String(open));
// Rotate arrow indicator if present. // Rotate arrow indicator if present.
const arrow = toggle.querySelector('.nav-portal-arrow'); const arrow = toggle.querySelector('.nav-portal-arrow');
if (arrow) arrow.textContent = open ? '▲' : '▼'; 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. // Restore persisted state; default is collapsed.
const stored = localStorage.getItem(STORAGE_KEY); const stored = store ? store.get(STORAGE_KEY) : null;
setOpen(stored === '1'); setOpen(stored === '1');
toggle.addEventListener('click', function () { toggle.addEventListener('click', function () {

View File

@ -472,7 +472,12 @@
if (!match) return Promise.resolve(null); if (!match) return Promise.resolve(null);
var ctx = { match: match, href: href }; 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; var fetcher = p.fetchType === 'xml' ? fetchXml : fetchJson;
return fetcher(url, p.fetchInit).then(function (data) { return fetcher(url, p.fetchInit).then(function (data) {
@ -951,10 +956,10 @@
var agoDays = daysBetween(start, today); var agoDays = daysBetween(start, today);
/* "~" prefix when we've rounded to a unit larger than days. */ /* "~" prefix when we've rounded to a unit larger than days. */
var span = humanDuration(spanDays, true); var span = humanDuration(spanDays, true);
var ago = humanAgo(agoDays); var ago = humanAgo(agoDays); /* '' when start is in the future */
lines.push( lines.push(
'<div class="popup-date-primary">' '<div class="popup-date-primary">'
+ esc(span) + ' · started ' + esc(ago) + esc(span) + (ago ? ' · started ' + esc(ago) : '')
+ '</div>'); + '</div>');
if (commits && /^\d+$/.test(commits)) { if (commits && /^\d+$/.test(commits)) {
var n = parseInt(commits, 10); var n = parseInt(commits, 10);
@ -965,10 +970,16 @@
} }
} else { } else {
var days = daysBetween(start, today); var days = daysBetween(start, today);
var ago2 = humanAgo(days); /* '' when the date is in the future */
if (ago2) {
lines.push( lines.push(
'<div class="popup-date-primary">' '<div class="popup-date-primary">'
+ esc(humanAgo(days)) + '</div>'); + esc(ago2) + '</div>');
} }
}
/* Nothing renderable (e.g. a lone future date): no popup. */
if (!lines.length) return Promise.resolve(null);
return Promise.resolve('<div class="popup-date">' + lines.join('') + '</div>'); return Promise.resolve('<div class="popup-date">' + lines.join('') + '</div>');
} }
@ -981,9 +992,10 @@
return isNaN(d.getTime()) ? null : d; 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) { function daysBetween(a, b) {
var ms = Math.abs(b.getTime() - a.getTime()); var ms = b.getTime() - a.getTime();
return Math.floor(ms / 86400000); return Math.floor(ms / 86400000);
} }
@ -1005,9 +1017,12 @@
return (approx ? '~' : '') + y + ' year' + (y === 1 ? '' : 's'); 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) { 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 === 1) return 'yesterday';
if (days < 14) return days + ' days ago'; if (days < 14) return days + ' days ago';
return humanDuration(days, true) + ' ago'; return humanDuration(days, true) + ' ago';

View File

@ -7,11 +7,18 @@
'use strict'; 'use strict';
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
var ui = new PagefindUI({ /* 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', element: '#search',
showImages: false, showImages: false,
excerptLength: 30, excerptLength: 30,
}); });
}
/* Timing instrumentation ------------------------------------------ */ /* Timing instrumentation ------------------------------------------ */
var timingEl = document.getElementById('search-timing'); var timingEl = document.getElementById('search-timing');
@ -46,7 +53,7 @@
/* Pre-fill from URL parameter and trigger the search -------------- */ /* Pre-fill from URL parameter and trigger the search -------------- */
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var q = params.get('q'); var q = params.get('q');
if (q) { if (q && ui) {
startTime = performance.now(); startTime = performance.now();
ui.triggerSearch(q); ui.triggerSearch(q);
} }

View File

@ -39,10 +39,17 @@
Index loading fetch once, lazily 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() { function loadIndex() {
if (indexReady) return Promise.resolve(); if (indexReady) return Promise.resolve();
if (loadIndexPromise) return loadIndexPromise;
return Promise.all([ loadIndexPromise = Promise.all([
fetch('/data/semantic-index.bin').then(function (r) { fetch('/data/semantic-index.bin').then(function (r) {
if (!r.ok) throw new Error('semantic-index.bin not found'); if (!r.ok) throw new Error('semantic-index.bin not found');
return r.arrayBuffer(); return r.arrayBuffer();
@ -54,8 +61,23 @@
]).then(function (results) { ]).then(function (results) {
vectors = new Float32Array(results[0]); vectors = new Float32Array(results[0]);
meta = results[1]; 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; 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) { function runSearch(query) {
var gen = ++searchGeneration;
query = query.trim(); query = query.trim();
if (!query) { clearResults(); return; } if (!query) { clearResults(); return; }
setStatus('Searching…'); setStatus('Searching…');
var indexPromise = loadIndex().catch(function (err) { var indexPromise = loadIndex().catch(function (err) {
if (gen === searchGeneration) {
setStatus('Semantic index not available — run make build first.'); setStatus('Semantic index not available — run make build first.');
}
throw err; throw err;
}); });
var modelPromise = loadModel(); var modelPromise = loadModel();
@ -130,12 +161,14 @@
var pipe = results[1]; var pipe = results[1];
return pipe(query, { pooling: 'mean', normalize: true }); return pipe(query, { pooling: 'mean', normalize: true });
}).then(function (output) { }).then(function (output) {
if (gen !== searchGeneration) return; /* superseded by a newer query */
var queryVec = output.data; /* Float32Array, length 384 */ var queryVec = output.data; /* Float32Array, length 384 */
var scores = cosineSims(queryVec); var scores = cosineSims(queryVec);
var hits = topK(scores); var hits = topK(scores);
renderResults(hits); renderResults(hits);
setStatus(hits.length ? '' : 'No results found.'); setStatus(hits.length ? '' : 'No results found.');
}).catch(function (err) { }).catch(function (err) {
if (gen !== searchGeneration) return; /* superseded by a newer query */
if (err.message && err.message.indexOf('not available') === -1) { if (err.message && err.message.indexOf('not available') === -1) {
setStatus('Search error — see console for details.'); setStatus('Search error — see console for details.');
console.error('semantic-search:', err); console.error('semantic-search:', err);