diff --git a/static/css/base.css b/static/css/base.css index 9cfaf7a..7f39bec 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -119,7 +119,9 @@ --page-padding: 1.5rem; /* 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) */ --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; diff --git a/static/css/build.css b/static/css/build.css index 41be7a0..33c4ffe 100644 --- a/static/css/build.css +++ b/static/css/build.css @@ -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; diff --git a/static/css/components.css b/static/css/components.css index 5d4ed0f..0c7d588 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -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 */ diff --git a/static/css/print.css b/static/css/print.css index a3d5dff..12b283d 100644 --- a/static/css/print.css +++ b/static/css/print.css @@ -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, diff --git a/static/css/typography.css b/static/css/typography.css index 0458d6e..014cccb 100644 --- a/static/css/typography.css +++ b/static/css/typography.css @@ -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; diff --git a/static/js/annotations.js b/static/js/annotations.js index eda9980..0baebae 100644 --- a/static/js/annotations.js +++ b/static/js/annotations.js @@ -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, '"'); + return window.lnUtils.escapeHtml(s); } function showTooltip(mark, ann) { diff --git a/static/js/gallery.js b/static/js/gallery.js index 35ec4e5..cd8aa64 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -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 diff --git a/static/js/katex-bootstrap.js b/static/js/katex-bootstrap.js new file mode 100644 index 0000000..1c553cb --- /dev/null +++ b/static/js/katex-bootstrap.js @@ -0,0 +1,40 @@ +/* katex-bootstrap.js — Render every /
+ 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 + $endif$ $if(reading)$$endif$ $for(page-scripts)$$endfor$ $if(math)$ - + + $endif$ diff --git a/templates/partials/head.html b/templates/partials/head.html index 6c70520..0e7e7a3 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -28,6 +28,7 @@ $endif$ $if(search)$ $endif$ + $if(viz)$ diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 97ceb6e..969de60 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -12,36 +12,36 @@ Search