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:
parent
c17c203747
commit
caa113e036
|
|
@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue