audit: frontend a11y, JS shared utils, CSS variable definitions
This commit is contained in:
parent
a358c8b246
commit
dd61fc0cc4
|
|
@ -120,6 +120,8 @@
|
|||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-medium: 0.28s ease;
|
||||
--transition-slow: 0.5s ease;
|
||||
|
||||
/* Writing activity heatmap (light mode) */
|
||||
--hm-0: #e8e8e4; /* empty cell */
|
||||
|
|
@ -127,6 +129,29 @@
|
|||
--hm-2: #787874; /* 500–1999 words */
|
||||
--hm-3: #424240; /* 2000–4999 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-nav: #181818;
|
||||
--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-muted: #8c8881;
|
||||
--text-faint: #6a6660;
|
||||
--text-faint: #8b8680;
|
||||
--border: #333333;
|
||||
--border-muted: #444444;
|
||||
|
||||
--link: #d4d0c8;
|
||||
--link-underline: #6a6660;
|
||||
--link-underline: #8b8680;
|
||||
--link-hover: #ffffff;
|
||||
--link-hover-underline: #ffffff;
|
||||
--link-visited: #a39f98;
|
||||
|
|
@ -154,6 +182,9 @@
|
|||
--selection-bg: #d4d0c8;
|
||||
--selection-text: #121212;
|
||||
|
||||
/* Aliases — kept in sync with the light-mode definitions above. */
|
||||
--bg-subtle: var(--bg-offset);
|
||||
|
||||
/* Writing activity heatmap (dark mode) */
|
||||
--hm-0: #252524;
|
||||
--hm-1: #484844;
|
||||
|
|
@ -170,12 +201,12 @@
|
|||
--bg-offset: #1a1a1a;
|
||||
--text: #d4d0c8;
|
||||
--text-muted: #8c8881;
|
||||
--text-faint: #6a6660;
|
||||
--text-faint: #8b8680;
|
||||
--border: #333333;
|
||||
--border-muted: #444444;
|
||||
|
||||
--link: #d4d0c8;
|
||||
--link-underline: #6a6660;
|
||||
--link-underline: #8b8680;
|
||||
--link-hover: #ffffff;
|
||||
--link-hover-underline: #ffffff;
|
||||
--link-visited: #a39f98;
|
||||
|
|
@ -183,6 +214,8 @@
|
|||
--selection-bg: #d4d0c8;
|
||||
--selection-text: #121212;
|
||||
|
||||
--bg-subtle: var(--bg-offset);
|
||||
|
||||
--hm-0: #252524;
|
||||
--hm-1: #484844;
|
||||
--hm-2: #6e6e6a;
|
||||
|
|
@ -228,6 +261,38 @@ body {
|
|||
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 {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -123,6 +123,24 @@
|
|||
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 */
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -424,7 +424,10 @@ nav.site-nav {
|
|||
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 {
|
||||
overflow: hidden;
|
||||
max-height: 80vh;
|
||||
|
|
@ -432,7 +435,15 @@ nav.site-nav {
|
|||
}
|
||||
#toc.is-collapsed .toc-nav {
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -5,17 +5,21 @@
|
|||
@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,
|
||||
[data-theme="dark"] {
|
||||
--bg: #ffffff;
|
||||
--bg-offset: #f5f5f5;
|
||||
--bg-subtle: #f9f9f9;
|
||||
--text: #000000;
|
||||
--text-muted: #333333;
|
||||
--text-faint: #555555;
|
||||
--border: #cccccc;
|
||||
--border-muted: #aaaaaa;
|
||||
--rule: #cccccc;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
|
|
@ -41,8 +45,8 @@
|
|||
body {
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -72,9 +76,9 @@
|
|||
width: auto !important;
|
||||
margin: 0.5em 2em;
|
||||
padding: 0.4em 0.8em;
|
||||
border-left: 2px solid #ccc;
|
||||
border-left: 2px solid var(--border);
|
||||
font-size: 9pt;
|
||||
color: #555;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
|
|
@ -109,7 +113,7 @@
|
|||
a[href^="http"]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
color: #555;
|
||||
color: var(--text-faint);
|
||||
word-break: break-all;
|
||||
}
|
||||
/* But not for nav or obvious UI links */
|
||||
|
|
@ -123,8 +127,8 @@
|
|||
Code blocks — strip background, border only
|
||||
---------------------------------------------------------------- */
|
||||
pre, code {
|
||||
background: #f9f9f9 !important;
|
||||
border: 1px solid #ddd !important;
|
||||
background: var(--bg-subtle) !important;
|
||||
border: 1px solid var(--border-muted) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +138,7 @@
|
|||
.page-meta-footer {
|
||||
margin-top: 1.5em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #ccc;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.meta-footer-full,
|
||||
|
|
|
|||
|
|
@ -463,9 +463,22 @@ pre code {
|
|||
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 {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-size-small);
|
||||
font-size: 0.92em;
|
||||
color: var(--text-muted);
|
||||
text-align: right; /* Editorial, museum-placard feel */
|
||||
margin-top: 1rem;
|
||||
|
|
|
|||
|
|
@ -150,12 +150,11 @@
|
|||
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) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
return window.lnUtils.escapeHtml(s);
|
||||
}
|
||||
|
||||
function showTooltip(mark, ann) {
|
||||
|
|
|
|||
|
|
@ -225,9 +225,37 @@
|
|||
if (e.key === 'Escape') { closeOverlay(); return; }
|
||||
if (e.key === 'ArrowLeft') { 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) {
|
||||
currentIdx = idx;
|
||||
/* 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');
|
||||
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');
|
||||
caption.className = 'lightbox-caption';
|
||||
|
|
@ -39,7 +42,10 @@
|
|||
function open(src, alt, captionText, trigger) {
|
||||
triggerEl = trigger || null;
|
||||
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.hidden = !captionText;
|
||||
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. */
|
||||
function getProvider(href) {
|
||||
if (!href) return null;
|
||||
|
|
@ -118,6 +130,11 @@
|
|||
}
|
||||
|
||||
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('mouseleave', scheduleHide);
|
||||
el.addEventListener('focus', function () { scheduleShow(el, provider); });
|
||||
|
|
@ -133,9 +150,20 @@
|
|||
clearTimeout(showTimer);
|
||||
activeTarget = target;
|
||||
showTimer = setTimeout(function () {
|
||||
provider(target).then(function (html) {
|
||||
if (!html || activeTarget !== target) return;
|
||||
popup.innerHTML = html;
|
||||
provider(target).then(function (content) {
|
||||
if (!content || activeTarget !== target) return;
|
||||
/* 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);
|
||||
popup.classList.add('is-visible');
|
||||
popup.setAttribute('aria-hidden', 'false');
|
||||
|
|
@ -183,6 +211,34 @@
|
|||
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 */
|
||||
function loadAnnotations() {
|
||||
if (annotations !== null) return Promise.resolve(annotations);
|
||||
|
|
@ -288,8 +344,7 @@
|
|||
+ '?action=query&prop=extracts&exintro=1&format=json&redirects=1'
|
||||
+ '&titles=' + encodeURIComponent(decodeURIComponent(m[1])) + '&origin=*';
|
||||
|
||||
return fetch(apiUrl)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson(apiUrl)
|
||||
.then(function (data) {
|
||||
var pages = data && data.query && data.query.pages;
|
||||
if (!pages) return null;
|
||||
|
|
@ -323,8 +378,7 @@
|
|||
if (!m) return Promise.resolve(null);
|
||||
|
||||
var id = m[1].replace(/v\d+$/, '');
|
||||
return fetch('https://export.arxiv.org/api/query?id_list=' + encodeURIComponent(id))
|
||||
.then(function (r) { return r.ok ? r.text() : null; })
|
||||
return fetchXml('https://export.arxiv.org/api/query?id_list=' + encodeURIComponent(id))
|
||||
.then(function (xml) {
|
||||
if (!xml) return null;
|
||||
var doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
|
|
@ -357,8 +411,7 @@
|
|||
var m = href.match(/(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/);
|
||||
if (!m) return Promise.resolve(null);
|
||||
|
||||
return fetch('https://api.crossref.org/works/' + encodeURIComponent(m[1]))
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson('https://api.crossref.org/works/' + encodeURIComponent(m[1]))
|
||||
.then(function (data) {
|
||||
var msg = data && data.message;
|
||||
if (!msg) return null;
|
||||
|
|
@ -394,9 +447,8 @@
|
|||
var m = href.match(/github\.com\/([^/]+)\/([^/?#]+)/);
|
||||
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' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !data.full_name) return 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);
|
||||
|
||||
var apiUrl = 'https://git.levineuwirth.org/api/v1/repos/' + m[1] + '/' + m[2];
|
||||
return fetch(apiUrl)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson(apiUrl)
|
||||
.then(function (data) {
|
||||
if (!data || !data.full_name) return null;
|
||||
var meta = [data.language, data.stars_count != null ? '\u2605\u00a0' + data.stars_count : null]
|
||||
|
|
@ -446,8 +497,7 @@
|
|||
var base = href.replace(/[?#].*$/, '');
|
||||
var apiUrl = base + '.json';
|
||||
|
||||
return fetch(apiUrl)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson(apiUrl)
|
||||
.then(function (data) {
|
||||
if (!data || !data.title) return null;
|
||||
var desc = data.description;
|
||||
|
|
@ -476,8 +526,7 @@
|
|||
var server = /medrxiv/.test(href) ? 'medrxiv' : 'biorxiv';
|
||||
var label = server === 'medrxiv' ? 'medRxiv' : 'bioRxiv';
|
||||
|
||||
return fetch('https://api.biorxiv.org/details/' + server + '/' + encodeURIComponent(doi) + '/json')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson('https://api.biorxiv.org/details/' + server + '/' + encodeURIComponent(doi) + '/json')
|
||||
.then(function (data) {
|
||||
var paper = data && data.collection && data.collection[0];
|
||||
if (!paper || !paper.title) return null;
|
||||
|
|
@ -505,8 +554,7 @@
|
|||
var href = target.getAttribute('href');
|
||||
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
|
||||
|
||||
return fetch('https://www.youtube.com/oembed?url=' + encodeURIComponent(href) + '&format=json')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson('https://www.youtube.com/oembed?url=' + encodeURIComponent(href) + '&format=json')
|
||||
.then(function (data) {
|
||||
if (!data || !data.title) return null;
|
||||
return store(href,
|
||||
|
|
@ -527,8 +575,7 @@
|
|||
var m = href.match(/archive\.org\/details\/([^/?#]+)/);
|
||||
if (!m) return Promise.resolve(null);
|
||||
|
||||
return fetch('https://archive.org/metadata/' + encodeURIComponent(m[1]))
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson('https://archive.org/metadata/' + encodeURIComponent(m[1]))
|
||||
.then(function (data) {
|
||||
var meta = data && data.metadata;
|
||||
if (!meta) return null;
|
||||
|
|
@ -564,8 +611,7 @@
|
|||
var apiUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi'
|
||||
+ '?db=pubmed&id=' + pmid + '&retmode=json';
|
||||
|
||||
return fetch(apiUrl)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
return fetchJson(apiUrl)
|
||||
.then(function (data) {
|
||||
var paper = data && data.result && data.result[pmid];
|
||||
if (!paper || !paper.title) return null;
|
||||
|
|
@ -597,25 +643,34 @@
|
|||
|
||||
/* Epistemic jump link — reads the #epistemic section already in the DOM
|
||||
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() {
|
||||
var section = document.getElementById('epistemic');
|
||||
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');
|
||||
if (compact) {
|
||||
html += '<div class="ep-compact">' + compact.innerHTML + '</div>';
|
||||
var compactClone = compact.cloneNode(true);
|
||||
wrap.appendChild(compactClone);
|
||||
}
|
||||
|
||||
var expanded = section.querySelector('.ep-expanded');
|
||||
if (expanded) {
|
||||
html += '<dl class="ep-expanded">' + expanded.innerHTML + '</dl>';
|
||||
var expandedClone = expanded.cloneNode(true);
|
||||
wrap.appendChild(expandedClone);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return Promise.resolve(html || null);
|
||||
if (!compact && !expanded) return Promise.resolve(null);
|
||||
return Promise.resolve(wrap);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
return window.lnUtils.escapeHtml(s);
|
||||
}
|
||||
|
||||
/* Emit a .popup-source label with a data-popup-source attribute so CSS
|
||||
|
|
|
|||
|
|
@ -62,10 +62,17 @@
|
|||
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() {
|
||||
if (extractor) return Promise.resolve(extractor);
|
||||
if (loadModelPromise) return loadModelPromise;
|
||||
setStatus('Loading model…');
|
||||
return import(CDN).then(function (mod) {
|
||||
loadModelPromise = import(CDN).then(function (mod) {
|
||||
/* Point transformers.js at our self-hosted model files. */
|
||||
mod.env.localModelPath = MODEL_PATH;
|
||||
mod.env.allowRemoteModels = false;
|
||||
|
|
@ -73,7 +80,13 @@
|
|||
}).then(function (pipe) {
|
||||
extractor = pipe;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
return window.lnUtils.escapeHtml(s);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
/* 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 () {
|
||||
'use strict';
|
||||
|
||||
var TEXT_SIZES = [20, 23, 26];
|
||||
var TEXT_SIZE_DEFAULT = 1; /* index of 23px */
|
||||
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
|
||||
|
|
@ -102,7 +110,7 @@
|
|||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
store.set('theme', theme);
|
||||
syncThemeButtons();
|
||||
}
|
||||
|
||||
|
|
@ -122,13 +130,13 @@
|
|||
/* Text size ------------------------------------------------------- */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function shiftSize(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');
|
||||
syncTextSizeButtons();
|
||||
}
|
||||
|
|
@ -140,10 +148,10 @@
|
|||
var on = html.hasAttribute(attrName);
|
||||
if (on) {
|
||||
html.removeAttribute(attrName);
|
||||
localStorage.removeItem(storageKey);
|
||||
store.remove(storageKey);
|
||||
} else {
|
||||
html.setAttribute(attrName, '');
|
||||
localStorage.setItem(storageKey, '1');
|
||||
store.set(storageKey, '1');
|
||||
}
|
||||
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. */
|
||||
let focusedPair = null;
|
||||
|
||||
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 focused = false;
|
||||
|
||||
|
|
@ -70,6 +76,22 @@
|
|||
|
||||
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('mouseleave', function () { hovering = false; update(); });
|
||||
sn.addEventListener('mouseenter', function () { hovering = true; update(); });
|
||||
|
|
@ -81,16 +103,25 @@
|
|||
if (link) {
|
||||
link.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (getComputedStyle(sn).display === 'none') return; /* narrow: no sidenote to focus */
|
||||
if (focused) {
|
||||
toggleFocus();
|
||||
});
|
||||
|
||||
/* 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;
|
||||
focusedPair = null;
|
||||
} else {
|
||||
if (focusedPair) focusedPair.unfocus();
|
||||
focused = true;
|
||||
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
|
||||
}
|
||||
update();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -105,19 +136,39 @@
|
|||
}
|
||||
});
|
||||
|
||||
function init() {
|
||||
const body = document.getElementById('markdownBody');
|
||||
if (!body) return;
|
||||
/* Global Escape dismisses any sticky-focused sidenote, even if focus
|
||||
has moved away from the ref link. */
|
||||
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 ref = document.getElementById(refId);
|
||||
if (ref) wireHover(ref, sn);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
const body = document.getElementById('markdownBody');
|
||||
if (!body) return;
|
||||
|
||||
wireAll(body);
|
||||
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);
|
||||
window.addEventListener('resize', positionSidenotes);
|
||||
}());
|
||||
|
|
|
|||
|
|
@ -1,29 +1,36 @@
|
|||
/* theme.js — Restores theme and text size from localStorage before first paint.
|
||||
Loaded synchronously (no defer/async) to prevent flash of wrong appearance.
|
||||
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 () {
|
||||
var TEXT_SIZES = [20, 23, 26];
|
||||
var store = window.lnUtils && window.lnUtils.safeStorage;
|
||||
function safeGet(key) { return store ? store.get(key) : null; }
|
||||
|
||||
/* Theme */
|
||||
var storedTheme = localStorage.getItem('theme');
|
||||
var storedTheme = safeGet('theme');
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', storedTheme);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[storedSize] + 'px');
|
||||
}
|
||||
|
||||
/* Focus mode */
|
||||
if (localStorage.getItem('focus-mode')) {
|
||||
if (safeGet('focus-mode')) {
|
||||
document.documentElement.setAttribute('data-focus-mode', '');
|
||||
}
|
||||
|
||||
/* Reduce motion */
|
||||
if (localStorage.getItem('reduce-motion')) {
|
||||
if (safeGet('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) {
|
||||
if (!toggleBtn) return;
|
||||
toc.classList.toggle('is-collapsed', !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);
|
||||
|
|
@ -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.
|
||||
function updateProgress() {
|
||||
|
|
@ -83,7 +91,7 @@
|
|||
if (visible.size > 0) {
|
||||
// Activate the topmost visible heading in document order.
|
||||
const top = headings.find(h => visible.has(h));
|
||||
if (top) { activate(top.id); collapseOnce(); }
|
||||
if (top) { activate(top.id); }
|
||||
} else {
|
||||
// Nothing in the trigger band: activate the last heading
|
||||
// whose top edge is above the sticky nav bar.
|
||||
|
|
@ -93,7 +101,7 @@
|
|||
if (h.getBoundingClientRect().top < navHeight + 16) candidate = h;
|
||||
else break;
|
||||
}
|
||||
if (candidate) { activate(candidate.id); collapseOnce(); }
|
||||
if (candidate) { activate(candidate.id); }
|
||||
else activateTitle();
|
||||
}
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -81,8 +81,21 @@
|
|||
|
||||
/* After injection, retrigger layout-dependent subsystems. */
|
||||
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'));
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
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$
|
||||
$for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$
|
||||
$if(math)$
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"
|
||||
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="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
||||
<script defer src="/js/katex-bootstrap.js"></script>
|
||||
$endif$
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ $endif$
|
|||
$if(search)$
|
||||
<link rel="stylesheet" href="/pagefind/pagefind-ui.css">
|
||||
$endif$
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
$if(viz)$
|
||||
<link rel="stylesheet" href="/css/viz.css">
|
||||
|
|
|
|||
|
|
@ -12,36 +12,36 @@
|
|||
<a href="/search.html">Search</a>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
<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-section">
|
||||
<div class="settings-label">Theme</div>
|
||||
<div class="settings-row">
|
||||
<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-light">Light</button>
|
||||
<button type="button" class="settings-btn" data-action="theme-dark">Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Text size</div>
|
||||
<div class="settings-row">
|
||||
<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-smaller" aria-label="Decrease text size">A−</button>
|
||||
<button type="button" class="settings-btn" data-action="text-larger" aria-label="Increase text size">A+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Display</div>
|
||||
<div class="settings-col">
|
||||
<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="focus-mode">Focus Mode</button>
|
||||
<button type="button" class="settings-btn settings-btn--full" data-action="reduce-motion">Reduce Motion</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<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" data-action="print">Print</button>
|
||||
<button type="button" class="settings-btn settings-btn--full settings-btn--danger" data-action="clear-annotations">Clear Annotations</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue