# levineuwirth.org — Design Specification v8

**Author:** Levi Neuwirth
**Date:** March 2026 (v8: 16 March 2026)
**Status:** LIVING DOCUMENT — Updated as implementation progresses.

---

## I. Vision & Philosophy

This website is an **intellectual home** — the permanent residence of a mind that moves freely between computer science, music composition, poetry, fiction, and whatever else catches fire.

### Commitments
1. **Long content over disposable content.** Essays are living documents.
2. **Semantic zoom.** Title → abstract → headers → body → sidenotes → citations → sources.
3. **Earned ornament.** Every decorative element serves a purpose.
4. **The site is the proof.** Entirely FOSS. No tracking. No analytics. No fingerprinting.
5. **Reader > Author.**
6. **Configuration is code.** The build system is a Haskell program.
7. **No homepage epigraph.**
8. **Extensible metadata.** Future-proofed for semantic embeddings via external JSON injection.

---

## II. All Resolved Decisions

### Typography

| Role | Font | License | Notes |
|------|------|---------|-------|
| **Body** | **Spectral** | SIL OFL | Screen-first serif. True smallcaps (`smcp`), four figure styles, ligatures, seven weights + italics. Self-hosted from source — Google Fonts strips OT features. |
| **UI / Headers** | **Fira Sans** | SIL OFL | Humanist sans-serif. Complements Spectral. |
| **Code** | **JetBrains Mono** | SIL OFL | Ligatures, excellent legibility. |

Font pairing has been tested across screens and confirmed.

**Self-hosting workflow:**
```bash
pyftsubset Spectral-Regular.ttf \
  --output-file=spectral-regular.woff2 \
  --flavor=woff2 \
  --layout-features='liga,dlig,smcp,c2sc,onum,lnum,pnum,tnum,frac,ordn,sups,subs,ss01,ss02,ss03,ss04,ss05,kern' \
  --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD' \
  --no-hinting --desubroutinize
```

### LaTeX Math

Client-side KaTeX (not pure build-time SSR — see Implementation Notes):
- Pandoc outputs math spans with `class="math inline"` / `class="math display"`
- KaTeX renders client-side from a deferred script
- KaTeX CSS/fonts loaded conditionally only on pages with math (`$if(math)$` in head template)

### Navigation

```
Home | Me | Current | New | Links | Search    [⚙]
───────────────────────────────────────────────
▼ Portals
AI | Fiction | Miscellany | Music | Nonfiction | Poetry | Research | Tech
```

- **Primary row (always visible):** Home, Me, Current (now-page), New (changelog), Links, Search; settings gear (⚙) on the right
- **Settings panel** (⚙ button): Theme (Light/Dark), Text size (A−/A+), Focus Mode, Reduce Motion, Print — managed by `settings.js`; state persisted via `localStorage`
- **Expandable portal row:** AI, Fiction, Miscellany, Music, Nonfiction, Poetry, Research, Tech
- Portal row collapsed by default; expansion state persisted via `localStorage`
- Fira Sans smallcaps for primary row

### Layout

- **Left margin:** Interactive sticky TOC (`IntersectionObserver`). Collapses on narrow screens.
- **Center column:** Body text in Spectral. 650–700px max-width.
- **Right margin:** Sidenotes only (right column).

### Color

Pure monochrome. No accent color. Light mode default (`#faf8f4` background, `#1a1a1a` text). Dark mode via `[data-theme="dark"]` + `prefers-color-scheme`.

### Content Systems

- **Tag system:** Hierarchical, slash-separated (`research/mathematics`). Hakyll `buildTags` + custom hierarchy. Tag pages at `/<tag>/` with no `/tags/` namespace prefix.
- **Pagination:** Blog index 20/page, tag pages 20/page. Essay index all on one page.
- **RSS:** Atom feed at `/feed.xml` (all content types, sorted by `date`) and `/music/feed.xml` (compositions only).
- **Citations:** Numbered superscript markers `[1]` linked to a bibliography section. Hover preview via `citations.js`. Further Reading section separate from cited works. `data/bibliography.bib` + Chicago Author-Date CSL.
- **Collapsible sections:** h2/h3 headings toggle their content via `collapse.js`. Smooth `max-height` transition. State persisted in `localStorage`.

### Gwern Codebase: Selective Adoption

| Component | Action | Actual outcome |
|-----------|--------|----------------|
| `sidenotes.js` | Adopt directly (Said Achmiz, MIT) | **Written from scratch** — purpose-built for our HTML structure |
| `popups.js` | Fork and simplify (Said Achmiz, MIT) | Exists in `static/js/popups.js`; Phase 3 |
| CSS typographic foundations | Extract and refactor | Done |
| Pandoc AST filters | Write from scratch | Done |
| Hakyll architecture | Rewrite, informed by gwern | Done |
| Everything else | Ignore | — |

### Metadata

Extensible YAML frontmatter. Hakyll strips frontmatter before passing to Pandoc, so all frontmatter access goes through Hakyll's metadata API (`lookupStringList`, `getMetadataField`, etc.), not through Pandoc `Meta`.

**Frontmatter keys in use:**
```yaml
title:              # page title
date:               # ISO date (YYYY-MM-DD) — used for sorting, feed, reading-time
abstract:           # short description (1–3 sentences)
tags:               # hierarchical tag list
authors:            # list of author names (defaults to Levi Neuwirth)
further-reading:    # list of BibTeX keys for the Further Reading section
bibliography:       # path to .bib file (optional; defaults to data/bibliography.bib)
csl:                # path to .csl file (optional; defaults to data/chicago-notes.csl)

# Epistemic profile (all optional; section shown only if `status` is present)
status:             # Draft | Working model | Durable | Refined | Superseded | Deprecated
confidence:         # 0–100 integer (%)
importance:         # 1–5 integer (rendered as filled/empty dots)
evidence:           # 1–5 integer (rendered as filled/empty dots)
scope:              # personal | local | average | broad | civilizational
novelty:            # conventional | moderate | idiosyncratic | innovative
practicality:       # abstract | low | moderate | high | exceptional
stability:          # volatile | revising | fairly stable | stable | established
                    # (auto-computed from git history; use IGNORE.txt to pin)
last-reviewed:      # ISO date — overrides git-derived date when in IGNORE.txt
confidence-history: # list of integers — trend derived from last two entries (↑↓→)

# Version history (optional; falls back to git log, then to date-created/date-modified)
history:
  - date: "2026-03-01"    # ISO date string (quote to prevent YAML date parsing)
    note: Initial draft   # human-readable annotation
  - date: "2026-03-14"
    note: Expanded typography section; added citations
```

Auto-computed at build time: `word-count`, `reading-time`.
Auto-derived at build time: `stability` (from `git log --follow`), `last-reviewed` (most recent commit date), `confidence-trend` (from `confidence-history`).

**`IGNORE.txt`:** A file in the project root listing content paths (one per line) whose `stability` and `last-reviewed` should not be recomputed. Cleared automatically after every `make build`. Useful for pinning manually-set stability labels on pages whose git history is misleading.

**Top metadata block:**
1. **Tags** — hierarchical tag list with links to tag index pages
2. **Description** — the `abstract` field, rendered in italic
3. **Authors** — `authors` list
4. **Page info** — jump links to bottom metadata sections (Epistemic/Bibliography/Backlinks shown conditionally)

**Bottom metadata footer:**
- **Version history** — three-tier priority: (1) frontmatter `history` list with authored notes → (2) git log dates (date-only) → (3) `date-created` / `date-modified` fallback. `make build` auto-commits `content/` before building, keeping git history current.
- **Epistemic** (if `status` set) — compact: status chip · confidence % · importance dots · evidence dots; expanded `<details>`: stability · scope · novelty · practicality · last reviewed · confidence trend
- **Bibliography** — formatted citations + Further Reading
- **Backlinks** — auto-generated; each entry shows source title (link) + collapsible context paragraph

### Licensing

- **Content:** CC BY-SA-NC 4.0
- **Code:** MIT

---

## III. Deployment & Infrastructure

### Deployment Pipeline

```
[Local machine]                          [Arch Linux VPS / DreamHost]

content/*.md
    ↓
cabal run site -- build                  nginx serving
    ↓                                    /var/www/levineuwirth.org/
pagefind --site _site
    ↓
rsync -avz --delete \
  _site/ \
  vps:/var/www/levineuwirth.org/  ──→   Live site
```

```makefile
build:
	cabal run site -- build
	pagefind --site _site
	> IGNORE.txt          # clear stability pins after each build

deploy: build
	rsync -avz --delete _site/ vps:/var/www/levineuwirth.org/

watch:
	cabal run site -- watch

clean:
	cabal run site -- clean
```

### Hosting Timeline

1. **Immediate:** Deploy to DreamHost (rsync static files)
2. **Phase 5:** Provision Arch VPS (Hetzner), configure nginx + certbot, migrate DNS

### VPS: nginx config (Arch Linux)

```nginx
server {
    listen 443 ssl http2;
    server_name levineuwirth.org www.levineuwirth.org;
    root /var/www/levineuwirth.org;

    # TLS (managed by certbot)
    ssl_certificate /etc/letsencrypt/live/levineuwirth.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/levineuwirth.org/privkey.pem;

    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self';" always;

    gzip on;
    gzip_types text/html text/css application/javascript application/json image/svg+xml;

    location ~* \.(woff2|css|js|svg|png|jpg|webp)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    location ~* \.html$ {
        expires 1h;
        add_header Cache-Control "public, must-revalidate";
    }

    try_files $uri $uri.html $uri/ =404;
    error_page 404 /404.html;
}
server {
    listen 80;
    server_name levineuwirth.org www.levineuwirth.org;
    return 301 https://$host$request_uri;
}
```

---

## IV. Repository Structure

```
levineuwirth.org/
├── content/
│   ├── essays/
│   │   └── test-essay.md           # Feature test document
│   ├── blog/
│   ├── music/
│   │   └── {slug}/
│   │       ├── index.md            # Composition frontmatter + program notes
│   │       ├── scores/             # LilyPond SVG pages + PDF
│   │       └── audio/              # Per-movement MP3s
│   └── *.md                        # Standalone pages (me, colophon, etc.)
├── static/
│   ├── css/
│   │   ├── base.css                # CSS variables, palette, dark mode
│   │   ├── typography.css          # Spectral OT features, dropcaps, smallcaps, link icons
│   │   ├── layout.css              # 3-column layout, responsive breakpoints
│   │   ├── sidenotes.css           # Sidenote positioning
│   │   ├── popups.css              # Link preview popup styles
│   │   ├── syntax.css              # Monochrome code highlighting (JetBrains Mono)
│   │   ├── components.css          # Nav (incl. settings panel), TOC, metadata, citations, collapsibles
│   │   ├── gallery.css             # Exhibit system + annotation callouts
│   │   ├── selection-popup.css     # Text-selection toolbar
│   │   ├── annotations.css         # User highlight marks + annotation tooltip
│   │   ├── images.css              # Figure layout, captions, lightbox overlay
│   │   ├── score-reader.css        # Full-page score reader layout
│   │   ├── catalog.css             # Music catalog page (`/music/`)
│   │   └── print.css               # Print stylesheet (media="print")
│   ├── js/
│   │   ├── theme.js                # Dark/light toggle (sync, not deferred)
│   │   ├── sidenotes.js            # Written from scratch — collision avoidance, hover/focus
│   │   ├── toc.js                  # Sticky TOC + scroll tracking + animated collapse
│   │   ├── nav.js                  # Portal row expand/collapse + localStorage
│   │   ├── collapse.js             # Section collapsing with localStorage persistence
│   │   ├── citations.js            # Citation hover previews
│   │   ├── gallery.js              # Exhibit overlay + annotation toggle
│   │   ├── popups.js               # Link preview popups (internal, Wikipedia, citations)
│   │   ├── settings.js             # Settings panel (theme, text size, focus mode, reduce motion, print)
│   │   ├── selection-popup.js      # Context-aware text-selection toolbar
│   │   ├── annotations.js          # localStorage highlight/annotation engine (UI deferred)
│   │   ├── score-reader.js         # Score reader: page-turn, movement jumps, deep linking
│   │   ├── search.js               # Pagefind UI init + ?q= pre-fill
│   │   └── prism.min.js            # Syntax highlighting
│   ├── fonts/                      # Self-hosted WOFF2 (subsetted with OT features)
│   └── images/
│       └── link-icons/             # SVG icons for external link classification
│           ├── external.svg
│           ├── wikipedia.svg
│           ├── github.svg
│           ├── arxiv.svg
│           └── doi.svg
├── templates/
│   ├── default.html                # Outer shell: nav, head, footer JS
│   ├── essay.html                  # 3-column layout with TOC
│   ├── composition.html            # Music landing page (metadata block, movements, body, recording player)
│   ├── music-catalog.html          # Music catalog index (`/music/`)
│   ├── score-reader.html           # Minimal score reader body (top bar + SVG stage)
│   ├── score-reader-default.html   # Minimal HTML shell for score reader (no nav/footer)
│   ├── blog-post.html
│   ├── page.html                   # Simple standalone pages
│   ├── essay-index.html
│   ├── blog-index.html
│   ├── tag-index.html
│   └── partials/
│       ├── head.html               # CSS, conditional JS (citations, collapse)
│       ├── nav.html                # Two-row nav with portals
│       ├── footer.html
│       ├── metadata.html           # Essay metadata block (top)
│       └── page-footer.html        # Essay footer (bibliography, backlinks)
├── build/
│   ├── Main.hs                     # Entry point
│   ├── Site.hs                     # Hakyll rules (all routes + Atom feed)
│   ├── Compilers.hs                # Pandoc compiler wrappers
│   ├── Contexts.hs                 # Template contexts (word-count, reading-time, bibliography)
│   ├── Citations.hs                # citeproc pipeline: Cite→superscript + bibliography HTML
│   ├── Filters.hs                  # Re-exports all filter modules
│   ├── Filters/
│   │   ├── Typography.hs           # Smart quotes, dashes
│   │   ├── Sidenotes.hs            # Footnote → sidenote conversion
│   │   ├── Dropcaps.hs             # Decorative first-letter drop caps
│   │   ├── Smallcaps.hs            # Smallcaps via smcp OT feature
│   │   ├── Wikilinks.hs            # [[wikilink]] syntax
│   │   ├── Links.hs                # External link classification + data-link-icon attributes
│   │   ├── Math.hs                 # Simple LaTeX → Unicode conversion
│   │   ├── Code.hs                 # Prepend language- prefix for Prism.js
│   │   ├── Images.hs               # Lazy loading, lightbox data-attributes
│   │   └── Score.hs                # Score fragment SVG inlining + currentColor replacement
│   ├── Authors.hs                  # Author-as-tag system (slugify, authorLinksField, author pages)
│   ├── Backlinks.hs                # Two-pass build-time backlinks with context paragraph extraction
│   ├── Catalog.hs                  # Music catalog: featured works + grouped-by-category HTML rendering
│   ├── Stability.hs                # Git-based stability auto-calculation + last-reviewed derivation
│   ├── Metadata.hs                 # Stub (Phase 2+)
│   ├── Tags.hs                     # Hierarchical tag system
│   ├── Pagination.hs               # 20/page for blog + tag indexes
│   └── Utils.hs                    # Shared helpers (wordCount, readingTime)
├── data/
│   ├── bibliography.bib            # BibTeX references
│   ├── chicago-notes.csl           # CSL style (in-text, Chicago Author-Date)
│   └── (future: embeddings.json, similar-links.json)
├── tools/
│   └── subset-fonts.sh
├── levineuwirth.cabal
├── cabal.project
├── cabal.project.freeze
├── Makefile
└── CLAUDE.md
```

---

## V. Implementation Phases

### Phase 1: Foundation ✓
- [x] Init Hakyll project, modular Haskell build system
- [x] Font subsetting + self-hosting (Spectral, Fira Sans, JetBrains Mono)
- [x] CSS: base (palette, variables, dark mode), typography (Spectral features), layout (3-column), sidenotes
- [x] `sidenotes.js` — written from scratch (not adopted; see Implementation Notes)
- [x] Two-row navigation with expandable portals
- [x] Templates: default, essay, blog-post, index
- [x] Dark/light toggle with `localStorage` + `prefers-color-scheme`
- [x] Basic Pandoc pipeline (Markdown → HTML, smart typography)
- [x] Deploy to DreamHost via rsync — deployed to Hetzner VPS instead

### Phase 2: Content Features ✓
- [x] Pandoc filters: sidenotes, dropcaps, smallcaps, wikilinks, typography, link classification, code, math
- [x] Interactive sticky TOC — IntersectionObserver, animated expand/collapse, page-title display, auto-collapse on scroll
- [x] Citation system — numbered superscript markers, hover preview, bibliography + Further Reading sections
- [x] Monochrome syntax highlighting (Prism.js + `Filters.Code`)
- [x] Collapsible h2/h3 sections (`collapse.js`) — `max-height` transition, localStorage persistence
- [x] Hierarchical tag system + tag index pages
- [x] Pagination (blog index and tag pages, 20/page)
- [x] Metadata: YAML frontmatter + auto-computed word count / reading time
- [x] Single Atom feed (`/feed.xml`, all content, sorted by date)
- [x] External link icons (SVG mask-image, domain-classified via `Filters.Links`)
- [x] Gallery / Exhibit system (`gallery.js`, `gallery.css`) — added (not in original spec)

### Phase 3: Rich Interactions
- [x] Link preview popups (`popups.js`) — internal page previews (title, abstract, authors, tags, reading time), Wikipedia excerpts, citation previews; relative-URL fix for index pages
- [x] Pagefind search (`/search.html`) — `search.js` pre-fills from `?q=` param so selection popup "Here" button lands ready
- [x] Author system — authors treated as tags; `build/Authors.hs`; author pages at `/authors/{slug}/`; `authorLinksField` in all contexts; defaults to Levi Neuwirth
- [x] Settings panel — `settings.js` + `settings.css` section in `components.css`; theme, text size (3 steps), focus mode, reduce motion, print; all state in `localStorage`; `theme.js` restores all settings before first paint
- [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

### Phase 4: Creative Content & Polish
- [x] Image handling (lazy load, lightbox, figures)
- [x] Homepage (replaces standalone index; gateway + curated recent content)
- [x] Poetry typesetting — codex reading mode (`reading.html`, `reading.css`, `reading.js`); `poetryCompiler` with `Ext_hard_line_breaks`; narrower measure, stanza spacing, drop-cap suppressed
- [x] Fiction reading mode — same codex layout; `fictionCompiler`; chapter drop caps + smallcaps lead-in via `h2 + p::first-letter`; reading mode infrastructure shared with poetry
- [x] Music section — score fragment system (A): inline SVG excerpts (motifs, passages) integrated into the gallery/exhibit system; named, TOC-listed, focusable in the shared overlay alongside equations; authored via `{.score-fragment score-name="..." score-caption="..."}` fenced-div; SVG inlined at build time by `Filters.Score`; black fills/strokes replaced with `currentColor` for dark-mode; see Implementation Notes
- [x] Music section — composition landing pages + full score reader (C): two-URL architecture per composition; `/music/{slug}/` (rich prose landing page with movement list, audio players, inline score fragments) and `/music/{slug}/score/` (minimal dedicated reader); Hakyll `version "score-reader"` mechanism; `compositionCtx` with `slug`, `score-url`, `has-score`, `score-page-count`, `score-pages` list, `has-movements`, `movements` list (Aeson-parsed nested YAML); `score-reader-default.html` minimal shell; `score-reader.js` (page navigation, movement jumps, `?p=` deep linking, preloading, keyboard); `score-reader.css`; dark mode via `filter: invert(1)`; see Implementation Notes
- [x] Accessibility audit — skip link, TOC collapsed-link tabbing (`visibility: hidden`), section-toggle focus visibility, lightbox/gallery/settings focus restoration, popup `aria-hidden`, metadata nav wrapping, footer `onclick` removal; settings panel focus-steal bug fixed (focus only returns to toggle when it was inside the panel, preventing interference with text-selection popup)
- [ ] Visualization pipeline — matplotlib / Altair figures generated at build time; each visualization lives in its own directory (e.g. `content/viz/my-chart/`) alongside a `generate.py` and a versioned dataset; Hakyll rule invokes `python generate.py` to produce SVG/HTML output and copies it into `_site/`; datasets can be updated independently and graphs regenerate on next build
- [ ] Content migration — migrate existing essays, poems, fiction, and music landing pages from prior formats into `content/`

### Phase 5: Infrastructure & Advanced
- [x] **Arch Linux VPS + nginx + certbot + DNS migration** — Hetzner VPS provisioned, Arch Linux installed, nginx configured (config in §III), TLS cert via certbot, DNS migrated from DreamHost. `make deploy` pushes to GitHub and rsyncs to VPS.
- [ ] **Semantic embedding pipeline** — Superseded by Phase 6 "Embedding-powered similar links" (local model, no API cost).
- [x] **Backlinks with context** — Two-pass build-time system (`build/Backlinks.hs`). Pass 1: `version "links"` compiles each page lightly (wikilinks preprocessed, links + context extracted, serialised as JSON). Pass 2: `create ["data/backlinks.json"]` inverts the map. `backlinksField` in `essayCtx` / `postCtx` loads the JSON and renders `<details>`-collapsible per-entry lists. `popups.js` excludes `.backlink-source` links from the preview popup. Context paragraph uses `runPure . writeHtml5String` on the surrounding `Para` block. See Implementation Notes.
- [ ] **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.

### 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.
- [ ] **Visualization pipeline** — Each visualization lives in `content/viz/{slug}/` alongside `generate.py` and a versioned dataset CSV/JSON. Hakyll rule: `unsafeCompiler (callProcess "python" ["generate.py"])` writes SVG/HTML output into the item body. Output is embedded in the page or served as a static asset. Datasets can be updated independently; graphs regenerate on next `make build`. Matplotlib for static figures; Altair for interactive (Vega-Lite JSON embedded, rendered client-side by Vega-Lite JS — loaded conditionally).
- [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.
- [x] **Full-piece audio on composition pages** — `recording` frontmatter key (path relative to the composition directory). Rendered as a full-width `<audio>` player in `composition.html`, above the per-movement list. Styled via `.comp-recording` / `.comp-recording-audio` in `components.css`. Per-movement `<audio>` players and `.comp-btn` / `.comp-movement-*` styles also added in the same pass.
- [x] **RSS/feed improvements** — `/feed.xml` now includes compositions (`content/music/*/index.md`) alongside essays, posts, fiction, poetry. New `/music/feed.xml` (compositions only, `musicFeedConfig`). Compositions already had `"content"` snapshots saved by the landing-page rule; no compiler changes needed.
- [ ] **Pagefind improvements** — Currently a basic full-text search. Consider: sub-result excerpts, portal-scoped search filters, weighting by `importance` frontmatter field.
- [ ] **Audio essays / podcast feed** — Record readings of select essays. Embed a native `<audio>` player at the top of the essay page, activated by an `audio` frontmatter key (path to MP3, relative to the content dir). Generate a separate `/podcast.xml` Atom feed with `<enclosure>` elements pointing to the MP3s so readers can subscribe in any podcast app. Stretch goal: a paragraph-sync mode where the player emits `timeupdate` events that highlight the paragraph currently being read — requires a `data/audio/{slug}-timestamps.json` file mapping paragraph indices to timestamps, authored manually or via forced-alignment tooling (e.g. `whisper` with word timestamps).
- [x] **Build telemetry page** — `/build/` page generated at build time. `build/Stats.hs` loads all content items by type, reads `"word-count"` snapshots, aggregates counts/words/reading-time per type, computes word-length distribution (5 buckets), and reads top-15 tags from the `Tags` object. Makefile writes `date +%s` to `data/build-start.txt` before Hakyll runs; after pagefind, computes elapsed and writes `data/last-build-seconds.txt` (read on next build). CSS in `static/css/build.css` (flex bar chart, tabular-nums table, grid dl); loaded conditionally via `$if(build)$` in head.html.
- [x] **Epistemic profile** — Replaces the old `certainty` / `importance` fields with a richer multi-axis system. **Compact** (always visible in footer): status chip · confidence % · importance dots · evidence dots. **Expanded** (`<details>`): stability (auto) · scope · novelty · practicality · last reviewed · confidence trend. Auto-calculation in `build/Stability.hs` via `git log --follow`; `IGNORE.txt` pins overrides. See Metadata section and Implementation Notes for full schema and vocabulary.
- [ ] **Writing statistics dashboard** — A `/stats` page computed entirely at build time from the corpus. Contents: total word count across all content types, essay/post/poem count, words written per month rendered as a GitHub-style contribution heatmap (SVG generated by Haskell or a Python script), average and median essay length, longest essay, most-cited essay (by backlink count), tag distribution as a treemap, reading-time histogram, site growth over time (cumulative word count by date). All data collected during the Hakyll build from compiled items and their snapshots; serialized to `data/stats.json` and rendered into a dedicated `stats.html` template.
- [x] **Memento mori** — Implemented at `/memento-mori/` as a full standalone page. 90×52 grid of weeks anchored to birthday anniversaries (nested year/week loop via `setFullYear`; week 52 stretched to eve of next birthday to absorb 365th/366th days). Week popup shows dynamic day-count and locale-derived day names. Score fragment (bassoon, `content/memento-mori/scores/bsn.svg`) inlined via `Filters.Score`. Linked from footer (MM).
- [ ] **Embedding-powered similar links** — Precompute dense vector embeddings for every page using a local model (e.g. `nomic-embed-text` or `gte-large` via `ollama` or `llama.cpp`) on personal hardware — no API dependency, no per-call cost. At build time, a Python script reads `_site/` HTML, embeds each page, computes top-N cosine neighbors, and writes `data/similar-links.json` (slug → [{slug, title, score}]). Hakyll injects this into each page's context (via `Metadata.hs` reading the JSON); template renders a "Related" section in the page footer. Analogous to gwern's `GenerateSimilar.hs` but model-agnostic and self-hosted. Note: supersedes the Phase 5 "Semantic embedding pipeline" stub — that stub should be replaced by this when implemented.
- [x] **Bidirectional backlinks with context** — See Phase 5 above; implemented with full context-paragraph extraction. Merged with the Phase 5 stub.
- [ ] **Signed pages / content integrity** — GPG-sign each HTML output file at build time using a detached ASCII-armored signature (`.sig` file per page). The signing step runs as a final Makefile target after Hakyll and Pagefind complete: `find _site -name '*.html' -exec gpg --batch --yes --detach-sign --armor {} \;`. Signatures are served alongside their pages (e.g. `/essays/my-essay.html.sig`). The page footer displays a verification block near the license: the signing key fingerprint, a link to `/gpg/` where the public key is published, and a link to the `.sig` file for that page — so readers can verify without hunting for the key. The public key is also available at the standard WKD location and published to keyservers. **Operational requirement:** a dedicated signing subkey (no passphrase) on the build machine; the master certifying key stays offline and passphrase-protected. A `tools/setup-signing.sh` script will walk through creating the signing subkey, exporting it, and configuring the build — so the setup is repeatable when moving between machines or provisioning the VPS. Philosophically consistent with the FOSS/privacy ethos and the "configuration is code" principle; extreme, but the site is already committed to doing things properly.
- [ ] **Full-text semantic search** — A secondary search mode alongside Pagefind's keyword index. Precompute embeddings for every paragraph (same pipeline as similar links). Store as a compact binary or JSON index. At query time, either: (a) compute the query embedding client-side using a small WASM model (e.g. `transformers.js` with a quantized MiniLM) and run cosine similarity against the stored paragraph vectors, or (b) use a precomputed query-expansion table (top-K words → relevant slugs, offline). Surfaced as a "Semantic search" toggle on `/search.html`. Returns paragraphs rather than pages as the result unit, with the source page title and a link to the specific section. This finds conceptually related content even when exact keywords differ — searching "the relationship between music and mathematics" surfaces relevant essays regardless of vocabulary.

---

## VI. Implementation Notes

### sidenotes.js — Written from scratch
The spec called for adopting Said Achmiz's `sidenotes.js` directly. Instead a purpose-built version was written for the `<span class="sidenote">` structure produced by `Filters.Sidenotes`. Features: JS collision avoidance (`positionSidenotes`), bidirectional hover highlight, click-to-focus (sticky highlight on wide viewport, anchor scroll fallback on narrow), document-click dismissal. `window.resize` is used as the reposition signal; `collapse.js` dispatches it after each section transition.

### Gallery / Exhibit system — Added (not in original spec)
- **Exhibits** (`.exhibit--equation`, `.exhibit--proof`): always-visible inline blocks with overlay zoom on click.
- **Annotations** (`.annotation--static`, `.annotation--collapsible`): editorial callout boxes.
- **TOC integration**: exhibits are listed under their parent heading.
- Implementation: `gallery.js`, `gallery.css`; Pandoc fenced-div syntax (`:::`) to avoid the 4-space code block trap.

### LaTeX Math — Client-side KaTeX
The spec described pure build-time SSR. In practice: Pandoc outputs `class="math"` spans, KaTeX renders client-side from a deferred script. Fully static (no server per request). Revisit if build-time SSR becomes important.

### Citation pipeline — key subtleties
1. **`Cite` nodes, not `Span` nodes.** `processCitations` with `class="in-text"` CSL does *not* convert `Cite` nodes to `Span class="citation"` nodes in the Pandoc AST — it only populates their inline content and creates the refs div. The HTML writer wraps them in `<span class="citation">` at write time. Our `Citations.hs` must match `Cite` nodes directly.
2. **Hakyll strips YAML frontmatter.** Hakyll reads frontmatter separately; the body passed to `readPandocWith` has no YAML block, so Pandoc `Meta` is empty. `further-reading` keys are read from Hakyll's metadata API (`lookupStringList`) in `Compilers.hs` and passed explicitly to `Citations.applyCitations`.
3. **`nocite` format.** Each further-reading key must be a *separate* `Cite` node with `AuthorInText` mode and non-empty fallback content — matching what pandoc produces from `"@key1 @key2"` in YAML. A single `Cite` node with multiple citations is not recognized by citeproc's nocite processing.
4. **`collectCiteOrder` queries blocks only**, not the full `Pandoc` (which includes metadata). Querying metadata would pick up the injected `nocite` `Cite` nodes and incorrectly classify further-reading entries as inline citations.

### External link icons
Implemented via `data-link-icon` and `data-link-icon-type="svg"` attributes set by `Filters.Links`. CSS uses `mask-image: url(...)` with `background-color: currentColor` so icons inherit the text color and work in dark mode. Icons in `static/images/link-icons/` as SVG files.

### Tags — Hierarchical, no namespace
Tags are slash-separated (`research/mathematics`). A tag is auto-expanded into all ancestor prefixes so that `/research/` aggregates all `research/*` content. Tag pages live directly at `/<tag>/` with no `/tags/` namespace.

### Collapsible sections
`collapse.js` wraps each h2/h3's following siblings in a `.section-body` div and injects a `.section-toggle` button into the heading. State is persisted per heading in `localStorage` under `section-collapsed:<id>`. After each `transitionend`, dispatches `window.resize` to retrigger sidenote positioning. Headings themselves are never hidden, preserving `IntersectionObserver` targets for `toc.js`.

### Atom feed
`/feed.xml` covers all essays and blog posts (up to 30 most recent). A `"content"` snapshot is saved in `Site.hs` *before* template application, so the feed body is just the compiled article HTML (not the full page with nav/footer). Dates from the `date` frontmatter key, formatted as RFC 3339.

### Author system
Authors are treated as a second tag dimension. `build/Authors.hs` provides `buildAllAuthors` (a `buildTagsWith` call keyed to `authors` frontmatter) and `authorLinksField` (a `listFieldWith` context that defaults to `["Levi Neuwirth"]` when no `authors` key is present, so all unattributed content contributes to his author page). Author pages live at `/authors/{slug}/`. `slugify` lowercases and hyphenates; pipe-separated values (`"Name | role"`) strip the role portion via `nameOf`.

### Settings panel
`settings.js` manages four independent settings, all persisted in `localStorage`:
- **Theme** (`data-theme` on `<html>`): light / dark, with `syncThemeButtons()` toggling `.is-active`.
- **Text size**: three steps `[20, 23, 26]` px (small / default / large), written as `--text-size` CSS custom property on `<html>`. Default index is 1 (23 px).
- **Focus mode** (`data-focus-mode` on `<html>`): hides TOC, fades header to 7% opacity until hover.
- **Reduce motion** (`data-reduce-motion` on `<html>`): collapses all `animation-duration` / `transition-duration` to `0.01ms`.

`theme.js` (sync, not deferred) restores all four attributes from `localStorage` before first paint to avoid flash.

### Selection popup
`selection-popup.js` / `selection-popup.css`. A toolbar appears 450 ms after any text selection of ≥ 2 characters. Context is detected from the DOM ancestors of the selection range:

| Context | Detection | Buttons |
|---------|-----------|---------|
| **code** (known lang) | `closest('pre, code, .sourceCode')` + `language-*` class | Copy · \<MDN / Hoogle / Docs…\> |
| **code** (unknown) | same, no `language-*` | Copy |
| **math** | `closest('.math, .katex')` + `Range.intersectsNode` fallback | Copy · nLab · OEIS · Wolfram |
| **prose** (multi-word) | fallback | BibTeX · Copy · DuckDuckGo · Here · Wikipedia |
| **prose** (single word) | `!/\s/.test(text)` | BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia |

16 languages are mapped to documentation providers (MDN, Hoogle, docs.python.org, doc.rust-lang.org, etc.) via `DOC_PROVIDERS`. **BibTeX** generates a `@online{...}` BibLaTeX entry (key = `lastname + year + firstWord`; selected text in `note={\enquote{...}}`; year scraped from `#version-history li`). **Here** opens `/search.html?q=` in a new tab. **Define** opens English Wiktionary. Popup positions above the selection, flips below if insufficient space; hides on scroll, outside mousedown, or Escape.

### Reading mode (poetry + fiction)
Shared codex layout for creative content. `templates/reading.html` omits the TOC and emits a `<div id="reading-progress">` progress bar instead. `body.reading-mode` (set via `$if(reading)$` in `default.html`) triggers a slightly warmer background (`#fdf9f1` / `#1c1917`). Poetry pages (`body.reading-mode.poetry`) use a 52ch measure, 1.85 line-height, stanza paragraph spacing, and suppressed dropcap/smallcaps lead-in; `poetryCompiler` enables `Ext_hard_line_breaks` so each source newline becomes `<br>`. Fiction pages (`body.reading-mode.fiction`) use a 62ch measure, centered Fira Sans smallcaps chapter headings, and a dropcap + smallcaps lead-in on each `h2 + p`. Progress bar is driven by `reading.js` (scroll position → `width` on `#reading-progress`). CSS and JS loaded conditionally via `$if(reading)$`. Content goes in `content/poetry/*.md` and `content/fiction/*.md`; tags `poetry` / `fiction` route items to the correct portal and library section.

### Score fragment system (option A)
`Filters/Score.hs` walks the Pandoc AST for `Div` nodes with class `score-fragment`. It reads the referenced SVG from disk (path resolved relative to the source file's directory via `getResourceFilePath` + `takeDirectory`), replaces hardcoded black fill/stroke values with `currentColor` (6-digit before 3-digit to prevent partial matches on `#000` vs `#000000`), and emits a `RawBlock "html"` `<figure>` carrying `class="score-fragment exhibit"`, `data-exhibit-name`, and `data-exhibit-type="score"` for gallery.js TOC integration. SVGs are inlined at build time and never served as separate files. `gallery.js` discovers `.score-fragment` elements via `discoverFocusableScores`, adds them to the shared `focusables[]` array with `type: 'score'`, and the overlay's `renderOverlay` branches on type — score path clones the SVG into the overlay body (no font-size loop); math path keeps the KaTeX re-render. Overlay body receives class `is-score` for tighter horizontal padding (`2rem 1.5rem` vs `3.5rem 4.5rem`). CSS: background rect removed via `svg > rect:first-child { fill: none }`, SVG responsive via `width: 100%; height: auto`, dark mode via `color: var(--text)`.

**Authoring syntax:**
```markdown
::: {.score-fragment score-name="Main Theme, mm. 1–8" score-caption="The opening gesture."}
![](scores/main-theme.svg)
:::
```

### Music — Composition landing pages + full score reader (option C)

**Implemented.** Two URLs per composition from one source directory.

#### Architecture

| URL | Templates | Purpose |
|-----|-----------|---------|
| `/music/{slug}/` | `composition.html` + `default.html` | Rich prose landing page |
| `/music/{slug}/score/` | `score-reader.html` + `score-reader-default.html` | Minimal page-turn reader |

The Hakyll `version "score-reader"` mechanism compiles the same `index.md` twice: once as the landing page (default version) and once as the reader (`customRoute` to `music/{slug}/score/index.html`). Score reader uses `makeItem ""` — the prose body is irrelevant; only frontmatter fields are needed.

#### Source directory layout

```
content/music/symphonic-dances/
├── index.md              ← composition frontmatter + program notes prose
├── scores/
│   ├── page-01.svg       ← one file per score page (LilyPond SVG output)
│   ├── page-02.svg
│   └── symphonic-dances.pdf
└── audio/
    ├── movement-1.mp3
    └── movement-2.mp3
```

SVG, MP3, and PDF files are copied to `_site/` via `copyFileCompiler`. Score reader SVGs are served as separate `<img>` files — inlining a full orchestral score is impractical.

#### Frontmatter schema

```yaml
---
title: "Symphonic Dances with Claude"
date: 2026-03-01
abstract: >
  A five-movement work for orchestra.
tags: [music]
instrumentation: "orchestra (2+picc.2+ca.2+bcl.2 — 4.3.3.1 — timp+3perc — hp — str)"
duration: "ca. 24'"
premiere: "2026-05-01"
commissioned-by: "—"          # optional
pdf: scores/symphonic-dances.pdf   # optional; path relative to composition dir
score-pages:                  # required for reader; landing page works without it
  - scores/page-01.svg
  - scores/page-02.svg
movements:                    # optional; omit entirely if no movement structure
  - name: "I. Allegro con brio"
    page: 1                   # 1-indexed starting page in the reader
    duration: "8'"
    audio: audio/movement-1.mp3   # optional; omit if no recording
  - name: "II. Adagio cantabile"
    page: 8
    duration: "10'"
---
```

#### `compositionCtx` fields

Extends `essayCtx` (all essay fields available — `abstract`, `toc`, `word-count`, etc.). Additional fields:

| Field | Type | Value |
|-------|------|-------|
| `$slug$` | string | `takeFileName . takeDirectory` of source path |
| `$score-url$` | string | `/music/{slug}/score/` |
| `$has-score$` | boolean | present when `score-pages` non-empty |
| `$score-page-count$` | string | `show (length score-pages)` |
| `$score-pages$` | list | each item: `$score-page-url$` (absolute URL) |
| `$has-movements$` | boolean | present when `movements` non-empty |
| `$movements$` | list | each item: `$movement-name$`, `$movement-page$`, `$movement-duration$`, `$movement-audio$`, `$has-audio$` |
| `$composition$` | flag | `"true"` — gates `score-reader.css` in `head.html` |

`movements` is parsed from the nested YAML using `Data.Aeson.KeyMap` (Aeson 2.x API). `score-pages` are resolved to absolute URLs (`/music/{slug}/{path}`) inside the context so the `data-pages` attribute in the score reader template needs no further processing.

#### Score reader

The reader template embeds page URLs as a comma-separated `data-pages` attribute on `#score-reader-stage`. `score-reader.js` splits on commas and filters empties.

`score-reader-default.html` loads only: `base.css`, `components.css` (for settings panel styles), `score-reader.css`, `theme.js` (sync, pre-paint), `settings.js` (theme toggle in the top bar), `score-reader.js`. No nav, no TOC, no sidenotes, no popups, no gallery, no lightbox.

`score-reader.js` behaviors:
- `navigate(page)`: swaps `<img src>`, updates counter, toggles prev/next disabled states, updates active movement button (last movement whose start page ≤ current page), calls `history.replaceState` for `?p=` deep linking, preloads ±1 pages.
- Keyboard: `ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown` for page turns; `Escape` → `history.back()`. Suppressed when settings panel is open.
- Dark mode: `[data-theme="dark"] .score-page { filter: invert(1); }` — clean for pure B&W notation; revisit if LilyPond embeds colored elements.
- Mobile: score scrolls horizontally at ≤ 640px (`min-width: 600px` on `<img>`); arrow buttons hidden; pinch-to-zoom is native.

#### Known limitations / future work

- **Full-piece audio**: a `recording` frontmatter key for a complete performance would add a top-level audio player on the landing page. Not yet implemented.
- **LilyPond margin cropping**: the `viewBox` drives scaling but LilyPond's default page includes margins. May need per-composition `viewBox` overrides or CSS `object-fit` once real scores are tested.

### Backlinks — Two-pass dependency-correct system

`build/Backlinks.hs`. The fundamental challenge: backlinks for page A require knowing what other pages link to A, but those pages haven't been compiled yet when A is compiled. Solved with a two-version architecture:

1. **Pass 1** (`version "links"`): each content file is compiled lightly — wikilinks preprocessed, Markdown parsed, AST walked block-by-block. For every internal link, the URL and the HTML of its surrounding `Para` block are recorded as a `LinkEntry { leUrl, leContext }`. Context rendered via `runPure (writeHtml5String opts (Pandoc nullMeta [Plain inlines]))` with `writerTemplate = Nothing` (fragment only). Result serialised as JSON per page.

2. **Pass 2** (`create ["data/backlinks.json"]`): loads all `version "links"` items, inverts the map (target → [source]), resolves each source's title and abstract from its metadata, emits `data/backlinks.json`.

3. **Context** (`backlinksField`): loads `data/backlinks.json` via `load`, looks up the current page's normalised URL, renders `<ul>` with `<details>`-collapsible context per entry.

**Key implementation details:**
- All `loadAll` / `loadAllSnapshots` / `buildTagsWith` / `buildPaginateWith` calls use `.&&. hasNoVersion` to prevent "links" version items from being picked up alongside default versions.
- `isPageLink` filters out `http://`, `https://`, `#`-anchors, `mailto:`, `tel:`, and static-asset extensions (`.pdf`, `.svg`, `.mp3`, etc.).
- JSON encoding uses `TL.unpack . TLE.decodeUtf8 . Aeson.encode` (not `LBSC.unpack`) to preserve non-ASCII characters in context paragraphs.
- Decoding uses `Aeson.decodeStrict (TE.encodeUtf8 (T.pack s))` symmetrically.
- `popups.js` excludes `.backlink-source` links from the internal-preview popup (same exception pattern as `.meta-authors`).

### Epistemic Profile

Implemented across `build/Stability.hs`, `build/Contexts.hs`, `templates/partials/page-footer.html`, `templates/partials/metadata.html`, and `static/css/components.css`.

**Context fields provided by `epistemicCtx`** (included in `essayCtx`):

| Field | Source | Notes |
|-------|--------|-------|
| `$status$` | frontmatter `status` | via `defaultContext` |
| `$confidence$` | frontmatter `confidence` | via `defaultContext` |
| `$importance-dots$` | frontmatter `importance` (1–5) | `●●●○○` rendered in Haskell |
| `$evidence-dots$` | frontmatter `evidence` (1–5) | same |
| `$confidence-trend$` | frontmatter `confidence-history` list | ↑ / ↓ / → from last two entries |
| `$stability$` | auto-computed via `git log --follow` | always resolves; never fails |
| `$last-reviewed$` | most recent commit date | formatted "%-d %B %Y"; `noResult` if no commits |
| `$scope$`, `$novelty$`, `$practicality$` | frontmatter | via `defaultContext` |

**Stability auto-calculation** (`build/Stability.hs`):
- Runs `git log --follow --format=%ad --date=short -- <filepath>` via `readProcessWithExitCode`.
- Heuristic: ≤ 1 commits or age < 14 days → *volatile*; ≤ 5 commits and age < 90 days → *revising*; ≤ 15 commits or age < 365 days → *fairly stable*; ≤ 30 commits or age < 730 days → *stable*; otherwise → *established*.
- `IGNORE.txt`: paths listed here use frontmatter `stability`/`last-reviewed` verbatim. Cleared by `> IGNORE.txt` in the Makefile's `build` target (one-shot pins).

**Critical implementation note:** Fields that use `unsafeCompiler` must return `Maybe` from the IO block and call `fail` in the `Compiler` monad afterward — not inside the `IO` action. Calling `fail` inside `unsafeCompiler`'s IO block throws an `IOError` that Hakyll's `$if()$` template evaluation does not catch as `NoResult`, causing the entire item compilation to error silently.

### Annotations (infrastructure only)
`annotations.js` stores annotations as JSON in `localStorage` under `site-annotations`, scoped per `location.pathname`. On `DOMContentLoaded`, `applyAll()` re-anchors saved annotations via a `TreeWalker` text-stream search (concatenates all text nodes in `#markdownBody`, finds exact match by index, builds a `Range`, wraps with `<mark>`). Cross-element ranges use `extractContents()` + `insertNode()` fallback. Four highlight colors (amber / sage / steel / rose) defined in `annotations.css` as `rgba` overlays with `box-decoration-break: clone`. Hover tooltip shows note, date, and delete button. Public API: `window.Annotations.add(text, color, note)` / `.remove(id)`. The selection-popup "Annotate" button is currently removed pending a UI revision.

---

## VII. Reference: Inspirations

- **gwern.net** — Primary model (Gwern Branwen + Said Achmiz). Semantic zoom, sidenotes, popups, monochrome, Pandoc+Hakyll.
- **Edward Tufte** — Sidenotes, information design
- **Matthew Butterick's Practical Typography** — Web typography in practice
- **Traditional book design** — The standard to aspire to on screen

---

*This specification is a living document updated as implementation progresses.*
