krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

a50bdfe77502f0e48848a1e1bf873862fa2ce1c0

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-14T21:17:22Z

Add four built-in site themes

A zero-config build produced a real site with no CSS at all: correct HTML that
nobody would publish without writing a stylesheet first. `site.theme` names one
of four compiled-in stylesheets — `plain`, `blog`, `wiki`, `docs` — and each
build writes it to the output root as `theme.css`, exactly as `syntax.css` is
already written from a syntect theme.

Empty by default, so no existing site is restyled by upgrading and no site that
ships its own CSS finds a second stylesheet competing with it. An unknown name
is an error listing the four. `orgo init` writes `theme = "blog"`, the one line
in the starter config that is not a default.

Templates reach it through a new `theme` variable, guarded and linked *before*
`stylesheet` so the highlighter's colours win over the theme's. The built-in
layout and the starter templates do this; a `base.html` of your own can ignore
`theme.css` entirely. The name lives in `Config`, so it is in the config hash
and switching themes re-renders every page that links it.

A theme styles only markup orgo already emits — headings, tags, TODO keywords,
checkbox lists, footnotes, tables — plus the chrome the layouts put around it.
There is no theme-specific HTML, so switching or removing one touches no
template. Colours are `--orgo-*` properties on `:root`, which is the supported
way to adjust one rather than replace it.

The documentation site is the first user, and switching it over paid for
itself twice:

- `--orgo-code-*` coloured inline `code` as well as code blocks, so a site
  pairing a dark `highlight.theme` with a theme — as the docs do — turned every
  inline `code` into a dark pill on a light page. Inline code now follows the
  page; those three properties colour blocks only. The docs site's `style.css`
  is down from 130 lines to those three.
- A bare URL widened the page to 573px in a 320px viewport. `body` now sets
  `overflow-wrap: break-word` in all four.

Code blocks are the one part that cannot follow `prefers-color-scheme`, since
`syntax.css` is generated from a single syntect theme; a theme's dark mode keeps
its block surface light to stay readable against the default `InspiredGitHub`,
and a dark highlight theme is paired by overriding the three properties.
Everything else — including `TODO`/`DONE`, which were a dark red on a dark
ground until this — resolves per scheme.
 README.md                       |   5 +-
 docs/guide/02-configuration.org |  50 +++++++
 docs/guide/04-templates.org     |  14 ++
 docs/guide/10-deploying.org     |   4 +-
 docs/guide/11-versioning.org    |   9 +-
 docs/orgo.toml                  |   5 +
 docs/quickstart.org             |   2 +-
 docs/style.css                  | 136 ++----------------
 docs/templates/base.html        |   5 +-
 src/config.rs                   |  32 ++++-
 src/lib.rs                      |   1 +
 src/main.rs                     |  14 +-
 src/site.rs                     |  26 ++++
 src/template.rs                 |  16 +++
 src/theme.rs                    |  43 ++++++
 tests/config.rs                 | 110 +++++++++++++++
 themes/blog.css                 | 298 ++++++++++++++++++++++++++++++++++++++++
 themes/docs.css                 | 247 +++++++++++++++++++++++++++++++++
 themes/plain.css                | 205 +++++++++++++++++++++++++++
 themes/wiki.css                 | 275 ++++++++++++++++++++++++++++++++++++
 20 files changed, 1362 insertions(+), 135 deletions(-)

diff --git a/README.md b/README.md
index fb7217d..bcbe533 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,9 @@ Open <http://127.0.0.1:3000>. Edit `my-site/index.org`, save, and the page reloa
 own — that is the loop you will spend your time in.
 
 `init` writes a starter post, a page layout you can edit, and a config file with every
-setting explained in comments. It never overwrites a file you already have.
+setting explained in comments. It never overwrites a file you already have. It also picks
+one of the four built-in themes — `plain`, `blog`, `wiki`, `docs` — so the site is styled
+from the first build; change `theme` in `orgo.toml`, or empty it and write your own CSS.
 
 ## Org-Mode, Anywhere
 
@@ -78,6 +80,7 @@ Each of these is a few lines of config, and each has a page in the guide:
 | Tag pages, and an index of tags | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) |
 | An RSS feed | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) |
 | Numbered pages when a list gets long | [Collections](https://krazywarez.github.io/orgo/guide/03-collections.html) |
+| A built-in theme for a blog, a wiki or a doc site | [Configuration](https://krazywarez.github.io/orgo/guide/02-configuration.html) |
 | Your own design, in ordinary HTML templates | [Templates](https://krazywarez.github.io/orgo/guide/04-templates.html) |
 | Drafts that stay unpublished until you say so | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) |
 | A table of contents on long posts | [Authoring](https://krazywarez.github.io/orgo/guide/06-authoring.html) |
diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
index f436525..4b94bc4 100644
--- a/docs/guide/02-configuration.org
+++ b/docs/guide/02-configuration.org
@@ -18,6 +18,7 @@ title = "orgo site"
 base_url = ""
 description = ""
 language = "en"
+theme = ""
 
 [nav]
 mode = "top-level"
@@ -52,6 +53,55 @@ Plus any number of =[[collections]]= blocks, documented in [[file:03-collections
 | =base_url= | ="" | Absolute origin, *no trailing slash*. |
 | =description= | ="" | Available as ={{ site.description }}=. |
 | =language= | ="en"= | Goes in =<html lang>= in the built-in layout. |
+| =theme= | ="" | A built-in stylesheet, written to the output as =theme.css=. |
+
+** theme
+
+Four themes are compiled into the binary. Name one and each build writes it to the
+output root as =theme.css=, which the built-in layout and the templates =orgo init=
+writes both link.
+
+| Theme | Shape | For |
+|-------+-------+-----|
+| ="plain"= | Narrow, system fonts, hairline rules. | Readable defaults to build your own CSS on. |
+| ="blog"= | Serif prose, a centred masthead, styled post lists. | Dated writing. |
+| ="wiki"= | Wide and dense, contents in the margin, TODO states as badges. | Notes, a reference site. |
+| ="docs"= | Narrow, a contents card, quote blocks as notes, =#+LEDE:=. | A guide read in order. |
+
+All four follow =prefers-color-scheme=, so a site gets a dark mode without a toggle, a
+setting or a line of JavaScript — and all four reflow from a 320px phone up, with tables
+and code blocks scrolling inside their own box rather than widening the page.
+
+The default is empty: no stylesheet is written and no page links one, so the output is
+unstyled HTML. That is deliberate — a site that already ships CSS of its own should not
+find a second stylesheet competing with it, and upgrading orgo should never restyle a
+site. An unknown name is an error listing the four.
+
+A theme styles the markup orgo already emits — headings, tags, TODO keywords, checkbox
+lists, footnotes, tables — plus the chrome the built-in layout puts around it. There is
+no theme-specific HTML, so switching or removing one touches no template.
+
+Every colour is a custom property on =:root=, named =--orgo-*=. To adjust rather than
+replace a theme, ship a stylesheet of your own as an asset, link it after =theme.css=,
+and redefine the handful you care about:
+
+#+BEGIN_SRC css
+:root {
+  --orgo-accent: #7a1fa2;
+  --orgo-measure: 46rem;
+}
+#+END_SRC
+
+Code /blocks/ are the one part a theme cannot make follow =prefers-color-scheme=:
+=syntax.css= is generated from a single syntect theme, so a theme's dark mode keeps its
+block surface light to stay readable against the default =InspiredGitHub=. Pair a dark
+=highlight.theme= with overrides of =--orgo-code-bg=, =--orgo-code-fg= and
+=--orgo-code-rule=. Those three colour blocks only — inline =~code~= follows the page's
+own scheme, so it stays legible whichever highlight theme you use. The documentation site
+does exactly this; its =style.css= is those three lines and nothing else.
+
+When you outgrow a theme, drop =theme= from the config and write =templates/base.html=
+against your own CSS. Nothing else changes.
 
 ** base_url
 
diff --git a/docs/guide/04-templates.org b/docs/guide/04-templates.org
index 941a8d3..1a8d757 100644
--- a/docs/guide/04-templates.org
+++ b/docs/guide/04-templates.org
@@ -150,6 +150,20 @@ depth:
 URL of the generated =syntax.css=, relative to this page. Link it or code blocks are
 unstyled.
 
+** theme
+
+URL of =theme.css=, relative to this page — the [[file:02-configuration.org][built-in theme]] named by
+=site.theme=. Empty when there is none, which is the default, so guard it and link it
+*before* =stylesheet= or the theme's code colours would override the highlighter's:
+
+#+BEGIN_SRC html
+{% if theme %}<link rel="stylesheet" href="{{ theme }}">{% endif %}
+{% if stylesheet %}<link rel="stylesheet" href="{{ stylesheet }}">{% endif %}
+#+END_SRC
+
+A layout that ignores it is a layout with its own CSS, which is the point at which you
+have outgrown the setting.
+
 ** pages, group, groups, paginator
 
 Present on generated pages; see [[file:03-collections.org][Collections]]. =pages= is also
diff --git a/docs/guide/10-deploying.org b/docs/guide/10-deploying.org
index 7ce122c..03e86eb 100644
--- a/docs/guide/10-deploying.org
+++ b/docs/guide/10-deploying.org
@@ -14,8 +14,8 @@ Two differences from the build you run while writing:
   bad build fails rather than shipping.
 - No =--drafts=, so pages marked =#+DRAFT:= stay out.
 
-Everything in =_site= is the site: HTML, the generated =syntax.css=, and every asset
-copied from the source. There is no runtime, no server requirement and no build step
+Everything in =_site= is the site: HTML, the generated =syntax.css=, =theme.css= if the
+config names a [[file:02-configuration.org][theme]], and every asset copied from the source. There is no runtime, no server requirement and no build step
 downstream.
 
 * Set base_url for production
diff --git a/docs/guide/11-versioning.org b/docs/guide/11-versioning.org
index 45b5e2d..80718a9 100644
--- a/docs/guide/11-versioning.org
+++ b/docs/guide/11-versioning.org
@@ -12,7 +12,7 @@ Changing any of this incompatibly requires a major version.
 | Stable | What that covers |
 |--------+------------------|
 | =orgo.toml= keys | Their names, types and meaning. |
-| Template context | =page=, =site=, =nav=, =root=, =pages=, =group=, =groups=, =paginator=, =stylesheet=, and the =absolute=, =rfc822= and =truncate= filters. |
+| Template context | =page=, =site=, =nav=, =root=, =pages=, =group=, =groups=, =paginator=, =stylesheet=, =theme=, and the =absolute=, =rfc822= and =truncate= filters. |
 | The CLI | Command names, flags and exit codes. |
 | URLs | How a source path becomes an output path, =#+SLUG:= included. |
 
@@ -42,6 +42,13 @@ The class names the documentation names are the ones to write CSS against:
 =post-list=, =post-list-item=, =figure-number=, =table-number=, =section-number-N=,
 =footnote-ref=, =verbatim=, and the =on=/=off=/=trans= classes on checkbox items.
 
+** What a built-in theme looks like
+
+The names — =plain=, =blog=, =wiki=, =docs= — and the fact that the chosen one is written
+to =theme.css= are stable. Its CSS is not: a theme is a starting point that improves
+between releases, and a site that cannot afford that should copy the stylesheet it likes
+into its own assets and stop naming a theme.
+
 ** The Rust API
 
 The crate is on crates.io so the binary can be installed with =cargo install=. The library
diff --git a/docs/orgo.toml b/docs/orgo.toml
index dbf4ad4..d520773 100644
--- a/docs/orgo.toml
+++ b/docs/orgo.toml
@@ -10,6 +10,9 @@ language = "en"
 # Left empty so the docs build with relative URLs and open from the filesystem. Set it to
 # your real origin to enable canonical links and feeds.
 base_url = ""
+# The built-in theme written for exactly this: a guide read in order, with a contents
+# block that matters. The site's own CSS is six lines of overrides on top of it.
+theme = "docs"
 
 [nav]
 # Explicit, because the header already links home: listing index.org here as well would
@@ -25,6 +28,8 @@ dir = "templates"
 # A dark theme, with code blocks styled dark in both colour schemes. syntax.css is
 # generated from a single syntect theme and cannot respond to prefers-color-scheme, so
 # the page CSS matches the theme rather than leaving code unreadable in one of them.
+# That match is style.css, which overrides the site theme's --orgo-code-* properties and
+# nothing else — the documented way to adjust a built-in theme instead of replacing it.
 theme = "base16-ocean.dark"
 
 [html]
diff --git a/docs/quickstart.org b/docs/quickstart.org
index 979076f..281e236 100644
--- a/docs/quickstart.org
+++ b/docs/quickstart.org
@@ -19,7 +19,7 @@ already has content is safe and additive.
 
 #+BEGIN_EXAMPLE
 my-site/
-  orgo.toml            every setting, at its default, commented
+  orgo.toml            every setting, commented — all at their defaults but `theme`
   index.org               the home page
   blog/first-post.org     a post, to show the collection working
   templates/
diff --git a/docs/style.css b/docs/style.css
index 8192c0d..c84f892 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -1,130 +1,16 @@
-/* Documentation site styling.
+/* Documentation site styling: the `docs` built-in theme, adjusted rather than replaced.
  *
  * A plain asset, copied through the build untouched — which is also how any other CSS,
- * image or font in a source directory reaches the output. */
+ * image or font in a source directory reaches the output. It is linked after theme.css,
+ * which is what lets these definitions win.
+ *
+ * Only the code surface is changed. syntax.css is generated from one syntect theme and
+ * cannot follow prefers-color-scheme, so a site pairing a dark `highlight.theme` with a
+ * theme has to say what colour code sits on — here, dark in both schemes, matching
+ * base16-ocean.dark. Everything else is the theme's. */
 
 :root {
-  --ink: #1c1f24;
-  --muted: #5b6472;
-  --rule: #dfe3e8;
-  --accent: #0b5fa5;
-  --surface: #f6f8fa;
-  --measure: 42rem;
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    --ink: #dee3ea;
-    --muted: #9aa4b2;
-    --rule: #2b3138;
-    --accent: #79b8ff;
-    --surface: #171a1f;
-  }
-  body { background: #0f1216; }
-}
-
-* { box-sizing: border-box; }
-
-body {
-  margin: 0;
-  color: var(--ink);
-  font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-}
-
-header.site {
-  border-bottom: 1px solid var(--rule);
-  padding: 1rem 1.5rem;
-  display: flex;
-  flex-wrap: wrap;
-  gap: 1rem 1.5rem;
-  align-items: baseline;
+  --orgo-code-bg: #2b303b;
+  --orgo-code-fg: #c0c5ce;
+  --orgo-code-rule: #1f232b;
 }
-
-header.site .site-title {
-  font-weight: 700;
-  font-size: 1.05rem;
-  color: var(--ink);
-  text-decoration: none;
-}
-
-header.site nav { display: flex; gap: 1.25rem; flex-wrap: wrap; }
-header.site nav a { color: var(--muted); text-decoration: none; }
-header.site nav a:hover { color: var(--accent); }
-
-main {
-  max-width: var(--measure);
-  margin: 0 auto;
-  padding: 2.5rem 1.5rem 5rem;
-}
-
-h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .5rem; letter-spacing: -0.02em; }
-h2 { font-size: 1.35rem; margin: 2.5rem 0 .75rem; letter-spacing: -0.01em; }
-h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; }
-
-p.page-date, p.lede { color: var(--muted); }
-p.lede { font-size: 1.1rem; margin-top: 0; }
-
-a { color: var(--accent); }
-
-code {
-  font: 0.875em/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
-  background: var(--surface);
-  padding: .1em .35em;
-  border-radius: 3px;
-}
-
-/* Code blocks are dark in both colour schemes, matching the syntect theme named in
-   orgo.toml. One generated stylesheet cannot follow prefers-color-scheme, so the page
-   commits to the theme's palette instead of leaving highlighted code unreadable in one
-   of the two. */
-pre {
-  background: #2b303b;
-  color: #c0c5ce;
-  border: 1px solid #1f232b;
-  border-radius: 6px;
-  padding: .9rem 1rem;
-  overflow-x: auto;
-}
-
-pre code { background: none; padding: 0; }
-
-blockquote {
-  margin: 1.5rem 0;
-  padding: .25rem 0 .25rem 1rem;
-  border-left: 3px solid var(--rule);
-  color: var(--muted);
-}
-
-table { border-collapse: collapse; width: 100%; margin: 1.25rem 0; display: block; overflow-x: auto; }
-th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--rule); }
-th { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
-
-hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5rem 0; }
-
-/* Table of contents, emitted from page.toc */
-nav.toc {
-  background: var(--surface);
-  border: 1px solid var(--rule);
-  border-radius: 6px;
-  padding: .75rem 1.25rem 1rem;
-  margin: 1.5rem 0 2.5rem;
-}
-nav.toc h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; margin: .25rem 0 .5rem; color: var(--muted); }
-nav.toc ul { margin: 0; padding-left: 1.1rem; }
-nav.toc li { margin: .15rem 0; }
-
-ul.post-list { list-style: none; padding: 0; }
-ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--rule); }
-ul.post-list a { font-weight: 600; font-size: 1.05rem; }
-p.excerpt { margin: .35rem 0 .2rem; color: var(--muted); }
-span.reading-time { font-size: .85rem; color: var(--muted); }
-
-footer.site {
-  border-top: 1px solid var(--rule);
-  padding: 1.5rem;
-  color: var(--muted);
-  font-size: .9rem;
-  text-align: center;
-}
-
-.tag { font-size: .75rem; background: var(--surface); border: 1px solid var(--rule); border-radius: 999px; padding: .1em .6em; color: var(--muted); }
diff --git a/docs/templates/base.html b/docs/templates/base.html
index 48e1af7..c7598bd 100644
--- a/docs/templates/base.html
+++ b/docs/templates/base.html
@@ -8,10 +8,13 @@
 <link rel="canonical" href="{{ page.url | absolute }}">
 {%- endif %}
 <meta name="description" content="{{ page.excerpt | truncate(150) }}">
-<link rel="stylesheet" href="{{ root }}style.css">
+{%- if theme %}
+<link rel="stylesheet" href="{{ theme }}">
+{%- endif %}
 {%- if stylesheet %}
 <link rel="stylesheet" href="{{ stylesheet }}">
 {%- endif %}
+<link rel="stylesheet" href="{{ root }}style.css">
 </head>
 <body>
 <header class="site">
diff --git a/src/config.rs b/src/config.rs
index c9ca853..5a83dd0 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -331,6 +331,14 @@ pub struct Site {
     pub description: String,
     /// `<html lang="…">` in the default layout.
     pub language: String,
+    /// A built-in theme name — see [`crate::theme::THEMES`] — written to the output root
+    /// as `theme.css` and linked by the built-in layout and the starter templates.
+    ///
+    /// Empty by default, which emits no stylesheet and leaves the HTML unstyled. A theme
+    /// is a convenience for a site that has not grown its own CSS yet, and defaulting
+    /// one on would restyle every existing site on upgrade and fight the stylesheets
+    /// people already ship as assets.
+    pub theme: String,
 }
 
 impl Default for Site {
@@ -340,6 +348,7 @@ impl Default for Site {
             base_url: String::new(),
             description: String::new(),
             language: "en".to_string(),
+            theme: String::new(),
         }
     }
 }
@@ -539,6 +548,14 @@ impl Config {
                 );
             }
         }
+        if !self.site.theme.is_empty() && crate::theme::theme_css(&self.site.theme).is_none() {
+            anyhow::bail!(
+                "unknown site.theme {:?}. Available: {} — or leave it empty for no \
+                 stylesheet",
+                self.site.theme,
+                crate::theme::available_themes().join(", ")
+            );
+        }
         if !self.site.base_url.is_empty() && self.site.base_url.ends_with('/') {
             anyhow::bail!(
                 "site.base_url must not end with a slash (got {:?}) — URLs are joined \
@@ -551,9 +568,12 @@ impl Config {
 }
 
 /// The starter config written by `orgo init`, and the documentation of record for
-/// what is configurable. Every value shown is the default, so deleting any line is safe.
-pub const STARTER_CONFIG: &str = r#"# orgo configuration. Every setting here is optional and shown at its default,
-# so you can delete any line you do not need — or the whole file.
+/// what is configurable. Every value shown is the default — except `site.theme`, which
+/// picks a stylesheet so a new site looks like something on its first build — so
+/// deleting any line is safe.
+pub const STARTER_CONFIG: &str = r#"# orgo configuration. Every setting here is optional and shown at its default — apart
+# from `theme`, noted below — so you can delete any line you do not need, or the whole
+# file.
 
 [site]
 title = "orgo site"
@@ -562,6 +582,12 @@ title = "orgo site"
 base_url = ""
 description = ""
 language = "en"
+# A built-in stylesheet, written to the output as theme.css: "plain" (readable defaults
+# to build your own CSS on), "blog" (serif prose, masthead, styled post lists), "wiki"
+# (wide and dense, contents in the margin, TODO states shown) or "docs" (a guide read in
+# order). The one line here that is not a default: the default is "", which emits no
+# stylesheet at all. Your own base.html can ignore theme.css and link whatever it likes.
+theme = "blog"
 
 [nav]
 # Which pages appear in the shared navigation:
diff --git a/src/lib.rs b/src/lib.rs
index ebf3210..de45c8d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,5 +20,6 @@ pub mod resolve;
 pub mod serve;
 pub mod site;
 pub mod template;
+pub mod theme;
 pub mod util;
 pub mod watch;
diff --git a/src/main.rs b/src/main.rs
index 32bb19a..f813609 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,7 +10,7 @@ use orgo::parser::parse;
 use orgo::config::{self, Config};
 use orgo::render::{self, render, Html, SyntectHighlighter};
 use orgo::resolve::ResolvedDoc;
-use orgo::site::{build_site, BuildOptions, SYNTAX_STYLESHEET};
+use orgo::site::{build_site, BuildOptions, SYNTAX_STYLESHEET, THEME_STYLESHEET};
 use orgo::template::{PageContext, RenderContext, SiteContext, Templater};
 
 #[derive(Parser)]
@@ -325,6 +325,13 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
     };
     let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
     ctx.body = &fragment;
+    // The page and its stylesheets are written side by side here, so the link is a bare
+    // filename rather than a path back to a site root that does not exist.
+    ctx.theme = if config.site.theme.is_empty() {
+        ""
+    } else {
+        THEME_STYLESHEET
+    };
     // `#+TEMPLATE:` and `[[pages]]` apply here too, so `build one.org` and a whole-site
     // build put the same page through the same layout.
     let name = config::page_template(
@@ -339,5 +346,10 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
 
     let css = output.with_file_name(SYNTAX_STYLESHEET);
     fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?;
+
+    if let Some(theme_css) = orgo::theme::theme_css(&config.site.theme) {
+        let path = output.with_file_name(THEME_STYLESHEET);
+        fs::write(&path, theme_css).with_context(|| format!("writing stylesheet {path}"))?;
+    }
     Ok(())
 }
diff --git a/src/site.rs b/src/site.rs
index 71fe86c..0dc835d 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -32,6 +32,7 @@ use crate::template::{
     GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext,
     Templater,
 };
+use crate::theme;
 use crate::util::{
     document_text, first_paragraph, is_draft, iso_date, iso_time, option_enabled,
     output_path, output_url,
@@ -882,8 +883,10 @@ fn render_page(
     // Relative to the *output* path, since `#+SLUG:` can move a page between depths.
     let root = relative_root(&p.output);
     let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
+    let theme = theme_href(config, &root);
     let mut ctx = RenderContext::new(site, &p.context, &p.nav, &stylesheet, &root);
     ctx.body = &fragment;
+    ctx.theme = &theme;
     ctx.pages = pages;
     templater
         .render(&p.template, &ctx)
@@ -893,6 +896,20 @@ fn render_page(
 /// Site-root-relative name of the generated syntax stylesheet. Every page links to it.
 pub const SYNTAX_STYLESHEET: &str = "syntax.css";
 
+/// Site-root-relative name of the built-in theme's stylesheet, written only when
+/// `site.theme` names one.
+pub const THEME_STYLESHEET: &str = "theme.css";
+
+/// Where a page at `root` finds `theme.css` — empty when the site has no theme, which
+/// is how a template knows to link nothing at all.
+fn theme_href(config: &Config, root: &str) -> String {
+    if config.site.theme.is_empty() {
+        String::new()
+    } else {
+        format!("{root}{THEME_STYLESHEET}")
+    }
+}
+
 /// Site-root-relative name of the generated sitemap.
 pub const SITEMAP: &str = "sitemap.xml";
 
@@ -1132,7 +1149,9 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
             let with_content = listing
                 .include_content
                 .then(|| entries_with_content(&listing.entries, &preps, &highlighter, &cfg));
+            let theme = theme_href(&cfg, &root);
             let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root);
+            ctx.theme = &theme;
             ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries));
             ctx.group = listing.group.as_ref();
             ctx.groups = &listing.groups;
@@ -1167,6 +1186,13 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
     fs::write(out.join(SYNTAX_STYLESHEET), &syntax_css)
         .with_context(|| format!("writing {SYNTAX_STYLESHEET} under {out}"))?;
 
+    // The built-in theme, on the same terms: compiled in, written every build, and named
+    // in the config hash so switching themes re-renders the pages that link it.
+    if let Some(css) = theme::theme_css(&cfg.site.theme) {
+        fs::write(out.join(THEME_STYLESHEET), css)
+            .with_context(|| format!("writing {THEME_STYLESHEET} under {out}"))?;
+    }
+
     // A sitemap covers every page the build emits, authored and generated alike, so it is
     // written here rather than declared as a collection: a collection lists the pages it
     // was pointed at, and this one has to know about all of them including itself.
diff --git a/src/template.rs b/src/template.rs
index dc12960..876fcd5 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -90,6 +90,9 @@ const BASE_TEMPLATE: &str = r##"<!DOCTYPE html>
 {%- if page.description %}
 <meta name="description" content="{{ page.description }}">
 {%- endif %}
+{%- if theme %}
+<link rel="stylesheet" href="{{ theme }}">
+{%- endif %}
 {%- if stylesheet %}
 <link rel="stylesheet" href="{{ stylesheet }}">
 {%- endif %}
@@ -267,6 +270,7 @@ impl Templater {
             body => ctx.body,
             nav => ctx.nav,
             stylesheet => ctx.stylesheet,
+            theme => ctx.theme,
             root => ctx.root,
             pages => ctx.pages,
             group => ctx.group,
@@ -338,6 +342,9 @@ pub struct RenderContext<'a> {
     pub nav: &'a [NavItem],
     /// URL of the syntax stylesheet, relative to this page.
     pub stylesheet: &'a str,
+    /// Path to the built-in theme's `theme.css`, relative to this page — empty when
+    /// `site.theme` names no theme, which is the default.
+    pub theme: &'a str,
     /// `../`-prefix back to the site root from this page.
     pub root: &'a str,
     /// The pages this listing shows, or every page when `expose_page_list` is on.
@@ -366,6 +373,9 @@ impl<'a> RenderContext<'a> {
             body: "",
             nav,
             stylesheet,
+            // Assigned after construction, like `body`: most callers have no theme, and
+            // an empty one is exactly "link no theme stylesheet".
+            theme: "",
             root,
             pages: None,
             group: None,
@@ -383,6 +393,9 @@ pub const STARTER_TAGS_TEMPLATE: &str = r#"<!DOCTYPE html>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>{{ page.title }} &middot; {{ site.title }}</title>
+{%- if theme %}
+<link rel="stylesheet" href="{{ theme }}">
+{%- endif %}
 {%- if stylesheet %}
 <link rel="stylesheet" href="{{ stylesheet }}">
 {%- endif %}
@@ -418,6 +431,9 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>{{ page.title }} &middot; {{ site.title }}</title>
+{%- if theme %}
+<link rel="stylesheet" href="{{ theme }}">
+{%- endif %}
 {%- if stylesheet %}
 <link rel="stylesheet" href="{{ stylesheet }}">
 {%- endif %}
diff --git a/src/theme.rs b/src/theme.rs
new file mode 100644
index 0000000..4e57cab
--- /dev/null
+++ b/src/theme.rs
@@ -0,0 +1,43 @@
+//! Built-in site themes: whole stylesheets compiled into the binary.
+//!
+//! A theme is one CSS file and nothing else. It styles the markup the RENDER stage
+//! emits — org's headings, tags, TODO keywords, checkbox lists, footnotes — plus the
+//! chrome the built-in layout and the starter templates put around it. There is no
+//! theme-specific HTML, so a theme can be switched, or removed, without touching a
+//! template.
+//!
+//! Compiled in for the same reason the syntax definitions are: `cargo install orgo`
+//! gives you one binary, and a site that needs a stylesheet fetched from somewhere else
+//! before it looks like anything is not that. The chosen theme is written to the output
+//! root as `theme.css` on every build, the way [`crate::render::syntax_css`] writes
+//! `syntax.css`.
+//!
+//! Nothing here is a wrapper you have to work through: `site.theme` empty emits no
+//! stylesheet at all, and a `base.html` of your own can ignore `theme.css` and link
+//! whatever it likes.
+
+/// Every built-in theme, as `(name, stylesheet)`, in the order they are offered.
+///
+/// - `plain` — readable defaults with no design opinion, to build your own CSS on.
+/// - `blog` — dated writing: serif prose, a masthead, styled listing pages.
+/// - `wiki` — a dense reference site: wide, sidebar contents, tables and TODO states.
+/// - `docs` — a guide read in order: prominent contents, code-forward, `#+LEDE:`.
+pub const THEMES: &[(&str, &str)] = &[
+    ("plain", include_str!("../themes/plain.css")),
+    ("blog", include_str!("../themes/blog.css")),
+    ("wiki", include_str!("../themes/wiki.css")),
+    ("docs", include_str!("../themes/docs.css")),
+];
+
+/// The stylesheet for a built-in theme, or `None` if no theme goes by that name.
+pub fn theme_css(name: &str) -> Option<&'static str> {
+    THEMES
+        .iter()
+        .find(|(theme, _)| *theme == name)
+        .map(|(_, css)| *css)
+}
+
+/// Every theme name [`theme_css`] accepts, for error messages and documentation.
+pub fn available_themes() -> Vec<&'static str> {
+    THEMES.iter().map(|(name, _)| *name).collect()
+}
diff --git a/tests/config.rs b/tests/config.rs
index 8bec048..dd86bed 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -2499,3 +2499,113 @@ fn well_known_is_published_but_other_dot_entries_are_not() {
     assert!(!out.join(".git/config").exists(), ".git stays out");
     assert!(!out.join(".env").exists(), ".env stays out");
 }
+
+// ---------------------------------------------------------------------------
+// Built-in themes
+// ---------------------------------------------------------------------------
+
+/// A theme is one compiled-in stylesheet: named in the config, written to the output
+/// root, linked from every page at whatever depth that page sits.
+#[test]
+fn a_built_in_theme_is_written_once_and_linked_from_every_depth() {
+    let root = tmpdir("theme-site");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"wiki\"\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let css = std::fs::read_to_string(out.join("theme.css")).expect("theme.css is written");
+    assert_eq!(
+        css,
+        orgo::theme::theme_css("wiki").unwrap(),
+        "verbatim, not a rebuilt approximation of it"
+    );
+
+    assert!(
+        page(&out, "index.html").contains("<link rel=\"stylesheet\" href=\"theme.css\">"),
+        "a root page links it directly"
+    );
+    assert!(
+        page(&out, "blog/post.html").contains("<link rel=\"stylesheet\" href=\"../theme.css\">"),
+        "a nested page reaches back up to it"
+    );
+}
+
+/// The theme has to come before `syntax.css`, or a theme's `pre code` colour would
+/// override the highlighter's and code blocks would render in one flat colour.
+#[test]
+fn the_theme_is_linked_ahead_of_the_syntax_stylesheet() {
+    let root = tmpdir("theme-order");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"docs\"\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let home = page(&out, "index.html");
+    let theme = home.find("theme.css").expect("theme link");
+    let syntax = home.find("syntax.css").expect("syntax link");
+    assert!(theme < syntax, "theme first, highlighting on top of it: {home}");
+}
+
+/// No theme is the default. An existing site upgrading must not find itself restyled,
+/// and a site with a stylesheet of its own must not have a second one competing with it.
+#[test]
+fn no_theme_is_the_default_and_emits_no_stylesheet() {
+    let root = tmpdir("theme-none");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(!out.join("theme.css").exists(), "nothing to write");
+    assert!(
+        !page(&out, "index.html").contains("theme.css"),
+        "and nothing to link"
+    );
+}
+
+/// A misspelled theme name would otherwise emit an unstyled site with no complaint,
+/// which looks exactly like the theme setting doing nothing.
+#[test]
+fn an_unknown_site_theme_is_rejected_with_the_available_ones() {
+    let root = tmpdir("theme-unknown");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"blogg\"\n").unwrap();
+
+    let err = build_site(&src, &root.join("out"), &BuildOptions::default())
+        .expect_err("unknown theme must fail");
+    let message = format!("{err:#}");
+    assert!(message.contains("blogg"), "names the bad theme: {message}");
+    for name in orgo::theme::available_themes() {
+        assert!(message.contains(name), "lists {name}: {message}");
+    }
+}
+
+/// Switching themes changes every page's `<head>`, so every page has to be re-rendered.
+/// The theme name lives in the config hash, which is what makes that happen.
+#[test]
+fn switching_the_theme_re_renders_the_pages_that_link_it() {
+    let root = tmpdir("theme-switch");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_site(&src);
+    std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"plain\"\n").unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    std::fs::write(src.join("orgo.toml"), "[site]\ntheme = \"blog\"\n").unwrap();
+    let report = build(&src, &out);
+    assert!(report.skipped.is_empty(), "no page may keep the old head");
+    assert_eq!(
+        std::fs::read_to_string(out.join("theme.css")).unwrap(),
+        orgo::theme::theme_css("blog").unwrap(),
+        "and the stylesheet on disk is the new one"
+    );
+}
diff --git a/themes/blog.css b/themes/blog.css
new file mode 100644
index 0000000..a15dbdf
--- /dev/null
+++ b/themes/blog.css
@@ -0,0 +1,298 @@
+/* orgo built-in theme: blog
+ *
+ * A personal site of dated writing. Serif prose at a narrow measure, a masthead rather
+ * than a toolbar, and listing pages — the blog index, a tag page, a feed's HTML
+ * counterpart — treated as first-class rather than as an afterthought.
+ *
+ * Every colour is a custom property on :root, so a stylesheet of your own loaded after
+ * this one can retheme the site by redefining a handful of values. */
+
+:root {
+  --orgo-ink: #23201d;
+  --orgo-muted: #6b645d;
+  --orgo-rule: #e5e0d8;
+  --orgo-accent: #9a3412;
+  --orgo-surface: #faf7f2;
+  --orgo-bg: #fdfcfa;
+  --orgo-measure: 36rem;
+  --orgo-font: Iowan Old Style, Charter, Palatino, Georgia, "Times New Roman", serif;
+  --orgo-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+  --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+  /* These three colour code *blocks* only; inline code follows the page. A block keeps
+     a light surface in both colour schemes because syntax.css is generated from one
+     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
+     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+  --orgo-todo: #b02a37;
+  --orgo-done: #2c7a4b;
+  --orgo-code-bg: #f5f2ec;
+  --orgo-code-fg: #323232;
+  --orgo-code-rule: #e5e0d8;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --orgo-ink: #e6e1da;
+    --orgo-muted: #a49c93;
+    --orgo-rule: #322e2a;
+    --orgo-accent: #f0a066;
+    --orgo-surface: #1b1917;
+    --orgo-todo: #f0909a;
+    --orgo-done: #74c795;
+    --orgo-bg: #141312;
+  }
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  background: var(--orgo-bg);
+  color: var(--orgo-ink);
+  font: 18px/1.7 var(--orgo-font);
+  -webkit-font-smoothing: antialiased;
+  /* A bare URL or a long identifier must wrap, not widen the page on a phone. */
+  overflow-wrap: break-word;
+}
+
+/* Masthead ----------------------------------------------------------------- */
+
+body > header {
+  max-width: var(--orgo-measure);
+  margin: 0 auto;
+  padding: 3rem 1.5rem 1.5rem;
+  text-align: center;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: .75rem;
+}
+
+.site-title {
+  font-size: 1.5rem;
+  font-weight: 400;
+  letter-spacing: .02em;
+  color: var(--orgo-ink);
+  text-decoration: none;
+}
+
+body > header nav {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 1.5rem;
+  font: .8rem/1 var(--orgo-ui);
+  text-transform: uppercase;
+  letter-spacing: .1em;
+}
+body > header nav a { color: var(--orgo-muted); text-decoration: none; }
+body > header nav a:hover { color: var(--orgo-accent); }
+
+/* Page body ---------------------------------------------------------------- */
+
+main {
+  max-width: var(--orgo-measure);
+  margin: 0 auto;
+  padding: 2rem 1.5rem 5rem;
+}
+
+h1 {
+  font-size: 2.1rem;
+  font-weight: 400;
+  line-height: 1.25;
+  letter-spacing: -0.01em;
+  margin: 0 0 .5rem;
+}
+h2 { font-size: 1.4rem; font-weight: 600; margin: 2.5rem 0 .75rem; }
+h3 { font-size: 1.15rem; font-weight: 600; margin: 2rem 0 .5rem; }
+h4, h5, h6 { font-size: 1rem; font-weight: 600; margin: 1.5rem 0 .5rem; }
+
+a { color: var(--orgo-accent); text-underline-offset: .15em; }
+
+/* The byline the built-in layout prints under the title. */
+p.page-date {
+  font: .8rem/1 var(--orgo-ui);
+  text-transform: uppercase;
+  letter-spacing: .1em;
+  color: var(--orgo-muted);
+  margin: 0 0 2.5rem;
+}
+
+img, video { max-width: 100%; height: auto; }
+
+figure { margin: 2rem 0; }
+figcaption {
+  font: .85rem/1.5 var(--orgo-ui);
+  color: var(--orgo-muted);
+  margin-top: .5rem;
+  text-align: center;
+}
+.figure-number, .table-number { font-weight: 600; }
+
+blockquote {
+  margin: 2rem 0;
+  padding: 0 1.5rem;
+  border-left: 2px solid var(--orgo-accent);
+  font-style: italic;
+  color: var(--orgo-muted);
+}
+
+hr {
+  border: 0;
+  margin: 3rem auto;
+  width: 6rem;
+  border-top: 1px solid var(--orgo-rule);
+}
+
+.center { text-align: center; }
+.verse { font-family: var(--orgo-mono); font-size: .95rem; white-space: pre-wrap; }
+
+dt { font-weight: 600; margin-top: .75rem; }
+dd { margin: 0 0 0 1.5rem; }
+
+/* Code --------------------------------------------------------------------- */
+
+/* Inline code is prose furniture, so it follows the page rather than the code blocks. */
+code {
+  font: .85em/1.5 var(--orgo-mono);
+  background: var(--orgo-surface);
+  color: inherit;
+  padding: .1em .35em;
+  border-radius: 3px;
+}
+
+pre {
+  background: var(--orgo-code-bg);
+  color: var(--orgo-code-fg);
+  border: 1px solid var(--orgo-code-rule);
+  border-radius: 4px;
+  padding: 1rem 1.1rem;
+  font-size: .9rem;
+  line-height: 1.55;
+  overflow-x: auto;
+}
+
+pre code { background: none; color: inherit; padding: 0; }
+
+/* Tables ------------------------------------------------------------------- */
+
+table {
+  border-collapse: collapse;
+  margin: 2rem 0;
+  font: .95rem/1.5 var(--orgo-ui);
+  display: block;
+  overflow-x: auto;
+}
+caption { text-align: left; color: var(--orgo-muted); font-size: .9rem; padding-bottom: .5rem; }
+th, td { text-align: left; padding: .5rem .85rem; border-bottom: 1px solid var(--orgo-rule); }
+th {
+  font-size: .75rem;
+  text-transform: uppercase;
+  letter-spacing: .06em;
+  color: var(--orgo-muted);
+}
+
+/* Org-specific markup ------------------------------------------------------ */
+
+.tag {
+  font: .7rem/1.6 var(--orgo-ui);
+  text-transform: uppercase;
+  letter-spacing: .08em;
+  color: var(--orgo-muted);
+  background: var(--orgo-surface);
+  border: 1px solid var(--orgo-rule);
+  border-radius: 999px;
+  padding: .1em .7em;
+  vertical-align: middle;
+}
+
+.todo, .done { font: .7em/1 var(--orgo-mono); letter-spacing: .05em; }
+.todo { color: var(--orgo-todo); }
+.done { color: var(--orgo-done); }
+.priority { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .8em; }
+
+time.timestamp { font: .9em/1 var(--orgo-ui); color: var(--orgo-muted); }
+
+li.on, li.trans { color: var(--orgo-muted); }
+li.on { text-decoration: line-through; }
+
+.footnotes {
+  margin-top: 4rem;
+  font-size: .9rem;
+  color: var(--orgo-muted);
+}
+.footnote-ref a { text-decoration: none; }
+
+/* Table of contents, from page.toc ----------------------------------------- */
+
+nav.toc {
+  border-top: 1px solid var(--orgo-rule);
+  border-bottom: 1px solid var(--orgo-rule);
+  padding: 1rem 0;
+  margin: 2rem 0 3rem;
+  font: .95rem/1.6 var(--orgo-ui);
+}
+nav.toc h2 {
+  font-size: .7rem;
+  text-transform: uppercase;
+  letter-spacing: .1em;
+  color: var(--orgo-muted);
+  margin: 0 0 .5rem;
+}
+nav.toc ul { margin: 0; padding-left: 1.1rem; }
+nav.toc li { margin: .15rem 0; }
+
+/* Listing pages ------------------------------------------------------------ */
+
+ul.post-list { list-style: none; padding: 0; margin: 2rem 0 0; }
+ul.post-list > li { padding: 1.75rem 0; border-bottom: 1px solid var(--orgo-rule); }
+ul.post-list > li:first-child { padding-top: 0; }
+ul.post-list time {
+  display: block;
+  font: .75rem/1 var(--orgo-ui);
+  text-transform: uppercase;
+  letter-spacing: .1em;
+  color: var(--orgo-muted);
+  margin-bottom: .4rem;
+}
+ul.post-list a {
+  font-size: 1.3rem;
+  line-height: 1.3;
+  color: var(--orgo-ink);
+  text-decoration: none;
+}
+ul.post-list a:hover { color: var(--orgo-accent); }
+p.excerpt { margin: .5rem 0 .35rem; color: var(--orgo-muted); font-size: .95rem; }
+span.reading-time { font: .75rem/1 var(--orgo-ui); letter-spacing: .06em; color: var(--orgo-muted); }
+
+ul.tag-list {
+  list-style: none;
+  padding: 0;
+  display: flex;
+  flex-wrap: wrap;
+  gap: .75rem 1.25rem;
+  font: .95rem/1.6 var(--orgo-ui);
+}
+
+nav.pagination {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  gap: 1rem;
+  margin-top: 3rem;
+  font: .85rem/1 var(--orgo-ui);
+  text-transform: uppercase;
+  letter-spacing: .08em;
+  color: var(--orgo-muted);
+}
+
+/* Footer ------------------------------------------------------------------- */
+
+body > footer {
+  max-width: var(--orgo-measure);
+  margin: 0 auto;
+  border-top: 1px solid var(--orgo-rule);
+  padding: 1.5rem;
+  font: .85rem/1.6 var(--orgo-ui);
+  color: var(--orgo-muted);
+  text-align: center;
+}
diff --git a/themes/docs.css b/themes/docs.css
new file mode 100644
index 0000000..d7964f1
--- /dev/null
+++ b/themes/docs.css
@@ -0,0 +1,247 @@
+/* orgo built-in theme: docs
+ *
+ * A documentation site: a guide read in order, with a contents block that matters, code
+ * blocks that carry as much of the meaning as the prose, and a `#+LEDE:` line under the
+ * title. Cooler and more technical than `blog`, narrower and more designed than `wiki`.
+ *
+ * Every colour is a custom property on :root, so a stylesheet of your own loaded after
+ * this one can retheme the site by redefining a handful of values. */
+
+:root {
+  --orgo-ink: #1c1f24;
+  --orgo-muted: #5b6472;
+  --orgo-rule: #dfe3e8;
+  --orgo-accent: #0b5fa5;
+  --orgo-surface: #f6f8fa;
+  --orgo-bg: #ffffff;
+  --orgo-measure: 42rem;
+  --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+  --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+  /* These three colour code *blocks* only; inline code follows the page. A block keeps
+     a light surface in both colour schemes because syntax.css is generated from one
+     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
+     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+  --orgo-todo: #b02a37;
+  --orgo-done: #2c7a4b;
+  --orgo-code-bg: #f6f8fa;
+  --orgo-code-fg: #323232;
+  --orgo-code-rule: #e3e7ec;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --orgo-ink: #dee3ea;
+    --orgo-muted: #9aa4b2;
+    --orgo-rule: #2b3138;
+    --orgo-accent: #79b8ff;
+    --orgo-surface: #171a1f;
+    --orgo-todo: #f0909a;
+    --orgo-done: #74c795;
+    --orgo-bg: #0f1216;
+  }
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  background: var(--orgo-bg);
+  color: var(--orgo-ink);
+  font: 16px/1.65 var(--orgo-font);
+  /* A bare URL or a long identifier must wrap, not widen the page on a phone. */
+  overflow-wrap: break-word;
+}
+
+/* Header ------------------------------------------------------------------- */
+
+body > header {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  background: var(--orgo-bg);
+  border-bottom: 1px solid var(--orgo-rule);
+  padding: 1rem 1.5rem;
+  display: flex;
+  flex-wrap: wrap;
+  gap: .5rem 1.5rem;
+  align-items: baseline;
+}
+
+.site-title {
+  font-weight: 700;
+  font-size: 1.05rem;
+  color: var(--orgo-ink);
+  text-decoration: none;
+}
+
+body > header nav { display: flex; flex-wrap: wrap; gap: 1.25rem; }
+body > header nav a {
+  color: var(--orgo-muted);
+  text-decoration: none;
+  padding-bottom: .15rem;
+  border-bottom: 2px solid transparent;
+}
+body > header nav a:hover { color: var(--orgo-ink); border-bottom-color: var(--orgo-accent); }
+
+/* Page body ---------------------------------------------------------------- */
+
+main {
+  max-width: var(--orgo-measure);
+  margin: 0 auto;
+  padding: 2.5rem 1.5rem 5rem;
+}
+
+h1 { font-size: 2rem; line-height: 1.2; letter-spacing: -0.02em; margin: 0 0 .5rem; }
+h2 { font-size: 1.35rem; letter-spacing: -0.01em; margin: 2.75rem 0 .75rem; }
+h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; }
+h4, h5, h6 { font-size: 1rem; margin: 1.5rem 0 .5rem; }
+
+[class^="section-number-"] { color: var(--orgo-muted); font-weight: 400; }
+
+a { color: var(--orgo-accent); }
+
+/* `#+LEDE:` reaches the layout as page.keywords.lede; page.date as the byline. */
+p.lede { font-size: 1.1rem; color: var(--orgo-muted); margin-top: 0; }
+p.page-date { color: var(--orgo-muted); font-size: .9rem; }
+
+img, video { max-width: 100%; height: auto; }
+
+figure { margin: 1.75rem 0; }
+figcaption { color: var(--orgo-muted); font-size: .9rem; margin-top: .4rem; }
+.figure-number, .table-number { font-weight: 600; }
+
+/* A quote block reads as a note or a caution in a documentation site. */
+blockquote {
+  margin: 1.5rem 0;
+  padding: .75rem 1rem;
+  background: var(--orgo-surface);
+  border-left: 3px solid var(--orgo-accent);
+  border-radius: 0 4px 4px 0;
+  color: var(--orgo-muted);
+}
+blockquote > :first-child { margin-top: 0; }
+blockquote > :last-child { margin-bottom: 0; }
+
+hr { border: 0; border-top: 1px solid var(--orgo-rule); margin: 2.5rem 0; }
+
+.center { text-align: center; }
+.verse { font-family: var(--orgo-mono); white-space: pre-wrap; }
+
+dt { font-weight: 600; margin-top: .75rem; font-family: var(--orgo-mono); font-size: .95rem; }
+dd { margin: 0 0 0 1.5rem; }
+
+/* Code --------------------------------------------------------------------- */
+
+/* Inline code is prose furniture, so it follows the page rather than the code blocks. */
+code {
+  font: .875em/1.5 var(--orgo-mono);
+  background: var(--orgo-surface);
+  color: inherit;
+  padding: .1em .35em;
+  border-radius: 3px;
+}
+
+pre {
+  background: var(--orgo-code-bg);
+  color: var(--orgo-code-fg);
+  border: 1px solid var(--orgo-code-rule);
+  border-radius: 6px;
+  padding: .9rem 1.1rem;
+  font-size: .9rem;
+  line-height: 1.55;
+  overflow-x: auto;
+}
+
+pre code { background: none; color: inherit; padding: 0; }
+
+/* Tables ------------------------------------------------------------------- */
+
+table {
+  border-collapse: collapse;
+  width: 100%;
+  margin: 1.5rem 0;
+  display: block;
+  overflow-x: auto;
+}
+caption { text-align: left; color: var(--orgo-muted); font-size: .9rem; padding-bottom: .4rem; }
+th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--orgo-rule); }
+th {
+  font-size: .8rem;
+  text-transform: uppercase;
+  letter-spacing: .05em;
+  color: var(--orgo-muted);
+}
+
+/* Org-specific markup ------------------------------------------------------ */
+
+.tag {
+  font: .72rem/1.6 var(--orgo-mono);
+  color: var(--orgo-muted);
+  background: var(--orgo-surface);
+  border: 1px solid var(--orgo-rule);
+  border-radius: 999px;
+  padding: .05em .6em;
+  vertical-align: middle;
+}
+
+.todo, .done { font: .72rem/1.6 var(--orgo-mono); letter-spacing: .04em; }
+.todo { color: var(--orgo-todo); }
+.done { color: var(--orgo-done); }
+.priority { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .8em; }
+
+time.timestamp { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .9em; }
+
+li.on, li.trans { color: var(--orgo-muted); }
+li.on { text-decoration: line-through; }
+
+.footnotes { margin-top: 3rem; font-size: .9rem; color: var(--orgo-muted); }
+.footnote-ref a { text-decoration: none; }
+
+/* Table of contents: a card, because in a guide it is navigation ----------- */
+
+nav.toc {
+  background: var(--orgo-surface);
+  border: 1px solid var(--orgo-rule);
+  border-radius: 6px;
+  padding: .75rem 1.25rem 1rem;
+  margin: 1.75rem 0 2.5rem;
+}
+nav.toc h2 {
+  font-size: .8rem;
+  text-transform: uppercase;
+  letter-spacing: .06em;
+  color: var(--orgo-muted);
+  margin: .25rem 0 .5rem;
+}
+nav.toc ul { margin: 0; padding-left: 1.1rem; }
+nav.toc li { margin: .15rem 0; }
+
+/* Listing pages: a guide's contents page, a tag index ---------------------- */
+
+ul.post-list { list-style: none; padding: 0; }
+ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--orgo-rule); }
+ul.post-list a { font-weight: 600; font-size: 1.05rem; }
+ul.post-list time { color: var(--orgo-muted); font-size: .9rem; }
+p.excerpt { margin: .35rem 0 .2rem; color: var(--orgo-muted); }
+span.reading-time { font-size: .85rem; color: var(--orgo-muted); }
+
+ul.tag-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .6rem 1.25rem; }
+
+nav.pagination {
+  display: flex;
+  gap: 1rem;
+  align-items: baseline;
+  margin-top: 2.5rem;
+  color: var(--orgo-muted);
+  font-size: .9rem;
+}
+
+/* Footer ------------------------------------------------------------------- */
+
+body > footer {
+  border-top: 1px solid var(--orgo-rule);
+  padding: 1.5rem;
+  color: var(--orgo-muted);
+  font-size: .9rem;
+  text-align: center;
+}
diff --git a/themes/plain.css b/themes/plain.css
new file mode 100644
index 0000000..eecc711
--- /dev/null
+++ b/themes/plain.css
@@ -0,0 +1,205 @@
+/* orgo built-in theme: plain
+ *
+ * Readable defaults and nothing else. System fonts, one measure, hairline rules — the
+ * styling a page needs to be read comfortably, with no design opinion for a site to
+ * grow out of. Start here when you intend to write your own CSS eventually.
+ *
+ * Every colour is a custom property on :root, so a stylesheet of your own loaded after
+ * this one can retheme the site by redefining a handful of values. */
+
+:root {
+  --orgo-ink: #1c1f24;
+  --orgo-muted: #5b6472;
+  --orgo-rule: #dfe3e8;
+  --orgo-accent: #0b5fa5;
+  --orgo-surface: #f6f8fa;
+  --orgo-bg: #ffffff;
+  --orgo-measure: 40rem;
+  --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+  --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+  /* These three colour code *blocks* only; inline code follows the page. A block keeps
+     a light surface in both colour schemes because syntax.css is generated from one
+     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
+     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+  --orgo-todo: #b02a37;
+  --orgo-done: #2c7a4b;
+  --orgo-code-bg: #f6f8fa;
+  --orgo-code-fg: #323232;
+  --orgo-code-rule: #e3e7ec;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --orgo-ink: #dee3ea;
+    --orgo-muted: #9aa4b2;
+    --orgo-rule: #2b3138;
+    --orgo-accent: #79b8ff;
+    --orgo-surface: #171a1f;
+    --orgo-todo: #f0909a;
+    --orgo-done: #74c795;
+    --orgo-bg: #0f1216;
+  }
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  background: var(--orgo-bg);
+  color: var(--orgo-ink);
+  font: 16px/1.65 var(--orgo-font);
+  /* A bare URL or a long identifier must wrap, not widen the page on a phone. */
+  overflow-wrap: break-word;
+}
+
+/* Header and navigation ---------------------------------------------------- */
+
+body > header {
+  border-bottom: 1px solid var(--orgo-rule);
+  padding: 1rem 1.5rem;
+  display: flex;
+  flex-wrap: wrap;
+  gap: .5rem 1.5rem;
+  align-items: baseline;
+}
+
+.site-title { font-weight: 700; color: var(--orgo-ink); text-decoration: none; }
+
+body > header nav { display: flex; flex-wrap: wrap; gap: 1.25rem; }
+body > header nav a { color: var(--orgo-muted); text-decoration: none; }
+body > header nav a:hover { color: var(--orgo-accent); text-decoration: underline; }
+
+/* Page body ---------------------------------------------------------------- */
+
+main {
+  max-width: var(--orgo-measure);
+  margin: 0 auto;
+  padding: 2.5rem 1.5rem 4rem;
+}
+
+h1 { font-size: 1.9rem; line-height: 1.2; margin: 0 0 1rem; }
+h2 { font-size: 1.35rem; margin: 2.25rem 0 .75rem; }
+h3 { font-size: 1.1rem; margin: 1.75rem 0 .5rem; }
+h4, h5, h6 { font-size: 1rem; margin: 1.5rem 0 .5rem; }
+
+a { color: var(--orgo-accent); }
+
+p.page-date { color: var(--orgo-muted); margin-top: -.5rem; }
+
+img, video { max-width: 100%; height: auto; }
+
+figure { margin: 1.5rem 0; }
+figcaption { color: var(--orgo-muted); font-size: .9rem; margin-top: .4rem; }
+.figure-number, .table-number { font-weight: 600; }
+
+blockquote {
+  margin: 1.5rem 0;
+  padding-left: 1rem;
+  border-left: 3px solid var(--orgo-rule);
+  color: var(--orgo-muted);
+}
+
+hr { border: 0; border-top: 1px solid var(--orgo-rule); margin: 2rem 0; }
+
+.center { text-align: center; }
+.verse { font-family: var(--orgo-mono); white-space: pre-wrap; }
+
+dt { font-weight: 600; margin-top: .75rem; }
+dd { margin: 0 0 0 1.5rem; }
+
+/* Code --------------------------------------------------------------------- */
+
+/* Inline code is prose furniture, so it follows the page rather than the code blocks. */
+code {
+  font: .875em/1.5 var(--orgo-mono);
+  background: var(--orgo-surface);
+  color: inherit;
+  padding: .1em .35em;
+  border-radius: 3px;
+}
+
+pre {
+  background: var(--orgo-code-bg);
+  color: var(--orgo-code-fg);
+  border: 1px solid var(--orgo-code-rule);
+  border-radius: 4px;
+  padding: .9rem 1rem;
+  overflow-x: auto;
+}
+
+pre code { background: none; color: inherit; padding: 0; }
+
+/* Tables ------------------------------------------------------------------- */
+
+table { border-collapse: collapse; margin: 1.25rem 0; display: block; overflow-x: auto; }
+caption { text-align: left; color: var(--orgo-muted); font-size: .9rem; padding-bottom: .4rem; }
+th, td { text-align: left; padding: .45rem .75rem; border-bottom: 1px solid var(--orgo-rule); }
+th { font-weight: 600; }
+
+/* Org-specific markup ------------------------------------------------------ */
+
+.tag {
+  font-size: .75rem;
+  font-family: var(--orgo-mono);
+  color: var(--orgo-muted);
+  border: 1px solid var(--orgo-rule);
+  border-radius: 999px;
+  padding: .05em .55em;
+  vertical-align: middle;
+}
+
+.todo, .done { font-size: .75em; font-family: var(--orgo-mono); letter-spacing: .04em; }
+.todo { color: var(--orgo-todo); }
+.done { color: var(--orgo-done); }
+.priority { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .8em; }
+
+time.timestamp { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .9em; }
+
+li.on, li.trans { color: var(--orgo-muted); }
+li.on { text-decoration: line-through; }
+
+.footnotes { margin-top: 3rem; font-size: .9rem; color: var(--orgo-muted); }
+.footnote-ref a { text-decoration: none; }
+
+/* Table of contents, from page.toc ----------------------------------------- */
+
+nav.toc { margin: 1.5rem 0 2.5rem; }
+nav.toc h2 {
+  font-size: .8rem;
+  text-transform: uppercase;
+  letter-spacing: .06em;
+  color: var(--orgo-muted);
+  margin: 0 0 .35rem;
+}
+nav.toc ul { margin: 0; padding-left: 1.1rem; }
+nav.toc li { margin: .1rem 0; }
+
+/* Listing pages: collections, tag indexes, pagination ---------------------- */
+
+ul.post-list { list-style: none; padding: 0; }
+ul.post-list > li { padding: .9rem 0; border-bottom: 1px solid var(--orgo-rule); }
+ul.post-list a { font-weight: 600; }
+ul.post-list time { color: var(--orgo-muted); font-size: .9rem; }
+p.excerpt { margin: .3rem 0 .2rem; color: var(--orgo-muted); }
+span.reading-time { font-size: .85rem; color: var(--orgo-muted); }
+
+ul.tag-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem 1rem; }
+
+nav.pagination {
+  display: flex;
+  gap: 1rem;
+  align-items: baseline;
+  margin-top: 2rem;
+  color: var(--orgo-muted);
+  font-size: .9rem;
+}
+
+/* Footer ------------------------------------------------------------------- */
+
+body > footer {
+  border-top: 1px solid var(--orgo-rule);
+  padding: 1.5rem;
+  color: var(--orgo-muted);
+  font-size: .9rem;
+  text-align: center;
+}
diff --git a/themes/wiki.css b/themes/wiki.css
new file mode 100644
index 0000000..4edaba3
--- /dev/null
+++ b/themes/wiki.css
@@ -0,0 +1,275 @@
+/* orgo built-in theme: wiki
+ *
+ * A dense reference site: notes, a personal knowledge base, a manual. Wide and
+ * information-first rather than roomy — a table of contents parked in the margin on a
+ * large screen, compact headings with visible structure, tables and task lists styled
+ * because a wiki is mostly made of them, and org's TODO keywords shown as badges since
+ * in a notes site they are content rather than decoration.
+ *
+ * Every colour is a custom property on :root, so a stylesheet of your own loaded after
+ * this one can retheme the site by redefining a handful of values. */
+
+:root {
+  --orgo-ink: #1f2328;
+  --orgo-muted: #656d76;
+  --orgo-rule: #d8dee4;
+  --orgo-accent: #0969da;
+  --orgo-surface: #f6f8fa;
+  --orgo-bg: #ffffff;
+  --orgo-measure: 46rem;
+  --orgo-wide: 74rem;
+  --orgo-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+  --orgo-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+  /* These three colour code *blocks* only; inline code follows the page. A block keeps
+     a light surface in both colour schemes because syntax.css is generated from one
+     syntect theme (`highlight.theme`, InspiredGitHub by default) and cannot follow
+     prefers-color-scheme. Pair a dark highlight theme with overrides of these three. */
+  --orgo-todo: #b02a37;
+  --orgo-done: #2c7a4b;
+  --orgo-code-bg: #f6f8fa;
+  --orgo-code-fg: #323232;
+  --orgo-code-rule: #d8dee4;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --orgo-ink: #e6edf3;
+    --orgo-muted: #8d96a0;
+    --orgo-rule: #30363d;
+    --orgo-accent: #6cb6ff;
+    --orgo-surface: #161b22;
+    --orgo-bg: #0d1117;
+  }
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  background: var(--orgo-bg);
+  color: var(--orgo-ink);
+  font: 15px/1.6 var(--orgo-font);
+  /* A bare URL or a long identifier must wrap, not widen the page on a phone. */
+  overflow-wrap: break-word;
+}
+
+/* Header ------------------------------------------------------------------- */
+
+body > header {
+  background: var(--orgo-surface);
+  border-bottom: 1px solid var(--orgo-rule);
+  padding: .7rem 1.5rem;
+  display: flex;
+  flex-wrap: wrap;
+  gap: .5rem 1.5rem;
+  align-items: baseline;
+}
+
+.site-title {
+  font-weight: 700;
+  font-size: 1rem;
+  color: var(--orgo-ink);
+  text-decoration: none;
+}
+
+body > header nav { display: flex; flex-wrap: wrap; gap: 1.1rem; font-size: .9rem; }
+body > header nav a { color: var(--orgo-muted); text-decoration: none; }
+body > header nav a:hover { color: var(--orgo-accent); text-decoration: underline; }
+
+/* Page body ---------------------------------------------------------------- */
+
+main {
+  max-width: var(--orgo-measure);
+  margin: 0 auto;
+  padding: 2rem 1.5rem 4rem;
+}
+
+h1 {
+  font-size: 1.8rem;
+  line-height: 1.25;
+  margin: 0 0 1rem;
+  padding-bottom: .4rem;
+  border-bottom: 1px solid var(--orgo-rule);
+}
+h2 {
+  font-size: 1.3rem;
+  margin: 2rem 0 .6rem;
+  padding-bottom: .25rem;
+  border-bottom: 1px solid var(--orgo-rule);
+}
+h3 { font-size: 1.05rem; margin: 1.5rem 0 .4rem; }
+h4, h5, h6 { font-size: .95rem; margin: 1.25rem 0 .4rem; }
+
+/* Section numbers, when html.section_numbers is on. */
+[class^="section-number-"] { color: var(--orgo-muted); font-weight: 400; }
+
+a { color: var(--orgo-accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+p.page-date { color: var(--orgo-muted); font-size: .9rem; margin-top: -.5rem; }
+
+p, ul, ol { margin: .6rem 0; }
+li { margin: .15rem 0; }
+
+img, video { max-width: 100%; height: auto; }
+
+figure { margin: 1.25rem 0; }
+figcaption { color: var(--orgo-muted); font-size: .85rem; margin-top: .35rem; }
+.figure-number, .table-number { font-weight: 600; }
+
+blockquote {
+  margin: 1rem 0;
+  padding: .1rem 0 .1rem 1rem;
+  border-left: 3px solid var(--orgo-rule);
+  color: var(--orgo-muted);
+}
+
+hr { border: 0; border-top: 1px solid var(--orgo-rule); margin: 1.75rem 0; }
+
+.center { text-align: center; }
+.verse { font-family: var(--orgo-mono); white-space: pre-wrap; }
+
+dt { font-weight: 600; margin-top: .5rem; }
+dd { margin: 0 0 0 1.5rem; color: var(--orgo-muted); }
+
+/* Code --------------------------------------------------------------------- */
+
+/* Inline code is prose furniture, so it follows the page rather than the code blocks. */
+code {
+  font: .875em/1.5 var(--orgo-mono);
+  background: var(--orgo-surface);
+  color: inherit;
+  border: 1px solid var(--orgo-rule);
+  padding: .05em .3em;
+  border-radius: 3px;
+}
+
+pre {
+  background: var(--orgo-code-bg);
+  color: var(--orgo-code-fg);
+  border: 1px solid var(--orgo-code-rule);
+  border-radius: 4px;
+  padding: .8rem 1rem;
+  font-size: .875rem;
+  overflow-x: auto;
+}
+
+pre code { background: none; color: inherit; border: 0; padding: 0; }
+
+/* Tables — a wiki lives in them, so they get zebra rows and a sticky-looking head. */
+
+table {
+  border-collapse: collapse;
+  margin: 1rem 0;
+  font-size: .9rem;
+  display: block;
+  overflow-x: auto;
+}
+caption { text-align: left; color: var(--orgo-muted); font-size: .85rem; padding-bottom: .35rem; }
+th, td { text-align: left; padding: .35rem .7rem; border: 1px solid var(--orgo-rule); }
+th { background: var(--orgo-surface); font-weight: 600; }
+tbody tr:nth-child(even) td { background: var(--orgo-surface); }
+
+/* Org-specific markup ------------------------------------------------------ */
+
+.tag {
+  font: .7rem/1.6 var(--orgo-mono);
+  color: var(--orgo-muted);
+  background: var(--orgo-surface);
+  border: 1px solid var(--orgo-rule);
+  border-radius: 3px;
+  padding: .05em .45em;
+  vertical-align: middle;
+}
+
+/* TODO state is content in a notes site, not decoration. */
+.todo, .done {
+  font: .7rem/1.6 var(--orgo-mono);
+  letter-spacing: .04em;
+  border-radius: 3px;
+  padding: .05em .45em;
+  vertical-align: middle;
+  color: #ffffff;
+}
+.todo { background: var(--orgo-todo); }
+.done { background: var(--orgo-done); }
+.priority { color: var(--orgo-todo); font-family: var(--orgo-mono); font-size: .8em; }
+
+time.timestamp { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .9em; }
+time.inactive { opacity: .75; }
+
+/* Checkbox lists: the three org states, including `[-]`, which HTML has no input for.
+   The `[X]` marker is the bullet, so the list's own is dropped. */
+li.on, li.off, li.trans { list-style: none; }
+li.on { color: var(--orgo-muted); text-decoration: line-through; }
+li.trans { color: var(--orgo-muted); }
+
+.footnotes { margin-top: 2.5rem; font-size: .9rem; color: var(--orgo-muted); }
+.footnote-ref a { text-decoration: none; }
+
+/* Table of contents: in the margin once there is a margin to put it in ------ */
+
+nav.toc {
+  background: var(--orgo-surface);
+  border: 1px solid var(--orgo-rule);
+  border-radius: 4px;
+  padding: .6rem 1rem .8rem;
+  margin: 0 0 1.5rem;
+  font-size: .875rem;
+}
+nav.toc h2 {
+  font-size: .7rem;
+  text-transform: uppercase;
+  letter-spacing: .07em;
+  color: var(--orgo-muted);
+  margin: 0 0 .35rem;
+  padding: 0;
+  border: 0;
+}
+nav.toc ul { margin: 0; padding-left: 1rem; }
+nav.toc li { margin: .1rem 0; }
+
+@media (min-width: 62rem) {
+  main { max-width: var(--orgo-wide); }
+  /* Floated rather than gridded: the layout puts the contents block wherever it likes
+     in the document, and a float lands in the margin from any of those positions. */
+  nav.toc {
+    float: right;
+    width: 16rem;
+    margin: 0 0 1.5rem 2.5rem;
+  }
+  /* Prose keeps its measure even though the page is wide; the things that actually
+     want the extra room — code, tables, figures — are let out of it. */
+  main > * { max-width: var(--orgo-measure); }
+  main > pre, main > table, main > figure, main > nav.toc { max-width: none; }
+}
+
+/* Listing pages ------------------------------------------------------------ */
+
+ul.post-list { list-style: none; padding: 0; }
+ul.post-list > li { padding: .6rem 0; border-bottom: 1px solid var(--orgo-rule); }
+ul.post-list a { font-weight: 600; }
+ul.post-list time { color: var(--orgo-muted); font-family: var(--orgo-mono); font-size: .85rem; }
+p.excerpt { margin: .25rem 0; color: var(--orgo-muted); font-size: .9rem; }
+span.reading-time { font-size: .8rem; color: var(--orgo-muted); }
+
+ul.tag-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; }
+
+nav.pagination {
+  display: flex;
+  gap: 1rem;
+  align-items: baseline;
+  margin-top: 1.5rem;
+  font-size: .9rem;
+  color: var(--orgo-muted);
+}
+
+/* Footer ------------------------------------------------------------------- */
+
+body > footer {
+  border-top: 1px solid var(--orgo-rule);
+  padding: 1.25rem 1.5rem;
+  color: var(--orgo-muted);
+  font-size: .85rem;
+  text-align: center;
+}