639 lines
51 KiB
Markdown
639 lines
51 KiB
Markdown
# levineuwirth.org — Design Specification v8
|
||
|
||
**Author:** Levi Neuwirth
|
||
**Date:** March 2026 (v8: 16 March 2026)
|
||
**Status:** LIVING DOCUMENT — Updated as implementation progresses.
|
||
|
||
---
|
||
|
||
## I. Vision & Philosophy
|
||
|
||
This website is an **intellectual home** — the permanent residence of a mind that moves freely between computer science, music composition, poetry, fiction, and whatever else catches fire.
|
||
|
||
### Commitments
|
||
1. **Long content over disposable content.** Essays are living documents.
|
||
2. **Semantic zoom.** Title → abstract → headers → body → sidenotes → citations → sources.
|
||
3. **Earned ornament.** Every decorative element serves a purpose.
|
||
4. **The site is the proof.** Entirely FOSS. No tracking. No analytics. No fingerprinting.
|
||
5. **Reader > Author.**
|
||
6. **Configuration is code.** The build system is a Haskell program.
|
||
7. **No homepage epigraph.**
|
||
8. **Extensible metadata.** Future-proofed for semantic embeddings via external JSON injection.
|
||
|
||
---
|
||
|
||
## II. All Resolved Decisions
|
||
|
||
### Typography
|
||
|
||
| Role | Font | License | Notes |
|
||
|------|------|---------|-------|
|
||
| **Body** | **Spectral** | SIL OFL | Screen-first serif. True smallcaps (`smcp`), four figure styles, ligatures, seven weights + italics. Self-hosted from source — Google Fonts strips OT features. |
|
||
| **UI / Headers** | **Fira Sans** | SIL OFL | Humanist sans-serif. Complements Spectral. |
|
||
| **Code** | **JetBrains Mono** | SIL OFL | Ligatures, excellent legibility. |
|
||
|
||
Font pairing has been tested across screens and confirmed.
|
||
|
||
**Self-hosting workflow:**
|
||
```bash
|
||
pyftsubset Spectral-Regular.ttf \
|
||
--output-file=spectral-regular.woff2 \
|
||
--flavor=woff2 \
|
||
--layout-features='liga,dlig,smcp,c2sc,onum,lnum,pnum,tnum,frac,ordn,sups,subs,ss01,ss02,ss03,ss04,ss05,kern' \
|
||
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD' \
|
||
--no-hinting --desubroutinize
|
||
```
|
||
|
||
### LaTeX Math
|
||
|
||
Client-side KaTeX (not pure build-time SSR — see Implementation Notes):
|
||
- Pandoc outputs math spans with `class="math inline"` / `class="math display"`
|
||
- KaTeX renders client-side from a deferred script
|
||
- KaTeX CSS/fonts loaded conditionally only on pages with math (`$if(math)$` in head template)
|
||
|
||
### Navigation
|
||
|
||
```
|
||
Home | Me | Current | New | Links | Search [⚙]
|
||
───────────────────────────────────────────────
|
||
▼ Portals
|
||
AI | Fiction | Miscellany | Music | Nonfiction | Poetry | Research | Tech
|
||
```
|
||
|
||
- **Primary row (always visible):** Home, Me, Current (now-page), New (changelog), Links, Search; settings gear (⚙) on the right
|
||
- **Settings panel** (⚙ button): Theme (Light/Dark), Text size (A−/A+), Focus Mode, Reduce Motion, Print — managed by `settings.js`; state persisted via `localStorage`
|
||
- **Expandable portal row:** AI, Fiction, Miscellany, Music, Nonfiction, Poetry, Research, Tech
|
||
- Portal row collapsed by default; expansion state persisted via `localStorage`
|
||
- Fira Sans smallcaps for primary row
|
||
|
||
### Layout
|
||
|
||
- **Left margin:** Interactive sticky TOC (`IntersectionObserver`). Collapses on narrow screens.
|
||
- **Center column:** Body text in Spectral. 650–700px max-width.
|
||
- **Right margin:** Sidenotes only (right column).
|
||
|
||
### Color
|
||
|
||
Pure monochrome. No accent color. Light mode default (`#faf8f4` background, `#1a1a1a` text). Dark mode via `[data-theme="dark"]` + `prefers-color-scheme`.
|
||
|
||
### Content Systems
|
||
|
||
- **Tag system:** Hierarchical, slash-separated (`research/mathematics`). Hakyll `buildTags` + custom hierarchy. Tag pages at `/<tag>/` with no `/tags/` namespace prefix.
|
||
- **Pagination:** Blog index 20/page, tag pages 20/page. Essay index all on one page.
|
||
- **RSS:** Atom feed at `/feed.xml` (all content types, sorted by `date`) and `/music/feed.xml` (compositions only).
|
||
- **Citations:** Numbered superscript markers `[1]` linked to a bibliography section. Hover preview via `citations.js`. Further Reading section separate from cited works. `data/bibliography.bib` + Chicago Author-Date CSL.
|
||
- **Collapsible sections:** h2/h3 headings toggle their content via `collapse.js`. Smooth `max-height` transition. State persisted in `localStorage`.
|
||
|
||
### Gwern Codebase: Selective Adoption
|
||
|
||
| Component | Action | Actual outcome |
|
||
|-----------|--------|----------------|
|
||
| `sidenotes.js` | Adopt directly (Said Achmiz, MIT) | **Written from scratch** — purpose-built for our HTML structure |
|
||
| `popups.js` | Fork and simplify (Said Achmiz, MIT) | Exists in `static/js/popups.js`; Phase 3 |
|
||
| CSS typographic foundations | Extract and refactor | Done |
|
||
| Pandoc AST filters | Write from scratch | Done |
|
||
| Hakyll architecture | Rewrite, informed by gwern | Done |
|
||
| Everything else | Ignore | — |
|
||
|
||
### Metadata
|
||
|
||
Extensible YAML frontmatter. Hakyll strips frontmatter before passing to Pandoc, so all frontmatter access goes through Hakyll's metadata API (`lookupStringList`, `getMetadataField`, etc.), not through Pandoc `Meta`.
|
||
|
||
**Frontmatter keys in use:**
|
||
```yaml
|
||
title: # page title
|
||
date: # ISO date (YYYY-MM-DD) — used for sorting, feed, reading-time
|
||
abstract: # short description (1–3 sentences)
|
||
tags: # hierarchical tag list
|
||
authors: # list of author names (defaults to Levi Neuwirth)
|
||
further-reading: # list of BibTeX keys for the Further Reading section
|
||
bibliography: # path to .bib file (optional; defaults to data/bibliography.bib)
|
||
csl: # path to .csl file (optional; defaults to data/chicago-notes.csl)
|
||
|
||
# Epistemic profile (all optional; section shown only if `status` is present)
|
||
status: # Draft | Working model | Durable | Refined | Superseded | Deprecated
|
||
confidence: # 0–100 integer (%)
|
||
importance: # 1–5 integer (rendered as filled/empty dots)
|
||
evidence: # 1–5 integer (rendered as filled/empty dots)
|
||
scope: # personal | local | average | broad | civilizational
|
||
novelty: # conventional | moderate | idiosyncratic | innovative
|
||
practicality: # abstract | low | moderate | high | exceptional
|
||
stability: # volatile | revising | fairly stable | stable | established
|
||
# (auto-computed from git history; use IGNORE.txt to pin)
|
||
last-reviewed: # ISO date — overrides git-derived date when in IGNORE.txt
|
||
confidence-history: # list of integers — trend derived from last two entries (↑↓→)
|
||
|
||
# Version history (optional; falls back to git log, then to date-created/date-modified)
|
||
history:
|
||
- date: "2026-03-01" # ISO date string (quote to prevent YAML date parsing)
|
||
note: Initial draft # human-readable annotation
|
||
- date: "2026-03-14"
|
||
note: Expanded typography section; added citations
|
||
```
|
||
|
||
Auto-computed at build time: `word-count`, `reading-time`.
|
||
Auto-derived at build time: `stability` (from `git log --follow`), `last-reviewed` (most recent commit date), `confidence-trend` (from `confidence-history`).
|
||
|
||
**`IGNORE.txt`:** A file in the project root listing content paths (one per line) whose `stability` and `last-reviewed` should not be recomputed. Cleared automatically after every `make build`. Useful for pinning manually-set stability labels on pages whose git history is misleading.
|
||
|
||
**Top metadata block:**
|
||
1. **Tags** — hierarchical tag list with links to tag index pages
|
||
2. **Description** — the `abstract` field, rendered in italic
|
||
3. **Authors** — `authors` list
|
||
4. **Page info** — jump links to bottom metadata sections (Epistemic/Bibliography/Backlinks shown conditionally)
|
||
|
||
**Bottom metadata footer:**
|
||
- **Version history** — three-tier priority: (1) frontmatter `history` list with authored notes → (2) git log dates (date-only) → (3) `date-created` / `date-modified` fallback. `make build` auto-commits `content/` before building, keeping git history current.
|
||
- **Epistemic** (if `status` set) — compact: status chip · confidence % · importance dots · evidence dots; expanded `<details>`: stability · scope · novelty · practicality · last reviewed · confidence trend
|
||
- **Bibliography** — formatted citations + Further Reading
|
||
- **Backlinks** — auto-generated; each entry shows source title (link) + collapsible context paragraph
|
||
|
||
### Licensing
|
||
|
||
- **Content:** CC BY-SA-NC 4.0
|
||
- **Code:** MIT
|
||
|
||
---
|
||
|
||
## III. Deployment & Infrastructure
|
||
|
||
### Deployment Pipeline
|
||
|
||
```
|
||
[Local machine] [Arch Linux VPS / DreamHost]
|
||
|
||
content/*.md
|
||
↓
|
||
cabal run site -- build nginx serving
|
||
↓ /var/www/levineuwirth.org/
|
||
pagefind --site _site
|
||
↓
|
||
rsync -avz --delete \
|
||
_site/ \
|
||
vps:/var/www/levineuwirth.org/ ──→ Live site
|
||
```
|
||
|
||
```makefile
|
||
build:
|
||
cabal run site -- build
|
||
pagefind --site _site
|
||
> IGNORE.txt # clear stability pins after each build
|
||
|
||
deploy: build
|
||
rsync -avz --delete _site/ vps:/var/www/levineuwirth.org/
|
||
|
||
watch:
|
||
cabal run site -- watch
|
||
|
||
clean:
|
||
cabal run site -- clean
|
||
```
|
||
|
||
### Hosting Timeline
|
||
|
||
1. **Immediate:** Deploy to DreamHost (rsync static files)
|
||
2. **Phase 5:** Provision Arch VPS (Hetzner), configure nginx + certbot, migrate DNS
|
||
|
||
### VPS: nginx config (Arch Linux)
|
||
|
||
```nginx
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name levineuwirth.org www.levineuwirth.org;
|
||
root /var/www/levineuwirth.org;
|
||
|
||
# TLS (managed by certbot)
|
||
ssl_certificate /etc/letsencrypt/live/levineuwirth.org/fullchain.pem;
|
||
ssl_certificate_key /etc/letsencrypt/live/levineuwirth.org/privkey.pem;
|
||
|
||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self';" always;
|
||
|
||
gzip on;
|
||
gzip_types text/html text/css application/javascript application/json image/svg+xml;
|
||
|
||
location ~* \.(woff2|css|js|svg|png|jpg|webp)$ {
|
||
expires 1y;
|
||
add_header Cache-Control "public, immutable";
|
||
}
|
||
location ~* \.html$ {
|
||
expires 1h;
|
||
add_header Cache-Control "public, must-revalidate";
|
||
}
|
||
|
||
try_files $uri $uri.html $uri/ =404;
|
||
error_page 404 /404.html;
|
||
}
|
||
server {
|
||
listen 80;
|
||
server_name levineuwirth.org www.levineuwirth.org;
|
||
return 301 https://$host$request_uri;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## IV. Repository Structure
|
||
|
||
```
|
||
levineuwirth.org/
|
||
├── content/
|
||
│ ├── essays/
|
||
│ │ └── test-essay.md # Feature test document
|
||
│ ├── blog/
|
||
│ ├── music/
|
||
│ │ └── {slug}/
|
||
│ │ ├── index.md # Composition frontmatter + program notes
|
||
│ │ ├── scores/ # LilyPond SVG pages + PDF
|
||
│ │ └── audio/ # Per-movement MP3s
|
||
│ └── *.md # Standalone pages (me, colophon, etc.)
|
||
├── static/
|
||
│ ├── css/
|
||
│ │ ├── base.css # CSS variables, palette, dark mode
|
||
│ │ ├── typography.css # Spectral OT features, dropcaps, smallcaps, link icons
|
||
│ │ ├── layout.css # 3-column layout, responsive breakpoints
|
||
│ │ ├── sidenotes.css # Sidenote positioning
|
||
│ │ ├── popups.css # Link preview popup styles
|
||
│ │ ├── syntax.css # Monochrome code highlighting (JetBrains Mono)
|
||
│ │ ├── components.css # Nav (incl. settings panel), TOC, metadata, citations, collapsibles
|
||
│ │ ├── gallery.css # Exhibit system + annotation callouts
|
||
│ │ ├── selection-popup.css # Text-selection toolbar
|
||
│ │ ├── annotations.css # User highlight marks + annotation tooltip
|
||
│ │ ├── images.css # Figure layout, captions, lightbox overlay
|
||
│ │ ├── score-reader.css # Full-page score reader layout
|
||
│ │ ├── catalog.css # Music catalog page (`/music/`)
|
||
│ │ └── print.css # Print stylesheet (media="print")
|
||
│ ├── js/
|
||
│ │ ├── theme.js # Dark/light toggle (sync, not deferred)
|
||
│ │ ├── sidenotes.js # Written from scratch — collision avoidance, hover/focus
|
||
│ │ ├── toc.js # Sticky TOC + scroll tracking + animated collapse
|
||
│ │ ├── nav.js # Portal row expand/collapse + localStorage
|
||
│ │ ├── collapse.js # Section collapsing with localStorage persistence
|
||
│ │ ├── citations.js # Citation hover previews
|
||
│ │ ├── gallery.js # Exhibit overlay + annotation toggle
|
||
│ │ ├── popups.js # Link preview popups (internal, Wikipedia, citations)
|
||
│ │ ├── settings.js # Settings panel (theme, text size, focus mode, reduce motion, print)
|
||
│ │ ├── selection-popup.js # Context-aware text-selection toolbar
|
||
│ │ ├── annotations.js # localStorage highlight/annotation engine (UI deferred)
|
||
│ │ ├── score-reader.js # Score reader: page-turn, movement jumps, deep linking
|
||
│ │ ├── search.js # Pagefind UI init + ?q= pre-fill
|
||
│ │ └── prism.min.js # Syntax highlighting
|
||
│ ├── fonts/ # Self-hosted WOFF2 (subsetted with OT features)
|
||
│ └── images/
|
||
│ └── link-icons/ # SVG icons for external link classification
|
||
│ ├── external.svg
|
||
│ ├── wikipedia.svg
|
||
│ ├── github.svg
|
||
│ ├── arxiv.svg
|
||
│ └── doi.svg
|
||
├── templates/
|
||
│ ├── default.html # Outer shell: nav, head, footer JS
|
||
│ ├── essay.html # 3-column layout with TOC
|
||
│ ├── composition.html # Music landing page (metadata block, movements, body, recording player)
|
||
│ ├── music-catalog.html # Music catalog index (`/music/`)
|
||
│ ├── score-reader.html # Minimal score reader body (top bar + SVG stage)
|
||
│ ├── score-reader-default.html # Minimal HTML shell for score reader (no nav/footer)
|
||
│ ├── blog-post.html
|
||
│ ├── page.html # Simple standalone pages
|
||
│ ├── essay-index.html
|
||
│ ├── blog-index.html
|
||
│ ├── tag-index.html
|
||
│ └── partials/
|
||
│ ├── head.html # CSS, conditional JS (citations, collapse)
|
||
│ ├── nav.html # Two-row nav with portals
|
||
│ ├── footer.html
|
||
│ ├── metadata.html # Essay metadata block (top)
|
||
│ └── page-footer.html # Essay footer (bibliography, backlinks)
|
||
├── build/
|
||
│ ├── Main.hs # Entry point
|
||
│ ├── Site.hs # Hakyll rules (all routes + Atom feed)
|
||
│ ├── Compilers.hs # Pandoc compiler wrappers
|
||
│ ├── Contexts.hs # Template contexts (word-count, reading-time, bibliography)
|
||
│ ├── Citations.hs # citeproc pipeline: Cite→superscript + bibliography HTML
|
||
│ ├── Filters.hs # Re-exports all filter modules
|
||
│ ├── Filters/
|
||
│ │ ├── Typography.hs # Smart quotes, dashes
|
||
│ │ ├── Sidenotes.hs # Footnote → sidenote conversion
|
||
│ │ ├── Dropcaps.hs # Decorative first-letter drop caps
|
||
│ │ ├── Smallcaps.hs # Smallcaps via smcp OT feature
|
||
│ │ ├── Wikilinks.hs # [[wikilink]] syntax
|
||
│ │ ├── Links.hs # External link classification + data-link-icon attributes
|
||
│ │ ├── Math.hs # Simple LaTeX → Unicode conversion
|
||
│ │ ├── Code.hs # Prepend language- prefix for Prism.js
|
||
│ │ ├── Images.hs # Lazy loading, lightbox data-attributes
|
||
│ │ └── Score.hs # Score fragment SVG inlining + currentColor replacement
|
||
│ ├── Authors.hs # Author-as-tag system (slugify, authorLinksField, author pages)
|
||
│ ├── Backlinks.hs # Two-pass build-time backlinks with context paragraph extraction
|
||
│ ├── Catalog.hs # Music catalog: featured works + grouped-by-category HTML rendering
|
||
│ ├── Stability.hs # Git-based stability auto-calculation + last-reviewed derivation
|
||
│ ├── Metadata.hs # Stub (Phase 2+)
|
||
│ ├── Tags.hs # Hierarchical tag system
|
||
│ ├── Pagination.hs # 20/page for blog + tag indexes
|
||
│ └── Utils.hs # Shared helpers (wordCount, readingTime)
|
||
├── data/
|
||
│ ├── bibliography.bib # BibTeX references
|
||
│ ├── chicago-notes.csl # CSL style (in-text, Chicago Author-Date)
|
||
│ └── (future: embeddings.json, similar-links.json)
|
||
├── tools/
|
||
│ └── subset-fonts.sh
|
||
├── levineuwirth.cabal
|
||
├── cabal.project
|
||
├── cabal.project.freeze
|
||
├── Makefile
|
||
└── CLAUDE.md
|
||
```
|
||
|
||
---
|
||
|
||
## V. Implementation Phases
|
||
|
||
### Phase 1: Foundation ✓
|
||
- [x] Init Hakyll project, modular Haskell build system
|
||
- [x] Font subsetting + self-hosting (Spectral, Fira Sans, JetBrains Mono)
|
||
- [x] CSS: base (palette, variables, dark mode), typography (Spectral features), layout (3-column), sidenotes
|
||
- [x] `sidenotes.js` — written from scratch (not adopted; see Implementation Notes)
|
||
- [x] Two-row navigation with expandable portals
|
||
- [x] Templates: default, essay, blog-post, index
|
||
- [x] Dark/light toggle with `localStorage` + `prefers-color-scheme`
|
||
- [x] Basic Pandoc pipeline (Markdown → HTML, smart typography)
|
||
- [x] Deploy to DreamHost via rsync — deployed to Hetzner VPS instead
|
||
|
||
### Phase 2: Content Features ✓
|
||
- [x] Pandoc filters: sidenotes, dropcaps, smallcaps, wikilinks, typography, link classification, code, math
|
||
- [x] Interactive sticky TOC — IntersectionObserver, animated expand/collapse, page-title display, auto-collapse on scroll
|
||
- [x] Citation system — numbered superscript markers, hover preview, bibliography + Further Reading sections
|
||
- [x] Monochrome syntax highlighting (Prism.js + `Filters.Code`)
|
||
- [x] Collapsible h2/h3 sections (`collapse.js`) — `max-height` transition, localStorage persistence
|
||
- [x] Hierarchical tag system + tag index pages
|
||
- [x] Pagination (blog index and tag pages, 20/page)
|
||
- [x] Metadata: YAML frontmatter + auto-computed word count / reading time
|
||
- [x] Single Atom feed (`/feed.xml`, all content, sorted by date)
|
||
- [x] External link icons (SVG mask-image, domain-classified via `Filters.Links`)
|
||
- [x] Gallery / Exhibit system (`gallery.js`, `gallery.css`) — added (not in original spec)
|
||
|
||
### Phase 3: Rich Interactions
|
||
- [x] Link preview popups (`popups.js`) — internal page previews (title, abstract, authors, tags, reading time), Wikipedia excerpts, citation previews; relative-URL fix for index pages
|
||
- [x] Pagefind search (`/search.html`) — `search.js` pre-fills from `?q=` param so selection popup "Here" button lands ready
|
||
- [x] Author system — authors treated as tags; `build/Authors.hs`; author pages at `/authors/{slug}/`; `authorLinksField` in all contexts; defaults to Levi Neuwirth
|
||
- [x] Settings panel — `settings.js` + `settings.css` section in `components.css`; theme, text size (3 steps), focus mode, reduce motion, print; all state in `localStorage`; `theme.js` restores all settings before first paint
|
||
- [x] Selection popup — `selection-popup.js` / `selection-popup.css`; context-aware toolbar appears 450 ms after text selection; see Implementation Notes
|
||
- [x] Print stylesheet — `print.css` (media="print"); single-column, light colors, sidenotes as indented blocks, external URLs shown
|
||
- [x] Current page (`/current.html`) — now-page; added to primary nav
|
||
- [~] Annotations — `annotations.js` / `annotations.css`; localStorage infrastructure + highlight re-anchoring written; UI (button in selection popup) deferred
|
||
|
||
### Phase 4: Creative Content & Polish
|
||
- [x] Image handling (lazy load, lightbox, figures)
|
||
- [x] Homepage (replaces standalone index; gateway + curated recent content)
|
||
- [x] Poetry typesetting — codex reading mode (`reading.html`, `reading.css`, `reading.js`); `poetryCompiler` with `Ext_hard_line_breaks`; narrower measure, stanza spacing, drop-cap suppressed
|
||
- [x] Fiction reading mode — same codex layout; `fictionCompiler`; chapter drop caps + smallcaps lead-in via `h2 + p::first-letter`; reading mode infrastructure shared with poetry
|
||
- [x] Music section — score fragment system (A): inline SVG excerpts (motifs, passages) integrated into the gallery/exhibit system; named, TOC-listed, focusable in the shared overlay alongside equations; authored via `{.score-fragment score-name="..." score-caption="..."}` fenced-div; SVG inlined at build time by `Filters.Score`; black fills/strokes replaced with `currentColor` for dark-mode; see Implementation Notes
|
||
- [x] Music section — composition landing pages + full score reader (C): two-URL architecture per composition; `/music/{slug}/` (rich prose landing page with movement list, audio players, inline score fragments) and `/music/{slug}/score/` (minimal dedicated reader); Hakyll `version "score-reader"` mechanism; `compositionCtx` with `slug`, `score-url`, `has-score`, `score-page-count`, `score-pages` list, `has-movements`, `movements` list (Aeson-parsed nested YAML); `score-reader-default.html` minimal shell; `score-reader.js` (page navigation, movement jumps, `?p=` deep linking, preloading, keyboard); `score-reader.css`; dark mode via `filter: invert(1)`; see Implementation Notes
|
||
- [x] Accessibility audit — skip link, TOC collapsed-link tabbing (`visibility: hidden`), section-toggle focus visibility, lightbox/gallery/settings focus restoration, popup `aria-hidden`, metadata nav wrapping, footer `onclick` removal; settings panel focus-steal bug fixed (focus only returns to toggle when it was inside the panel, preventing interference with text-selection popup)
|
||
- [ ] Visualization pipeline — matplotlib / Altair figures generated at build time; each visualization lives in its own directory (e.g. `content/viz/my-chart/`) alongside a `generate.py` and a versioned dataset; Hakyll rule invokes `python generate.py` to produce SVG/HTML output and copies it into `_site/`; datasets can be updated independently and graphs regenerate on next build
|
||
- [ ] Content migration — migrate existing essays, poems, fiction, and music landing pages from prior formats into `content/`
|
||
|
||
### Phase 5: Infrastructure & Advanced
|
||
- [x] **Arch Linux VPS + nginx + certbot + DNS migration** — Hetzner VPS provisioned, Arch Linux installed, nginx configured (config in §III), TLS cert via certbot, DNS migrated from DreamHost. `make deploy` pushes to GitHub and rsyncs to VPS.
|
||
- [ ] **Semantic embedding pipeline** — Superseded by Phase 6 "Embedding-powered similar links" (local model, no API cost).
|
||
- [x] **Backlinks with context** — Two-pass build-time system (`build/Backlinks.hs`). Pass 1: `version "links"` compiles each page lightly (wikilinks preprocessed, links + context extracted, serialised as JSON). Pass 2: `create ["data/backlinks.json"]` inverts the map. `backlinksField` in `essayCtx` / `postCtx` loads the JSON and renders `<details>`-collapsible per-entry lists. `popups.js` excludes `.backlink-source` links from the preview popup. Context paragraph uses `runPure . writeHtml5String` on the surrounding `Para` block. See Implementation Notes.
|
||
- [ ] **Link archiving** — For all external links in `data/bibliography.bib` and in page bodies, check availability and save snapshots (Wayback Machine `save` API or local archivebox instance). Store archive URLs in `data/link-archive.json`; `Filters.Links` injects `data-archive-url` attributes; `popups.js` falls back to the archive if the live URL returns 404.
|
||
- [ ] **Self-hosted git (Forgejo)** — Run Forgejo on the VPS. Mirror the build repo. Link from the colophon. Not essential; can remain on GitHub indefinitely.
|
||
- [ ] **Reader mode** — Distraction-free reading overlay: hides nav, TOC, sidenotes; widens the body column to ~70ch; activated via a keyboard shortcut or settings panel toggle. Distinct from focus mode (which affects the nav) — reader mode affects the content layout.
|
||
|
||
### Phase 6: Deferred Features
|
||
- [ ] **Annotation UI** — The `annotations.js` / `annotations.css` infrastructure exists (localStorage storage, re-anchoring on load, four highlight colors, hover tooltip). The selection popup "Annotate" button was removed pending a design decision on the color-picker and note-entry UX. Revisit: a popover with four color swatches and an optional text field, triggered from the selection popup.
|
||
- [ ] **Visualization pipeline** — Each visualization lives in `content/viz/{slug}/` alongside `generate.py` and a versioned dataset CSV/JSON. Hakyll rule: `unsafeCompiler (callProcess "python" ["generate.py"])` writes SVG/HTML output into the item body. Output is embedded in the page or served as a static asset. Datasets can be updated independently; graphs regenerate on next `make build`. Matplotlib for static figures; Altair for interactive (Vega-Lite JSON embedded, rendered client-side by Vega-Lite JS — loaded conditionally).
|
||
- [x] **Music catalog page** — `/music/` index listing all compositions grouped by instrumentation category (orchestral → chamber → solo → vocal → choral → electronic → other), with an optional Featured section. Auto-generated from composition frontmatter by `build/Catalog.hs`; renders HTML in Haskell (same pattern as backlinks). Category, year, duration, instrumentation, and ◼/♫ indicators for score/recording availability. `content/music/index.md` provides prose intro + abstract. Template: `templates/music-catalog.html`. CSS: `static/css/catalog.css`. Context: `musicCatalogCtx` (provides `catalog: true` flag, `featured-works`, `has-featured`, `catalog-by-category`).
|
||
- [x] **Score reader swipe gestures** — `touchstart`/`touchend` listeners on `#score-reader-stage` with passive: true. Threshold: ≥ 50 px horizontal, < 30 px vertical drift. Left swipe → next page; right swipe → previous page.
|
||
- [x] **Full-piece audio on composition pages** — `recording` frontmatter key (path relative to the composition directory). Rendered as a full-width `<audio>` player in `composition.html`, above the per-movement list. Styled via `.comp-recording` / `.comp-recording-audio` in `components.css`. Per-movement `<audio>` players and `.comp-btn` / `.comp-movement-*` styles also added in the same pass.
|
||
- [x] **RSS/feed improvements** — `/feed.xml` now includes compositions (`content/music/*/index.md`) alongside essays, posts, fiction, poetry. New `/music/feed.xml` (compositions only, `musicFeedConfig`). Compositions already had `"content"` snapshots saved by the landing-page rule; no compiler changes needed.
|
||
- [ ] **Pagefind improvements** — Currently a basic full-text search. Consider: sub-result excerpts, portal-scoped search filters, weighting by `importance` frontmatter field.
|
||
- [ ] **Audio essays / podcast feed** — Record readings of select essays. Embed a native `<audio>` player at the top of the essay page, activated by an `audio` frontmatter key (path to MP3, relative to the content dir). Generate a separate `/podcast.xml` Atom feed with `<enclosure>` elements pointing to the MP3s so readers can subscribe in any podcast app. Stretch goal: a paragraph-sync mode where the player emits `timeupdate` events that highlight the paragraph currently being read — requires a `data/audio/{slug}-timestamps.json` file mapping paragraph indices to timestamps, authored manually or via forced-alignment tooling (e.g. `whisper` with word timestamps).
|
||
- [x] **Build telemetry page** — `/build/` page generated at build time. `build/Stats.hs` loads all content items by type, reads `"word-count"` snapshots, aggregates counts/words/reading-time per type, computes word-length distribution (5 buckets), and reads top-15 tags from the `Tags` object. Makefile writes `date +%s` to `data/build-start.txt` before Hakyll runs; after pagefind, computes elapsed and writes `data/last-build-seconds.txt` (read on next build). CSS in `static/css/build.css` (flex bar chart, tabular-nums table, grid dl); loaded conditionally via `$if(build)$` in head.html.
|
||
- [x] **Epistemic profile** — Replaces the old `certainty` / `importance` fields with a richer multi-axis system. **Compact** (always visible in footer): status chip · confidence % · importance dots · evidence dots. **Expanded** (`<details>`): stability (auto) · scope · novelty · practicality · last reviewed · confidence trend. Auto-calculation in `build/Stability.hs` via `git log --follow`; `IGNORE.txt` pins overrides. See Metadata section and Implementation Notes for full schema and vocabulary.
|
||
- [ ] **Writing statistics dashboard** — A `/stats` page computed entirely at build time from the corpus. Contents: total word count across all content types, essay/post/poem count, words written per month rendered as a GitHub-style contribution heatmap (SVG generated by Haskell or a Python script), average and median essay length, longest essay, most-cited essay (by backlink count), tag distribution as a treemap, reading-time histogram, site growth over time (cumulative word count by date). All data collected during the Hakyll build from compiled items and their snapshots; serialized to `data/stats.json` and rendered into a dedicated `stats.html` template.
|
||
- [x] **Memento mori** — Implemented at `/memento-mori/` as a full standalone page. 90×52 grid of weeks anchored to birthday anniversaries (nested year/week loop via `setFullYear`; week 52 stretched to eve of next birthday to absorb 365th/366th days). Week popup shows dynamic day-count and locale-derived day names. Score fragment (bassoon, `content/memento-mori/scores/bsn.svg`) inlined via `Filters.Score`. Linked from footer (MM).
|
||
- [ ] **Embedding-powered similar links** — Precompute dense vector embeddings for every page using a local model (e.g. `nomic-embed-text` or `gte-large` via `ollama` or `llama.cpp`) on personal hardware — no API dependency, no per-call cost. At build time, a Python script reads `_site/` HTML, embeds each page, computes top-N cosine neighbors, and writes `data/similar-links.json` (slug → [{slug, title, score}]). Hakyll injects this into each page's context (via `Metadata.hs` reading the JSON); template renders a "Related" section in the page footer. Analogous to gwern's `GenerateSimilar.hs` but model-agnostic and self-hosted. Note: supersedes the Phase 5 "Semantic embedding pipeline" stub — that stub should be replaced by this when implemented.
|
||
- [x] **Bidirectional backlinks with context** — See Phase 5 above; implemented with full context-paragraph extraction. Merged with the Phase 5 stub.
|
||
- [ ] **Signed pages / content integrity** — GPG-sign each HTML output file at build time using a detached ASCII-armored signature (`.sig` file per page). The signing step runs as a final Makefile target after Hakyll and Pagefind complete: `find _site -name '*.html' -exec gpg --batch --yes --detach-sign --armor {} \;`. Signatures are served alongside their pages (e.g. `/essays/my-essay.html.sig`). The page footer displays a verification block near the license: the signing key fingerprint, a link to `/gpg/` where the public key is published, and a link to the `.sig` file for that page — so readers can verify without hunting for the key. The public key is also available at the standard WKD location and published to keyservers. **Operational requirement:** a dedicated signing subkey (no passphrase) on the build machine; the master certifying key stays offline and passphrase-protected. A `tools/setup-signing.sh` script will walk through creating the signing subkey, exporting it, and configuring the build — so the setup is repeatable when moving between machines or provisioning the VPS. Philosophically consistent with the FOSS/privacy ethos and the "configuration is code" principle; extreme, but the site is already committed to doing things properly.
|
||
- [ ] **Full-text semantic search** — A secondary search mode alongside Pagefind's keyword index. Precompute embeddings for every paragraph (same pipeline as similar links). Store as a compact binary or JSON index. At query time, either: (a) compute the query embedding client-side using a small WASM model (e.g. `transformers.js` with a quantized MiniLM) and run cosine similarity against the stored paragraph vectors, or (b) use a precomputed query-expansion table (top-K words → relevant slugs, offline). Surfaced as a "Semantic search" toggle on `/search.html`. Returns paragraphs rather than pages as the result unit, with the source page title and a link to the specific section. This finds conceptually related content even when exact keywords differ — searching "the relationship between music and mathematics" surfaces relevant essays regardless of vocabulary.
|
||
|
||
---
|
||
|
||
## VI. Implementation Notes
|
||
|
||
### sidenotes.js — Written from scratch
|
||
The spec called for adopting Said Achmiz's `sidenotes.js` directly. Instead a purpose-built version was written for the `<span class="sidenote">` structure produced by `Filters.Sidenotes`. Features: JS collision avoidance (`positionSidenotes`), bidirectional hover highlight, click-to-focus (sticky highlight on wide viewport, anchor scroll fallback on narrow), document-click dismissal. `window.resize` is used as the reposition signal; `collapse.js` dispatches it after each section transition.
|
||
|
||
### Gallery / Exhibit system — Added (not in original spec)
|
||
- **Exhibits** (`.exhibit--equation`, `.exhibit--proof`): always-visible inline blocks with overlay zoom on click.
|
||
- **Annotations** (`.annotation--static`, `.annotation--collapsible`): editorial callout boxes.
|
||
- **TOC integration**: exhibits are listed under their parent heading.
|
||
- Implementation: `gallery.js`, `gallery.css`; Pandoc fenced-div syntax (`:::`) to avoid the 4-space code block trap.
|
||
|
||
### LaTeX Math — Client-side KaTeX
|
||
The spec described pure build-time SSR. In practice: Pandoc outputs `class="math"` spans, KaTeX renders client-side from a deferred script. Fully static (no server per request). Revisit if build-time SSR becomes important.
|
||
|
||
### Citation pipeline — key subtleties
|
||
1. **`Cite` nodes, not `Span` nodes.** `processCitations` with `class="in-text"` CSL does *not* convert `Cite` nodes to `Span class="citation"` nodes in the Pandoc AST — it only populates their inline content and creates the refs div. The HTML writer wraps them in `<span class="citation">` at write time. Our `Citations.hs` must match `Cite` nodes directly.
|
||
2. **Hakyll strips YAML frontmatter.** Hakyll reads frontmatter separately; the body passed to `readPandocWith` has no YAML block, so Pandoc `Meta` is empty. `further-reading` keys are read from Hakyll's metadata API (`lookupStringList`) in `Compilers.hs` and passed explicitly to `Citations.applyCitations`.
|
||
3. **`nocite` format.** Each further-reading key must be a *separate* `Cite` node with `AuthorInText` mode and non-empty fallback content — matching what pandoc produces from `"@key1 @key2"` in YAML. A single `Cite` node with multiple citations is not recognized by citeproc's nocite processing.
|
||
4. **`collectCiteOrder` queries blocks only**, not the full `Pandoc` (which includes metadata). Querying metadata would pick up the injected `nocite` `Cite` nodes and incorrectly classify further-reading entries as inline citations.
|
||
|
||
### External link icons
|
||
Implemented via `data-link-icon` and `data-link-icon-type="svg"` attributes set by `Filters.Links`. CSS uses `mask-image: url(...)` with `background-color: currentColor` so icons inherit the text color and work in dark mode. Icons in `static/images/link-icons/` as SVG files.
|
||
|
||
### Tags — Hierarchical, no namespace
|
||
Tags are slash-separated (`research/mathematics`). A tag is auto-expanded into all ancestor prefixes so that `/research/` aggregates all `research/*` content. Tag pages live directly at `/<tag>/` with no `/tags/` namespace.
|
||
|
||
### Collapsible sections
|
||
`collapse.js` wraps each h2/h3's following siblings in a `.section-body` div and injects a `.section-toggle` button into the heading. State is persisted per heading in `localStorage` under `section-collapsed:<id>`. After each `transitionend`, dispatches `window.resize` to retrigger sidenote positioning. Headings themselves are never hidden, preserving `IntersectionObserver` targets for `toc.js`.
|
||
|
||
### Atom feed
|
||
`/feed.xml` covers all essays and blog posts (up to 30 most recent). A `"content"` snapshot is saved in `Site.hs` *before* template application, so the feed body is just the compiled article HTML (not the full page with nav/footer). Dates from the `date` frontmatter key, formatted as RFC 3339.
|
||
|
||
### Author system
|
||
Authors are treated as a second tag dimension. `build/Authors.hs` provides `buildAllAuthors` (a `buildTagsWith` call keyed to `authors` frontmatter) and `authorLinksField` (a `listFieldWith` context that defaults to `["Levi Neuwirth"]` when no `authors` key is present, so all unattributed content contributes to his author page). Author pages live at `/authors/{slug}/`. `slugify` lowercases and hyphenates; pipe-separated values (`"Name | role"`) strip the role portion via `nameOf`.
|
||
|
||
### Settings panel
|
||
`settings.js` manages four independent settings, all persisted in `localStorage`:
|
||
- **Theme** (`data-theme` on `<html>`): light / dark, with `syncThemeButtons()` toggling `.is-active`.
|
||
- **Text size**: three steps `[20, 23, 26]` px (small / default / large), written as `--text-size` CSS custom property on `<html>`. Default index is 1 (23 px).
|
||
- **Focus mode** (`data-focus-mode` on `<html>`): hides TOC, fades header to 7% opacity until hover.
|
||
- **Reduce motion** (`data-reduce-motion` on `<html>`): collapses all `animation-duration` / `transition-duration` to `0.01ms`.
|
||
|
||
`theme.js` (sync, not deferred) restores all four attributes from `localStorage` before first paint to avoid flash.
|
||
|
||
### Selection popup
|
||
`selection-popup.js` / `selection-popup.css`. A toolbar appears 450 ms after any text selection of ≥ 2 characters. Context is detected from the DOM ancestors of the selection range:
|
||
|
||
| Context | Detection | Buttons |
|
||
|---------|-----------|---------|
|
||
| **code** (known lang) | `closest('pre, code, .sourceCode')` + `language-*` class | Copy · \<MDN / Hoogle / Docs…\> |
|
||
| **code** (unknown) | same, no `language-*` | Copy |
|
||
| **math** | `closest('.math, .katex')` + `Range.intersectsNode` fallback | Copy · nLab · OEIS · Wolfram |
|
||
| **prose** (multi-word) | fallback | BibTeX · Copy · DuckDuckGo · Here · Wikipedia |
|
||
| **prose** (single word) | `!/\s/.test(text)` | BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia |
|
||
|
||
16 languages are mapped to documentation providers (MDN, Hoogle, docs.python.org, doc.rust-lang.org, etc.) via `DOC_PROVIDERS`. **BibTeX** generates a `@online{...}` BibLaTeX entry (key = `lastname + year + firstWord`; selected text in `note={\enquote{...}}`; year scraped from `#version-history li`). **Here** opens `/search.html?q=` in a new tab. **Define** opens English Wiktionary. Popup positions above the selection, flips below if insufficient space; hides on scroll, outside mousedown, or Escape.
|
||
|
||
### Reading mode (poetry + fiction)
|
||
Shared codex layout for creative content. `templates/reading.html` omits the TOC and emits a `<div id="reading-progress">` progress bar instead. `body.reading-mode` (set via `$if(reading)$` in `default.html`) triggers a slightly warmer background (`#fdf9f1` / `#1c1917`). Poetry pages (`body.reading-mode.poetry`) use a 52ch measure, 1.85 line-height, stanza paragraph spacing, and suppressed dropcap/smallcaps lead-in; `poetryCompiler` enables `Ext_hard_line_breaks` so each source newline becomes `<br>`. Fiction pages (`body.reading-mode.fiction`) use a 62ch measure, centered Fira Sans smallcaps chapter headings, and a dropcap + smallcaps lead-in on each `h2 + p`. Progress bar is driven by `reading.js` (scroll position → `width` on `#reading-progress`). CSS and JS loaded conditionally via `$if(reading)$`. Content goes in `content/poetry/*.md` and `content/fiction/*.md`; tags `poetry` / `fiction` route items to the correct portal and library section.
|
||
|
||
### Score fragment system (option A)
|
||
`Filters/Score.hs` walks the Pandoc AST for `Div` nodes with class `score-fragment`. It reads the referenced SVG from disk (path resolved relative to the source file's directory via `getResourceFilePath` + `takeDirectory`), replaces hardcoded black fill/stroke values with `currentColor` (6-digit before 3-digit to prevent partial matches on `#000` vs `#000000`), and emits a `RawBlock "html"` `<figure>` carrying `class="score-fragment exhibit"`, `data-exhibit-name`, and `data-exhibit-type="score"` for gallery.js TOC integration. SVGs are inlined at build time and never served as separate files. `gallery.js` discovers `.score-fragment` elements via `discoverFocusableScores`, adds them to the shared `focusables[]` array with `type: 'score'`, and the overlay's `renderOverlay` branches on type — score path clones the SVG into the overlay body (no font-size loop); math path keeps the KaTeX re-render. Overlay body receives class `is-score` for tighter horizontal padding (`2rem 1.5rem` vs `3.5rem 4.5rem`). CSS: background rect removed via `svg > rect:first-child { fill: none }`, SVG responsive via `width: 100%; height: auto`, dark mode via `color: var(--text)`.
|
||
|
||
**Authoring syntax:**
|
||
```markdown
|
||
::: {.score-fragment score-name="Main Theme, mm. 1–8" score-caption="The opening gesture."}
|
||

|
||
:::
|
||
```
|
||
|
||
### Music — Composition landing pages + full score reader (option C)
|
||
|
||
**Implemented.** Two URLs per composition from one source directory.
|
||
|
||
#### Architecture
|
||
|
||
| URL | Templates | Purpose |
|
||
|-----|-----------|---------|
|
||
| `/music/{slug}/` | `composition.html` + `default.html` | Rich prose landing page |
|
||
| `/music/{slug}/score/` | `score-reader.html` + `score-reader-default.html` | Minimal page-turn reader |
|
||
|
||
The Hakyll `version "score-reader"` mechanism compiles the same `index.md` twice: once as the landing page (default version) and once as the reader (`customRoute` to `music/{slug}/score/index.html`). Score reader uses `makeItem ""` — the prose body is irrelevant; only frontmatter fields are needed.
|
||
|
||
#### Source directory layout
|
||
|
||
```
|
||
content/music/symphonic-dances/
|
||
├── index.md ← composition frontmatter + program notes prose
|
||
├── scores/
|
||
│ ├── page-01.svg ← one file per score page (LilyPond SVG output)
|
||
│ ├── page-02.svg
|
||
│ └── symphonic-dances.pdf
|
||
└── audio/
|
||
├── movement-1.mp3
|
||
└── movement-2.mp3
|
||
```
|
||
|
||
SVG, MP3, and PDF files are copied to `_site/` via `copyFileCompiler`. Score reader SVGs are served as separate `<img>` files — inlining a full orchestral score is impractical.
|
||
|
||
#### Frontmatter schema
|
||
|
||
```yaml
|
||
---
|
||
title: "Symphonic Dances with Claude"
|
||
date: 2026-03-01
|
||
abstract: >
|
||
A five-movement work for orchestra.
|
||
tags: [music]
|
||
instrumentation: "orchestra (2+picc.2+ca.2+bcl.2 — 4.3.3.1 — timp+3perc — hp — str)"
|
||
duration: "ca. 24'"
|
||
premiere: "2026-05-01"
|
||
commissioned-by: "—" # optional
|
||
pdf: scores/symphonic-dances.pdf # optional; path relative to composition dir
|
||
score-pages: # required for reader; landing page works without it
|
||
- scores/page-01.svg
|
||
- scores/page-02.svg
|
||
movements: # optional; omit entirely if no movement structure
|
||
- name: "I. Allegro con brio"
|
||
page: 1 # 1-indexed starting page in the reader
|
||
duration: "8'"
|
||
audio: audio/movement-1.mp3 # optional; omit if no recording
|
||
- name: "II. Adagio cantabile"
|
||
page: 8
|
||
duration: "10'"
|
||
---
|
||
```
|
||
|
||
#### `compositionCtx` fields
|
||
|
||
Extends `essayCtx` (all essay fields available — `abstract`, `toc`, `word-count`, etc.). Additional fields:
|
||
|
||
| Field | Type | Value |
|
||
|-------|------|-------|
|
||
| `$slug$` | string | `takeFileName . takeDirectory` of source path |
|
||
| `$score-url$` | string | `/music/{slug}/score/` |
|
||
| `$has-score$` | boolean | present when `score-pages` non-empty |
|
||
| `$score-page-count$` | string | `show (length score-pages)` |
|
||
| `$score-pages$` | list | each item: `$score-page-url$` (absolute URL) |
|
||
| `$has-movements$` | boolean | present when `movements` non-empty |
|
||
| `$movements$` | list | each item: `$movement-name$`, `$movement-page$`, `$movement-duration$`, `$movement-audio$`, `$has-audio$` |
|
||
| `$composition$` | flag | `"true"` — gates `score-reader.css` in `head.html` |
|
||
|
||
`movements` is parsed from the nested YAML using `Data.Aeson.KeyMap` (Aeson 2.x API). `score-pages` are resolved to absolute URLs (`/music/{slug}/{path}`) inside the context so the `data-pages` attribute in the score reader template needs no further processing.
|
||
|
||
#### Score reader
|
||
|
||
The reader template embeds page URLs as a comma-separated `data-pages` attribute on `#score-reader-stage`. `score-reader.js` splits on commas and filters empties.
|
||
|
||
`score-reader-default.html` loads only: `base.css`, `components.css` (for settings panel styles), `score-reader.css`, `theme.js` (sync, pre-paint), `settings.js` (theme toggle in the top bar), `score-reader.js`. No nav, no TOC, no sidenotes, no popups, no gallery, no lightbox.
|
||
|
||
`score-reader.js` behaviors:
|
||
- `navigate(page)`: swaps `<img src>`, updates counter, toggles prev/next disabled states, updates active movement button (last movement whose start page ≤ current page), calls `history.replaceState` for `?p=` deep linking, preloads ±1 pages.
|
||
- Keyboard: `ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown` for page turns; `Escape` → `history.back()`. Suppressed when settings panel is open.
|
||
- Dark mode: `[data-theme="dark"] .score-page { filter: invert(1); }` — clean for pure B&W notation; revisit if LilyPond embeds colored elements.
|
||
- Mobile: score scrolls horizontally at ≤ 640px (`min-width: 600px` on `<img>`); arrow buttons hidden; pinch-to-zoom is native.
|
||
|
||
#### Known limitations / future work
|
||
|
||
- **Full-piece audio**: a `recording` frontmatter key for a complete performance would add a top-level audio player on the landing page. Not yet implemented.
|
||
- **LilyPond margin cropping**: the `viewBox` drives scaling but LilyPond's default page includes margins. May need per-composition `viewBox` overrides or CSS `object-fit` once real scores are tested.
|
||
|
||
### Backlinks — Two-pass dependency-correct system
|
||
|
||
`build/Backlinks.hs`. The fundamental challenge: backlinks for page A require knowing what other pages link to A, but those pages haven't been compiled yet when A is compiled. Solved with a two-version architecture:
|
||
|
||
1. **Pass 1** (`version "links"`): each content file is compiled lightly — wikilinks preprocessed, Markdown parsed, AST walked block-by-block. For every internal link, the URL and the HTML of its surrounding `Para` block are recorded as a `LinkEntry { leUrl, leContext }`. Context rendered via `runPure (writeHtml5String opts (Pandoc nullMeta [Plain inlines]))` with `writerTemplate = Nothing` (fragment only). Result serialised as JSON per page.
|
||
|
||
2. **Pass 2** (`create ["data/backlinks.json"]`): loads all `version "links"` items, inverts the map (target → [source]), resolves each source's title and abstract from its metadata, emits `data/backlinks.json`.
|
||
|
||
3. **Context** (`backlinksField`): loads `data/backlinks.json` via `load`, looks up the current page's normalised URL, renders `<ul>` with `<details>`-collapsible context per entry.
|
||
|
||
**Key implementation details:**
|
||
- All `loadAll` / `loadAllSnapshots` / `buildTagsWith` / `buildPaginateWith` calls use `.&&. hasNoVersion` to prevent "links" version items from being picked up alongside default versions.
|
||
- `isPageLink` filters out `http://`, `https://`, `#`-anchors, `mailto:`, `tel:`, and static-asset extensions (`.pdf`, `.svg`, `.mp3`, etc.).
|
||
- JSON encoding uses `TL.unpack . TLE.decodeUtf8 . Aeson.encode` (not `LBSC.unpack`) to preserve non-ASCII characters in context paragraphs.
|
||
- Decoding uses `Aeson.decodeStrict (TE.encodeUtf8 (T.pack s))` symmetrically.
|
||
- `popups.js` excludes `.backlink-source` links from the internal-preview popup (same exception pattern as `.meta-authors`).
|
||
|
||
### Epistemic Profile
|
||
|
||
Implemented across `build/Stability.hs`, `build/Contexts.hs`, `templates/partials/page-footer.html`, `templates/partials/metadata.html`, and `static/css/components.css`.
|
||
|
||
**Context fields provided by `epistemicCtx`** (included in `essayCtx`):
|
||
|
||
| Field | Source | Notes |
|
||
|-------|--------|-------|
|
||
| `$status$` | frontmatter `status` | via `defaultContext` |
|
||
| `$confidence$` | frontmatter `confidence` | via `defaultContext` |
|
||
| `$importance-dots$` | frontmatter `importance` (1–5) | `●●●○○` rendered in Haskell |
|
||
| `$evidence-dots$` | frontmatter `evidence` (1–5) | same |
|
||
| `$confidence-trend$` | frontmatter `confidence-history` list | ↑ / ↓ / → from last two entries |
|
||
| `$stability$` | auto-computed via `git log --follow` | always resolves; never fails |
|
||
| `$last-reviewed$` | most recent commit date | formatted "%-d %B %Y"; `noResult` if no commits |
|
||
| `$scope$`, `$novelty$`, `$practicality$` | frontmatter | via `defaultContext` |
|
||
|
||
**Stability auto-calculation** (`build/Stability.hs`):
|
||
- Runs `git log --follow --format=%ad --date=short -- <filepath>` via `readProcessWithExitCode`.
|
||
- Heuristic: ≤ 1 commits or age < 14 days → *volatile*; ≤ 5 commits and age < 90 days → *revising*; ≤ 15 commits or age < 365 days → *fairly stable*; ≤ 30 commits or age < 730 days → *stable*; otherwise → *established*.
|
||
- `IGNORE.txt`: paths listed here use frontmatter `stability`/`last-reviewed` verbatim. Cleared by `> IGNORE.txt` in the Makefile's `build` target (one-shot pins).
|
||
|
||
**Critical implementation note:** Fields that use `unsafeCompiler` must return `Maybe` from the IO block and call `fail` in the `Compiler` monad afterward — not inside the `IO` action. Calling `fail` inside `unsafeCompiler`'s IO block throws an `IOError` that Hakyll's `$if()$` template evaluation does not catch as `NoResult`, causing the entire item compilation to error silently.
|
||
|
||
### Annotations (infrastructure only)
|
||
`annotations.js` stores annotations as JSON in `localStorage` under `site-annotations`, scoped per `location.pathname`. On `DOMContentLoaded`, `applyAll()` re-anchors saved annotations via a `TreeWalker` text-stream search (concatenates all text nodes in `#markdownBody`, finds exact match by index, builds a `Range`, wraps with `<mark>`). Cross-element ranges use `extractContents()` + `insertNode()` fallback. Four highlight colors (amber / sage / steel / rose) defined in `annotations.css` as `rgba` overlays with `box-decoration-break: clone`. Hover tooltip shows note, date, and delete button. Public API: `window.Annotations.add(text, color, note)` / `.remove(id)`. The selection-popup "Annotate" button is currently removed pending a UI revision.
|
||
|
||
---
|
||
|
||
## VII. Reference: Inspirations
|
||
|
||
- **gwern.net** — Primary model (Gwern Branwen + Said Achmiz). Semantic zoom, sidenotes, popups, monochrome, Pandoc+Hakyll.
|
||
- **Edward Tufte** — Sidenotes, information design
|
||
- **Matthew Butterick's Practical Typography** — Web typography in practice
|
||
- **Traditional book design** — The standard to aspire to on screen
|
||
|
||
---
|
||
|
||
*This specification is a living document updated as implementation progresses.*
|