audit: frontend a11y, JS shared utils, CSS variable definitions
This commit is contained in:
parent
a358c8b246
commit
dd61fc0cc4
|
|
@ -120,6 +120,8 @@
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--transition-fast: 0.15s ease;
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-medium: 0.28s ease;
|
||||||
|
--transition-slow: 0.5s ease;
|
||||||
|
|
||||||
/* Writing activity heatmap (light mode) */
|
/* Writing activity heatmap (light mode) */
|
||||||
--hm-0: #e8e8e4; /* empty cell */
|
--hm-0: #e8e8e4; /* empty cell */
|
||||||
|
|
@ -127,6 +129,29 @@
|
||||||
--hm-2: #787874; /* 500–1999 words */
|
--hm-2: #787874; /* 500–1999 words */
|
||||||
--hm-3: #424240; /* 2000–4999 words */
|
--hm-3: #424240; /* 2000–4999 words */
|
||||||
--hm-4: #1a1a1a; /* 5000+ words */
|
--hm-4: #1a1a1a; /* 5000+ words */
|
||||||
|
|
||||||
|
/* Aliases (introduced for build.css, components.css, and the
|
||||||
|
annotation system, all of which referenced custom properties that
|
||||||
|
were never defined). Browsers silently fall back to the property's
|
||||||
|
initial value when var(--undefined) is used, so without these
|
||||||
|
aliases the build/annotation pages would degrade to default
|
||||||
|
greys/serif fonts.
|
||||||
|
|
||||||
|
Defining them here keeps a single source of truth — change the
|
||||||
|
primitive token (--border-muted, --font-sans, --bg-offset) and
|
||||||
|
every consumer follows. */
|
||||||
|
--rule: var(--border-muted);
|
||||||
|
--font-ui: var(--font-sans);
|
||||||
|
--bg-subtle: var(--bg-offset);
|
||||||
|
|
||||||
|
/* Layout breakpoints — referenced from JS via getComputedStyle and
|
||||||
|
documented here for grep. CSS @media queries cannot use custom
|
||||||
|
properties, so the @media values throughout components.css and
|
||||||
|
layout.css must be kept in lockstep with these. */
|
||||||
|
--bp-phone: 540px;
|
||||||
|
--bp-tablet: 680px;
|
||||||
|
--bp-desktop: 900px;
|
||||||
|
--bp-wide: 1500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,14 +164,17 @@
|
||||||
--bg: #121212;
|
--bg: #121212;
|
||||||
--bg-nav: #181818;
|
--bg-nav: #181818;
|
||||||
--bg-offset: #1a1a1a;
|
--bg-offset: #1a1a1a;
|
||||||
|
/* --text-faint was previously #6a6660, which yields ~2.8:1 contrast
|
||||||
|
on the #121212 background and fails WCAG AA. Bumped to #8b8680 for
|
||||||
|
a contrast of ~3.5:1, the minimum for non-text UI elements. */
|
||||||
--text: #d4d0c8;
|
--text: #d4d0c8;
|
||||||
--text-muted: #8c8881;
|
--text-muted: #8c8881;
|
||||||
--text-faint: #6a6660;
|
--text-faint: #8b8680;
|
||||||
--border: #333333;
|
--border: #333333;
|
||||||
--border-muted: #444444;
|
--border-muted: #444444;
|
||||||
|
|
||||||
--link: #d4d0c8;
|
--link: #d4d0c8;
|
||||||
--link-underline: #6a6660;
|
--link-underline: #8b8680;
|
||||||
--link-hover: #ffffff;
|
--link-hover: #ffffff;
|
||||||
--link-hover-underline: #ffffff;
|
--link-hover-underline: #ffffff;
|
||||||
--link-visited: #a39f98;
|
--link-visited: #a39f98;
|
||||||
|
|
@ -154,6 +182,9 @@
|
||||||
--selection-bg: #d4d0c8;
|
--selection-bg: #d4d0c8;
|
||||||
--selection-text: #121212;
|
--selection-text: #121212;
|
||||||
|
|
||||||
|
/* Aliases — kept in sync with the light-mode definitions above. */
|
||||||
|
--bg-subtle: var(--bg-offset);
|
||||||
|
|
||||||
/* Writing activity heatmap (dark mode) */
|
/* Writing activity heatmap (dark mode) */
|
||||||
--hm-0: #252524;
|
--hm-0: #252524;
|
||||||
--hm-1: #484844;
|
--hm-1: #484844;
|
||||||
|
|
@ -170,12 +201,12 @@
|
||||||
--bg-offset: #1a1a1a;
|
--bg-offset: #1a1a1a;
|
||||||
--text: #d4d0c8;
|
--text: #d4d0c8;
|
||||||
--text-muted: #8c8881;
|
--text-muted: #8c8881;
|
||||||
--text-faint: #6a6660;
|
--text-faint: #8b8680;
|
||||||
--border: #333333;
|
--border: #333333;
|
||||||
--border-muted: #444444;
|
--border-muted: #444444;
|
||||||
|
|
||||||
--link: #d4d0c8;
|
--link: #d4d0c8;
|
||||||
--link-underline: #6a6660;
|
--link-underline: #8b8680;
|
||||||
--link-hover: #ffffff;
|
--link-hover: #ffffff;
|
||||||
--link-hover-underline: #ffffff;
|
--link-hover-underline: #ffffff;
|
||||||
--link-visited: #a39f98;
|
--link-visited: #a39f98;
|
||||||
|
|
@ -183,6 +214,8 @@
|
||||||
--selection-bg: #d4d0c8;
|
--selection-bg: #d4d0c8;
|
||||||
--selection-text: #121212;
|
--selection-text: #121212;
|
||||||
|
|
||||||
|
--bg-subtle: var(--bg-offset);
|
||||||
|
|
||||||
--hm-0: #252524;
|
--hm-0: #252524;
|
||||||
--hm-1: #484844;
|
--hm-1: #484844;
|
||||||
--hm-2: #6e6e6a;
|
--hm-2: #6e6e6a;
|
||||||
|
|
@ -228,6 +261,38 @@ body {
|
||||||
color: var(--selection-text);
|
color: var(--selection-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global keyboard-focus indicator. Applies only when the user navigates
|
||||||
|
with the keyboard (`:focus-visible`), not on mouse click, so it does
|
||||||
|
not interfere with normal click feedback. Buttons, anchors, summaries
|
||||||
|
and form controls all share a single 2px outline so the focus path is
|
||||||
|
visible regardless of the surrounding component styling.
|
||||||
|
|
||||||
|
Individual components may override this with a tighter or differently
|
||||||
|
positioned ring, but the default is always present. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
a:focus,
|
||||||
|
summary:focus,
|
||||||
|
[role="button"]:focus {
|
||||||
|
outline: none; /* fall back to :focus-visible above */
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
summary:focus-visible,
|
||||||
|
[role="button"]:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid var(--text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
img, video, svg {
|
img, video, svg {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,24 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Heatmap intensity classes (driven by CSS vars in base.css) */
|
||||||
|
.heatmap-svg .hm0,
|
||||||
|
.heatmap-legend .hm0 { fill: var(--hm-0); }
|
||||||
|
.heatmap-svg .hm1,
|
||||||
|
.heatmap-legend .hm1 { fill: var(--hm-1); }
|
||||||
|
.heatmap-svg .hm2,
|
||||||
|
.heatmap-legend .hm2 { fill: var(--hm-2); }
|
||||||
|
.heatmap-svg .hm3,
|
||||||
|
.heatmap-legend .hm3 { fill: var(--hm-3); }
|
||||||
|
.heatmap-svg .hm4,
|
||||||
|
.heatmap-legend .hm4 { fill: var(--hm-4); }
|
||||||
|
|
||||||
|
.heatmap-svg .hm-lbl {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: var(--text-faint);
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* Legend row below heatmap */
|
/* Legend row below heatmap */
|
||||||
.heatmap-legend {
|
.heatmap-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -424,7 +424,10 @@ nav.site-nav {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav: animates open/closed via max-height */
|
/* Nav: animates open/closed via max-height. The collapsed state hides
|
||||||
|
the nav from the keyboard tab order *and* the accessibility tree
|
||||||
|
using `aria-hidden="true"` (set by toc.js). The transition still
|
||||||
|
works because we keep `max-height: 0` for the visual collapse. */
|
||||||
.toc-nav {
|
.toc-nav {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
|
|
@ -432,7 +435,15 @@ nav.site-nav {
|
||||||
}
|
}
|
||||||
#toc.is-collapsed .toc-nav {
|
#toc.is-collapsed .toc-nav {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
visibility: hidden;
|
}
|
||||||
|
#toc.is-collapsed .toc-nav a,
|
||||||
|
#toc.is-collapsed .toc-nav button {
|
||||||
|
/* Belt-and-suspenders: even if aria-hidden is somehow stripped,
|
||||||
|
the collapsed nav cannot receive focus. The earlier rule used
|
||||||
|
`visibility: hidden` which already removed elements from the
|
||||||
|
focus order, but `visibility` interacts poorly with the
|
||||||
|
max-height transition; tabindex=-1 set by toc.js is preferred. */
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav list */
|
/* Nav list */
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,21 @@
|
||||||
@media print {
|
@media print {
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
Force light on paper
|
Force light on paper. The custom-property overrides drive the
|
||||||
|
rest of the cascade — use them consistently below instead of
|
||||||
|
reaching for hardcoded #fff/#000 again.
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
:root,
|
:root,
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--bg: #ffffff;
|
--bg: #ffffff;
|
||||||
--bg-offset: #f5f5f5;
|
--bg-offset: #f5f5f5;
|
||||||
|
--bg-subtle: #f9f9f9;
|
||||||
--text: #000000;
|
--text: #000000;
|
||||||
--text-muted: #333333;
|
--text-muted: #333333;
|
||||||
--text-faint: #555555;
|
--text-faint: #555555;
|
||||||
--border: #cccccc;
|
--border: #cccccc;
|
||||||
--border-muted: #aaaaaa;
|
--border-muted: #aaaaaa;
|
||||||
|
--rule: #cccccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
|
|
@ -41,8 +45,8 @@
|
||||||
body {
|
body {
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
background: #fff;
|
background: var(--bg);
|
||||||
color: #000;
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -72,9 +76,9 @@
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
margin: 0.5em 2em;
|
margin: 0.5em 2em;
|
||||||
padding: 0.4em 0.8em;
|
padding: 0.4em 0.8em;
|
||||||
border-left: 2px solid #ccc;
|
border-left: 2px solid var(--border);
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
color: #555;
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
|
|
@ -109,7 +113,7 @@
|
||||||
a[href^="http"]::after {
|
a[href^="http"]::after {
|
||||||
content: " (" attr(href) ")";
|
content: " (" attr(href) ")";
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: #555;
|
color: var(--text-faint);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
/* But not for nav or obvious UI links */
|
/* But not for nav or obvious UI links */
|
||||||
|
|
@ -123,8 +127,8 @@
|
||||||
Code blocks — strip background, border only
|
Code blocks — strip background, border only
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
pre, code {
|
pre, code {
|
||||||
background: #f9f9f9 !important;
|
background: var(--bg-subtle) !important;
|
||||||
border: 1px solid #ddd !important;
|
border: 1px solid var(--border-muted) !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,7 +138,7 @@
|
||||||
.page-meta-footer {
|
.page-meta-footer {
|
||||||
margin-top: 1.5em;
|
margin-top: 1.5em;
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
border-top: 1px solid #ccc;
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-footer-full,
|
.meta-footer-full,
|
||||||
|
|
|
||||||
|
|
@ -463,9 +463,22 @@ pre code {
|
||||||
border: 1px solid var(--border-muted); /* Inner bounding box for the image */
|
border: 1px solid var(--border-muted); /* Inner bounding box for the image */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image figures: size the box to the image and constrain the caption to the
|
||||||
|
same width. `display: table` + `caption-side: bottom` makes the figure's
|
||||||
|
intrinsic width depend only on its table-cell content (the image), so long
|
||||||
|
captions wrap to the image width instead of stretching the figure off-screen. */
|
||||||
|
#markdownBody figure:has(> img) {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
#markdownBody figure:has(> img) > figcaption {
|
||||||
|
display: table-caption;
|
||||||
|
caption-side: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
#markdownBody figcaption {
|
#markdownBody figcaption {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-size-small);
|
font-size: 0.92em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-align: right; /* Editorial, museum-placard feel */
|
text-align: right; /* Editorial, museum-placard feel */
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -150,12 +150,11 @@
|
||||||
tooltip.addEventListener('mouseleave', function () { hideTooltip(false); });
|
tooltip.addEventListener('mouseleave', function () { hideTooltip(false); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Defer to the shared utility (loaded synchronously from
|
||||||
|
templates/partials/head.html) so this file cannot drift from
|
||||||
|
popups.js, semantic-search.js, or build/Utils.hs. */
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s)
|
return window.lnUtils.escapeHtml(s);
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTooltip(mark, ann) {
|
function showTooltip(mark, ann) {
|
||||||
|
|
|
||||||
|
|
@ -225,9 +225,37 @@
|
||||||
if (e.key === 'Escape') { closeOverlay(); return; }
|
if (e.key === 'Escape') { closeOverlay(); return; }
|
||||||
if (e.key === 'ArrowLeft') { navigate(-1); return; }
|
if (e.key === 'ArrowLeft') { navigate(-1); return; }
|
||||||
if (e.key === 'ArrowRight') { navigate(+1); return; }
|
if (e.key === 'ArrowRight') { navigate(+1); return; }
|
||||||
|
if (e.key === 'Tab') { trapTab(e); return; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus trap for the overlay: cycle Tab/Shift+Tab through the
|
||||||
|
focusable controls inside #gallery-overlay so keyboard users
|
||||||
|
cannot tab out into the (currently inert) page background. */
|
||||||
|
function trapTab(e) {
|
||||||
|
var focusable = Array.from(overlay.querySelectorAll(
|
||||||
|
'button:not([disabled]), [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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openOverlay(idx) {
|
function openOverlay(idx) {
|
||||||
currentIdx = idx;
|
currentIdx = idx;
|
||||||
/* Show before rendering — measurements (scrollWidth etc.) return 0
|
/* Show before rendering — measurements (scrollWidth etc.) return 0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* katex-bootstrap.js — Render every <span class="math"> / <div class="math">
|
||||||
|
block once KaTeX has finished loading.
|
||||||
|
|
||||||
|
Pandoc emits math blocks with the `math` class and the LaTeX source as
|
||||||
|
the element's text content. KaTeX is loaded with `defer` so this
|
||||||
|
bootstrap can simply run on DOMContentLoaded — KaTeX guarantees its
|
||||||
|
own definitions are available by then.
|
||||||
|
|
||||||
|
Used to live as an inline `onload="..."` attribute on the KaTeX
|
||||||
|
<script> tag in templates/default.html, which blocked any future
|
||||||
|
strict CSP. Externalized here so the entire site can run with
|
||||||
|
`script-src 'self'` plus a single CDN allowance.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
if (typeof katex === 'undefined') return;
|
||||||
|
var nodes = Array.from(document.getElementsByClassName('math'));
|
||||||
|
nodes.forEach(function (el) {
|
||||||
|
if (el.tagName !== 'SPAN' && el.tagName !== 'DIV') return;
|
||||||
|
var src = el.textContent;
|
||||||
|
try {
|
||||||
|
katex.render(src, el, {
|
||||||
|
displayMode: el.classList.contains('display'),
|
||||||
|
output: 'htmlAndMathml',
|
||||||
|
throwOnError: false
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
/* leave the original source visible if KaTeX rejects it */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', renderAll);
|
||||||
|
} else {
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -15,7 +15,10 @@
|
||||||
|
|
||||||
var img = document.createElement('img');
|
var img = document.createElement('img');
|
||||||
img.className = 'lightbox-img';
|
img.className = 'lightbox-img';
|
||||||
img.alt = '';
|
/* Default accessible name; overwritten in open() with the
|
||||||
|
triggering image's alt when present. Avoids a nameless
|
||||||
|
lightbox image when the source img has no alt text. */
|
||||||
|
img.alt = 'Lightbox image';
|
||||||
|
|
||||||
var caption = document.createElement('p');
|
var caption = document.createElement('p');
|
||||||
caption.className = 'lightbox-caption';
|
caption.className = 'lightbox-caption';
|
||||||
|
|
@ -39,7 +42,10 @@
|
||||||
function open(src, alt, captionText, trigger) {
|
function open(src, alt, captionText, trigger) {
|
||||||
triggerEl = trigger || null;
|
triggerEl = trigger || null;
|
||||||
img.src = src;
|
img.src = src;
|
||||||
img.alt = alt || '';
|
/* Prefer the source img's alt; fall back to the figure
|
||||||
|
caption; fall back to a generic label so the lightbox
|
||||||
|
image always has an accessible name. */
|
||||||
|
img.alt = alt || captionText || 'Lightbox image';
|
||||||
caption.textContent = captionText || '';
|
caption.textContent = captionText || '';
|
||||||
caption.hidden = !captionText;
|
caption.hidden = !captionText;
|
||||||
overlay.classList.add('is-open');
|
overlay.classList.add('is-open');
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,18 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public re-init hook used by transclude.js after it injects new
|
||||||
|
content into the DOM. Idempotent — bind() marks each element so
|
||||||
|
repeated calls don't stack listeners. */
|
||||||
|
window.reinitPopups = function (container) {
|
||||||
|
if (!popup) return; /* init() not yet run / touch device */
|
||||||
|
if (annotations === null) {
|
||||||
|
loadAnnotations().then(function () { bindTargets(container || document.body); });
|
||||||
|
} else {
|
||||||
|
bindTargets(container || document.body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* Returns the appropriate provider function for a given URL, or null. */
|
/* Returns the appropriate provider function for a given URL, or null. */
|
||||||
function getProvider(href) {
|
function getProvider(href) {
|
||||||
if (!href) return null;
|
if (!href) return null;
|
||||||
|
|
@ -118,6 +130,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function bind(el, provider) {
|
function bind(el, provider) {
|
||||||
|
/* Idempotent: skip elements that already have a popup binding,
|
||||||
|
so reinitPopups() called from transclude.js cannot stack
|
||||||
|
listeners on already-bound nodes. */
|
||||||
|
if (el.dataset.popupBound === '1') return;
|
||||||
|
el.dataset.popupBound = '1';
|
||||||
el.addEventListener('mouseenter', function () { scheduleShow(el, provider); });
|
el.addEventListener('mouseenter', function () { scheduleShow(el, provider); });
|
||||||
el.addEventListener('mouseleave', scheduleHide);
|
el.addEventListener('mouseleave', scheduleHide);
|
||||||
el.addEventListener('focus', function () { scheduleShow(el, provider); });
|
el.addEventListener('focus', function () { scheduleShow(el, provider); });
|
||||||
|
|
@ -133,9 +150,20 @@
|
||||||
clearTimeout(showTimer);
|
clearTimeout(showTimer);
|
||||||
activeTarget = target;
|
activeTarget = target;
|
||||||
showTimer = setTimeout(function () {
|
showTimer = setTimeout(function () {
|
||||||
provider(target).then(function (html) {
|
provider(target).then(function (content) {
|
||||||
if (!html || activeTarget !== target) return;
|
if (!content || activeTarget !== target) return;
|
||||||
popup.innerHTML = html;
|
/* Providers may return either an HTML string or a DOM
|
||||||
|
Node — the latter is used by epistemicContent so the
|
||||||
|
popup receives cloned nodes instead of a re-parsed
|
||||||
|
HTML round-trip. Handle both forms. */
|
||||||
|
popup.innerHTML = '';
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
popup.innerHTML = content;
|
||||||
|
} else if (content instanceof Node) {
|
||||||
|
popup.appendChild(content);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
positionPopup(target);
|
positionPopup(target);
|
||||||
popup.classList.add('is-visible');
|
popup.classList.add('is-visible');
|
||||||
popup.setAttribute('aria-hidden', 'false');
|
popup.setAttribute('aria-hidden', 'false');
|
||||||
|
|
@ -183,6 +211,34 @@
|
||||||
Content providers
|
Content providers
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Cross-origin JSON fetch helper.
|
||||||
|
Validates Content-Type before parsing so a CORS-enabled endpoint
|
||||||
|
cannot return text/html and have it interpreted as JSON. The
|
||||||
|
caller's `.catch` still applies if the JSON parse itself fails.
|
||||||
|
|
||||||
|
Mirror helpers exist for text/* (XML/Atom) and HTML responses. */
|
||||||
|
function fetchJson(url, init) {
|
||||||
|
return fetch(url, init).then(function (r) {
|
||||||
|
if (!r.ok) return null;
|
||||||
|
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct && !/(?:^|[\s;,])(?:application\/[a-z+.-]*json|text\/json)\b/.test(ct)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchXml(url, init) {
|
||||||
|
return fetch(url, init).then(function (r) {
|
||||||
|
if (!r.ok) return null;
|
||||||
|
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct && !/(?:xml|atom)/.test(ct)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return r.text();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* 0. Local annotations — synchronous map lookup after eager load */
|
/* 0. Local annotations — synchronous map lookup after eager load */
|
||||||
function loadAnnotations() {
|
function loadAnnotations() {
|
||||||
if (annotations !== null) return Promise.resolve(annotations);
|
if (annotations !== null) return Promise.resolve(annotations);
|
||||||
|
|
@ -288,8 +344,7 @@
|
||||||
+ '?action=query&prop=extracts&exintro=1&format=json&redirects=1'
|
+ '?action=query&prop=extracts&exintro=1&format=json&redirects=1'
|
||||||
+ '&titles=' + encodeURIComponent(decodeURIComponent(m[1])) + '&origin=*';
|
+ '&titles=' + encodeURIComponent(decodeURIComponent(m[1])) + '&origin=*';
|
||||||
|
|
||||||
return fetch(apiUrl)
|
return fetchJson(apiUrl)
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var pages = data && data.query && data.query.pages;
|
var pages = data && data.query && data.query.pages;
|
||||||
if (!pages) return null;
|
if (!pages) return null;
|
||||||
|
|
@ -323,8 +378,7 @@
|
||||||
if (!m) return Promise.resolve(null);
|
if (!m) return Promise.resolve(null);
|
||||||
|
|
||||||
var id = m[1].replace(/v\d+$/, '');
|
var id = m[1].replace(/v\d+$/, '');
|
||||||
return fetch('https://export.arxiv.org/api/query?id_list=' + encodeURIComponent(id))
|
return fetchXml('https://export.arxiv.org/api/query?id_list=' + encodeURIComponent(id))
|
||||||
.then(function (r) { return r.ok ? r.text() : null; })
|
|
||||||
.then(function (xml) {
|
.then(function (xml) {
|
||||||
if (!xml) return null;
|
if (!xml) return null;
|
||||||
var doc = new DOMParser().parseFromString(xml, 'application/xml');
|
var doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
|
@ -357,8 +411,7 @@
|
||||||
var m = href.match(/(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/);
|
var m = href.match(/(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/);
|
||||||
if (!m) return Promise.resolve(null);
|
if (!m) return Promise.resolve(null);
|
||||||
|
|
||||||
return fetch('https://api.crossref.org/works/' + encodeURIComponent(m[1]))
|
return fetchJson('https://api.crossref.org/works/' + encodeURIComponent(m[1]))
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var msg = data && data.message;
|
var msg = data && data.message;
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
|
|
@ -394,9 +447,8 @@
|
||||||
var m = href.match(/github\.com\/([^/]+)\/([^/?#]+)/);
|
var m = href.match(/github\.com\/([^/]+)\/([^/?#]+)/);
|
||||||
if (!m) return Promise.resolve(null);
|
if (!m) return Promise.resolve(null);
|
||||||
|
|
||||||
return fetch('https://api.github.com/repos/' + m[1] + '/' + m[2],
|
return fetchJson('https://api.github.com/repos/' + m[1] + '/' + m[2],
|
||||||
{ headers: { 'Accept': 'application/vnd.github.v3+json' } })
|
{ headers: { 'Accept': 'application/vnd.github.v3+json' } })
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data || !data.full_name) return null;
|
if (!data || !data.full_name) return null;
|
||||||
var meta = [data.language, data.stargazers_count != null ? '\u2605\u00a0' + data.stargazers_count : null]
|
var meta = [data.language, data.stargazers_count != null ? '\u2605\u00a0' + data.stargazers_count : null]
|
||||||
|
|
@ -421,8 +473,7 @@
|
||||||
if (!m) return Promise.resolve(null);
|
if (!m) return Promise.resolve(null);
|
||||||
|
|
||||||
var apiUrl = 'https://git.levineuwirth.org/api/v1/repos/' + m[1] + '/' + m[2];
|
var apiUrl = 'https://git.levineuwirth.org/api/v1/repos/' + m[1] + '/' + m[2];
|
||||||
return fetch(apiUrl)
|
return fetchJson(apiUrl)
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data || !data.full_name) return null;
|
if (!data || !data.full_name) return null;
|
||||||
var meta = [data.language, data.stars_count != null ? '\u2605\u00a0' + data.stars_count : null]
|
var meta = [data.language, data.stars_count != null ? '\u2605\u00a0' + data.stars_count : null]
|
||||||
|
|
@ -446,8 +497,7 @@
|
||||||
var base = href.replace(/[?#].*$/, '');
|
var base = href.replace(/[?#].*$/, '');
|
||||||
var apiUrl = base + '.json';
|
var apiUrl = base + '.json';
|
||||||
|
|
||||||
return fetch(apiUrl)
|
return fetchJson(apiUrl)
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data || !data.title) return null;
|
if (!data || !data.title) return null;
|
||||||
var desc = data.description;
|
var desc = data.description;
|
||||||
|
|
@ -476,8 +526,7 @@
|
||||||
var server = /medrxiv/.test(href) ? 'medrxiv' : 'biorxiv';
|
var server = /medrxiv/.test(href) ? 'medrxiv' : 'biorxiv';
|
||||||
var label = server === 'medrxiv' ? 'medRxiv' : 'bioRxiv';
|
var label = server === 'medrxiv' ? 'medRxiv' : 'bioRxiv';
|
||||||
|
|
||||||
return fetch('https://api.biorxiv.org/details/' + server + '/' + encodeURIComponent(doi) + '/json')
|
return fetchJson('https://api.biorxiv.org/details/' + server + '/' + encodeURIComponent(doi) + '/json')
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var paper = data && data.collection && data.collection[0];
|
var paper = data && data.collection && data.collection[0];
|
||||||
if (!paper || !paper.title) return null;
|
if (!paper || !paper.title) return null;
|
||||||
|
|
@ -505,8 +554,7 @@
|
||||||
var href = target.getAttribute('href');
|
var href = target.getAttribute('href');
|
||||||
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
|
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
|
||||||
|
|
||||||
return fetch('https://www.youtube.com/oembed?url=' + encodeURIComponent(href) + '&format=json')
|
return fetchJson('https://www.youtube.com/oembed?url=' + encodeURIComponent(href) + '&format=json')
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data || !data.title) return null;
|
if (!data || !data.title) return null;
|
||||||
return store(href,
|
return store(href,
|
||||||
|
|
@ -527,8 +575,7 @@
|
||||||
var m = href.match(/archive\.org\/details\/([^/?#]+)/);
|
var m = href.match(/archive\.org\/details\/([^/?#]+)/);
|
||||||
if (!m) return Promise.resolve(null);
|
if (!m) return Promise.resolve(null);
|
||||||
|
|
||||||
return fetch('https://archive.org/metadata/' + encodeURIComponent(m[1]))
|
return fetchJson('https://archive.org/metadata/' + encodeURIComponent(m[1]))
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var meta = data && data.metadata;
|
var meta = data && data.metadata;
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
|
|
@ -564,8 +611,7 @@
|
||||||
var apiUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi'
|
var apiUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi'
|
||||||
+ '?db=pubmed&id=' + pmid + '&retmode=json';
|
+ '?db=pubmed&id=' + pmid + '&retmode=json';
|
||||||
|
|
||||||
return fetch(apiUrl)
|
return fetchJson(apiUrl)
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
var paper = data && data.result && data.result[pmid];
|
var paper = data && data.result && data.result[pmid];
|
||||||
if (!paper || !paper.title) return null;
|
if (!paper || !paper.title) return null;
|
||||||
|
|
@ -597,25 +643,34 @@
|
||||||
|
|
||||||
/* Epistemic jump link — reads the #epistemic section already in the DOM
|
/* Epistemic jump link — reads the #epistemic section already in the DOM
|
||||||
and renders a compact summary: status/confidence/dots + expanded DL.
|
and renders a compact summary: status/confidence/dots + expanded DL.
|
||||||
All ep-* classes are already styled via components.css. */
|
All ep-* classes are already styled via components.css.
|
||||||
|
|
||||||
|
Returns a DocumentFragment instead of an HTML string so the popup
|
||||||
|
receives cloned nodes (defense in depth — if a future change ever
|
||||||
|
allowed user-authored HTML into the source section, the popup
|
||||||
|
would still see exactly the same already-rendered DOM rather than
|
||||||
|
a re-parsed string). */
|
||||||
function epistemicContent() {
|
function epistemicContent() {
|
||||||
var section = document.getElementById('epistemic');
|
var section = document.getElementById('epistemic');
|
||||||
if (!section) return Promise.resolve(null);
|
if (!section) return Promise.resolve(null);
|
||||||
|
|
||||||
var html = '<div class="popup-epistemic">';
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'popup-epistemic';
|
||||||
|
|
||||||
var compact = section.querySelector('.ep-compact');
|
var compact = section.querySelector('.ep-compact');
|
||||||
if (compact) {
|
if (compact) {
|
||||||
html += '<div class="ep-compact">' + compact.innerHTML + '</div>';
|
var compactClone = compact.cloneNode(true);
|
||||||
|
wrap.appendChild(compactClone);
|
||||||
}
|
}
|
||||||
|
|
||||||
var expanded = section.querySelector('.ep-expanded');
|
var expanded = section.querySelector('.ep-expanded');
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
html += '<dl class="ep-expanded">' + expanded.innerHTML + '</dl>';
|
var expandedClone = expanded.cloneNode(true);
|
||||||
|
wrap.appendChild(expandedClone);
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>';
|
if (!compact && !expanded) return Promise.resolve(null);
|
||||||
return Promise.resolve(html || null);
|
return Promise.resolve(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Local PDF — shows the build-time first-page thumbnail (.thumb.png).
|
/* Local PDF — shows the build-time first-page thumbnail (.thumb.png).
|
||||||
|
|
@ -652,12 +707,11 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Defer to the shared utility (loaded synchronously from
|
||||||
|
templates/partials/head.html) so this file cannot drift from
|
||||||
|
annotations.js, semantic-search.js, or build/Utils.hs. */
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s)
|
return window.lnUtils.escapeHtml(s);
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Emit a .popup-source label with a data-popup-source attribute so CSS
|
/* Emit a .popup-source label with a data-popup-source attribute so CSS
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,17 @@
|
||||||
Model loading — dynamic import from CDN, lazy
|
Model loading — dynamic import from CDN, lazy
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* In-flight promise so concurrent searches share a single model
|
||||||
|
load. Without this guard, two rapid keystrokes would each call
|
||||||
|
`import(CDN)` and `pipeline(...)`, wasting CPU and memory before
|
||||||
|
the second resolves. */
|
||||||
|
var loadModelPromise = null;
|
||||||
|
|
||||||
function loadModel() {
|
function loadModel() {
|
||||||
if (extractor) return Promise.resolve(extractor);
|
if (extractor) return Promise.resolve(extractor);
|
||||||
|
if (loadModelPromise) return loadModelPromise;
|
||||||
setStatus('Loading model…');
|
setStatus('Loading model…');
|
||||||
return import(CDN).then(function (mod) {
|
loadModelPromise = import(CDN).then(function (mod) {
|
||||||
/* Point transformers.js at our self-hosted model files. */
|
/* Point transformers.js at our self-hosted model files. */
|
||||||
mod.env.localModelPath = MODEL_PATH;
|
mod.env.localModelPath = MODEL_PATH;
|
||||||
mod.env.allowRemoteModels = false;
|
mod.env.allowRemoteModels = false;
|
||||||
|
|
@ -73,7 +80,13 @@
|
||||||
}).then(function (pipe) {
|
}).then(function (pipe) {
|
||||||
extractor = pipe;
|
extractor = pipe;
|
||||||
return extractor;
|
return extractor;
|
||||||
|
}).catch(function (err) {
|
||||||
|
/* Allow a retry on the next call instead of caching the
|
||||||
|
failed promise forever. */
|
||||||
|
loadModelPromise = null;
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
|
return loadModelPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
|
@ -163,12 +176,11 @@
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Defer to the shared utility (loaded synchronously from
|
||||||
|
templates/partials/head.html) so this file cannot drift from
|
||||||
|
popups.js, annotations.js, or build/Utils.hs. */
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s)
|
return window.lnUtils.escapeHtml(s);
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
/* settings.js — Settings panel: theme, text size, print.
|
/* settings.js — Settings panel: theme, text size, print.
|
||||||
Must stay in sync with TEXT_SIZES in theme.js. */
|
Must stay in sync with TEXT_SIZES in theme.js.
|
||||||
|
|
||||||
|
All localStorage access routes through window.lnUtils.safeStorage so
|
||||||
|
Safari private-mode SecurityErrors on writes do not throw uncaught. */
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var TEXT_SIZES = [20, 23, 26];
|
var TEXT_SIZES = [20, 23, 26];
|
||||||
var TEXT_SIZE_DEFAULT = 1; /* index of 23px */
|
var TEXT_SIZE_DEFAULT = 1; /* index of 23px */
|
||||||
var TEXT_SIZE_KEY = 'text-size';
|
var TEXT_SIZE_KEY = 'text-size';
|
||||||
|
var store = (window.lnUtils && window.lnUtils.safeStorage) || {
|
||||||
|
get: function () { return null; },
|
||||||
|
set: function () { return false; },
|
||||||
|
remove: function () { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
Init
|
Init
|
||||||
|
|
@ -102,7 +110,7 @@
|
||||||
|
|
||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
localStorage.setItem('theme', theme);
|
store.set('theme', theme);
|
||||||
syncThemeButtons();
|
syncThemeButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,13 +130,13 @@
|
||||||
/* Text size ------------------------------------------------------- */
|
/* Text size ------------------------------------------------------- */
|
||||||
|
|
||||||
function getSizeIndex() {
|
function getSizeIndex() {
|
||||||
var n = parseInt(localStorage.getItem(TEXT_SIZE_KEY), 10);
|
var n = parseInt(store.get(TEXT_SIZE_KEY), 10);
|
||||||
return (isNaN(n) || n < 0 || n >= TEXT_SIZES.length) ? TEXT_SIZE_DEFAULT : n;
|
return (isNaN(n) || n < 0 || n >= TEXT_SIZES.length) ? TEXT_SIZE_DEFAULT : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftSize(delta) {
|
function shiftSize(delta) {
|
||||||
var idx = Math.max(0, Math.min(TEXT_SIZES.length - 1, getSizeIndex() + delta));
|
var idx = Math.max(0, Math.min(TEXT_SIZES.length - 1, getSizeIndex() + delta));
|
||||||
localStorage.setItem(TEXT_SIZE_KEY, idx);
|
store.set(TEXT_SIZE_KEY, idx);
|
||||||
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[idx] + 'px');
|
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[idx] + 'px');
|
||||||
syncTextSizeButtons();
|
syncTextSizeButtons();
|
||||||
}
|
}
|
||||||
|
|
@ -140,10 +148,10 @@
|
||||||
var on = html.hasAttribute(attrName);
|
var on = html.hasAttribute(attrName);
|
||||||
if (on) {
|
if (on) {
|
||||||
html.removeAttribute(attrName);
|
html.removeAttribute(attrName);
|
||||||
localStorage.removeItem(storageKey);
|
store.remove(storageKey);
|
||||||
} else {
|
} else {
|
||||||
html.setAttribute(attrName, '');
|
html.setAttribute(attrName, '');
|
||||||
localStorage.setItem(storageKey, '1');
|
store.set(storageKey, '1');
|
||||||
}
|
}
|
||||||
syncToggleButton(storageKey, attrName);
|
syncToggleButton(storageKey, attrName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Hover + click wiring */
|
/* Hover + click + keyboard wiring */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/* At most one sidenote is "focused" (click-sticky) at a time. */
|
/* At most one sidenote is "focused" (click-sticky) at a time. */
|
||||||
let focusedPair = null;
|
let focusedPair = null;
|
||||||
|
|
||||||
function wireHover(ref, sn) {
|
function wireHover(ref, sn) {
|
||||||
|
/* Idempotent: skip pairs already wired so 'reinitSidenotes'
|
||||||
|
after a transclusion injection cannot stack listeners. */
|
||||||
|
if (ref.dataset.snBound === '1') return;
|
||||||
|
ref.dataset.snBound = '1';
|
||||||
|
sn.dataset.snBound = '1';
|
||||||
|
|
||||||
let hovering = false;
|
let hovering = false;
|
||||||
let focused = false;
|
let focused = false;
|
||||||
|
|
||||||
|
|
@ -70,6 +76,22 @@
|
||||||
|
|
||||||
function unfocus() { focused = false; update(); }
|
function unfocus() { focused = false; update(); }
|
||||||
|
|
||||||
|
function toggleFocus() {
|
||||||
|
/* Sticky focus only makes sense on wide viewports where the
|
||||||
|
sidenote is actually visible. On narrow screens there's
|
||||||
|
nothing to pin. */
|
||||||
|
if (getComputedStyle(sn).display === 'none') return;
|
||||||
|
if (focused) {
|
||||||
|
focused = false;
|
||||||
|
focusedPair = null;
|
||||||
|
} else {
|
||||||
|
if (focusedPair) focusedPair.unfocus();
|
||||||
|
focused = true;
|
||||||
|
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
ref.addEventListener('mouseenter', function () { hovering = true; update(); });
|
ref.addEventListener('mouseenter', function () { hovering = true; update(); });
|
||||||
ref.addEventListener('mouseleave', function () { hovering = false; update(); });
|
ref.addEventListener('mouseleave', function () { hovering = false; update(); });
|
||||||
sn.addEventListener('mouseenter', function () { hovering = true; update(); });
|
sn.addEventListener('mouseenter', function () { hovering = true; update(); });
|
||||||
|
|
@ -81,16 +103,25 @@
|
||||||
if (link) {
|
if (link) {
|
||||||
link.addEventListener('click', function (e) {
|
link.addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (getComputedStyle(sn).display === 'none') return; /* narrow: no sidenote to focus */
|
toggleFocus();
|
||||||
if (focused) {
|
});
|
||||||
|
|
||||||
|
/* Keyboard activation: Enter follows the link by default (the
|
||||||
|
browser synthesizes a click), but Space does not. Both are
|
||||||
|
expected to toggle focus on a focus-activated element, so
|
||||||
|
we normalize: Enter/Space toggle, Escape clears if focused.
|
||||||
|
The <a href="#sn-N"> retains its native focusability so
|
||||||
|
Tab reaches it; we only intercept the key. */
|
||||||
|
link.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFocus();
|
||||||
|
} else if (e.key === 'Escape' && focused) {
|
||||||
|
e.preventDefault();
|
||||||
focused = false;
|
focused = false;
|
||||||
focusedPair = null;
|
focusedPair = null;
|
||||||
} else {
|
|
||||||
if (focusedPair) focusedPair.unfocus();
|
|
||||||
focused = true;
|
|
||||||
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
|
|
||||||
}
|
|
||||||
update();
|
update();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,19 +136,39 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function init() {
|
/* Global Escape dismisses any sticky-focused sidenote, even if focus
|
||||||
const body = document.getElementById('markdownBody');
|
has moved away from the ref link. */
|
||||||
if (!body) return;
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && focusedPair) {
|
||||||
|
focusedPair.unfocus();
|
||||||
|
focusedPair = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
body.querySelectorAll('.sidenote').forEach(function (sn) {
|
function wireAll(root) {
|
||||||
|
root.querySelectorAll('.sidenote').forEach(function (sn) {
|
||||||
const refId = 'snref-' + sn.id.slice(3);
|
const refId = 'snref-' + sn.id.slice(3);
|
||||||
const ref = document.getElementById(refId);
|
const ref = document.getElementById(refId);
|
||||||
if (ref) wireHover(ref, sn);
|
if (ref) wireHover(ref, sn);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const body = document.getElementById('markdownBody');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
wireAll(body);
|
||||||
positionSidenotes();
|
positionSidenotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public re-init hook used by transclude.js after it injects new
|
||||||
|
content. wireAll is idempotent so calling this multiple times is
|
||||||
|
safe. */
|
||||||
|
window.reinitSidenotes = function (container) {
|
||||||
|
wireAll(container || document.getElementById('markdownBody') || document);
|
||||||
|
positionSidenotes();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
window.addEventListener('resize', positionSidenotes);
|
window.addEventListener('resize', positionSidenotes);
|
||||||
}());
|
}());
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,36 @@
|
||||||
/* theme.js — Restores theme and text size from localStorage before first paint.
|
/* theme.js — Restores theme and text size from localStorage before first paint.
|
||||||
Loaded synchronously (no defer/async) to prevent flash of wrong appearance.
|
Loaded synchronously (no defer/async) to prevent flash of wrong appearance.
|
||||||
DOM interaction (button wiring) is handled by settings.js (deferred).
|
DOM interaction (button wiring) is handled by settings.js (deferred).
|
||||||
|
|
||||||
|
All storage access routes through window.lnUtils.safeStorage (from
|
||||||
|
utils.js, loaded immediately before this script) so Safari private-mode
|
||||||
|
SecurityErrors degrade to default appearance rather than blowing up
|
||||||
|
the synchronous bootstrap.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
var TEXT_SIZES = [20, 23, 26];
|
var TEXT_SIZES = [20, 23, 26];
|
||||||
|
var store = window.lnUtils && window.lnUtils.safeStorage;
|
||||||
|
function safeGet(key) { return store ? store.get(key) : null; }
|
||||||
|
|
||||||
/* Theme */
|
/* Theme */
|
||||||
var storedTheme = localStorage.getItem('theme');
|
var storedTheme = safeGet('theme');
|
||||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||||
document.documentElement.setAttribute('data-theme', storedTheme);
|
document.documentElement.setAttribute('data-theme', storedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text size */
|
/* Text size */
|
||||||
var storedSize = parseInt(localStorage.getItem('text-size'), 10);
|
var storedSize = parseInt(safeGet('text-size'), 10);
|
||||||
if (!isNaN(storedSize) && storedSize >= 0 && storedSize < TEXT_SIZES.length) {
|
if (!isNaN(storedSize) && storedSize >= 0 && storedSize < TEXT_SIZES.length) {
|
||||||
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[storedSize] + 'px');
|
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[storedSize] + 'px');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus mode */
|
/* Focus mode */
|
||||||
if (localStorage.getItem('focus-mode')) {
|
if (safeGet('focus-mode')) {
|
||||||
document.documentElement.setAttribute('data-focus-mode', '');
|
document.documentElement.setAttribute('data-focus-mode', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce motion */
|
/* Reduce motion */
|
||||||
if (localStorage.getItem('reduce-motion')) {
|
if (safeGet('reduce-motion')) {
|
||||||
document.documentElement.setAttribute('data-reduce-motion', '');
|
document.documentElement.setAttribute('data-reduce-motion', '');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse / expand
|
// Collapse / expand. The collapsed state is hidden from
|
||||||
|
// assistive technology (aria-hidden="true") and removed from
|
||||||
|
// the keyboard tab order (tabindex=-1 on each link), so users
|
||||||
|
// navigating with a screen reader or only the keyboard cannot
|
||||||
|
// land on links inside a collapsed TOC.
|
||||||
|
const tocNav = toc.querySelector('.toc-nav');
|
||||||
function setExpanded(open) {
|
function setExpanded(open) {
|
||||||
if (!toggleBtn) return;
|
if (!toggleBtn) return;
|
||||||
toc.classList.toggle('is-collapsed', !open);
|
toc.classList.toggle('is-collapsed', !open);
|
||||||
toggleBtn.setAttribute('aria-expanded', String(open));
|
toggleBtn.setAttribute('aria-expanded', String(open));
|
||||||
|
if (tocNav) {
|
||||||
|
tocNav.setAttribute('aria-hidden', String(!open));
|
||||||
|
}
|
||||||
|
links.forEach(function (a) {
|
||||||
|
if (open) {
|
||||||
|
a.removeAttribute('tabindex');
|
||||||
|
} else {
|
||||||
|
a.setAttribute('tabindex', '-1');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
|
|
@ -54,13 +69,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-collapse once the first section becomes active via scrolling.
|
|
||||||
let autoCollapsed = false;
|
|
||||||
function collapseOnce() {
|
|
||||||
if (autoCollapsed) return;
|
|
||||||
autoCollapsed = true;
|
|
||||||
setExpanded(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress indicator — drives the horizontal bar under .toc-header.
|
// Progress indicator — drives the horizontal bar under .toc-header.
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
|
|
@ -83,7 +91,7 @@
|
||||||
if (visible.size > 0) {
|
if (visible.size > 0) {
|
||||||
// Activate the topmost visible heading in document order.
|
// Activate the topmost visible heading in document order.
|
||||||
const top = headings.find(h => visible.has(h));
|
const top = headings.find(h => visible.has(h));
|
||||||
if (top) { activate(top.id); collapseOnce(); }
|
if (top) { activate(top.id); }
|
||||||
} else {
|
} else {
|
||||||
// Nothing in the trigger band: activate the last heading
|
// Nothing in the trigger band: activate the last heading
|
||||||
// whose top edge is above the sticky nav bar.
|
// whose top edge is above the sticky nav bar.
|
||||||
|
|
@ -93,7 +101,7 @@
|
||||||
if (h.getBoundingClientRect().top < navHeight + 16) candidate = h;
|
if (h.getBoundingClientRect().top < navHeight + 16) candidate = h;
|
||||||
else break;
|
else break;
|
||||||
}
|
}
|
||||||
if (candidate) { activate(candidate.id); collapseOnce(); }
|
if (candidate) { activate(candidate.id); }
|
||||||
else activateTitle();
|
else activateTitle();
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,21 @@
|
||||||
|
|
||||||
/* After injection, retrigger layout-dependent subsystems. */
|
/* After injection, retrigger layout-dependent subsystems. */
|
||||||
function reinitFragment(container) {
|
function reinitFragment(container) {
|
||||||
/* sidenotes.js repositions on resize — dispatch to trigger it. */
|
/* sidenotes.js — wire newly injected sidenote refs/spans and
|
||||||
|
reposition the column. Falls back to a manual resize event
|
||||||
|
for older builds that haven't been redeployed yet. */
|
||||||
|
if (typeof window.reinitSidenotes === 'function') {
|
||||||
|
window.reinitSidenotes(container);
|
||||||
|
} else {
|
||||||
window.dispatchEvent(new Event('resize'));
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* popups.js — bind hover popups for newly injected links so
|
||||||
|
transcluded content has the same preview behaviour as the
|
||||||
|
host page. */
|
||||||
|
if (typeof window.reinitPopups === 'function') {
|
||||||
|
window.reinitPopups(container);
|
||||||
|
}
|
||||||
|
|
||||||
/* collapse.js exposes reinitCollapse for newly added headings. */
|
/* collapse.js exposes reinitCollapse for newly added headings. */
|
||||||
if (typeof window.reinitCollapse === 'function') {
|
if (typeof window.reinitCollapse === 'function') {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/* utils.js — Tiny shared helpers loaded before any other script.
|
||||||
|
Loaded synchronously (no `defer`) from templates/partials/head.html so
|
||||||
|
that defer'd scripts can rely on `window.lnUtils` existing at run time.
|
||||||
|
|
||||||
|
Keep this file dependency-free and minimal. It's the lowest-level
|
||||||
|
layer in the JS stack — anything heavier should live in a feature
|
||||||
|
module instead. */
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var lnUtils = global.lnUtils || {};
|
||||||
|
|
||||||
|
/* Escape a string for safe interpolation into HTML text content or
|
||||||
|
double-quoted attribute values. The order of replacements matters:
|
||||||
|
`&` MUST come first, otherwise the `&` injected by other rules
|
||||||
|
gets re-escaped to `&amp;`.
|
||||||
|
|
||||||
|
Mirror of `Utils.escapeHtml` in build/Utils.hs. */
|
||||||
|
lnUtils.escapeHtml = function (s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Safe localStorage wrapper. Safari throws SecurityError in private
|
||||||
|
browsing mode on every access — including writes — so reads AND
|
||||||
|
writes need to be guarded. Every consumer that touches storage
|
||||||
|
should route through this helper so degradation is uniform. */
|
||||||
|
lnUtils.safeStorage = {
|
||||||
|
get: function (key) {
|
||||||
|
try { return localStorage.getItem(key); }
|
||||||
|
catch (_) { return null; }
|
||||||
|
},
|
||||||
|
set: function (key, value) {
|
||||||
|
try { localStorage.setItem(key, value); return true; }
|
||||||
|
catch (_) { return false; }
|
||||||
|
},
|
||||||
|
remove: function (key) {
|
||||||
|
try { localStorage.removeItem(key); return true; }
|
||||||
|
catch (_) { return false; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
global.lnUtils = lnUtils;
|
||||||
|
})(window);
|
||||||
|
|
@ -28,8 +28,8 @@ $if(home)$<script src="/js/random.js" defer></script>$endif$
|
||||||
$if(reading)$<script src="/js/reading.js" defer></script>$endif$
|
$if(reading)$<script src="/js/reading.js" defer></script>$endif$
|
||||||
$for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$
|
$for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$
|
||||||
$if(math)$
|
$if(math)$
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
||||||
onload="(function(){var els=Array.from(document.getElementsByClassName('math'));els.forEach(function(el){if(el.tagName==='SPAN'){var src=el.textContent;katex.render(src,el,{displayMode:el.classList.contains('display'),output:'htmlAndMathml',throwOnError:false})}})})()"></script>
|
<script defer src="/js/katex-bootstrap.js"></script>
|
||||||
$endif$
|
$endif$
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ $endif$
|
||||||
$if(search)$
|
$if(search)$
|
||||||
<link rel="stylesheet" href="/pagefind/pagefind-ui.css">
|
<link rel="stylesheet" href="/pagefind/pagefind-ui.css">
|
||||||
$endif$
|
$endif$
|
||||||
|
<script src="/js/utils.js"></script>
|
||||||
<script src="/js/theme.js"></script>
|
<script src="/js/theme.js"></script>
|
||||||
$if(viz)$
|
$if(viz)$
|
||||||
<link rel="stylesheet" href="/css/viz.css">
|
<link rel="stylesheet" href="/css/viz.css">
|
||||||
|
|
|
||||||
|
|
@ -12,36 +12,36 @@
|
||||||
<a href="/search.html">Search</a>
|
<a href="/search.html">Search</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-controls">
|
<div class="nav-controls">
|
||||||
<button class="nav-portal-toggle" aria-label="Toggle portals" aria-expanded="false">
|
<button type="button" class="nav-portal-toggle" aria-label="Toggle portals" aria-expanded="false">
|
||||||
<span class="nav-portal-arrow">▼</span>Portals
|
<span class="nav-portal-arrow">▼</span>Portals
|
||||||
</button>
|
</button>
|
||||||
<div class="settings-wrap">
|
<div class="settings-wrap">
|
||||||
<button class="settings-toggle" aria-label="Open settings" aria-expanded="false">⚙</button>
|
<button type="button" class="settings-toggle" aria-label="Open settings" aria-expanded="false">⚙</button>
|
||||||
<div class="settings-panel" aria-hidden="true">
|
<div class="settings-panel" aria-hidden="true">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-label">Theme</div>
|
<div class="settings-label">Theme</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<button class="settings-btn" data-action="theme-light">Light</button>
|
<button type="button" class="settings-btn" data-action="theme-light">Light</button>
|
||||||
<button class="settings-btn" data-action="theme-dark">Dark</button>
|
<button type="button" class="settings-btn" data-action="theme-dark">Dark</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-label">Text size</div>
|
<div class="settings-label">Text size</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<button class="settings-btn" data-action="text-smaller" aria-label="Decrease text size">A−</button>
|
<button type="button" class="settings-btn" data-action="text-smaller" aria-label="Decrease text size">A−</button>
|
||||||
<button class="settings-btn" data-action="text-larger" aria-label="Increase text size">A+</button>
|
<button type="button" class="settings-btn" data-action="text-larger" aria-label="Increase text size">A+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-label">Display</div>
|
<div class="settings-label">Display</div>
|
||||||
<div class="settings-col">
|
<div class="settings-col">
|
||||||
<button class="settings-btn settings-btn--full" data-action="focus-mode">Focus Mode</button>
|
<button type="button" class="settings-btn settings-btn--full" data-action="focus-mode">Focus Mode</button>
|
||||||
<button class="settings-btn settings-btn--full" data-action="reduce-motion">Reduce Motion</button>
|
<button type="button" class="settings-btn settings-btn--full" data-action="reduce-motion">Reduce Motion</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<button class="settings-btn settings-btn--full" data-action="print">Print</button>
|
<button type="button" class="settings-btn settings-btn--full" data-action="print">Print</button>
|
||||||
<button class="settings-btn settings-btn--full settings-btn--danger" data-action="clear-annotations">Clear Annotations</button>
|
<button type="button" class="settings-btn settings-btn--full settings-btn--danger" data-action="clear-annotations">Clear Annotations</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue