About this website
Contents
- 1. Design principles
- 2. Architecture and delivery
- 3. Content model
- 4. Type system
- 5. Color and theme
- 6. Layout and spacing
- 7. Markdown pipeline
- 8. Reading apparatus and post features
- 9. Search
- 10. Sheet-music reader
- 11. Newsletter
- 12. Feeds and automation
- 13. Progressive enhancement
- 14. Accessibility
- 15. What the site does without
- 16. Credits
- How I use AI
“You must earn your ornaments.”
— gwern, whose website influenced this page
This site is static HTML generated by Astro, styled with Tailwind, and written in MDX content collections. Long-form content is compiled by an in-repo remark/rehype pipeline, with JavaScript treated as progressive enhancement rather than a requirement.
1. Design principles
- Static, and mine — pages are build artifacts served from a CDN; content and rendering rules live in the repository.
- Fast and light — CSS, fonts, and feature scripts are scoped to the pages that need them.
- Works with JavaScript off — markdown output, content pages, native details, feeds, scores-as-downloads, and static navigation remain usable without client scripts.
- No dependency I do not understand — site-specific Markdown behavior lives in readable local transforms instead of opaque plugin stacks.
A handful of slogans the rest of the site is measured against:
- Static over dynamic.
- Reader over author.
- Enhancement, never requirement.
- Self-hosted over CDN.
- No dependency I can’t read.
- Earn your ornaments.
2. Architecture and delivery
The site is an Astro static build (output: 'static') deployed to Cloudflare Pages. Astro renders the content collections to HTML, Tailwind emits the CSS, Pagefind indexes the built dist/ directory after the build, and Cloudflare serves the generated files from its CDN. The one runtime service is the newsletter subscription endpoint, which the Cloudflare adapter compiles to a single Worker; every reading page, listing, feed, search-index file, font, image, score, and audio file is a static build artifact.
| Layer | Choice | Technical reason |
|---|---|---|
| Generator | Astro, output: 'static' | HTML/CSS by default; no app-wide framework runtime in the browser |
| Host | Cloudflare CDN plus one Worker | Static files from dist/; the Worker backs only the newsletter route |
| Search | Pagefind, indexed post-build | Search is client-side and static; no search server or database |
| Runtime service | Newsletter subscribe API | The only dynamic user workflow on the site |
flowchart LR C["content collections<br/>(.md / .mdx)"] --> R["remark + rehype<br/>(+ Shiki, KaTeX)"] R --> A["Astro build<br/>static dist/"] A --> P["Pagefind<br/>postbuild index"] P --> CF["Cloudflare CDN<br/>+ one Worker"] CF --> L["Listmonk<br/>(newsletter)"]
Abandoned: a Vercel adapter (built 3eb4fa3) — replaced by Cloudflare (64675b7); a stale .vercel/output/ still lingers in the repo.
3. Content model
Content lives in six type-safe Astro collections, each validated against a Zod schema at build time:
| Collection | Holds | Notable schema |
|---|---|---|
| blog (Writing) | long-form posts | category (technical / slice-of-life / rants), tags, draft, unlisted; accepts .md and .mdx |
| projects | project pages | optional github / live URLs |
| garden | TILs, notes, resources | type (til / resource / note), optional external link |
| works | musical compositions | year, status, optional audio / score / instrumentation / duration; no date, no tags |
| reading | books and articles | discriminated union: books carry author/status/rating; articles require a link |
| pages | standalone pages, including this one | rendered directly, not listed |
The reading collection is a z.discriminatedUnion on type: a book may carry author/status/rating but is forbidden a link; an article requires a link but is forbidden the book fields, enforced with z.never(). Shape and information architecture are separate surfaces: schemas define what an entry is, while separate config decides which collections appear in navigation, on the homepage, and in feeds.
Blog posts carry two visibility switches. draft: true keeps a post out of
the build entirely; unlisted: true builds the page at its normal URL but
withholds it from every enumeration — listings, tag pages, both feeds, the
sitemap, and the search index — and adds a noindex hint for crawlers.
Link-only sharing, not access control; when both are set, draft wins.
flowchart TD
RD["reading entry"] --> T{"type"}
T -->|book| BK["author · status · rating<br/>(link forbidden)"]
T -->|article| AR["link required<br/>(book fields forbidden)"]
Abandoned: a /writings slug — reverted to /blog (e6a9464, fixed during templatization).
4. Type system
The site uses four typefaces with deliberately narrow roles. Font roles are runtime CSS variables in tokens.css; Tailwind bridges those roles into utility names. The unusual part is intentional: font-sans and font-mono both alias the serif body face, while code must use font-code.
| Role | Face | CSS role | Shipped file(s) | Size | Preloaded |
|---|---|---|---|---|---|
| Display and headings | Bodoni Std | --font-display | BodoniStd-Roman.woff2 | 14.9 KB | yes |
| Reading body | EB Garamond | --font-serif | EBGaramond-Variable.woff2; EBGaramond-Italic-Variable.woff2 | 41.3 KB; 42.4 KB | roman only |
| Code | Comic Mono | --font-code | ComicMono.ttf; ComicMono-Bold.ttf | 18.7 KB; 20.5 KB | no |
| Resume only | Inter | font-dense | Inter-Variable.woff2 | 48.3 KB | no |
All fonts are self-hosted with font-display: swap. The shipped files are the subset boundary; there is no CSS unicode-range split. Only the above-the-fold reading/display faces, EB Garamond roman and Bodoni roman, are preloaded.
The quick brown fox
ABCDEFG abcdefg 0123456789
Titles and section headings. A Didone with hard thin-to-thick contrast — and the face I set my musical scores in. Self-hosted woff2, roman + bold (~30 KB), the roman preloaded.
The quick brown fox
ABCDEFG abcdefg 0123456789
The workhorse — this paragraph, and nearly every word on the site. An old-style serif built for long paragraphs. Self-hosted variable woff2, roman + italic (~80 KB), the roman preloaded.
The quick brown fox
ABCDEFG abcdefg 0123456789
All code, inline and fenced. Matches my terminal and makes code read a little handwritten. Self-hosted — still a pair of plain .ttf files (~38 KB).
The quick brown fox
ABCDEFG abcdefg 0123456789
Reserved for the one page too dense for a serif — the résumé. Self-hosted variable woff2 (~47 KB), so it looks the same for everyone.
UI scale
The UI scale resizes from --ui-base: 1.33rem. Tailwind generates text-xs through text-4xl from SCALE_RATIO = 1.172; lg, xl, 2xl, and 3xl are pinned overrides to preserve the current display proportions. The generated classes are safelisted so every step exists even if a page does not reference it directly.
| Step | Exponent | Multiplier | Line height | Use |
|---|---|---|---|---|
xs | -2 | 1.172^-2 | 1.47 | metadata |
sm | -1 | 1.172^-1 | 1.6 | secondary UI |
base | 0 | 1 | 1.57 | UI base |
lg | 1 | 1.1064 | 1.5 | emphasized UI |
xl | 2 | 1.2766 | 1.4 | compact display |
2xl | 3 | 1.5319 | 1.28 | section display |
3xl | 4 | 1.8298 | 1.21 | large display |
4xl | 5 | 1.172^5 | 1.15 | display maximum |
UI type scale — each step is --ui-base × 1.172^n; lg–3xl are pinned overrides.
Live sizes:
- Ag4xl · text-4xl · display maximum
- Ag3xl · text-3xl · large display
- Ag2xl · text-2xl · section display
- Agxl · text-xl · subhead display
- Aglg · text-lg · emphasized UI
- Agbase · text-base · UI base
- Agsm · text-sm · secondary UI
- Agxs · text-xs · metadata
Prose and resume scales
Reading prose is separate from UI chrome. --prose-size: 1.375rem sets body copy; --lh: 1.7 and --rhythm: calc(1em * var(--lh)) define the vertical rhythm. Prose headings are em ratios of body size: h1 uses clamp(1.92em, 5.5vw, 2.88em) at line-height 1.04, h2 is 1.48em, and h3 is 1.12em. Flow spacing is three rhythm multipliers: --flow-gap: 0.85, --flow-above-heading: 0.5, and --flow-below-heading: 0.35.
The resume page uses a third, dense scale. Under .resume-base, text-xs through text-4xl are pinned back to stock Tailwind rem values and rendered in Inter, keeping print and dense-list layouts compact without changing the rest of the site.
Considered: Google Fonts via CDN, and the conventional font-sans / font-mono defaults — declined in favor of self-hosted woff2 subsets and serif aliases.
Abandoned: an all-mono setup (JetBrains Mono everywhere) — replaced by the four-typeface system (b556e6b); JetBrains Mono was later dropped entirely, leaving font-sans / font-mono as serif aliases; fonts were once only named in CSS, now self-hosted.
5. Color and theme
Color tokens are stored as space-separated RGB channels and consumed as rgb(var(--color-...) / <alpha-value>). That form is what lets Tailwind opacity utilities work against themeable variables.
| Token | Dark | Light | Role |
|---|---|---|---|
--color-background | 19 15 10 | 241 236 224 | page background |
--color-surface | 29 24 18 | 248 244 233 | cards and panels |
--color-surface-muted | 38 32 24 | 232 226 210 | subdued surfaces |
--color-surface-contrast | 22 18 12 | 223 216 198 | contrast surfaces |
--color-border | 44 38 29 | 210 201 182 | borders and dividers |
--color-text-primary | 248 247 244 | 26 24 20 | body text |
--color-text-secondary | 165 161 154 | 92 86 74 | muted text |
--color-accent | 59 130 246 | 37 99 235 | links, selection, active UI |
Prose colors are not a separate palette; Tailwind Typography reads the same tokens through the --tw-prose-* bridge. Links are accent-colored and use color-only hover/focus treatment, with external content links marked by an icon rather than a persistent underline.
Background
Surface
Surface muted
Border
Text
Text muted
Accent
Accents and admonitions
Collection accents are data-driven. A registry maps categories and collections to five theme tokens, validates unknown values, and emits inline --card-accent / --row-accent values while the styling stays static.
| Accent token | Dark | Light | Used by |
|---|---|---|---|
--accent-technical | 90 158 152 | 47 99 94 | technical blog, default |
--accent-slice-of-life | 195 154 58 | 125 96 22 | slice-of-life blog |
--accent-rants | 164 127 176 | 126 90 140 | rants blog |
--accent-project | 125 154 106 | 74 97 62 | projects |
--accent-work | 135 141 192 | 86 91 134 | works |
- technical
- slice-of-life
- rants
- project
- work
Admonitions use separate --adm-tip, --adm-caution, and --adm-danger tokens, with note/default boxes falling back to the page accent. The same type registry is used for body callouts and typed sidenotes.
Theme selection
Dark is the default :root theme. An inline script in the document head reads localStorage['theme'], sets html[data-theme] and color-scheme before first paint, and falls back to dark if storage is unavailable. prefers-color-scheme is deliberately not consulted; the header toggle is the explicit control surface. Light theme gets thicker hairlines (--theme-outline-width: 2px, --theme-divider-width: 1.5px) because cream backgrounds hide single-pixel borders more easily. Audio controls also switch from invert(1) in dark to none in light.
flowchart LR LS["localStorage['theme']"] --> H["inline <head> script<br/>(before paint)"] H --> D["html[data-theme]"] D --> PT["first paint<br/>without theme flash"]
Considered: a slate-blue accent (#3a5a78, rejected at design D3), and consulting prefers-color-scheme — both declined in favor of the blue accent and an explicit toggle.
Abandoned: a clay/rust primary accent — shipped (87dee95), then reverted to blue (c4d7623); a neon link halo / ambient glow on every link — replaced by color-only hover (e30b96a); and accent-colored inline code — demoted to body text color because the box and Comic Mono already signal code.
6. Layout and spacing
The reading column is controlled by --measure: 50rem. .prose and EntryLayout cap content to that measure; post pages add side room with max-w-[calc(var(--measure)+3rem)], wide pages can reach max-w-[96rem], and general collection pages use max-w-5xl.
--measure-half: calc(var(--measure) / 2) derives the sidenote and table-of-contents gutter geometry. The TOC rail uses the space between the viewport and the measure; sidenotes position themselves from the same half-measure value, so changing the reading width keeps the margin apparatus in proportion.
| Breakpoint | Value | Surface |
|---|---|---|
| mobile | 640px | Tailwind sm; directive/mobile cutovers |
| toc | 1280px | desktop section lens / TOC rail |
| sidenote | 1366px | footnote-to-margin-note cutover |
Spacing is intentionally narrow as a control surface. The site centralizes reading measure, line-height, rhythm, and flow gaps; it does not define general --space-*, --radius-*, or --font-weight-* token families. Component chrome uses Tailwind defaults inline unless a repeated semantic token has earned a name.
Considered: a hardcoded 800px / max-w-[50rem] reading width — declined in favor of the --measure token.
7. Markdown pipeline
Every long-form page is compiled by dependency-free local transforms: 1,063 lines across src/lib/markdown/ plus an 11-line re-export barrel, with an 860-line unit-test file. Two plugins run on raw Markdown before parsing; the rest transform the HTML AST. Order matters: figure credits run after figures, and collapsibles run after heading anchors.
| Stage | Transform | What it does |
|---|---|---|
| pre-parse | multiline blockquotes | rewrites GitLab >>> ... >>> fences to > blockquotes |
| pre-parse | math | remark-math marks $...$ / $$...$$ for KaTeX |
| AST | table quotes | corrects a smartypants quote at the start of a table cell |
| AST | figures | wraps lone images as <figure>, promotes a following italic line to a caption |
| AST | image dimensions | reads SVG/JPEG/PNG/WebP dimensions from the file header at build time |
| AST | figure credits | fuzzy-matches each figure to its line in the image-credits list |
| AST | directives | ::: float, ::: columns N, ::: table sortable |
| AST | admonitions | > [!TYPE] callout boxes |
| AST | heading anchors | adds stable ids and # permalinks to h2-h4 |
| AST | collapsibles | folds back-matter sections into native <details> |
| AST | mermaid | converts Mermaid fences to render targets, source preserved |
| AST | KaTeX | renders math to static markup at build time |
| AST | sidenotes | turns footnotes into Tufte-style margin notes |
Highlighting is Shiki, KaTeX is rendered, and image dimensions are parsed at build time. The pipeline is unit-tested; runtime features still rely on build and browser checks because not every parser or client behavior is covered by the unit file.
flowchart TD SRC["Markdown / MDX"] --> PRE["pre-parse:<br/>multiline blockquotes · math"] PRE --> AST["HTML AST transforms (ordered):<br/>table-quotes → figures → image-dims →<br/>figure-credits → directives → admonitions →<br/>heading-anchors → collapsibles → mermaid →<br/>KaTeX → sidenotes"] AST --> OUT["Shiki highlight<br/>static HTML"]
Considered: client-side MathJax for math — declined in favor of build-time KaTeX.
Abandoned: a blog-only pipeline gate (scope.mjs / isBlogFile) — removed (PR #20) so rich prose renders across all collections.
Live parser specimens
Note
A neutral clarification or definition.
Tip
Actionable advice or a shortcut.
Caution
Tricky or easy to misread.
Danger
A pitfall with real cost.
A plain sidenote keeps an aside next to the sentence that needs it.1 A typed sidenote uses the same admonition registry as the body callouts.2
export function staticFirst(feature) {
return feature.enhancement ?? feature.html;
}
- Markdown stays readable before transforms.
- The generated HTML carries stable anchors.
- Diagrams keep their source text.
- Details remain native HTML.
8. Reading apparatus and post features
The runtime reading apparatus is separate from the build-pipeline line count. SectionLens maps headings down the page edge with reading progress and a synchronized collapsible table of contents; on narrower screens the table of contents is native <details>. The lens is enhancement only: headings, anchors, and the article remain static without it.

Lightbox behavior is also enhancement only. Figures and diagrams are ordinary content first; the client script adds image zoom when present.

Sortable tables, native collapsibles, expandable details, multi-column lists, and floated figures all preserve a static substrate. A reader without JavaScript gets the article, the figures, the source text for diagrams, native disclosure controls, and ordinary footnotes; JavaScript adds sorting, diagram rendering, section-progress UI, and image zoom.
Abandoned: a sticky 14rem table-of-contents rail plus a standalone reading-progress bar — replaced by the focus-dots SectionLens (rebuilt roughly three times; 1cb5c44, b464474).
9. Search
Search is Pagefind, and it runs entirely in the browser against an index built at deploy time. After astro build, the postbuild step runs pagefind --site dist, crawls the emitted HTML, and writes a static /pagefind/ index. Only the <main> body is indexed, and newsletter signup blocks are ignored, so repeated navigation, footer, and form copy stay out of results.
The UI is a command palette opened from the header. It lazy-loads the search script on first use, so search adds no JavaScript to the initial page load. With JavaScript off the palette is inert, but every indexed page is still static and crawlable.

Considered: a hosted search service / Google CSE — declined in favor of in-browser Pagefind.
10. Sheet-music reader
Compositions are PDFs of orchestral scores, read through a custom two-page-spread reader built on pdf.js. The reader runs pdf.js in a Web Worker, off the main thread, and renders each page to a canvas scaled by devicePixelRatio for sharpness on high-density screens.
The spread model is pure math: a lone cover page, then facing pairs (2-3, 4-5, and so on). Rendering uses a single-flight queue; a new page request cancels any in-flight render instead of racing it. Controls cover zoom from 60% to 200%, drag-to-pan, and keyboard paging. With JavaScript off, the score surface degrades to a direct “Download Score” link.
Live score pages: Dance for the screenshot source, and Symphony No. 1 remains linked from the colophon copy.

Considered: embedding a live pdf.js reader directly on this colophon — declined in favor of a screenshot plus a link to a live score page.
11. Newsletter
The newsletter is a self-hosted Listmonk instance behind one Cloudflare Worker. The signup form is a native HTML POST; with JavaScript it upgrades to fetch with inline status. The Worker validates the email, drops bot submissions via a honeypot field, and, when configured, verifies a reCAPTCHA v3 token server-side before forwarding to Listmonk’s public subscription API. Missing configuration returns 503 rather than silently dropping a signup.
The same handler is exposed at two URLs: /api/newsletter-subscribe and the alias /api/subscribe. GET returns 405; subscription is a POST workflow.
When reCAPTCHA is enabled, the form loads Google’s reCAPTCHA script. That is the one third-party request on the site, scoped to the contact surface and configuration-gated. It also creates the one JavaScript-required workflow: under enforced reCAPTCHA, a no-JavaScript form post cannot mint a token.
sequenceDiagram participant F as Form participant W as Worker participant G as Google siteverify participant L as Listmonk F->>W: POST (email, honeypot, reCAPTCHA token?) W->>G: verify token (when enabled) G-->>W: score W->>L: subscribe (public API) W-->>F: 303 redirect (no-JS) / JSON (JS)
Abandoned: automated campaign sending plus @TrackLink click-tracking (removed b36c9ea) — replaced by draft-only RSS→Listmonk with a manual send.
12. Feeds and automation
Two RSS feeds are generated as static files at build time: /rss.xml for feed-enabled collections and /rss-blog.xml for blog-only automation. The document head points readers to /rss.xml; npm run rss:sync consumes /rss-blog.xml so newsletter automation remains blog-scoped.
rss:sync, run from CI on blog pushes, turns the latest blog item into a draft Listmonk campaign. It dedupes by content hash, builds the email body, and never sends; sending stays manual.
flowchart LR B["blog content"] --> RSS["/rss-blog.xml<br/>(static build output)"] RSS --> SYNC["rss:sync<br/>dedupe by content hash"] SYNC --> LM["Listmonk draft campaign"] LM --> H["human send step"]
13. Progressive enhancement
The site ships no framework runtime by default. JavaScript is opt-in per feature and code-split, so the base experience is HTML and CSS.
| Feature | No-JS substrate | Enhancement |
|---|---|---|
| Theme | dark pre-paint default from inline head script | header toggle and persistence |
| Mermaid | source text remains visible | lazy render and theme re-render |
| Collapsibles | native <details> | styling and generated back-matter folding |
| Sidenotes | footnotes remain in the document | wide-screen margin placement |
| Score reader | download link | pdf.js two-page canvas reader |
| Search | static pages remain crawlable | command palette and Pagefind query UI |
| Newsletter | native POST form | fetch status, except reCAPTCHA requires JS for token minting |
14. Accessibility
The document structure uses semantic landmarks (header, nav, main, footer), lang="en", and color-scheme. Interactive components carry ARIA where they need state or announcements: the search dialog traps and restores focus, navigation marks the current page, score controls are labelled, and newsletter status uses live regions. prefers-reduced-motion disables motion-heavy effects.
Known gap: there is no skip-to-content link yet.
15. What the site does without
- Analytics: no tracking script.
- Third-party font CDN: every font is self-hosted; there is no Google Fonts request.
- Comments: no comment system or moderation surface.
- SPA framework: no app-wide runtime for static text.
- Popups or transclusion: no hover preview system.
The only third-party request is Google reCAPTCHA on the newsletter form, and only when enabled.
16. Credits
The implementation stands on Astro, Tailwind, Pagefind, KaTeX, Mermaid, Shiki, pdf.js, and Listmonk. The reading ideas cite Edward Tufte for margin notes, Donald Knuth and TeX for dangerous-bend callouts, Wikipedia for collapsible contents, and gwern for the public design-colophon form.
The type is self-hosted: EB Garamond for body text, Bodoni Std for display, Comic Mono for code, and Inter for the resume.
How I use AI
This site discloses AI involvement per post. A small signal-strength indicator on each post’s metadata line rates that post on the 0–5 scale below; hover it for the exact level and rubric name. This table is the source of truth for what each level means. The principle: if text carries my voice or an I, I wrote it — AI does not speak as me.
| Level | Name | Meaning |
|---|---|---|
| 0 | no AI | No AI. Written by hand (mechanical spell/grammar aside). |
| 1 | AI-assisted research | AI for research / brainstorming / feedback only; all published prose human-written. |
| 2 | AI-edited | AI polished human prose (grammar, phrasing, tightening); substance and structure human. |
| 3 | human–AI collaboration | Meaningful portions AI-drafted, then human-revised and verified. |
| 4 | AI-drafted, human-reviewed | AI wrote most of the draft; human edited, restructured, fact-checked. |
| 5 | AI-generated | Substantially AI-generated, little to no human review. |
Disclosure is per-post frontmatter (ai.level 0–5, optional tools / note), rendered as the metadata-line indicator — machine-readable now, and ready to emit provenance metadata later.