visual enhancements

This commit is contained in:
Levi Neuwirth 2026-04-15 22:25:38 -04:00
parent ec19367eaf
commit acb3ae7066
16 changed files with 421 additions and 770 deletions

View File

@ -1 +0,0 @@
Search:

View File

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

View File

@ -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 `<br>`, 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 `<div class="transclude"
data-src="..." [data-section="..."]></div>` 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 | `/<tag>/` | Paginated pages per tag, auto-generated |
| Author indexes | `/authors/<slug>/` | 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

View File

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

View File

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

View File

@ -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")
"<div class=\"aftermatter-divider\" aria-hidden=\"true\">\
\<a href=\"/new.html\" class=\"aftermatter-logo\" aria-label=\"New\"></a>\
\</div>"

View File

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

Binary file not shown.

View File

@ -39,6 +39,7 @@ executable site
Filters.Code
Filters.Images
Filters.Score
Filters.Aftermatter
Filters.Viz
Utils
build-depends:

742
spec.md
View File

@ -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. 650700px 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 (13 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: # 0100 integer (%)
importance: # 15 integer (rendered as filled/empty dots)
evidence: # 15 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 `<dl>`: 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 <picture> 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 `<picture>` 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 <script>`, capture SVG stdout, inline with `currentColor` replacement; `.visualization` fenced divs embed Vega-Lite JSON in a `<script type="application/json" class="vega-spec">` tag rendered by `viz.js`; `viz: true` frontmatter gates CDN Vega/Vega-Lite/Vega-Embed + `viz.js`; dark mode re-renders via `MutationObserver`; `tools/viz_theme.py` provides matplotlib monochrome helpers. Infrastructure complete; not yet used in production content.
- [ ] 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.
- [x] **Semantic embedding pipeline** — Implemented. See Phase 6 "Embedding-powered similar links" and "Full-text semantic search".
- [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.
- [ ] **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
- [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.
- [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).
- [x] **Embedding-powered similar links**`tools/embed.py` encodes every `#markdownBody` page with `all-MiniLM-L6-v2` (384 dims, unit-normalised), builds a FAISS `IndexFlatIP`, queries top-5 neighbours per page (cosine ≥ 0.30), writes `data/similar-links.json`. `build/SimilarLinks.hs` provides `similarLinksField` in `essayCtx`/`postCtx` with Hakyll dependency tracking; "Related" section rendered in `page-footer.html`. Staleness check skips re-embedding when JSON is newer than all HTML. Called by `make build` via `uv run`; non-fatal if `.venv` absent. See Implementation Notes.
- [x] **Bidirectional backlinks with context** — See Phase 5 above; implemented with full context-paragraph extraction. Merged with the Phase 5 stub.
- [x] **Signed pages / content integrity**`make sign` (called by `make deploy`) runs `tools/sign-site.sh`: walks `_site/**/*.html`, produces a detached ASCII-armored `.sig` per page. Signing uses a dedicated Ed25519 subkey isolated in `~/.gnupg-signing/` (master `sec#` stub + `ssb` signing subkey). Passphrase cached 24 h in the signing agent via `tools/preset-signing-passphrase.sh` + `gpg-preset-passphrase`; `~/.gnupg-signing/gpg-agent.conf` sets `allow-preset-passphrase`. Footer "sig" link points to `$url$.sig`; hovering shows the ASCII armor via `popups.js` `sigContent` provider. Public key at `static/gpg/pubkey.asc` → served at `/gpg/pubkey.asc`. Fingerprints: master `CD90AE96…B5C9663`; signing subkey `C9A42A6F…2707066` (keygrip `619844703EC398E70B0045D7150F08179CFEEFE3`). See Implementation Notes.
- [x] **Self-hosted semantic search model**`tools/download-model.sh` fetches the quantized ONNX model (`all-MiniLM-L6-v2`, ~22 MB, 5 files) from HuggingFace into `static/models/all-MiniLM-L6-v2/` (gitignored). `semantic-search.js` sets `env.localModelPath = '/models/'` and `env.allowRemoteModels = false` before calling `pipeline()`, so all model weight fetches are same-origin. The CDN import of transformers.js itself still requires `cdn.jsdelivr.net` in `script-src`; `connect-src` stays `'self'`. `make download-model` is a one-time setup step per machine. See Implementation Notes.
- [x] **Responsive images (WebP)**`tools/convert-images.sh` walks `static/` and `content/`, calls `cwebp -q 85` to produce `.webp` companions alongside every JPEG/PNG (skips existing; exits gracefully if `cwebp` absent). `make build` runs it before Hakyll so WebP files are present when `static/**` is copied. `build/Filters/Images.hs` detects local raster images and emits `RawInline "html"` `<picture>` elements with a `<source srcset="…webp" type="image/webp">` and an `<img>` fallback; SVG, external URLs, and `data:` URIs pass through as plain `<img>`. Generated `.webp` files gitignored via `static/**/*.webp` / `content/**/*.webp`. See Implementation Notes.
- [x] **Full-text semantic search**`tools/embed.py` also produces paragraph-level embeddings (same `all-MiniLM-L6-v2` model, same pass): walks `<p>/<li>/<blockquote>` in `#markdownBody`, tracks nearest preceding heading for context, writes `data/semantic-index.bin` (raw Float32, N×384) + `data/semantic-meta.json` ([{url, title, heading, excerpt}]). Client: `static/js/semantic-search.js` dynamically imports `@xenova/transformers@2` from CDN, embeds the query, brute-force cosine-ranks all paragraph vectors (fast at <5k paragraphs in JS), renders top-8 results as title + section heading + excerpt. Surfaced on `/search.html` as a **Keyword / Semantic** tab strip; active tab persists in `localStorage` (keyword default on first visit). Both tabs on same page; `semantic-search.js` loaded within existing `$if(search)$` block. See Implementation Notes.
---
## 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.
*Note:* The `abstract` field is parsed natively through Pandoc via a custom `abstractField` compiler in `Contexts.hs` (using KaTeX settings) so that LaTeX math in the frontmatter renders identically to the body text.
### 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 and custom `bibliography` paths are read from Hakyll's metadata API (`lookupStringList` / `lookupString`) in `Compilers.hs` and passed explicitly to `Citations.applyCitations` to support custom per-essay `.bib` files (defaulting to `data/bibliography.bib`).
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. 18" 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` (15) | `●●●○○` rendered in Haskell |
| `$evidence-dots$` | frontmatter `evidence` (15) | same |
| `$overall-score$` | computed from `confidence` + `evidence` | trust score: `conf/100 · 0.6 + (ev1)/4 · 0.4`, clamped 0100. Importance/scope/novelty/practicality intentionally excluded — see colophon "Living Documents" for the rationale. |
| `$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.
### Visualization pipeline — Pandoc IO filter approach
`build/Filters/Viz.hs` walks the AST for `Div` blocks with class `figure` or `visualization`.
**Static figures (`.figure`):** reads the `script` attribute, runs `python3 <script>` with the source file's directory as cwd, captures stdout as SVG. Replaces hardcoded `#000000`/`black` fills/strokes with `currentColor` (same trick as `Filters.Score`). Wraps in `<figure class="viz-figure">`. Script is expected to import `tools/viz_theme` and call `save_svg()` which writes to stdout.
**Interactive figures (`.visualization`):** runs the script, expects Vega-Lite JSON on stdout. Embeds as `<script type="application/json" class="vega-spec">` inside a `.vega-container` div. `viz.js` finds all `.vega-spec` scripts, stores parsed spec on `container._vegaSpec`, calls `vegaEmbed`. Always applies the site's monochrome Vega config, ignoring the spec's own `config`. MutationObserver on `document.documentElement[data-theme]` triggers `reRenderAll()` on theme change.
**Frontmatter:** `viz: true` gates CDN loading of Vega/Vega-Lite/Vega-Embed and `viz.js` in `head.html` via `$if(viz)$`.
**`tools/viz_theme.py`:** `apply_monochrome()` sets matplotlib rcParams (transparent backgrounds, black lines); `save_svg(fig)` writes SVG to stdout via `io.StringIO`; `LINESTYLE_CYCLE` provides dash-pattern sequences for multi-series charts (no color distinction needed).
**Authoring syntax:**
```markdown
::: {.figure script="figures/plot.py" caption="Caption text"}
:::
::: {.visualization script="figures/chart.py" caption="Caption text"}
:::
```
### GPG signing — dedicated subkey + preset passphrase
**Key architecture:** master certifying key in `~/.gnupg` (passphrase-protected, used for email). Dedicated signing keyring at `~/.gnupg-signing/` holds: `sec#` (master stub, no secret) + `ssb` Ed25519 signing subkey (with secret). Correct isolation: `gpg --export-secret-subkeys "FINGERPRINT!"` exports only the subkey secret.
**Passphrase caching:** GPG 2.4's `passwd` in `--edit-key` requires the master secret to be present — it cannot change a subkey passphrase in a stub+subkey-only keyring. Instead, `gpg-preset-passphrase` (`/usr/lib/gnupg/gpg-preset-passphrase`) caches the passphrase by keygrip directly in the agent. `~/.gnupg-signing/gpg-agent.conf` sets `allow-preset-passphrase` and `max-cache-ttl 86400`. `tools/preset-signing-passphrase.sh` prompts via the terminal, calls `gpg-preset-passphrase --preset <keygrip>`. Must be run once per boot (or when the 24h cache expires).
**Popup preview:** `popups.js` `sigContent` provider fetches the `.sig` URL (same-origin), renders the ASCII armor in a `<pre>` inside a `.popup-sig` div. Bound to `a.footer-sig-link` explicitly in `bindTargets`, bypassing the footer-exclusion guard on internal links. Result cached in the shared `cache` map.
**nginx:** `.sig` files need no special handling — they're served as static files alongside `.html`. The `try_files` directive handles `$uri` directly.
### Embedding pipeline + semantic search
**Model unification:** Both similar-links (page-level) and semantic search (paragraph-level) use `all-MiniLM-L6-v2` (384 dims). This is a deliberate simplification: the same model runs at build time (Python/sentence-transformers) and query time (browser/transformers.js `Xenova/all-MiniLM-L6-v2` quantized), guaranteeing that query vectors and corpus vectors are in the same embedding space.
**Build-time (`tools/embed.py`):** One HTML parse pass per file extracts both the full-page text (for similar-links) and individual paragraphs (for semantic search). Model is loaded once and both encoding jobs run sequentially. Outputs: `data/similar-links.json`, `data/semantic-index.bin` (raw `float32`, shape `[N_paragraphs, 384]`), `data/semantic-meta.json`. All three are gitignored (generated). Staleness check: skips the entire run if all three outputs are newer than all `_site/` HTML.
**Binary index format:** `para_vecs.tobytes()` writes a flat, little-endian `float32` array. In JS: `new Float32Array(arrayBuffer)`. No header, no framing — row `i` starts at byte offset `i × 384 × 4`. This is the simplest possible format and avoids a numpy/npy parser in the browser.
**Client-side search (`semantic-search.js`):** Dynamically imports `@xenova/transformers@2` from jsDelivr CDN on first query (lazy — no load cost on pages that never use semantic search). Fetches binary index + metadata JSON (also lazy, browser-cached). Brute-force dot product over a `Float32Array` in a tight JS loop — fast enough at <5k paragraphs; revisit with a WASM FAISS binding if the corpus grows beyond ~20k paragraphs. Vectors are unit-normalised at build time, so dot product = cosine similarity.
**Tab default + localStorage:** Keyword (Pagefind) is the default on first visit — zero cold-start. User's last-used tab is stored under `search-tab` in `localStorage` and restored on load, so returning users who prefer semantic always land there. If `localStorage` is unavailable (private browsing restrictions), falls back silently to keyword.
**Self-hosted model:** `semantic-search.js` sets `mod.env.localModelPath = '/models/'` and `mod.env.allowRemoteModels = false` immediately after the CDN import. transformers.js then resolves model files as `GET /models/all-MiniLM-L6-v2/{file}`, which are same-origin. `make download-model` (= `tools/download-model.sh`) fetches 5 files from HuggingFace: `config.json`, `tokenizer.json`, `tokenizer_config.json`, `special_tokens_map.json`, `onnx/model_quantized.onnx`. The files live in `static/models/` (gitignored) and are copied to `_site/models/` by the existing `static/**` Hakyll rule.
**CSP:** `script-src` requires `https://cdn.jsdelivr.net` for the transformers.js library import. `connect-src` stays `'self'` — all model weight fetches are same-origin after `allowRemoteModels = false`. The binary index and meta JSON are also same-origin.
### Responsive images — WebP `<picture>` wrapping
`build/Filters/Images.hs` inspects each `Image` inline's `src`. If it is a local raster (not starting with `http://`, `https://`, `//`, or `data:`; extension `.jpg`/`.jpeg`/`.png`/`.gif`), it emits `RawInline (Format "html")` containing a `<picture>` element:
```html
<picture>
<source srcset="/images/foo.webp" type="image/webp">
<img src="/images/foo.jpg" alt="…" loading="lazy" data-lightbox="true">
</picture>
```
The WebP `srcset` is computed at build time by `System.FilePath.replaceExtension`. No IO is needed in the filter — the `<source>` is always emitted; browsers silently ignore it if the file doesn't exist (falling back to `<img>`). SVG, external URLs, and `data:` URIs remain plain `<img>` tags. Images inside `<a>` links get no `data-lightbox` marker (same as before).
`tools/convert-images.sh` performs the actual conversion: `cwebp -q 85` per file. Runs before Hakyll in `make build` and is also available as a standalone `make convert-images` target. Exits 0 with a notice if `cwebp` is not installed, so the build never fails on machines without `libwebp`. Generated `.webp` files are gitignored; `git add -f` to commit an authored WebP.
**Quality note:** `-q 85` is a good default for photographic images. For pixel-art, diagrams, or images that are already highly compressed, `-lossless` or a higher quality setting may be appropriate (edit the script).
### 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.*

View File

@ -252,19 +252,29 @@ nav.site-nav {
/* ============================================================
FOCUS MODE header fade
FOCUS MODE header hide
TOC hide lives in layout.css.
Settings gear stays accessible at top-right.
============================================================ */
[data-focus-mode] body > header {
opacity: 0.07;
transition: opacity 0.5s ease;
[data-focus-mode] body > header .site-nav {
visibility: hidden;
height: 0;
overflow: hidden;
}
[data-focus-mode] body > header:hover,
[data-focus-mode] body > header:focus-within {
opacity: 1;
transition: opacity 0.15s ease;
[data-focus-mode] .settings-wrap {
visibility: visible;
position: fixed;
top: 0.75rem;
right: 1.25rem;
z-index: 1000;
}
[data-focus-mode] .settings-panel {
position: fixed;
top: 2.5rem;
right: 1.25rem;
}
@ -753,6 +763,133 @@ nav.site-nav {
}
/* ============================================================
ESSAY SUMMARY BOX
Optional key-points / summary block rendered from the
`summary:` frontmatter field, between metadata and content.
============================================================ */
.essay-summary {
margin: 1.5rem 0 0;
padding: 1.25rem 1.5rem;
border-left: 3px solid var(--border-muted);
background: var(--bg-offset, var(--bg));
font-size: 0.92rem;
line-height: 1.6;
}
.essay-summary-label {
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 600;
font-variant-caps: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
margin-bottom: 0.5rem;
}
.essay-summary p {
margin: 0 0 0.5em;
}
.essay-summary p:last-child {
margin-bottom: 0;
}
/* ============================================================
CONTENT DIVIDERS
Decorative rule with centered logo. Used both at the top of
the body (content-divider, between metadata and prose) and at
the aftermatter boundary (aftermatter-divider).
============================================================ */
.content-divider,
.aftermatter-divider {
display: flex;
align-items: center;
gap: 1.25rem;
}
.content-divider {
margin: 2rem 0 2.5rem;
}
.aftermatter-divider {
margin: 3.5rem 0 2.5rem;
}
.content-divider::before,
.content-divider::after,
.aftermatter-divider::before,
.aftermatter-divider::after {
content: '';
flex: 1;
border-top: 1px dashed var(--text-faint);
opacity: 0.5;
}
.content-divider-logo,
.aftermatter-logo {
display: block;
width: 1.6rem;
height: 1.6rem;
flex-shrink: 0;
background-color: var(--text-faint);
opacity: 0.45;
mask-image: url('/images/link-icons/internal.svg');
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-image: url('/images/link-icons/internal.svg');
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
transition: opacity var(--transition-fast);
}
.content-divider-logo:hover,
.aftermatter-logo:hover {
opacity: 0.7;
}
/* ============================================================
AFTERMATTER HEADINGS
Right-aligned, all-small-caps, muted visually distinct from
main-body headings to signal supplementary material.
============================================================ */
.aftermatter :is(h2, h3, h4) {
text-align: right;
font-variant-caps: all-small-caps;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.aftermatter h2 {
font-size: 1.3rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border-muted);
}
.aftermatter h3 {
font-size: 1.05rem;
}
/* ============================================================
FOOTER ORNAMENT
Inline SVG floral ornament separating body from page metadata.
Uses currentColor for automatic light/dark theming.
============================================================ */
.footer-ornament {
display: block;
width: 100%;
height: auto;
padding: 0.5rem var(--page-padding);
color: var(--text-faint);
opacity: 0.5;
}
/* ============================================================
BOTTOM METADATA FOOTER
Full-width section outside the essay's three-column layout.
@ -760,8 +897,7 @@ nav.site-nav {
============================================================ */
.page-meta-footer {
border-top: 1px solid var(--border);
padding: 2rem var(--page-padding);
padding: 1.5rem var(--page-padding) 2rem;
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--text-muted);
@ -802,7 +938,6 @@ nav.site-nav {
justify-content: center;
gap: 1.65rem 2rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-muted, var(--border));
}
.meta-footer-section p,
@ -1435,9 +1570,13 @@ h3:hover .section-toggle,
============================================================ */
.copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
position: sticky;
float: right;
top: 0;
right: 0;
margin-top: -0.4rem;
margin-right: -0.5rem;
margin-bottom: -1.6em;
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 500;
@ -1453,6 +1592,7 @@ h3:hover .section-toggle,
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
user-select: none;
line-height: 1.6;
z-index: 1;
}
pre:hover .copy-btn,

View File

@ -36,7 +36,7 @@
});
});
pre.appendChild(btn);
pre.insertBefore(btn, pre.firstChild);
}
document.addEventListener('DOMContentLoaded', function () {

View File

@ -66,6 +66,9 @@
$endfor$
</div>
$endif$
<div class="content-divider" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
$body$
</main>
</div>

View File

@ -11,6 +11,15 @@
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
<h1 class="page-title">$title$</h1>
$partial("templates/partials/metadata.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
<div class="essay-summary-label">Summary</div>
$summary$
</div>
$endif$
<div class="content-divider" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
$body$
</main>
</div>

View File

@ -1,3 +1,46 @@
<svg class="footer-ornament" viewBox="0 0 1200 44" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Horizontal rules -->
<g fill="none" stroke="currentColor" stroke-linecap="round">
<line x1="30" y1="22" x2="420" y2="22" stroke-width="0.6"/>
<line x1="780" y1="22" x2="1170" y2="22" stroke-width="0.6"/>
</g>
<!-- Rule accent diamonds -->
<g fill="currentColor">
<rect x="148" y="20" width="3.5" height="3.5" rx="0.4" transform="rotate(45,149.75,21.75)"/>
<rect x="298" y="20" width="3.5" height="3.5" rx="0.4" transform="rotate(45,299.75,21.75)"/>
<rect x="898" y="20" width="3.5" height="3.5" rx="0.4" transform="rotate(45,899.75,21.75)"/>
<rect x="1048" y="20" width="3.5" height="3.5" rx="0.4" transform="rotate(45,1049.75,21.75)"/>
</g>
<!-- Scroll eyes -->
<g fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">
<path d="M425 22 C445 22, 455 8, 485 8 C510 8, 505 22, 530 22"/>
<path d="M425 22 C445 22, 455 36, 485 36 C510 36, 505 22, 530 22"/>
<path d="M775 22 C755 22, 745 8, 715 8 C690 8, 695 22, 670 22"/>
<path d="M775 22 C755 22, 745 36, 715 36 C690 36, 695 22, 670 22"/>
</g>
<!-- Junction dots -->
<g fill="currentColor">
<circle cx="425" cy="22" r="2"/>
<circle cx="775" cy="22" r="2"/>
<circle cx="530" cy="22" r="1.5"/>
<circle cx="670" cy="22" r="1.5"/>
</g>
<!-- Leaves inside scroll eyes -->
<g fill="currentColor" opacity="0.7">
<path d="M460 16 Q475 10, 490 16 Q475 22, 460 16Z"/>
<path d="M460 28 Q475 22, 490 28 Q475 34, 460 28Z"/>
<path d="M740 16 Q725 10, 710 16 Q725 22, 740 16Z"/>
<path d="M740 28 Q725 22, 710 28 Q725 34, 740 28Z"/>
</g>
<!-- Central rosette -->
<g fill="currentColor" transform="translate(600, 22)">
<ellipse rx="3.5" ry="11"/>
<ellipse rx="3.5" ry="11" transform="rotate(45)"/>
<ellipse rx="3.5" ry="11" transform="rotate(90)"/>
<ellipse rx="3.5" ry="11" transform="rotate(135)"/>
<circle r="3.5"/>
</g>
</svg>
<div class="page-meta-footer" data-pagefind-ignore="all">
$if(further-reading-refs)$

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -2,6 +2,15 @@
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
<h1 class="page-title">$title$</h1>
$partial("templates/partials/metadata.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
<div class="essay-summary-label">Summary</div>
$summary$
</div>
$endif$
<div class="content-divider" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
$body$
</main>
$partial("templates/partials/page-footer.html")$