Replace the mini-Jekyll-shaped build layer with one specific build script
Removing Jekyll was supposed to discard its complexity, not rebuild its
shape in Ruby. The ERB port kept a generic collections config (directory
-> permalink pattern -> default section), layout-inheritance chaining,
an includes-with-locals partial renderer built on define_singleton_method,
and a Dot class reimplementing Liquid's forgiving dotted-hash access —
339 lines across 3 files plus 2 layouts and 4 includes, none of which
this ~25-page personal site actually needs to be generic.

Replaced with lib/build.rb (133 lines, one file): a literal list of
top-level pages plus three directory globs, plain hash/array access
(no wrapper class), and one merged _layouts/page.erb for the whole page
shell instead of a layout chain plus four separate include files.
_data/meta.yml's single version string is now a constant.

Also fixed real drift this surfaced: the earlier type="button" cleanup
only matched literal quotes and missed the HTML-entity-escaped
type="button" form inside the static 'Markup' code-sample
panels, so several components' copy-paste snippets still showed the
attribute their live demos no longer had.

Verified: rake build succeeds, all 26 pages render, screenshots of
home/button/dialog/color/loop/mobile match the prior visual baseline
exactly (including the nav-toggle and dialog fixes from the last pass).
change
commit 414cf5864db58554ef35363cc0a190c9fa360c15
author Claude <noreply@anthropic.com>
date
parent 597956b9
diff --git a/README.md b/README.md
index 0f697b8..de97d42 100644
--- a/README.md
+++ b/README.md
@@ -58,15 +58,15 @@ $ bundle exec rake serve    # http://127.0.0.1:4000
 | --- | --- |
 | `assets/css/tokens.css` | Every color, type, space, radius, elevation, and motion value in the system, hand-maintained. |
 | `_sass/` | Plain CSS: base/reset, components (core/forms/feedback/navigation), and this documentation site's own chrome. Concatenated into `style.css` at build time. |
-| `lib/arbitrary_definitions/` | The whole site builder: front matter, ERB, and Kramdown, wrapped in a layout. No Jekyll anywhere. |
-| `_layouts/`, `_includes/` | Plain ERB templates for the page chrome. |
+| `lib/build.rb` | The whole site builder in one file: a literal list of pages, front matter, ERB, and Kramdown. No collections config, no layout inheritance, no generic includes system — this is one specific site, not a framework. |
+| `_layouts/page.erb` | The one page shell (head, header, sidebar, footer). |
 | `_foundations/`, `_components/`, `_patterns/` | The documentation pages — static Markdown and HTML with a little ERB for links and nav. |
 | `.github/workflows/pages.yml` | Build on Ruby 4.0, deploy to Pages. |
 
 ## Deployment
 
 There is no Jekyll to run here — `Rakefile`'s `build` task drives
-`lib/arbitrary_definitions/site_builder.rb` directly — but the build still
+`lib/build.rb` directly — but the build still
 runs on GitHub Actions rather than relying on GitHub's hosted build, mainly
 so it runs on the pinned Ruby 4.0 rather than whatever Ruby the Pages runner
 ships. `.github/workflows/pages.yml` builds it with `ruby/setup-ruby` and
diff --git a/Rakefile b/Rakefile
index 8bd405d..ab3e3ec 100644
--- a/Rakefile
+++ b/Rakefile
@@ -4,8 +4,8 @@ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
 
 desc "Build the site into _site"
 task :build do
-  require "arbitrary_definitions/site_builder"
-  ArbitraryDefinitions::SiteBuilder.new.build
+  require "build"
+  build
 end
 
 desc "Serve the already-built site at http://127.0.0.1:4000"
@@ -17,8 +17,8 @@ task :serve do
   abort "_site is empty — run `bundle exec rake build` first" unless Dir.exist?(site_dir)
 
   # Every link the build emits is prefixed with `baseurl` (see relative_url
-  # in lib/arbitrary_definitions/page_context.rb), so the site has to be
-  # mounted under that same path locally, the way `jekyll serve` used to.
+  # in lib/build.rb), so the site has to be mounted under that same path
+  # locally, the way `jekyll serve` used to.
   baseurl = YAML.safe_load_file(File.expand_path("_config.yml", __dir__)).fetch("baseurl", "")
 
   server = WEBrick::HTTPServer.new(Port: 4000, BindAddress: "127.0.0.1", DocumentRoot: site_dir)
diff --git a/_components/button.md b/_components/button.md
index 8c1a143..4dd42c6 100644
--- a/_components/button.md
+++ b/_components/button.md
@@ -23,11 +23,11 @@ is destructive and irreversible only.
 <button data-variant="danger">Delete</button>
 </div>
 <details open><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot;&gt;Cancel&lt;/button&gt;
-&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot;&gt;Save&lt;/button&gt;
-&lt;button type=&quot;button&quot; data-variant=&quot;dark&quot;&gt;Finish setup&lt;/button&gt;
-&lt;button type=&quot;button&quot; data-variant=&quot;ghost&quot;&gt;Dismiss&lt;/button&gt;
-&lt;button type=&quot;button&quot; data-variant=&quot;danger&quot;&gt;Delete&lt;/button&gt;</code></pre>
+<pre><code>&lt;button&gt;Cancel&lt;/button&gt;
+&lt;button data-variant=&quot;primary&quot;&gt;Save&lt;/button&gt;
+&lt;button data-variant=&quot;dark&quot;&gt;Finish setup&lt;/button&gt;
+&lt;button data-variant=&quot;ghost&quot;&gt;Dismiss&lt;/button&gt;
+&lt;button data-variant=&quot;danger&quot;&gt;Delete&lt;/button&gt;</code></pre>
 </details>
 </figure>
 
@@ -44,9 +44,9 @@ grow a step of its own.
 <button data-variant="primary" data-size="lg">Large</button>
 </div>
 <details><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot; data-size=&quot;sm&quot;&gt;Small&lt;/button&gt;
-&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot;&gt;Medium&lt;/button&gt;
-&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot; data-size=&quot;lg&quot;&gt;Large&lt;/button&gt;</code></pre>
+<pre><code>&lt;button data-variant=&quot;primary&quot; data-size=&quot;sm&quot;&gt;Small&lt;/button&gt;
+&lt;button data-variant=&quot;primary&quot;&gt;Medium&lt;/button&gt;
+&lt;button data-variant=&quot;primary&quot; data-size=&quot;lg&quot;&gt;Large&lt;/button&gt;</code></pre>
 </details>
 </figure>
 
@@ -62,8 +62,8 @@ disabled primary still reads as the primary.
 <button aria-disabled="true">Not yet available</button>
 </div>
 <details><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot; disabled=&quot;&quot;&gt;Disabled&lt;/button&gt;
-&lt;button type=&quot;button&quot; aria-disabled=&quot;true&quot;&gt;Not yet available&lt;/button&gt;</code></pre>
+<pre><code>&lt;button data-variant=&quot;primary&quot; disabled=&quot;&quot;&gt;Disabled&lt;/button&gt;
+&lt;button aria-disabled=&quot;true&quot;&gt;Not yet available&lt;/button&gt;</code></pre>
 </details>
 </figure>
 
@@ -92,7 +92,7 @@ button itself.
 </button>
 </div>
 <details><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot;&gt;
+<pre><code>&lt;button data-variant=&quot;primary&quot;&gt;
   &lt;svg width=&quot;14&quot; height=&quot;14&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M12 5v14M5 12h14&quot; /&gt;&lt;/svg&gt;
   New
 &lt;/button&gt;</code></pre>
@@ -115,9 +115,9 @@ buttons, so it doubles as the styling hook — no wrapper class.
 </div>
 <details><summary>Markup</summary>
 <pre><code>&lt;div role=&quot;group&quot; aria-label=&quot;View&quot;&gt;
-  &lt;button type=&quot;button&quot; data-size=&quot;sm&quot;&gt;List&lt;/button&gt;
-  &lt;button type=&quot;button&quot; data-size=&quot;sm&quot;&gt;Board&lt;/button&gt;
-  &lt;button type=&quot;button&quot; data-size=&quot;sm&quot;&gt;Calendar&lt;/button&gt;
+  &lt;button data-size=&quot;sm&quot;&gt;List&lt;/button&gt;
+  &lt;button data-size=&quot;sm&quot;&gt;Board&lt;/button&gt;
+  &lt;button data-size=&quot;sm&quot;&gt;Calendar&lt;/button&gt;
 &lt;/div&gt;</code></pre>
 </details>
 </figure>
@@ -130,7 +130,7 @@ buttons, so it doubles as the styling hook — no wrapper class.
 <button data-variant="primary" data-block>Take a photo</button>
 </div>
 <details><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot; data-variant=&quot;primary&quot; data-block&gt;Take a photo&lt;/button&gt;</code></pre>
+<pre><code>&lt;button data-variant=&quot;primary&quot; data-block&gt;Take a photo&lt;/button&gt;</code></pre>
 </details>
 </figure>
 
diff --git a/_components/card-row.md b/_components/card-row.md
index ebc73a5..425d142 100644
--- a/_components/card-row.md
+++ b/_components/card-row.md
@@ -46,8 +46,8 @@ a hover fill on its own; no `--clickable` modifier to remember.
 </div>
 <details><summary>Markup</summary>
 <pre><code>&lt;ul class=&quot;card&quot; style=&quot;max-width: 22rem;&quot;&gt;
-  &lt;li&gt;&lt;button type=&quot;button&quot;&gt;Tax return 2025.pdf&lt;/button&gt;&lt;/li&gt;
-  &lt;li&gt;&lt;button type=&quot;button&quot;&gt;Warranty card.pdf&lt;/button&gt;&lt;/li&gt;
+  &lt;li&gt;&lt;button&gt;Tax return 2025.pdf&lt;/button&gt;&lt;/li&gt;
+  &lt;li&gt;&lt;button&gt;Warranty card.pdf&lt;/button&gt;&lt;/li&gt;
 &lt;/ul&gt;</code></pre>
 </details>
 </figure>
diff --git a/_components/card.md b/_components/card.md
index e899101..52ca02a 100644
--- a/_components/card.md
+++ b/_components/card.md
@@ -52,8 +52,8 @@ it that has no element of its own to hang a selector on.
     &lt;p style=&quot;margin:0; color: var(--ad-color-text-secondary); font-size: var(--ad-step--1);&quot;&gt;Three people have called it &quot;yellow&quot; in review this month.&lt;/p&gt;
   &lt;/div&gt;
   &lt;div class=&quot;card-footer&quot;&gt;
-    &lt;button type=&quot;button&quot; data-variant=&quot;primary&quot; data-size=&quot;sm&quot;&gt;Approve&lt;/button&gt;
-    &lt;button type=&quot;button&quot; data-variant=&quot;ghost&quot; data-size=&quot;sm&quot;&gt;Dismiss&lt;/button&gt;
+    &lt;button data-variant=&quot;primary&quot; data-size=&quot;sm&quot;&gt;Approve&lt;/button&gt;
+    &lt;button data-variant=&quot;ghost&quot; data-size=&quot;sm&quot;&gt;Dismiss&lt;/button&gt;
   &lt;/div&gt;
 &lt;/article&gt;</code></pre>
 </details>
diff --git a/_components/dialog.md b/_components/dialog.md
index 54388a0..9cb1328 100644
--- a/_components/dialog.md
+++ b/_components/dialog.md
@@ -38,8 +38,8 @@ pseudo-element only exists for a dialog shown modally.
   &lt;header&gt;Delete series?&lt;/header&gt;
   &lt;p&gt;This removes all of its task history. This cannot be undone.&lt;/p&gt;
   &lt;footer&gt;
-    &lt;button type=&quot;button&quot;&gt;Cancel&lt;/button&gt;
-    &lt;button type=&quot;button&quot; data-variant=&quot;danger&quot;&gt;Delete&lt;/button&gt;
+    &lt;button&gt;Cancel&lt;/button&gt;
+    &lt;button data-variant=&quot;danger&quot;&gt;Delete&lt;/button&gt;
   &lt;/footer&gt;
 &lt;/dialog&gt;</code></pre>
 </details>
diff --git a/_components/icon-button.md b/_components/icon-button.md
index 333353a..4e20a41 100644
--- a/_components/icon-button.md
+++ b/_components/icon-button.md
@@ -31,13 +31,13 @@ sitting alone — `variant`/`size` mean different things for each — so
 </button>
 </div>
 <details open><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot; class=&quot;icon-button&quot; aria-label=&quot;Delete&quot;&gt;
+<pre><code>&lt;button class=&quot;icon-button&quot; aria-label=&quot;Delete&quot;&gt;
   &lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.75&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13&quot; /&gt;&lt;/svg&gt;
 &lt;/button&gt;
-&lt;button type=&quot;button&quot; class=&quot;icon-button&quot; data-variant=&quot;outline&quot; aria-label=&quot;Settings&quot;&gt;
+&lt;button class=&quot;icon-button&quot; data-variant=&quot;outline&quot; aria-label=&quot;Settings&quot;&gt;
   &lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.75&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;3&quot; /&gt;&lt;path d=&quot;M12 2.5v3M12 18.5v3M3.6 7.2l2.6 1.5M17.8 15.3l2.6 1.5M3.6 16.8l2.6-1.5M17.8 8.7l2.6-1.5&quot; /&gt;&lt;/svg&gt;
 &lt;/button&gt;
-&lt;button type=&quot;button&quot; class=&quot;icon-button&quot; data-variant=&quot;accent&quot; aria-label=&quot;Filter (active)&quot;&gt;
+&lt;button class=&quot;icon-button&quot; data-variant=&quot;accent&quot; aria-label=&quot;Filter (active)&quot;&gt;
   &lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.75&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M4 5h16l-6 8v5l-4 2v-7z&quot; /&gt;&lt;/svg&gt;
 &lt;/button&gt;</code></pre>
 </details>
@@ -53,9 +53,9 @@ sitting alone — `variant`/`size` mean different things for each — so
 <button class="icon-button" data-variant="outline" data-size="lg" aria-label="Back"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
 </div>
 <details><summary>Markup</summary>
-<pre><code>&lt;button type=&quot;button&quot; class=&quot;icon-button&quot; data-variant=&quot;outline&quot; data-size=&quot;sm&quot; aria-label=&quot;Back&quot;&gt;&lt;svg width=&quot;14&quot; height=&quot;14&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M15 18l-6-6 6-6&quot; /&gt;&lt;/svg&gt;&lt;/button&gt;
-&lt;button type=&quot;button&quot; class=&quot;icon-button&quot; data-variant=&quot;outline&quot; aria-label=&quot;Back&quot;&gt;&lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M15 18l-6-6 6-6&quot; /&gt;&lt;/svg&gt;&lt;/button&gt;
-&lt;button type=&quot;button&quot; class=&quot;icon-button&quot; data-variant=&quot;outline&quot; data-size=&quot;lg&quot; aria-label=&quot;Back&quot;&gt;&lt;svg width=&quot;18&quot; height=&quot;18&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M15 18l-6-6 6-6&quot; /&gt;&lt;/svg&gt;&lt;/button&gt;</code></pre>
+<pre><code>&lt;button class=&quot;icon-button&quot; data-variant=&quot;outline&quot; data-size=&quot;sm&quot; aria-label=&quot;Back&quot;&gt;&lt;svg width=&quot;14&quot; height=&quot;14&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M15 18l-6-6 6-6&quot; /&gt;&lt;/svg&gt;&lt;/button&gt;
+&lt;button class=&quot;icon-button&quot; data-variant=&quot;outline&quot; aria-label=&quot;Back&quot;&gt;&lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M15 18l-6-6 6-6&quot; /&gt;&lt;/svg&gt;&lt;/button&gt;
+&lt;button class=&quot;icon-button&quot; data-variant=&quot;outline&quot; data-size=&quot;lg&quot; aria-label=&quot;Back&quot;&gt;&lt;svg width=&quot;18&quot; height=&quot;18&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.9&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M15 18l-6-6 6-6&quot; /&gt;&lt;/svg&gt;&lt;/button&gt;</code></pre>
 </details>
 </figure>
 
@@ -75,7 +75,7 @@ An icon-only control almost always wants a [Tooltip](<%= relative_url('/componen
 </div>
 <details><summary>Markup</summary>
 <pre><code>&lt;span&gt;
-  &lt;button type=&quot;button&quot; class=&quot;icon-button&quot; aria-label=&quot;Delete&quot; aria-describedby=&quot;icon-btn-tip&quot;&gt;
+  &lt;button class=&quot;icon-button&quot; aria-label=&quot;Delete&quot; aria-describedby=&quot;icon-btn-tip&quot;&gt;
     &lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.75&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13&quot; /&gt;&lt;/svg&gt;
   &lt;/button&gt;
   &lt;span role=&quot;tooltip&quot; id=&quot;icon-btn-tip&quot;&gt;Delete&lt;/span&gt;
diff --git a/_components/tag.md b/_components/tag.md
index e8c7182..df6f30d 100644
--- a/_components/tag.md
+++ b/_components/tag.md
@@ -26,7 +26,7 @@ descendant selector `.tag button` — no class of its own.
 &lt;span class=&quot;tag&quot;&gt;category:appliance&lt;/span&gt;
 &lt;span class=&quot;tag&quot;&gt;
   category:receipt
-  &lt;button type=&quot;button&quot; aria-label=&quot;Remove tag&quot;&gt;×&lt;/button&gt;
+  &lt;button aria-label=&quot;Remove tag&quot;&gt;×&lt;/button&gt;
 &lt;/span&gt;</code></pre>
 </details>
 </figure>
diff --git a/_components/toast.md b/_components/toast.md
index ded42a9..4eea977 100644
--- a/_components/toast.md
+++ b/_components/toast.md
@@ -28,8 +28,8 @@ anyway.
 <details open><summary>Markup</summary>
 <pre><code>&lt;div role=&quot;status&quot;&gt;
   &lt;span&gt;Deleted &quot;Call Mom&quot;&lt;/span&gt;
-  &lt;button type=&quot;button&quot;&gt;Undo&lt;/button&gt;
-  &lt;button type=&quot;button&quot; aria-label=&quot;Dismiss&quot;&gt;×&lt;/button&gt;
+  &lt;button&gt;Undo&lt;/button&gt;
+  &lt;button aria-label=&quot;Dismiss&quot;&gt;×&lt;/button&gt;
 &lt;/div&gt;</code></pre>
 </details>
 </figure>
@@ -47,7 +47,7 @@ anyway.
 <details><summary>Markup</summary>
 <pre><code>&lt;div role=&quot;status&quot;&gt;
   &lt;span&gt;Series created&lt;/span&gt;
-  &lt;button type=&quot;button&quot; aria-label=&quot;Dismiss&quot;&gt;×&lt;/button&gt;
+  &lt;button aria-label=&quot;Dismiss&quot;&gt;×&lt;/button&gt;
 &lt;/div&gt;</code></pre>
 </details>
 </figure>
diff --git a/_components/tooltip.md b/_components/tooltip.md
index c31f62f..f8d9f16 100644
--- a/_components/tooltip.md
+++ b/_components/tooltip.md
@@ -28,7 +28,7 @@ Hover or tab to the button below.
 </div>
 <details open><summary>Markup</summary>
 <pre><code>&lt;span&gt;
-  &lt;button type=&quot;button&quot; class=&quot;icon-button&quot; aria-label=&quot;Delete&quot; aria-describedby=&quot;tooltip-basic&quot;&gt;
+  &lt;button class=&quot;icon-button&quot; aria-label=&quot;Delete&quot; aria-describedby=&quot;tooltip-basic&quot;&gt;
     &lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.75&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13&quot; /&gt;&lt;/svg&gt;
   &lt;/button&gt;
   &lt;span role=&quot;tooltip&quot; id=&quot;tooltip-basic&quot;&gt;Delete&lt;/span&gt;
diff --git a/_data/meta.yml b/_data/meta.yml
deleted file mode 100644
index 78e3791..0000000
--- a/_data/meta.yml
+++ /dev/null
@@ -1 +0,0 @@
-version: "2.0.0"
diff --git a/_includes/head.erb b/_includes/head.erb
deleted file mode 100644
index a6b60e0..0000000
--- a/_includes/head.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<meta name="theme-color" content="#faf9f7" media="(prefers-color-scheme: light)">
-<meta name="theme-color" content="#0e0d0b" media="(prefers-color-scheme: dark)">
-
-<%= render("seo") %>
-
-<!-- The system's one loaded webfont — see _data/tokens/scale.yml's
-     typography.webfont_import. Sans is a web-safe system stack; nothing is
-     fetched for it. -->
-<link rel="preconnect" href="https://fonts.googleapis.com">
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap">
-
-<!-- Generated from _data/tokens/*.yml at build time. -->
-<link rel="stylesheet" href="<%= relative_url('/assets/css/tokens.css') %>">
-<link rel="stylesheet" href="<%= relative_url('/assets/css/style.css') %>">
-
-<link rel="icon" href="<%= relative_url('/assets/favicon.svg') %>" type="image/svg+xml">
-
-<!-- Applied before first paint so an explicit theme choice never flashes. -->
-<script>
-  (function () {
-    try {
-      var stored = localStorage.getItem("ad-theme");
-      if (stored === "dark" || stored === "light") {
-        document.documentElement.setAttribute("data-theme", stored);
-      }
-    } catch (error) {
-      /* Private windows and blocked storage are fine; the media query covers us. */
-    }
-  })();
-</script>
diff --git a/_includes/seo.erb b/_includes/seo.erb
deleted file mode 100644
index 29b40f2..0000000
--- a/_includes/seo.erb
+++ /dev/null
@@ -1,10 +0,0 @@
-<%
-  seo_description = page.description || page.summary || site.description
--%>
-<title><%= page.title %> | <%= site.title %></title>
-<meta name="description" content="<%= escape(seo_description) %>">
-<link rel="canonical" href="<%= site.url %><%= relative_url(page.url) %>">
-<meta property="og:title" content="<%= escape(page.title) %>">
-<meta property="og:site_name" content="<%= escape(site.title) %>">
-<meta property="og:url" content="<%= site.url %><%= relative_url(page.url) %>">
-<meta property="og:description" content="<%= escape(seo_description) %>">
diff --git a/_includes/sidebar.erb b/_includes/sidebar.erb
deleted file mode 100644
index 79dc82c..0000000
--- a/_includes/sidebar.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-<aside class="ad-doc-sidebar" data-sidebar data-collapsed="true">
-  <button class="ad-doc-nav-toggle" data-size="sm"
-          data-nav-toggle aria-expanded="false" aria-controls="ad-doc-nav">
-    <span>Browse the system</span>
-    <span aria-hidden="true">☰</span>
-  </button>
-
-  <nav class="ad-doc-nav" id="ad-doc-nav" aria-label="Design system">
-    <% site.data.nav.each do |group| -%>
-    <div class="ad-doc-nav__group">
-      <h2 class="ad-doc-nav__heading"><%= group.title %></h2>
-      <% group.links.each do |link| -%>
-      <% href = relative_url(link.url) -%>
-      <a class="ad-doc-nav__link" href="<%= href %>"
-         <% if page.url == link.url %>aria-current="page"<% end %>><%= link.text %></a>
-      <% end -%>
-    </div>
-    <% end -%>
-  </nav>
-</aside>
diff --git a/_includes/theme-toggle.erb b/_includes/theme-toggle.erb
deleted file mode 100644
index 7ac1053..0000000
--- a/_includes/theme-toggle.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<button data-variant="ghost" data-size="sm" data-theme-toggle
-        aria-label="Switch color theme" title="Switch color theme">
-  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="light">
-    <circle cx="12" cy="12" r="4.5" stroke="currentColor" stroke-width="2"/>
-    <path d="M12 1.8v2.4M12 19.8v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M1.8 12h2.4M19.8 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7"
-          stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
-  </svg>
-  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="dark" hidden>
-    <path d="M20 14.2A8.4 8.4 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
-  </svg>
-  <span class="ad-visually-hidden" data-theme-label>Theme</span>
-</button>
diff --git a/_includes/wordmark.erb b/_includes/wordmark.erb
deleted file mode 100644
index c881e7f..0000000
--- a/_includes/wordmark.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-<a class="ad-doc-wordmark" href="<%= relative_url('/') %>">
-  <svg class="ad-doc-wordmark__mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-    <rect x="1.5" y="1.5" width="21" height="21" rx="5" stroke="currentColor" stroke-width="2"/>
-    <path d="M7 16.5 12 7l5 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-    <path d="M9.2 13.4h5.6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
-  </svg>
-  <span>Arbitrary Definitions</span>
-  <span class="ad-doc-wordmark__version">v<%= site.data.meta.version %></span>
-</a>
diff --git a/_layouts/default.erb b/_layouts/default.erb
deleted file mode 100644
index 86d9845..0000000
--- a/_layouts/default.erb
+++ /dev/null
@@ -1,39 +0,0 @@
-<!DOCTYPE html>
-<html lang="<%= site.lang || 'en' %>">
-<head>
-  <%= render("head") %>
-</head>
-<body>
-  <a class="ad-doc-skip" href="#main">Skip to content</a>
-
-  <header class="ad-doc-header">
-    <div class="ad-doc-header__inner">
-      <%= render("wordmark") %>
-      <div class="ad-doc-header__actions">
-        <%= render("theme-toggle") %>
-        <a class="btn" data-size="sm"
-           href="https://github.com/<%= site.repository %>">Source</a>
-      </div>
-    </div>
-  </header>
-
-  <div class="ad-doc-layout">
-    <%= render("sidebar") %>
-    <main class="ad-doc-main" id="main">
-      <%= content %>
-    </main>
-  </div>
-
-  <footer class="ad-doc-footer">
-    <div class="ad-doc-footer__inner">
-      <p>
-        <%= site.title %> v<%= site.data.meta.version %> — built with Ruby
-        <%= RUBY_VERSION %>.
-      </p>
-      <p><a href="https://github.com/<%= site.repository %>">View the source</a></p>
-    </div>
-  </footer>
-
-  <script src="<%= relative_url('/assets/js/system.js') %>" defer></script>
-</body>
-</html>
diff --git a/_layouts/page.erb b/_layouts/page.erb
index b067e11..8bd11b6 100644
--- a/_layouts/page.erb
+++ b/_layouts/page.erb
@@ -1,12 +1,114 @@
----
-layout: default
----
-<header class="ad-doc-title"><% if page.section %><span class="ad-doc-title__section"><%= page.section %></span><% end %>
-  <h1><%= page.title %></h1>
-</header>
-
-<% if page.summary -%>
-<p class="ad-lead"><%= page.summary %></p>
-<% end -%>
-
-<%= content %>
+<%# The whole page shell — head, header, sidebar, footer. One file, because
+    there is exactly one shell and it is not going to grow a second one.
+    Rendered by lib/build.rb#render_shell, which sets local variables
+    title/description/url/body before evaluating this template; NAV and
+    VERSION are constants from lib/build.rb, and relative_url/escape are
+    plain top-level methods. %>
+<!DOCTYPE html>
+<html lang="<%= CONFIG.fetch("lang", "en") %>">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="theme-color" content="#faf9f7" media="(prefers-color-scheme: light)">
+<meta name="theme-color" content="#0e0d0b" media="(prefers-color-scheme: dark)">
+
+<title><%= title %> | <%= CONFIG.fetch("title") %></title>
+<meta name="description" content="<%= escape(description) %>">
+<link rel="canonical" href="<%= CONFIG.fetch("url") %><%= relative_url(url) %>">
+<meta property="og:title" content="<%= escape(title) %>">
+<meta property="og:site_name" content="<%= escape(CONFIG.fetch("title")) %>">
+<meta property="og:url" content="<%= CONFIG.fetch("url") %><%= relative_url(url) %>">
+<meta property="og:description" content="<%= escape(description) %>">
+
+<!-- The system's one loaded webfont — see assets/css/tokens.css. Sans is a
+     web-safe system stack; nothing is fetched for it. -->
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap">
+
+<link rel="stylesheet" href="<%= relative_url('/assets/css/tokens.css') %>">
+<link rel="stylesheet" href="<%= relative_url('/assets/css/style.css') %>">
+<link rel="icon" href="<%= relative_url('/assets/favicon.svg') %>" type="image/svg+xml">
+
+<!-- Applied before first paint so an explicit theme choice never flashes. -->
+<script>
+  (function () {
+    try {
+      var stored = localStorage.getItem("ad-theme");
+      if (stored === "dark" || stored === "light") {
+        document.documentElement.setAttribute("data-theme", stored);
+      }
+    } catch (error) {
+      /* Private windows and blocked storage are fine; the media query covers us. */
+    }
+  })();
+</script>
+</head>
+<body>
+  <a class="ad-doc-skip" href="#main">Skip to content</a>
+
+  <header class="ad-doc-header">
+    <div class="ad-doc-header__inner">
+      <a class="ad-doc-wordmark" href="<%= relative_url('/') %>">
+        <svg class="ad-doc-wordmark__mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+          <rect x="1.5" y="1.5" width="21" height="21" rx="5" stroke="currentColor" stroke-width="2"/>
+          <path d="M7 16.5 12 7l5 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+          <path d="M9.2 13.4h5.6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+        </svg>
+        <span>Arbitrary Definitions</span>
+        <span class="ad-doc-wordmark__version">v<%= VERSION %></span>
+      </a>
+      <div class="ad-doc-header__actions">
+        <button data-variant="ghost" data-size="sm" data-theme-toggle
+                aria-label="Switch color theme" title="Switch color theme">
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="light">
+            <circle cx="12" cy="12" r="4.5" stroke="currentColor" stroke-width="2"/>
+            <path d="M12 1.8v2.4M12 19.8v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M1.8 12h2.4M19.8 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7"
+                  stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+          </svg>
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="dark" hidden>
+            <path d="M20 14.2A8.4 8.4 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
+          </svg>
+          <span class="ad-visually-hidden" data-theme-label>Theme</span>
+        </button>
+        <a class="btn" data-size="sm" href="https://github.com/<%= CONFIG.fetch("repository") %>">Source</a>
+      </div>
+    </div>
+  </header>
+
+  <div class="ad-doc-layout">
+    <aside class="ad-doc-sidebar" data-sidebar data-collapsed="true">
+      <button class="ad-doc-nav-toggle" data-size="sm"
+              data-nav-toggle aria-expanded="false" aria-controls="ad-doc-nav">
+        <span>Browse the system</span>
+        <span aria-hidden="true">☰</span>
+      </button>
+
+      <nav class="ad-doc-nav" id="ad-doc-nav" aria-label="Design system">
+        <% NAV.each do |group| -%>
+        <div class="ad-doc-nav__group">
+          <h2 class="ad-doc-nav__heading"><%= group["title"] %></h2>
+          <% group["links"].each do |link| -%>
+          <a class="ad-doc-nav__link" href="<%= relative_url(link["url"]) %>"
+             <% if url == link["url"] %>aria-current="page"<% end %>><%= link["text"] %></a>
+          <% end -%>
+        </div>
+        <% end -%>
+      </nav>
+    </aside>
+
+    <main class="ad-doc-main" id="main">
+      <%= body %>
+    </main>
+  </div>
+
+  <footer class="ad-doc-footer">
+    <div class="ad-doc-footer__inner">
+      <p><%= CONFIG.fetch("title") %> v<%= VERSION %> — built with Ruby <%= RUBY_VERSION %>.</p>
+      <p><a href="https://github.com/<%= CONFIG.fetch("repository") %>">View the source</a></p>
+    </div>
+  </footer>
+
+  <script src="<%= relative_url('/assets/js/system.js') %>" defer></script>
+</body>
+</html>
diff --git a/lib/arbitrary_definitions/dot.rb b/lib/arbitrary_definitions/dot.rb
deleted file mode 100644
index fc882ca..0000000
--- a/lib/arbitrary_definitions/dot.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-module ArbitraryDefinitions
-  # A thin, read-only wrapper that lets plain Ruby Hashes (as loaded from the
-  # YAML under `_data/`) be addressed with dotted method calls the way Liquid
-  # addressed them — `group.name`, `token.usage`, `step.value` — so the
-  # templates ported from `{{ }}` interpolation read the same way they always
-  # did, without inventing a second token-access API.
-  #
-  # Hashes are wrapped; Arrays are mapped eagerly to arrays of wrapped
-  # elements (so `Array` methods like `size`, `find`, `each`, `sort_by` all
-  # keep working, since Ruby's ERB templates write plain Ruby rather than a
-  # ported Liquid filter chain); anything else passes through untouched.
-  class Dot
-    def self.wrap(value)
-      case value
-      when Dot then value
-      when Hash then new(value)
-      when Array then value.map { |element| wrap(element) }
-      else value
-      end
-    end
-
-    def initialize(hash)
-      @hash = hash
-    end
-
-    def [](key)
-      Dot.wrap(@hash[key.to_s])
-    end
-
-    def key?(key)
-      @hash.key?(key.to_s)
-    end
-
-    def to_h
-      @hash
-    end
-
-    # Hash-style iteration, yielding the key and the wrapped value — used for
-    # `_data/tokens/contrast.yml`, which is a hash of sections rather than a
-    # list.
-    def each
-      return enum_for(:each) unless block_given?
-
-      @hash.each { |key, value| yield key, Dot.wrap(value) }
-    end
-
-    # An absent key returns nil rather than raising — Liquid's `{{ page.foo }}`
-    # silently rendered blank for an undefined property, and several pages
-    # rely on that for genuinely optional front matter (`page.summary`,
-    # `page.section`, `page.description`), so this keeps the same forgiving
-    # behavior rather than making every optional field a special case at
-    # every call site.
-    def method_missing(name, *args)
-      return super unless args.empty? && !name.to_s.end_with?("=")
-
-      Dot.wrap(@hash[name.to_s])
-    end
-
-    def respond_to_missing?(_name, include_private = false)
-      true
-    end
-
-    def inspect
-      "#<ArbitraryDefinitions::Dot #{@hash.inspect}>"
-    end
-  end
-end
diff --git a/lib/arbitrary_definitions/page_context.rb b/lib/arbitrary_definitions/page_context.rb
deleted file mode 100644
index d14c2ca..0000000
--- a/lib/arbitrary_definitions/page_context.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-# frozen_string_literal: true
-
-require "cgi/escape"
-require "erb"
-
-module ArbitraryDefinitions
-  # Everything an ERB template can reach: `site`/`page` data, `relative_url`
-  # for links, `escape` for attribute text, and `render` for includes.
-  #
-  # One instance is built per page and reused for every layout and partial
-  # that page passes through, so `page`/`site` stay consistent from the
-  # innermost content template out through every wrapping layout.
-  class PageContext
-    attr_accessor :site, :page, :content
-
-    def initialize(site:, page: nil, content: nil)
-      @site = site
-      @page = page
-      @content = content
-    end
-
-    # A fresh `binding` on every call, with `self` fixed to this context, so
-    # each ERB evaluation (the page body, each layout it wraps in, every
-    # partial) gets its own `_erbout` output buffer while still resolving
-    # `site`, `page`, `content`, and every helper below as plain method calls.
-    def erb_binding
-      binding
-    end
-
-    # Prefixes `baseurl` onto a path, without doubling slashes.
-    def relative_url(path)
-      path = path.to_s
-      path = "/#{path}" unless path.start_with?("/")
-      "#{site.baseurl.to_s.chomp('/')}#{path}"
-    end
-
-    def escape(value)
-      CGI.escapeHTML(value.to_s)
-    end
-
-    # -- includes -------------------------------------------------------
-
-    INCLUDES_DIR = File.expand_path("../../_includes", __dir__)
-
-    # Renders an ERB partial from `_includes/`. `locals` are exposed to the
-    # partial as local-ish reader methods for the duration of the call.
-    def render(name, locals = {})
-      body = File.read(File.join(INCLUDES_DIR, "#{name}.erb"))
-
-      previous = {}
-      locals.each do |key, value|
-        previous[key] = respond_to?(key) ? send(key) : :__unset__
-        define_singleton_method(key) { value }
-      end
-
-      ERB.new(body, trim_mode: "-").result(binding)
-    ensure
-      previous&.each do |key, value|
-        if value == :__unset__
-          singleton_class.send(:remove_method, key)
-        else
-          define_singleton_method(key) { value }
-        end
-      end
-    end
-  end
-end
diff --git a/lib/arbitrary_definitions/site_builder.rb b/lib/arbitrary_definitions/site_builder.rb
deleted file mode 100644
index 1f8bfdc..0000000
--- a/lib/arbitrary_definitions/site_builder.rb
+++ /dev/null
@@ -1,203 +0,0 @@
-# frozen_string_literal: true
-
-require "yaml"
-require "erb"
-require "fileutils"
-
-# The site is UTF-8 regardless of the locale the build happens to run under;
-# a runner with LANG unset would otherwise read the Markdown as US-ASCII.
-Encoding.default_external = Encoding::UTF_8
-Encoding.default_internal = Encoding::UTF_8
-require "kramdown"
-require "kramdown-parser-gfm"
-
-require_relative "dot"
-require_relative "page_context"
-
-module ArbitraryDefinitions
-  # The whole static site build: reads `_config.yml` and `_data/`, concatenates
-  # the plain CSS under `_sass/` and copies `assets/css/tokens.css`, renders
-  # every Markdown page through ERB and then Kramdown, wraps it in its layout,
-  # and writes the result under `_site/`.
-  #
-  # This replaces Jekyll itself, not just its plugins — `Rakefile`'s `build`
-  # task is the only caller.
-  class SiteBuilder
-    ROOT        = File.expand_path("../..", __dir__)
-    SITE_DIR    = File.join(ROOT, "_site")
-    LAYOUTS_DIR = File.join(ROOT, "_layouts")
-    SASS_DIR    = File.join(ROOT, "_sass")
-
-    # Plain CSS files, concatenated in this order into assets/css/style.css:
-    # reset/base first, then components, then this site's own chrome.
-    CSS_FILES = %w[
-      base/reset.css
-      base/typography.css
-      components/button.css
-      components/field.css
-      components/badge.css
-      components/card.css
-      components/feedback.css
-      components/tabs.css
-      site/shell.css
-      site/example.css
-      site/tokens-docs.css
-    ].freeze
-
-    # dir under repository root => [permalink pattern, default section]
-    COLLECTIONS = {
-      "_foundations" => ["/foundations/%s/", "Foundations"],
-      "_components"  => ["/components/%s/", "Components"],
-      "_patterns"    => ["/patterns/%s/", "Patterns"],
-    }.freeze
-
-    # Top-level content pages. README.md is deliberately left out, matching
-    # _config.yml's old `exclude:` list — it documents the repository, not
-    # the design system, and was never rendered as a site page.
-    TOP_LEVEL_PAGES = %w[index.md install.md rationale.md].freeze
-
-    PageSource = Struct.new(:front_matter, :body, :url, :layout, :section, keyword_init: true)
-
-    def initialize
-      @config = YAML.safe_load_file(File.join(ROOT, "_config.yml"))
-      @site   = build_site
-    end
-
-    def build
-      FileUtils.rm_rf(SITE_DIR)
-      FileUtils.mkdir_p(SITE_DIR)
-
-      write_css
-      copy_assets
-      collect_pages.each { |page_source| render_page(page_source) }
-    end
-
-    private
-
-    def build_site
-      Site.new(
-        title: @config.fetch("title"),
-        tagline: @config["tagline"],
-        description: @config["description"],
-        url: @config["url"],
-        baseurl: @config.fetch("baseurl", ""),
-        repository: @config["repository"],
-        lang: @config.fetch("lang", "en"),
-        data: Dot.wrap(
-          "nav" => YAML.safe_load_file(File.join(ROOT, "_data", "nav.yml")),
-          "meta" => YAML.safe_load_file(File.join(ROOT, "_data", "meta.yml")),
-        ),
-      )
-    end
-
-    Site = Struct.new(:title, :tagline, :description, :url, :baseurl, :repository, :lang, :data, keyword_init: true)
-
-    def write_css
-      css = CSS_FILES.map { |name| File.read(File.join(SASS_DIR, name)) }.join("\n")
-      write(File.join(SITE_DIR, "assets", "css", "style.css"), css)
-
-      tokens_out = File.join(SITE_DIR, "assets", "css", "tokens.css")
-      FileUtils.mkdir_p(File.dirname(tokens_out))
-      FileUtils.cp(File.join(ROOT, "assets", "css", "tokens.css"), tokens_out)
-    end
-
-    def copy_assets
-      FileUtils.mkdir_p(File.join(SITE_DIR, "assets", "js"))
-      FileUtils.cp(File.join(ROOT, "assets", "favicon.svg"), File.join(SITE_DIR, "assets", "favicon.svg"))
-      Dir.glob(File.join(ROOT, "assets", "js", "*")).each do |path|
-        FileUtils.cp(path, File.join(SITE_DIR, "assets", "js", File.basename(path)))
-      end
-    end
-
-    def collect_pages
-      pages = []
-
-      TOP_LEVEL_PAGES.each do |filename|
-        front_matter, body = split_front_matter(File.read(File.join(ROOT, filename)))
-        default_url = filename == "index.md" ? "/" : "/#{File.basename(filename, '.md')}/"
-
-        pages << PageSource.new(
-          front_matter: front_matter,
-          body: body,
-          url: front_matter.fetch("permalink", default_url),
-          layout: front_matter.fetch("layout", "page"),
-          section: front_matter["section"],
-        )
-      end
-
-      COLLECTIONS.each do |dir, (permalink_pattern, default_section)|
-        Dir.glob(File.join(ROOT, dir, "*.md")).sort.each do |path|
-          front_matter, body = split_front_matter(File.read(path))
-          name = File.basename(path, ".md")
-
-          pages << PageSource.new(
-            front_matter: front_matter,
-            body: body,
-            url: format(permalink_pattern, name),
-            layout: front_matter.fetch("layout", "page"),
-            section: front_matter.fetch("section", default_section),
-          )
-        end
-      end
-
-      pages
-    end
-
-    def split_front_matter(raw)
-      match = raw.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
-      raise "no front matter found" unless match
-
-      [(YAML.safe_load(match[1]) || {}), match[2]]
-    end
-
-    def render_page(page_source)
-      page = Dot.wrap(page_source.front_matter.merge(
-        "url" => page_source.url,
-        "section" => page_source.section,
-      ))
-
-      context = PageContext.new(site: @site, page: page)
-
-      rendered = ERB.new(page_source.body, trim_mode: "-").result(context.erb_binding)
-
-      # hard_wrap: false — the GFM parser otherwise turns every manually
-      # word-wrapped source line into a <br>, since GitHub's own comment
-      # rendering treats a single newline as a line break. This content is
-      # wrapped for editor readability, not for literal line breaks.
-      html_body = Kramdown::Document.new(rendered, input: "GFM", syntax_highlighter: nil, hard_wrap: false).to_html
-
-      html = wrap_layout(page_source.layout, html_body, context)
-      write(File.join(output_dir(page_source.url), "index.html"), html)
-    end
-
-    def output_dir(url)
-      relative = url.sub(%r{\A/}, "").sub(%r{/\z}, "")
-      relative.empty? ? SITE_DIR : File.join(SITE_DIR, relative)
-    end
-
-    # Layouts may themselves declare a `layout:` in their own front matter
-    # (`_layouts/page.erb` wraps in `default`), so this recurses outward
-    # until a layout with no further parent is reached.
-    def wrap_layout(name, content, context)
-      return content if name.nil? || name == "none"
-
-      raw = File.read(File.join(LAYOUTS_DIR, "#{name}.erb"))
-      front_matter, body =
-        if raw.start_with?("---\n")
-          split_front_matter(raw)
-        else
-          [{}, raw]
-        end
-
-      context.content = content
-      rendered = ERB.new(body, trim_mode: "-").result(context.erb_binding)
-
-      wrap_layout(front_matter["layout"], rendered, context)
-    end
-
-    def write(path, content)
-      FileUtils.mkdir_p(File.dirname(path))
-      File.write(path, content)
-    end
-  end
-end
diff --git a/lib/build.rb b/lib/build.rb
new file mode 100644
index 0000000..cd601e1
--- /dev/null
+++ b/lib/build.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+# The whole site build. Not a framework — this is one specific site with
+# ~25 known pages, so it lists them and builds them, rather than porting
+# Jekyll's generic machinery (collections, permalink patterns, layout
+# inheritance, a Liquid-like dotted-hash type, includes-with-locals) into
+# Ruby. Adding a page means adding a line to PAGES or a file to one of the
+# three content directories below; nothing here needs to get more generic
+# than that to do its job.
+
+require "yaml"
+require "erb"
+require "fileutils"
+require "cgi/escape"
+
+# A runner with LANG unset would otherwise read the Markdown as US-ASCII.
+Encoding.default_external = Encoding::UTF_8
+Encoding.default_internal = Encoding::UTF_8
+
+require "kramdown"
+require "kramdown-parser-gfm"
+
+ROOT    = File.expand_path("..", __dir__)
+SITE    = File.join(ROOT, "_site")
+CONFIG  = YAML.safe_load_file(File.join(ROOT, "_config.yml"))
+NAV     = YAML.safe_load_file(File.join(ROOT, "_data", "nav.yml"))
+VERSION = "2.0.0"
+
+# Plain CSS files, concatenated in this order into assets/css/style.css:
+# reset/base first, then components, then this site's own chrome.
+CSS_FILES = %w[
+  base/reset.css
+  base/typography.css
+  components/button.css
+  components/field.css
+  components/badge.css
+  components/card.css
+  components/feedback.css
+  components/tabs.css
+  site/shell.css
+  site/example.css
+  site/tokens-docs.css
+].freeze
+
+# [source file (absolute or relative to ROOT), url, section label for the
+# doc-title header (nil to omit it entirely — index.md builds its own hero
+# markup and has no h1 header)].
+PAGES = [
+  ["index.md", "/", nil],
+  ["rationale.md", "/rationale/", nil],
+  ["install.md", "/install/", nil],
+  *Dir[File.join(ROOT, "_foundations/*.md")].sort.map { |f| [f, "/foundations/#{File.basename(f, ".md")}/", "Foundations"] },
+  *Dir[File.join(ROOT, "_components/*.md")].sort.map { |f| [f, "/components/#{File.basename(f, ".md")}/", "Components"] },
+  *Dir[File.join(ROOT, "_patterns/*.md")].sort.map { |f| [f, "/patterns/#{File.basename(f, ".md")}/", "Patterns"] },
+].map { |source, url, section| [File.expand_path(source, ROOT), url, section] }.freeze
+
+# Prefixes `baseurl` onto a path, without doubling slashes. Used both by
+# page bodies (a handful link to other pages) and the layout.
+def relative_url(path)
+  path = "/#{path}" unless path.start_with?("/")
+  "#{CONFIG.fetch("baseurl", "").chomp("/")}#{path}"
+end
+
+def escape(value) = CGI.escapeHTML(value.to_s)
+
+def split_front_matter(raw)
+  raw =~ /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m or raise "no front matter found"
+  [YAML.safe_load(::Regexp.last_match(1)) || {}, ::Regexp.last_match(2)]
+end
+
+def render_shell(title:, description:, url:, body:)
+  template = File.read(File.join(ROOT, "_layouts", "page.erb"))
+  ERB.new(template, trim_mode: "-").result(binding)
+end
+
+def render_page(source_path, url, section)
+  front, markdown = split_front_matter(File.read(source_path))
+
+  content = ERB.new(markdown, trim_mode: "-").result(binding)
+  # hard_wrap: false — the GFM parser otherwise turns every manually
+  # word-wrapped source line into a <br>, since GitHub's own comment
+  # rendering treats a single newline as a line break. This content is
+  # wrapped for editor readability, not for literal line breaks.
+  content_html = Kramdown::Document.new(content, input: "GFM", syntax_highlighter: nil, hard_wrap: false).to_html
+
+  # index.md sets `layout: default` to skip the auto title header and build
+  # its own hero markup instead; every other page gets one generated here.
+  body =
+    if front["layout"] == "default"
+      content_html
+    else
+      title_header = section ? %(<span class="ad-doc-title__section">#{section}</span>) : ""
+      summary = front["summary"] ? %(<p class="ad-lead">#{front["summary"]}</p>\n) : ""
+      <<~HTML
+        <header class="ad-doc-title">#{title_header}
+          <h1>#{front.fetch("title")}</h1>
+        </header>
+        #{summary}#{content_html}
+      HTML
+    end
+
+  html = render_shell(
+    title: front.fetch("title"),
+    description: front["description"] || front["summary"] || CONFIG["description"],
+    url: url,
+    body: body,
+  )
+
+  out_dir = url == "/" ? SITE : File.join(SITE, url.delete_prefix("/").delete_suffix("/"))
+  FileUtils.mkdir_p(out_dir)
+  File.write(File.join(out_dir, "index.html"), html)
+end
+
+def write_css
+  css = CSS_FILES.map { |name| File.read(File.join(ROOT, "_sass", name)) }.join("\n")
+  FileUtils.mkdir_p(File.join(SITE, "assets", "css"))
+  File.write(File.join(SITE, "assets", "css", "style.css"), css)
+  FileUtils.cp(File.join(ROOT, "assets", "css", "tokens.css"), File.join(SITE, "assets", "css", "tokens.css"))
+end
+
+def copy_assets
+  FileUtils.mkdir_p(File.join(SITE, "assets", "js"))
+  FileUtils.cp(File.join(ROOT, "assets", "favicon.svg"), File.join(SITE, "assets", "favicon.svg"))
+  Dir[File.join(ROOT, "assets", "js", "*")].each { |path| FileUtils.cp(path, File.join(SITE, "assets", "js", File.basename(path))) }
+end
+
+def build
+  FileUtils.rm_rf(SITE)
+  FileUtils.mkdir_p(SITE)
+  write_css
+  copy_assets
+  PAGES.each { |source, url, section| render_page(source, url, section) }
+end