diff --git a/ *Minibuf-1* b/ *Minibuf-1*
deleted file mode 100644
index 0e58e14..0000000
--- a/ *Minibuf-1*
+++ /dev/null
@@ -1 +0,0 @@
-Search:
diff --git a/Makefile b/Makefile
index 9b95bc2..e894e01 100644
--- a/Makefile
+++ b/Makefile
@@ -63,6 +63,7 @@ deploy: clean build sign
@test -n "$(VPS_USER)" || (echo "deploy: VPS_USER not set in .env" >&2; exit 1)
@test -n "$(VPS_HOST)" || (echo "deploy: VPS_HOST not set in .env" >&2; exit 1)
@test -n "$(VPS_PATH)" || (echo "deploy: VPS_PATH not set in .env" >&2; exit 1)
+ @command -v notify-send >/dev/null 2>&1 && notify-send "make deploy" "Ready to rsync — waiting for SSH auth" || true
rsync -avz --delete _site/ $(VPS_USER)@$(VPS_HOST):$(VPS_PATH)/
git push -u origin main
diff --git a/WRITING.md b/WRITING.md
index 50e9fac..421e73a 100644
--- a/WRITING.md
+++ b/WRITING.md
@@ -10,12 +10,15 @@ frontmatter fields, and every authoring feature available in the Markdown source
| Type | Location | Output URL |
|------|----------|------------|
| Essay | `content/essays/my-essay.md` | `/essays/my-essay.html` |
+| Essay (with co-located assets) | `content/essays/my-essay/index.md` | `/essays/my-essay/index.html` |
| Blog post | `content/blog/my-post.md` | `/blog/my-post.html` |
| Poetry | `content/poetry/my-poem.md` | `/poetry/my-poem.html` |
+| Poetry collection | `content/poetry/collection-name/*.md` | `/poetry/collection-name/*.html` |
| Fiction | `content/fiction/my-story.md` | `/fiction/my-story.html` |
| Composition | `content/music/{slug}/index.md` | `/music/{slug}/` |
| Standalone page | `content/my-page.md` | `/my-page.html` |
| Standalone page (with co-located assets) | `content/my-page/index.md` | `/my-page.html` |
+| Draft essay | `content/drafts/essays/my-draft.md` | `/drafts/essays/my-draft.html` (dev only) |
File names become URL slugs. Use lowercase, hyphen-separated words.
@@ -26,6 +29,27 @@ Score fragment paths are resolved relative to the source file's directory; a fla
---
+## Drafts
+
+In-progress essays go under `content/drafts/essays/`. Both flat files
+(`content/drafts/essays/my-draft.md`) and directory-based essays
+(`content/drafts/essays/my-draft/index.md`) are supported.
+
+Drafts are **included** in `make watch` and `make dev` (which set `SITE_ENV=dev`)
+and **excluded** from every production build (`make build`, `make deploy`).
+They are also invisible to tags, author pages, backlinks, stats, and feeds.
+
+To preview a draft:
+
+```bash
+make watch # Hakyll live-reload with drafts visible
+make dev # clean build + Python HTTP server with drafts visible
+```
+
+When the draft is ready, move it into `content/essays/`.
+
+---
+
## Frontmatter
Every file begins with a YAML block fenced by `---`. Keys are read by Hakyll
@@ -56,6 +80,7 @@ further-reading: # optional; see Citations section
bibliography: data/custom.bib # optional; overrides data/bibliography.bib
csl: data/custom.csl # optional; overrides Chicago Author-Date
no-collapse: true # optional; disables collapsible h2/h3 sections
+repository: https://git.levineuwirth.org/levi/repo # optional; "Repository" link in metadata
js: scripts/my-widget.js # optional; per-page JS file (see Page scripts)
# js: [scripts/a.js, scripts/b.js] # or a list
@@ -93,6 +118,20 @@ Same fields as essays. Poetry uses a narrow-measure codex reading mode
`poetryCompiler` enables `Ext_hard_line_breaks` — each source newline becomes
a `
`, so verse lines render without trailing-space tricks.
+**Poetry collections** — poems can be grouped into subdirectories:
+
+```
+content/poetry/shakespeare-sonnets/
+├── index.md ← collection landing page (uses pageCtx)
+├── sonnet-1.md ← individual poem
+├── sonnet-2.md
+└── …
+```
+
+Collection index pages (`content/poetry/*/index.md`) compile as standalone
+pages. Poems inside collections (`content/poetry/*/*.md`, excluding
+`index.md`) compile with `poetryCompiler` just like flat poems.
+
**External / non-original poems** — use `poet:` instead of `authors:` to credit
an external author without generating a (broken) author index page:
@@ -272,6 +311,26 @@ not derivable from the page title.
---
+## Transclusion
+
+Embed the rendered content of another page (or a section of it) inline using
+`{{slug}}` directives. The directive must be on its own line.
+
+```markdown
+{{my-essay}} → embeds the full body of /my-essay.html
+{{essays/deep-dive}} → embeds /essays/deep-dive.html
+{{my-essay#introduction}} → embeds only the "introduction" section
+```
+
+At build time, `Filters.Transclusion` rewrites these to `
` placeholders. At runtime,
+`transclude.js` fetches the target page and injects the content.
+
+Transclusions inside code blocks or inline prose are not replaced — only
+block-level directives (sole content of a line) are processed.
+
+---
+
## PDF Embeds
Embed a hosted PDF in a full PDF.js viewer (page navigation, zoom, text
@@ -343,26 +402,82 @@ $$
## Links
-Internal links: standard Markdown or wikilinks.
+Internal links: standard Markdown or wikilinks. Internal links get a
+`link-internal` class and an internal icon.
External links are classified automatically at build time:
- Opened in a new tab (`target="_blank" rel="noopener noreferrer"`)
- Decorated with a domain icon via CSS `mask-image`
+Links to sibling subdomains (e.g. `git.levineuwirth.org`) are classified as
+external — only `levineuwirth.org` and `www.levineuwirth.org` are treated as
+the content host.
+
| Domain | Icon |
|--------|------|
| `wikipedia.org` | Wikipedia |
| `arxiv.org` | arXiv |
| `doi.org` | DOI |
+| `worldcat.org` | WorldCat |
+| `orcid.org` | ORCID |
+| `archive.org` | Internet Archive |
| `github.com` | GitHub |
+| `git.levineuwirth.org` | Forgejo |
+| `tensorflow.org` | TensorFlow |
+| `anthropic.com` | Anthropic |
+| `openai.com` | OpenAI |
+| `twitter.com` / `x.com` | Twitter/X |
+| `reddit.com` | Reddit |
+| `youtube.com` / `youtu.be` | YouTube |
+| `tiktok.com` | TikTok |
+| `substack.com` | Substack |
+| `news.ycombinator.com` | Hacker News |
+| `nytimes.com` | New York Times |
+| `nasa.gov` | NASA |
+| `apple.com` | Apple |
| Everything else | Generic external arrow |
### Link preview popups
-Hovering over an internal link shows a popup: title, abstract, authors, tags,
-reading time. Hovering over a Wikipedia link shows an article excerpt.
-Citation markers (`[1]`) show the full reference. All automatic.
+Hovering over a link shows a context-aware popup. All automatic — no
+markup needed.
+
+| Target | Popup content |
+|--------|--------------|
+| Internal page | Title, abstract, authors, tags, word count, reading time |
+| Citation marker (`[1]`) | Full bibliography reference (multi-cite groups supported) |
+| Wikipedia | Lead section extract via MediaWiki API |
+| arXiv | Title, authors, abstract via Atom API |
+| DOI / CrossRef | Title, authors, journal, year, abstract |
+| GitHub | Repo name, description, language, stars |
+| Forgejo (`git.levineuwirth.org`) | Repo name, description, language, stars |
+| Open Library | Book title, description |
+| bioRxiv / medRxiv | Title, authors, abstract |
+| YouTube | Video title, channel name (oEmbed) |
+| Internet Archive | Title, creator, description |
+| PubMed | Title, authors, journal, year |
+| PDF link | First-page thumbnail (from build-time `pdftoppm`) |
+| Epistemic jump link (`#epistemic`) | Clone of the full epistemic profile |
+| Epistemic term label (`data-ep-term`) | Term definition from colophon |
+| PGP signature link | ASCII armor of the `.sig` file |
+
+**Custom annotations** — author-defined previews for any URL. Add entries
+to `data/annotations.json`:
+
+```json
+{
+ "https://example.com/article": {
+ "title": "Article Title",
+ "annotation": "A brief note about why this link is relevant."
+ }
+}
+```
+
+Annotation entries take priority over all other popup providers.
+
+Popups are disabled on touch-primary devices and inside nav/TOC/footer
+elements.
---
@@ -864,24 +979,48 @@ when the Python environment is set up (`uv sync`). No markup needed.
---
+## Auto-generated pages
+
+These pages are built automatically and require no content files or markup:
+
+| Page | URL | Description |
+|------|-----|-------------|
+| Essay index | `/essays/` | All essays, newest first |
+| Blog index | `/blog/` | Paginated blog posts |
+| New | `/new.html` | All content types sorted by date, newest first |
+| Library | `/library.html` | All content grouped by portal (AI, Fiction, Music, etc.) |
+| Build telemetry | `/build/` | Corpus stats, word-length distribution, tag frequencies, link analysis, epistemic coverage, output metrics, repository overview, build timing, and a 52-week writing activity heatmap |
+| Tag indexes | `//` | Paginated pages per tag, auto-generated |
+| Author indexes | `/authors//` | All content attributed to an author |
+| Random manifest | `/random-pages.json` | JSON array of page URLs for the random-page button |
+| Atom feeds | `/feed.xml`, `/music/feed.xml` | All content feed + music-only feed |
+| Search | `/search.html` | Pagefind full-text search + client-side semantic search (`nomic-embed-text-v1.5` ONNX model) |
+
+---
+
## Build
```bash
-make build # auto-commit content/, compile, run pagefind + embeddings, clear IGNORE.txt
-make sign # GPG detach-sign every _site/**/*.html → .html.sig (requires passphrase cached)
-make deploy # build + sign + optional GitHub push + rsync to VPS
-make watch # Hakyll live-reload dev server at http://localhost:8000
-make dev # clean build + python HTTP server at http://localhost:8000
+make build # auto-commit content/, convert images, PDF thumbs, compile,
+ # run pagefind + embeddings, clear IGNORE.txt
+make sign # GPG detach-sign every _site/**/*.html → .html.sig
+make deploy # clean + build + sign + rsync to VPS + git push
+make watch # Hakyll live-reload dev server (SITE_ENV=dev — includes drafts)
+make dev # clean build + Python HTTP server (SITE_ENV=dev — includes drafts)
make clean # wipe _site/ and _cache/
```
+`make watch` and `make dev` set `SITE_ENV=dev`, which includes drafts under
+`content/drafts/essays/` in the build. All other targets exclude drafts.
+
`make watch` hot-reloads changes to Markdown, CSS, JS, and templates.
**After any change to a `.hs` file, always run `make clean && make build`** —
Hakyll's cache is keyed to source file mtimes and will serve stale output after
Haskell-side changes.
-`make deploy` pushes to GitHub if `GITHUB_TOKEN` and `GITHUB_REPO` are set in
-`.env` (see `.env.example`), then rsyncs `_site/` to the VPS.
+`make deploy` requires `VPS_USER`, `VPS_HOST`, and `VPS_PATH` to be set in
+`.env` (see `.env.example`). It runs `clean → build → sign`, sends a desktop
+notification, rsyncs `_site/` to the VPS, and pushes to `origin main`.
**GPG signing:** `make sign` and `make deploy` require the signing subkey
passphrase to be cached. Run once per boot (or per 24h expiry):
@@ -890,6 +1029,11 @@ passphrase to be cached. Run once per boot (or per 24h expiry):
./tools/preset-signing-passphrase.sh
```
+**Image conversion:** `make build` automatically runs `tools/convert-images.sh`
+to generate WebP companions for JPEG/PNG images (requires `cwebp`). It also
+generates first-page PDF thumbnails via `pdftoppm` (requires `poppler`).
+Both are skipped silently when the tools are not installed.
+
**Python environment:** the embedding pipeline requires `uv sync` to be run
once. After that, `make build` invokes `uv run python tools/embed.py`
automatically. If `.venv` is absent, the step is skipped with a warning and
diff --git a/build/Contexts.hs b/build/Contexts.hs
index 151ed41..8c1fa73 100644
--- a/build/Contexts.hs
+++ b/build/Contexts.hs
@@ -211,6 +211,28 @@ abstractField = field "abstract" $ \item -> do
isPara (Para _) = True
isPara _ = False
+-- ---------------------------------------------------------------------------
+-- Summary field
+-- ---------------------------------------------------------------------------
+
+-- | Renders the @summary@ frontmatter key through Pandoc, preserving full
+-- block structure (paragraphs, bold, lists). Unlike 'abstractField', no
+-- paragraph flattening is applied because the summary renders inside its
+-- own styled box rather than inline in the metadata strip.
+summaryField :: Context String
+summaryField = field "summary" $ \item -> do
+ meta <- getMetadata (itemIdentifier item)
+ case lookupString "summary" meta of
+ Nothing -> fail "no summary"
+ Just src -> do
+ let pandocResult = runPure $ do
+ doc <- readMarkdown defaultHakyllReaderOptions (T.pack src)
+ let wOpts = defaultHakyllWriterOptions { writerHTMLMathMethod = MathML }
+ writeHtml5String wOpts doc
+ case pandocResult of
+ Left err -> fail $ "Pandoc error rendering summary: " ++ show err
+ Right html -> return (T.unpack html)
+
siteCtx :: Context String
siteCtx =
constField "site-title" "Levi Neuwirth"
@@ -218,6 +240,7 @@ siteCtx =
<> buildTimeField
<> pageScriptsField
<> abstractField
+ <> summaryField
<> defaultContext
-- ---------------------------------------------------------------------------
diff --git a/build/Filters.hs b/build/Filters.hs
index 9f00073..0f532e1 100644
--- a/build/Filters.hs
+++ b/build/Filters.hs
@@ -19,6 +19,7 @@ import qualified Filters.Transclusion as Transclusion
import qualified Filters.EmbedPdf as EmbedPdf
import qualified Filters.Code as Code
import qualified Filters.Images as Images
+import qualified Filters.Aftermatter as Aftermatter
-- | Apply all AST-level filters in pipeline order.
-- Run on the Pandoc document after reading, before writing.
@@ -33,6 +34,7 @@ applyAll :: FilePath -> Pandoc -> IO Pandoc
applyAll srcDir doc = do
imagesDone <- Images.apply srcDir doc
pure
+ . Aftermatter.apply
. Sidenotes.apply
. Typography.apply
. Links.apply
diff --git a/build/Filters/Aftermatter.hs b/build/Filters/Aftermatter.hs
new file mode 100644
index 0000000..a1535df
--- /dev/null
+++ b/build/Filters/Aftermatter.hs
@@ -0,0 +1,19 @@
+{-# LANGUAGE GHC2021 #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Filters.Aftermatter (apply) where
+
+import Text.Pandoc.Definition (Pandoc (..), Block (..), Format (..))
+
+apply :: Pandoc -> Pandoc
+apply (Pandoc meta blocks) = Pandoc meta (concatMap go blocks)
+ where
+ go (Div attr@(_, classes, _) content)
+ | "aftermatter" `elem` classes
+ = [dividerBlock, Div attr content]
+ go b = [b]
+
+dividerBlock :: Block
+dividerBlock = RawBlock (Format "html")
+ ""
diff --git a/cabal.project.freeze b/cabal.project.freeze
index c4a3e57..3897fe4 100644
--- a/cabal.project.freeze
+++ b/cabal.project.freeze
@@ -100,7 +100,7 @@ constraints: any.Glob ==0.10.2,
http-conduit +aeson,
any.http-date ==0.0.11,
any.http-types ==0.12.4,
- any.http2 ==5.1.0,
+ any.http2 ==5.1.1,
any.indexed-traversable ==0.1.4,
any.indexed-traversable-instances ==0.1.2,
any.integer-conversion ==0.1.1,
diff --git a/favicon.zip b/favicon.zip
deleted file mode 100644
index 5f1eb7d..0000000
Binary files a/favicon.zip and /dev/null differ
diff --git a/levineuwirth.cabal b/levineuwirth.cabal
index 747f0a2..b27b131 100644
--- a/levineuwirth.cabal
+++ b/levineuwirth.cabal
@@ -39,6 +39,7 @@ executable site
Filters.Code
Filters.Images
Filters.Score
+ Filters.Aftermatter
Filters.Viz
Utils
build-depends:
diff --git a/spec.md b/spec.md
deleted file mode 100644
index 7e96da4..0000000
--- a/spec.md
+++ /dev/null
@@ -1,742 +0,0 @@
-# levineuwirth.org — Design Specification v11
-
-**Author:** Levi Neuwirth
-**Date:** March 2026 (v11: 21 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 `//` 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). Rendered via Pandoc to support LaTeX math and Markdown.
-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)
-repository: # external URL pointing to the content's source code or data repository
-
-# 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 via Pandoc (supporting LaTeX math and Markdown formatting), typically 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) — primary row: trust-score chip + status; labeled rows beneath: confidence · importance · evidence · scope · novelty · practicality (each shown only if its frontmatter field is set); always-visible ``: stability · 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:
- @git add content/
- @git diff --cached --quiet || git commit -m "auto: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
- @date +%s > data/build-start.txt
- @./tools/convert-images.sh # WebP conversion (skipped if cwebp absent)
- cabal run site -- build
- pagefind --site _site
- @if [ -d .venv ]; then \
- uv run python tools/embed.py || echo "Warning: embedding failed"; \
- fi
- > IGNORE.txt # clear stability pins after each build
- @BUILD_END=$(date +%s); BUILD_START=$(cat data/build-start.txt); \
- echo $((BUILD_END - BUILD_START)) > data/last-build-seconds.txt
-
-sign:
- @./tools/sign-site.sh # detach-sign every _site/**/*.html; requires passphrase cached via preset-signing-passphrase.sh
-
-deploy: build sign
- rsync -avz --delete _site/ vps:/var/www/levineuwirth.org/
-
-watch:
- cabal run site -- watch
-
-clean:
- cabal run site -- clean
-
-download-model:
- @./tools/download-model.sh # fetch quantized ONNX model to static/models/ (once per machine)
-
-convert-images:
- @./tools/convert-images.sh # manual trigger; also runs in build
-```
-
-### 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;
-
- # cdn.jsdelivr.net required for transformers.js (semantic search library).
- # Model weights served same-origin from /models/ — connect-src stays 'self'.
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; 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
-│ │ ├── viz.css # Visualization figure layout (.viz-figure, .vega-container, .viz-caption)
-│ │ ├── 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
-│ │ ├── viz.js # Vega-Lite render + dark mode re-render via MutationObserver
-│ │ ├── semantic-search.js # Client-side semantic search: transformers.js + Float32Array cosine ranking
-│ │ ├── search.js # Pagefind UI init + ?q= pre-fill + search timing (#search-timing)
-│ │ └── prism.min.js # Syntax highlighting
-│ ├── fonts/ # Self-hosted WOFF2 (subsetted with OT features)
-│ ├── gpg/
-│ │ └── pubkey.asc # Ed25519 signing subkey public key (master: CD90AE96…; subkey: C9A42A6F…)
-│ ├── models/ # Self-hosted ONNX model (gitignored; run: make download-model)
-│ │ └── all-MiniLM-L6-v2/ # ~22 MB quantized — served at /models/ for semantic-search.js
-│ └── 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, WebP wrapper for local raster images
-│ │ ├── Score.hs # Score fragment SVG inlining + currentColor replacement
-│ │ └── Viz.hs # Visualization IO filter: runs Python scripts, inlines SVG / Vega-Lite JSON
-│ ├── 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
-│ ├── viz_theme.py # Matplotlib monochrome helpers (apply_monochrome, save_svg, LINESTYLE_CYCLE)
-│ ├── sign-site.sh # Detach-sign every _site/**/*.html → .html.sig (called by `make sign`)
-│ ├── preset-signing-passphrase.sh # Cache signing subkey passphrase in gpg-agent (run once per boot)
-│ ├── download-model.sh # Fetch quantized ONNX model to static/models/ (run once per machine)
-│ ├── convert-images.sh # Convert JPEG/PNG → WebP companions via cwebp (runs automatically in build)
-│ └── embed.py # Build-time embedding pipeline: similar-links + semantic search index
-├── 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; `#search-timing` shows elapsed ms (mono, faint) via `MutationObserver` on search results subtree
-- [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
-- [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)
-- [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 — Pandoc filter approach (`Filters.Viz`): `.figure` fenced divs run `python3