← About

About this website

· 17 min read · AI
Contents

“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.

LayerChoiceTechnical reason
GeneratorAstro, output: 'static'HTML/CSS by default; no app-wide framework runtime in the browser
HostCloudflare CDN plus one WorkerStatic files from dist/; the Worker backs only the newsletter route
SearchPagefind, indexed post-buildSearch is client-side and static; no search server or database
Runtime serviceNewsletter subscribe APIThe 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:

CollectionHoldsNotable schema
blog (Writing)long-form postscategory (technical / slice-of-life / rants), tags, draft, unlisted; accepts .md and .mdx
projectsproject pagesoptional github / live URLs
gardenTILs, notes, resourcestype (til / resource / note), optional external link
worksmusical compositionsyear, status, optional audio / score / instrumentation / duration; no date, no tags
readingbooks and articlesdiscriminated union: books carry author/status/rating; articles require a link
pagesstandalone pages, including this onerendered 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.

RoleFaceCSS roleShipped file(s)SizePreloaded
Display and headingsBodoni Std--font-displayBodoniStd-Roman.woff214.9 KByes
Reading bodyEB Garamond--font-serifEBGaramond-Variable.woff2; EBGaramond-Italic-Variable.woff241.3 KB; 42.4 KBroman only
CodeComic Mono--font-codeComicMono.ttf; ComicMono-Bold.ttf18.7 KB; 20.5 KBno
Resume onlyInterfont-denseInter-Variable.woff248.3 KBno

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.

Display & headingsBodoni Std

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.

Reading & bodyEB Garamond

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.

CodeComic Mono

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).

Résumé onlyInter

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.

StepExponentMultiplierLine heightUse
xs-21.172^-21.47metadata
sm-11.172^-11.6secondary UI
base011.57UI base
lg11.10641.5emphasized UI
xl21.27661.4compact display
2xl31.53191.28section display
3xl41.82981.21large display
4xl51.172^51.15display 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.

TokenDarkLightRole
--color-background19 15 10241 236 224page background
--color-surface29 24 18248 244 233cards and panels
--color-surface-muted38 32 24232 226 210subdued surfaces
--color-surface-contrast22 18 12223 216 198contrast surfaces
--color-border44 38 29210 201 182borders and dividers
--color-text-primary248 247 24426 24 20body text
--color-text-secondary165 161 15492 86 74muted text
--color-accent59 130 24637 99 235links, 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 tokenDarkLightUsed by
--accent-technical90 158 15247 99 94technical blog, default
--accent-slice-of-life195 154 58125 96 22slice-of-life blog
--accent-rants164 127 176126 90 140rants blog
--accent-project125 154 10674 97 62projects
--accent-work135 141 19286 91 134works
  • 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 &lt;head&gt; 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.

BreakpointValueSurface
mobile640pxTailwind sm; directive/mobile cutovers
toc1280pxdesktop section lens / TOC rail
sidenote1366pxfootnote-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.

StageTransformWhat it does
pre-parsemultiline blockquotesrewrites GitLab >>> ... >>> fences to > blockquotes
pre-parsemathremark-math marks $...$ / $$...$$ for KaTeX
ASTtable quotescorrects a smartypants quote at the start of a table cell
ASTfigureswraps lone images as <figure>, promotes a following italic line to a caption
ASTimage dimensionsreads SVG/JPEG/PNG/WebP dimensions from the file header at build time
ASTfigure creditsfuzzy-matches each figure to its line in the image-credits list
ASTdirectives::: float, ::: columns N, ::: table sortable
ASTadmonitions> [!TYPE] callout boxes
ASTheading anchorsadds stable ids and # permalinks to h2-h4
ASTcollapsiblesfolds back-matter sections into native <details>
ASTmermaidconverts Mermaid fences to render targets, source preserved
ASTKaTeXrenders math to static markup at build time
ASTsidenotesturns 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

eiπ+1=0e^{i\pi} + 1 = 0
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.
Site favicon
Figure handling wraps a lone image and caption while preserving intrinsic dimensions.

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.

Section lens rail and reading progress on the colophon page.
SectionLens maps the long article into a fixed progress rail while the static headings and anchors remain ordinary page content.

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

Diagram opened in the colophon lightbox.
The same figure remains inline HTML, then upgrades into a keyboardable lightbox when the client script is available.

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).

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.

Pagefind command palette with a colophon search result.
The header search palette lazy-loads Pagefind, queries the static index, and links back into the generated colophon page.

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.

Two-page spread in the pdf.js score reader.
The reader opens a real score as a two-page spread, with paging and zoom controls kept outside the rendered canvases.

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.

FeatureNo-JS substrateEnhancement
Themedark pre-paint default from inline head scriptheader toggle and persistence
Mermaidsource text remains visiblelazy render and theme re-render
Collapsiblesnative <details>styling and generated back-matter folding
Sidenotesfootnotes remain in the documentwide-screen margin placement
Score readerdownload linkpdf.js two-page canvas reader
Searchstatic pages remain crawlablecommand palette and Pagefind query UI
Newsletternative POST formfetch 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.

LevelNameMeaning
0no AINo AI. Written by hand (mechanical spell/grammar aside).
1AI-assisted researchAI for research / brainstorming / feedback only; all published prose human-written.
2AI-editedAI polished human prose (grammar, phrasing, tightening); substance and structure human.
3human–AI collaborationMeaningful portions AI-drafted, then human-revised and verified.
4AI-drafted, human-reviewedAI wrote most of the draft; human edited, restructured, fact-checked.
5AI-generatedSubstantially 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.

Footnotes

  1. This is a plain sidenote. On wide screens it can sit in the gutter; on narrow screens it remains a regular footnote.

  2. Caution This is a typed sidenote. It shares the caution token and icon language with the body callout above.