From b38e9359d5635bedcac41f18765ae6960e2a4721 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 26 Mar 2026 13:29:37 -0400 Subject: [PATCH] annotation system --- spec.md | 5 +- static/css/annotations.css | 112 +++++++++++++++++++++++++++++++++ static/css/components.css | 7 +++ static/css/typography.css | 35 +++++++---- static/js/annotations.js | 11 ++++ static/js/selection-popup.js | 116 +++++++++++++++++++++++++++++++---- static/js/settings.js | 9 ++- templates/partials/nav.html | 1 + 8 files changed, 269 insertions(+), 27 deletions(-) diff --git a/spec.md b/spec.md index d5a4c11..155eb80 100644 --- a/spec.md +++ b/spec.md @@ -412,7 +412,7 @@ levineuwirth.org/ - [x] Selection popup — `selection-popup.js` / `selection-popup.css`; context-aware toolbar appears 450 ms after text selection; see Implementation Notes - [x] Print stylesheet — `print.css` (media="print"); single-column, light colors, sidenotes as indented blocks, external URLs shown - [x] Current page (`/current.html`) — now-page; added to primary nav -- [~] Annotations — `annotations.js` / `annotations.css`; localStorage infrastructure + highlight re-anchoring written; UI (button in selection popup) deferred +- [x] Annotations — `annotations.js` / `annotations.css`; localStorage storage, text re-anchoring, highlight marks, tooltip with delete; color-picker UI in selection popup (four swatches + optional note field) ### Phase 4: Creative Content & Polish - [x] Image handling (lazy load, lightbox, figures, WebP `` wrapper for local raster images) @@ -432,9 +432,10 @@ levineuwirth.org/ - [ ] **Link archiving** — For all external links in `data/bibliography.bib` and in page bodies, check availability and save snapshots (Wayback Machine `save` API or local archivebox instance). Store archive URLs in `data/link-archive.json`; `Filters.Links` injects `data-archive-url` attributes; `popups.js` falls back to the archive if the live URL returns 404. - [ ] **Self-hosted git (Forgejo)** — Run Forgejo on the VPS. Mirror the build repo. Link from the colophon. Not essential; can remain on GitHub indefinitely. - [ ] **Reader mode** — Distraction-free reading overlay: hides nav, TOC, sidenotes; widens the body column to ~70ch; activated via a keyboard shortcut or settings panel toggle. Distinct from focus mode (which affects the nav) — reader mode affects the content layout. +- [ ] **HTTP/3 + QUIC** — nginx 1.25+ supports HTTP/3 via `listen 443 quic reuseport` + `http3 on` + `Alt-Svc` header. Requires UDP 443 open in Hetzner's Cloud Firewall. Deferred: latency is currently geographic RTT, not server processing; gains would be modest for a static site from a single DC. CDN alternatives (Bunny.net, multi-region Hetzner with GeoDNS) would address the root cause but raise ethical or operational complexity concerns. Revisit if latency becomes a real user complaint. Server-side improvements (brotli pre-compression, `open_file_cache`) are a lower-cost step first. ### Phase 6: Deferred Features -- [ ] **Annotation UI** — The `annotations.js` / `annotations.css` infrastructure exists (localStorage storage, re-anchoring on load, four highlight colors, hover tooltip). The selection popup "Annotate" button was removed pending a design decision on the color-picker and note-entry UX. Revisit: a popover with four color swatches and an optional text field, triggered from the selection popup. +- [x] **Annotation UI** — `annotations.js` / `annotations.css`: localStorage storage, text-stream re-anchoring, four highlight colors (amber/sage/steel/rose), hover tooltip with delete. Selection popup "Annotate" button triggers a color-swatch + optional note picker; Enter or "Highlight" button commits; Escape cancels. Picker positioned above the selection, same inverted style as the tooltip. Settings panel includes a "Clear Annotations" button (with confirmation) that wipes all annotations site-wide via `Annotations.clearAll()`. - [~] **Visualization pipeline** — Implemented as a Pandoc IO filter (`Filters.Viz`), not a per-slug Hakyll rule. See Phase 4 entry and Implementation Notes. Infrastructure complete; production content pending. - [x] **Music catalog page** — `/music/` index listing all compositions grouped by instrumentation category (orchestral → chamber → solo → vocal → choral → electronic → other), with an optional Featured section. Auto-generated from composition frontmatter by `build/Catalog.hs`; renders HTML in Haskell (same pattern as backlinks). Category, year, duration, instrumentation, and ◼/♫ indicators for score/recording availability. `content/music/index.md` provides prose intro + abstract. Template: `templates/music-catalog.html`. CSS: `static/css/catalog.css`. Context: `musicCatalogCtx` (provides `catalog: true` flag, `featured-works`, `has-featured`, `catalog-by-category`). - [x] **Score reader swipe gestures** — `touchstart`/`touchend` listeners on `#score-reader-stage` with passive: true. Threshold: ≥ 50 px horizontal, < 30 px vertical drift. Left swipe → next page; right swipe → previous page. diff --git a/static/css/annotations.css b/static/css/annotations.css index 397beae..5e4cfd5 100644 --- a/static/css/annotations.css +++ b/static/css/annotations.css @@ -30,6 +30,14 @@ mark.user-annotation:hover { filter: brightness(0.80); } [data-theme="dark"] mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); } [data-theme="dark"] mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); } +/* System dark mode fallback (for prefers-color-scheme without explicit data-theme) */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.40); } + :root:not([data-theme="light"]) mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.42); } + :root:not([data-theme="light"]) mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); } + :root:not([data-theme="light"]) mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); } +} + /* ============================================================ ANNOTATION TOOLTIP Appears on hover over a mark. Inverted colours like the @@ -95,9 +103,113 @@ mark.user-annotation:hover { filter: brightness(0.80); } .ann-tooltip-delete:hover { opacity: 1; } +/* ============================================================ + ANNOTATE PICKER + Appears above the selection when "Annotate" is clicked. + Inverted colors (dark bg, light text) — same as the tooltip. + ============================================================ */ + +.ann-picker { + position: absolute; + z-index: 760; + background: var(--text); + color: var(--bg); + border-radius: 5px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.28); + padding: 0.55rem 0.6rem; + font-family: var(--font-sans); + font-size: 0.75rem; + width: 13rem; + + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, visibility 0.12s ease; + pointer-events: none; +} + +.ann-picker.is-visible { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.ann-picker-swatches { + display: flex; + gap: 0.45rem; + margin-bottom: 0.5rem; + align-items: center; +} + +.ann-picker-swatch { + width: 1.05rem; + height: 1.05rem; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + flex-shrink: 0; + outline: none; + transition: transform 0.1s ease, border-color 0.1s ease; +} + +.ann-picker-swatch--amber { background: rgba(245, 158, 11, 0.9); } +.ann-picker-swatch--sage { background: rgba(107, 158, 120, 0.9); } +.ann-picker-swatch--steel { background: rgba(112, 150, 184, 0.9); } +.ann-picker-swatch--rose { background: rgba(200, 116, 116, 0.9); } + +.ann-picker-swatch.is-selected, +.ann-picker-swatch:focus-visible { + border-color: var(--bg); + transform: scale(1.2); +} + +.ann-picker-note { + display: block; + width: 100%; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + color: inherit; + font-family: var(--font-sans); + font-size: 0.72rem; + padding: 0.28rem 0.4rem; + margin-bottom: 0.45rem; + outline: none; +} + +.ann-picker-note::placeholder { opacity: 0.5; } + +.ann-picker-note:focus { + border-color: rgba(255, 255, 255, 0.45); +} + +.ann-picker-actions { + display: flex; + justify-content: flex-end; +} + +.ann-picker-submit { + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + color: inherit; + font-family: var(--font-sans); + font-size: 0.72rem; + padding: 0.22rem 0.55rem; + cursor: pointer; + line-height: 1.4; + transition: background 0.1s ease; +} + +.ann-picker-submit:hover { + background: rgba(255, 255, 255, 0.28); +} + /* ============================================================ REDUCE MOTION ============================================================ */ [data-reduce-motion] mark.user-annotation { transition: none; } [data-reduce-motion] .ann-tooltip { transition: none; } +[data-reduce-motion] .ann-picker { transition: none; } diff --git a/static/css/components.css b/static/css/components.css index 8a3b601..f69c2f7 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -206,6 +206,13 @@ nav.site-nav { text-align: center; } +.settings-btn--danger { + color: rgba(180, 80, 80, 0.85); +} +.settings-btn--danger:hover { + color: rgba(180, 80, 80, 1); +} + .settings-col { display: flex; flex-direction: column; diff --git a/static/css/typography.css b/static/css/typography.css index 99b23d0..646210a 100644 --- a/static/css/typography.css +++ b/static/css/typography.css @@ -215,31 +215,42 @@ abbr { sup { font-variant-position: super; line-height: 1; } sub { font-variant-position: sub; line-height: 1; } -/* Realistic Ink Highlighting */ -#markdownBody mark { +/* Realistic Ink Highlighting — excludes user annotation marks */ +#markdownBody mark:not(.user-annotation) { background-color: transparent; background-image: linear-gradient( - 104deg, - rgba(250, 235, 120, 0) 0%, - rgba(250, 235, 120, 0.8) 2%, - rgba(250, 220, 100, 0.9) 98%, + 104deg, + rgba(250, 235, 120, 0) 0%, + rgba(250, 235, 120, 0.8) 2%, + rgba(250, 220, 100, 0.9) 98%, rgba(250, 220, 100, 0) 100% ); - background-size: 100% 0.7em; + background-size: 100% 0.7em; background-position: 0 88%; background-repeat: no-repeat; padding: 0 0.2em; color: inherit; } -[data-theme="dark"] #markdownBody mark { +[data-theme="dark"] #markdownBody mark:not(.user-annotation) { background-image: linear-gradient( - 104deg, - rgba(100, 130, 180, 0) 0%, - rgba(100, 130, 180, 0.4) 2%, - rgba(80, 110, 160, 0.5) 98%, + 104deg, + rgba(100, 130, 180, 0) 0%, + rgba(100, 130, 180, 0.4) 2%, + rgba(80, 110, 160, 0.5) 98%, rgba(80, 110, 160, 0) 100% ); } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) #markdownBody mark:not(.user-annotation) { + background-image: linear-gradient( + 104deg, + rgba(100, 130, 180, 0) 0%, + rgba(100, 130, 180, 0.4) 2%, + rgba(80, 110, 160, 0.5) 98%, + rgba(80, 110, 160, 0) 100% + ); + } +} /* ============================================================ diff --git a/static/js/annotations.js b/static/js/annotations.js index 1093f3f..eda9980 100644 --- a/static/js/annotations.js +++ b/static/js/annotations.js @@ -234,6 +234,17 @@ return ann; }, remove: removeById, + clearAll: function () { + saveAll([]); + document.querySelectorAll('mark.user-annotation').forEach(function (mark) { + var parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + hideTooltip(true); + }, }; document.addEventListener('DOMContentLoaded', function () { diff --git a/static/js/selection-popup.js b/static/js/selection-popup.js index 0c55611..1b5dbbc 100644 --- a/static/js/selection-popup.js +++ b/static/js/selection-popup.js @@ -5,10 +5,8 @@ code (known lang) → Copy · [MDN / Hoogle / Docs…] code (unknown) → Copy math → Copy · nLab · OEIS · Wolfram - prose (multi-word) → Annotate* · BibTeX · Copy · DuckDuckGo · Here · Wikipedia - prose (one word) → Annotate* · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia - - (* = placeholder, not yet wired) + prose (multi-word) → Annotate · BibTeX · Copy · DuckDuckGo · Here · Wikipedia + prose (one word) → Annotate · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia */ (function () { 'use strict'; @@ -17,6 +15,8 @@ var popup = null; var showTimer = null; + var picker = null; + var pickerColor = 'amber'; /* ------------------------------------------------------------------ Documentation providers keyed by Prism language identifier. @@ -58,7 +58,7 @@ document.addEventListener('keyup', onKeyUp); document.addEventListener('mousedown', onMouseDown); document.addEventListener('keydown', onKeyDown); - window.addEventListener('scroll', hide, { passive: true }); + window.addEventListener('scroll', function () { hide(); hidePicker(); }, { passive: true }); } /* ------------------------------------------------------------------ @@ -80,11 +80,13 @@ function onMouseDown(e) { if (popup.contains(e.target)) return; + if (picker && picker.classList.contains('is-visible') && picker.contains(e.target)) return; hide(); + hidePicker(); } function onKeyDown(e) { - if (e.key === 'Escape') hide(); + if (e.key === 'Escape') { hide(); hidePicker(); } } /* ------------------------------------------------------------------ @@ -149,7 +151,7 @@ position(rect); popup.style.visibility = ''; - bindActions(text); + bindActions(text, rect); } function hide() { @@ -211,8 +213,9 @@ + btn('wolfram', 'Wolfram'); } - /* Prose — alphabetical: BibTeX · Copy · [Define] · DuckDuckGo · Here · Wikipedia */ - return btn('cite', 'BibTeX') + /* Prose: Annotate · BibTeX · Copy · [Define] · DuckDuckGo · Here · Wikipedia */ + return btn('annotate', 'Annotate') + + btn('cite', 'BibTeX') + btn('copy', 'Copy') + (oneWord ? btn('define', 'Define') : '') + btn('search', 'DuckDuckGo') @@ -240,11 +243,11 @@ Action bindings ------------------------------------------------------------------ */ - function bindActions(text) { + function bindActions(text, rect) { popup.querySelectorAll('[data-action]').forEach(function (el) { if (el.getAttribute('aria-disabled') === 'true') return; el.addEventListener('click', function () { - dispatch(el.getAttribute('data-action'), text, el); + dispatch(el.getAttribute('data-action'), text, el, rect); hide(); }); }); @@ -325,7 +328,7 @@ Action dispatch ------------------------------------------------------------------ */ - function dispatch(action, text, el) { + function dispatch(action, text, el, rect) { var q = encodeURIComponent(text); if (action === 'search') { window.open('https://duckduckgo.com/?q=' + q, '_blank', 'noopener,noreferrer'); @@ -362,8 +365,97 @@ } else if (action === 'here') { /* Site search via Pagefind — opens search page with query pre-filled. */ window.open('/search.html?q=' + q, '_blank', 'noopener,noreferrer'); + + } else if (action === 'annotate') { + showAnnotatePicker(text, rect); } } + /* ------------------------------------------------------------------ + Annotate picker — color swatches + optional note input + ------------------------------------------------------------------ */ + + function showAnnotatePicker(text, selRect) { + if (!picker) { + picker = document.createElement('div'); + picker.className = 'ann-picker'; + picker.setAttribute('role', 'dialog'); + picker.setAttribute('aria-label', 'Annotate selection'); + document.body.appendChild(picker); + } + + pickerColor = 'amber'; + picker.innerHTML = + '
' + + swatchBtn('amber') + swatchBtn('sage') + swatchBtn('steel') + swatchBtn('rose') + + '
' + + '' + + '
' + + '' + + '
'; + + picker.style.visibility = 'hidden'; + picker.classList.add('is-visible'); + positionPicker(selRect); + picker.style.visibility = ''; + + var note = picker.querySelector('.ann-picker-note'); + var submitBtn = picker.querySelector('.ann-picker-submit'); + + picker.querySelector('.ann-picker-swatch[data-color="amber"]').classList.add('is-selected'); + + picker.querySelectorAll('.ann-picker-swatch').forEach(function (sw) { + sw.addEventListener('click', function () { + picker.querySelectorAll('.ann-picker-swatch').forEach(function (s) { + s.classList.remove('is-selected'); + }); + sw.classList.add('is-selected'); + pickerColor = sw.getAttribute('data-color'); + }); + }); + + function commit() { + if (window.Annotations) { + window.Annotations.add(text, pickerColor, note.value.trim()); + } + hidePicker(); + } + + submitBtn.addEventListener('click', commit); + note.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (e.key === 'Escape') { hidePicker(); } + }); + + setTimeout(function () { note.focus(); }, 0); + } + + function hidePicker() { + if (picker) picker.classList.remove('is-visible'); + } + + function positionPicker(rect) { + var pw = picker.offsetWidth; + var ph = picker.offsetHeight; + var GAP = 8; + var sy = window.scrollY; + var sx = window.scrollX; + var vw = window.innerWidth; + + var left = rect.left + sx + rect.width / 2 - pw / 2; + left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP)); + + var top = rect.top + sy - ph - GAP; + if (top < sy + GAP) top = rect.bottom + sy + GAP; + + picker.style.left = left + 'px'; + picker.style.top = top + 'px'; + } + + function swatchBtn(color) { + return ''; + } + document.addEventListener('DOMContentLoaded', init); }()); diff --git a/static/js/settings.js b/static/js/settings.js index 18d5c22..7e31dc1 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -88,7 +88,14 @@ else if (action === 'text-larger') shiftSize(+1); else if (action === 'focus-mode') toggleDataAttr('focus-mode', 'data-focus-mode'); else if (action === 'reduce-motion') toggleDataAttr('reduce-motion', 'data-reduce-motion'); - else if (action === 'print') { setOpen(false); window.print(); } + else if (action === 'print') { setOpen(false); window.print(); } + else if (action === 'clear-annotations') { clearAnnotations(); } + } + + function clearAnnotations() { + if (!confirm('Remove all highlights and annotations across every page?')) return; + if (window.Annotations) window.Annotations.clearAll(); + setOpen(false); } /* Theme ----------------------------------------------------------- */ diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 5c3fd94..97ceb6e 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -41,6 +41,7 @@
+