annotation system

This commit is contained in:
Levi Neuwirth 2026-03-26 13:29:37 -04:00
parent 1210314cc8
commit b38e9359d5
8 changed files with 269 additions and 27 deletions

View File

@ -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 `<picture>` 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.

View File

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

View File

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

View File

@ -215,8 +215,8 @@ 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,
@ -231,7 +231,7 @@ sub { font-variant-position: sub; line-height: 1; }
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%,
@ -240,6 +240,17 @@ sub { font-variant-position: sub; line-height: 1; }
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%
);
}
}
/* ============================================================

View File

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

View File

@ -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 =
'<div class="ann-picker-swatches">'
+ swatchBtn('amber') + swatchBtn('sage') + swatchBtn('steel') + swatchBtn('rose')
+ '</div>'
+ '<input class="ann-picker-note" type="text" placeholder="Note (optional)" maxlength="200">'
+ '<div class="ann-picker-actions">'
+ '<button class="ann-picker-submit">Highlight</button>'
+ '</div>';
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 '<button class="ann-picker-swatch ann-picker-swatch--' + color
+ '" data-color="' + color + '" aria-label="' + color + '"></button>';
}
document.addEventListener('DOMContentLoaded', init);
}());

View File

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

View File

@ -41,6 +41,7 @@
</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>
</div>
</div>
</div>