audit: frontend a11y, JS shared utils, CSS variable definitions

This commit is contained in:
Levi Neuwirth 2026-04-10 17:41:21 -04:00
parent a358c8b246
commit dd61fc0cc4
20 changed files with 497 additions and 111 deletions

View File

@ -119,7 +119,9 @@
--page-padding: 1.5rem; --page-padding: 1.5rem;
/* 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; /* 5001999 words */ --hm-2: #787874; /* 5001999 words */
--hm-3: #424240; /* 20004999 words */ --hm-3: #424240; /* 20004999 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;

View File

@ -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;

View File

@ -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 */

View File

@ -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,

View File

@ -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;

View File

@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
function showTooltip(mark, ann) { function showTooltip(mark, ann) {

View File

@ -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

View File

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

View File

@ -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');

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
/* 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

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------

View File

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

View File

@ -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 { update();
if (focusedPair) focusedPair.unfocus();
focused = true;
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
} }
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);
}()); }());

View File

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

View File

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

View File

@ -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
window.dispatchEvent(new Event('resize')); 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'));
}
/* 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') {

48
static/js/utils.js Normal file
View File

@ -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 `&amp;` injected by other rules
gets re-escaped to `&amp;amp;`.
Mirror of `Utils.escapeHtml` in build/Utils.hs. */
lnUtils.escapeHtml = function (s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
/* 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);

View File

@ -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>

View File

@ -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">

View File

@ -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>