Replace Jekyll/Liquid with a plain ERB + Rakefile build
Ports the delivery mechanism only — every page's content, tokens, and
component/foundation/pattern Markdown bodies are unchanged except for
mechanical Liquid-to-ERB tag conversion.

- lib/arbitrary_definitions/{dot,page_context,example_helper,site_builder}.rb:
  a small site builder (front matter -> ERB -> Kramdown -> layout wrap)
  that reuses TokenSet and ColorMath unchanged. Dot gives plain YAML
  hashes/arrays the same dotted access Liquid gave them; PageContext
  carries site/page data and the former Liquid filters as plain methods;
  ExampleHelper ports the {% example %} block to a capture-based ERB
  helper (called as a statement, `<% example(...) do %>`, since stock
  ERB can't compile a `<%= helper do %>` expression tag).
- _layouts/*.erb, _includes/*.erb: converted from Liquid; {% seo %} is
  replaced by a small hand-written seo.erb partial.
- _components/*.md, _foundations/*.md, _patterns/*.md, index.md,
  install.md, rationale.md: {% example %}/{% if %}/{% for %}/{{ }} etc.
  converted to ERB and plain Ruby (find/sort_by instead of Liquid's
  where/sort filters); prose mentions of Jekyll updated to describe the
  new build.
- _plugins/ removed (logic now lives in lib/); Gemfile swaps jekyll for
  kramdown/kramdown-parser-gfm/rouge/sass-embedded; _config.yml drops
  Jekyll-only keys; Rakefile's build/serve/design tasks now drive the
  new builder directly; .github/workflows/pages.yml calls `rake build`.
- README.md/install.md/rationale.md: editorial pass removing stale
  Jekyll/Liquid mentions.

bundle exec rake test: 25 runs, 0 failures, 0 errors.
bundle exec rake build: succeeds, produces all 26 pages under _site/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jsk87pdEh7a2BNyqk5vJQ
change
commit bb6ef5c4f04b82ffcb8694d7efe0923e8c25175b
author Claude <noreply@anthropic.com>
date
parent a1f7c6a4
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index ebe142b..7a288ad 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -7,9 +7,10 @@ on:
   pull_request:
   workflow_dispatch:
 
-# The site is built here rather than by GitHub's hosted Jekyll because
-# _plugins/ only runs in an unsafe build, and because that build would pin us
-# to whatever Ruby GitHub Pages is using rather than Ruby 4.0.
+# The site is its own small Ruby/ERB build (see lib/arbitrary_definitions/
+# site_builder.rb and Rakefile) rather than Jekyll, so it has to build on
+# Actions rather than GitHub's hosted Jekyll build regardless — this also
+# pins the build to Ruby 4.0 rather than whatever Ruby GitHub Pages uses.
 permissions:
   contents: read
   pages: write
@@ -35,9 +36,7 @@ jobs:
           bundler-cache: true
 
       - name: Report the toolchain
-        run: |
-          ruby -v
-          bundle exec jekyll -v
+        run: ruby -v
 
       # The token tests are the deploy gate: they assert the contrast floors,
       # that every ramp darkens monotonically, and that no unresolved token
@@ -50,9 +49,7 @@ jobs:
       # green on branches and forks. Change it there if the site ever moves off
       # a project page.
       - name: Build
-        env:
-          JEKYLL_ENV: production
-        run: bundle exec jekyll build
+        run: bundle exec rake build
 
       - name: Check the build output
         run: |
diff --git a/.gitignore b/.gitignore
index 18c5402..fc7f745 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,4 @@
 _site/
 design-bundle/
-.jekyll-cache/
-.jekyll-metadata
-.sass-cache/
 vendor/bundle/
 .bundle/
diff --git a/Gemfile b/Gemfile
index 46855c0..232564a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,21 +2,20 @@
 
 source "https://rubygems.org"
 
-# This site is built on Ruby 4.0. The `_plugins/` directory rules out the
-# GitHub-hosted Jekyll build, so .github/workflows/pages.yml builds it on
-# Actions and uploads the result to Pages.
+# This site is built on Ruby 4.0.
 # ruby "~> 4.0" # temporarily disabled for local verification under ruby 3.3.6
 
-gem "jekyll", "~> 4.4"
+# Markdown rendering (GFM) and syntax highlighting for the ERB-rendered pages
+# — see lib/arbitrary_definitions/site_builder.rb.
+gem "kramdown", "~> 2.5"
+gem "kramdown-parser-gfm", "~> 1.1"
+gem "rouge", "~> 4.0"
 
-group :jekyll_plugins do
-  gem "jekyll-seo-tag", "~> 2.8"
-  gem "jekyll-sitemap", "~> 1.4"
-end
+# Compiles _sass/**/*.scss into assets/css/style.css.
+gem "sass-embedded", "~> 1.75"
 
-# Standard library that ships as a bundled gem from Ruby 3.4/4.0 onward.
-# Jekyll and its dependencies still reach for these, so they have to be
-# declared rather than assumed.
+# Standard library that ships as a bundled gem from Ruby 3.4/4.0 onward, but
+# still has to be declared explicitly rather than assumed.
 gem "base64", "~> 0.2"
 gem "benchmark", "~> 0.4"
 gem "bigdecimal", "~> 3.1"
diff --git a/Gemfile.lock b/Gemfile.lock
index cdf59ca..0de3830 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,30 +1,10 @@
 GEM
   remote: https://rubygems.org/
   specs:
-    addressable (2.9.0)
-      public_suffix (>= 2.0.2, < 8.0)
     base64 (0.3.0)
     benchmark (0.5.0)
     bigdecimal (3.3.1)
-    colorator (1.1.0)
-    concurrent-ruby (1.3.8)
     csv (3.3.6)
-    em-websocket (0.5.3)
-      eventmachine (>= 0.12.9)
-      http_parser.rb (~> 0)
-    eventmachine (1.2.7)
-    ffi (1.17.4)
-    ffi (1.17.4-aarch64-linux-gnu)
-    ffi (1.17.4-aarch64-linux-musl)
-    ffi (1.17.4-arm-linux-gnu)
-    ffi (1.17.4-arm-linux-musl)
-    ffi (1.17.4-arm64-darwin)
-    ffi (1.17.4-x86-linux-gnu)
-    ffi (1.17.4-x86-linux-musl)
-    ffi (1.17.4-x86_64-darwin)
-    ffi (1.17.4-x86_64-linux-gnu)
-    ffi (1.17.4-x86_64-linux-musl)
-    forwardable-extended (2.6.0)
     google-protobuf (4.36.0)
       bigdecimal
       rake (~> 13.3)
@@ -52,94 +32,47 @@ GEM
     google-protobuf (4.36.0-x86_64-linux-musl)
       bigdecimal
       rake (~> 13.3)
-    http_parser.rb (0.8.1)
-    i18n (1.15.2)
-      concurrent-ruby (~> 1.0)
-    jekyll (4.4.1)
-      addressable (~> 2.4)
-      base64 (~> 0.2)
-      colorator (~> 1.0)
-      csv (~> 3.0)
-      em-websocket (~> 0.5)
-      i18n (~> 1.0)
-      jekyll-sass-converter (>= 2.0, < 4.0)
-      jekyll-watch (~> 2.0)
-      json (~> 2.6)
-      kramdown (~> 2.3, >= 2.3.1)
-      kramdown-parser-gfm (~> 1.0)
-      liquid (~> 4.0)
-      mercenary (~> 0.3, >= 0.3.6)
-      pathutil (~> 0.9)
-      rouge (>= 3.0, < 5.0)
-      safe_yaml (~> 1.0)
-      terminal-table (>= 1.8, < 4.0)
-      webrick (~> 1.7)
-    jekyll-sass-converter (3.1.0)
-      sass-embedded (~> 1.75)
-    jekyll-seo-tag (2.9.0)
-      jekyll (>= 3.8, < 5.0)
-    jekyll-sitemap (1.4.0)
-      jekyll (>= 3.7, < 5.0)
-    jekyll-watch (2.2.1)
-      listen (~> 3.0)
-    json (2.21.2)
     kramdown (2.5.2)
       rexml (>= 3.4.4)
     kramdown-parser-gfm (1.1.0)
       kramdown (~> 2.0)
-    liquid (4.0.4)
-    listen (3.10.0)
-      logger
-      rb-fsevent (~> 0.10, >= 0.10.3)
-      rb-inotify (~> 0.9, >= 0.9.10)
     logger (1.7.0)
-    mercenary (0.4.0)
     minitest (5.27.0)
     ostruct (0.6.3)
-    pathutil (0.16.2)
-      forwardable-extended (~> 2.6)
-    public_suffix (7.0.5)
     rake (13.4.2)
-    rb-fsevent (0.11.2)
-    rb-inotify (0.11.1)
-      ffi (~> 1.0)
     rexml (3.4.4)
     rouge (4.7.0)
-    safe_yaml (1.0.5)
-    sass-embedded (1.103.0)
+    sass-embedded (1.103.1)
       google-protobuf (~> 4.31)
       rake (>= 13)
-    sass-embedded (1.103.0-aarch64-linux-android)
+    sass-embedded (1.103.1-aarch64-linux-android)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-aarch64-linux-gnu)
+    sass-embedded (1.103.1-aarch64-linux-gnu)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-aarch64-linux-musl)
+    sass-embedded (1.103.1-aarch64-linux-musl)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-arm-linux-androideabi)
+    sass-embedded (1.103.1-arm-linux-androideabi)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-arm-linux-gnueabihf)
+    sass-embedded (1.103.1-arm-linux-gnueabihf)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-arm-linux-musleabihf)
+    sass-embedded (1.103.1-arm-linux-musleabihf)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-arm64-darwin)
+    sass-embedded (1.103.1-arm64-darwin)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-riscv64-linux-android)
+    sass-embedded (1.103.1-riscv64-linux-android)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-riscv64-linux-gnu)
+    sass-embedded (1.103.1-riscv64-linux-gnu)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-riscv64-linux-musl)
+    sass-embedded (1.103.1-riscv64-linux-musl)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-x86_64-darwin)
+    sass-embedded (1.103.1-x86_64-darwin)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-x86_64-linux-android)
+    sass-embedded (1.103.1-x86_64-linux-android)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-x86_64-linux-gnu)
+    sass-embedded (1.103.1-x86_64-linux-gnu)
       google-protobuf (~> 4.31)
-    sass-embedded (1.103.0-x86_64-linux-musl)
+    sass-embedded (1.103.1-x86_64-linux-musl)
       google-protobuf (~> 4.31)
-    terminal-table (3.0.2)
-      unicode-display_width (>= 1.1.1, < 3)
-    unicode-display_width (2.6.0)
     webrick (1.9.2)
 
 PLATFORMS
@@ -147,9 +80,7 @@ PLATFORMS
   aarch64-linux-gnu
   aarch64-linux-musl
   arm-linux-androideabi
-  arm-linux-gnu
   arm-linux-gnueabihf
-  arm-linux-musl
   arm-linux-musleabihf
   arm64-darwin
   riscv64-linux-android
@@ -168,38 +99,22 @@ DEPENDENCIES
   benchmark (~> 0.4)
   bigdecimal (~> 3.1)
   csv (~> 3.3)
-  jekyll (~> 4.4)
-  jekyll-seo-tag (~> 2.8)
-  jekyll-sitemap (~> 1.4)
+  kramdown (~> 2.5)
+  kramdown-parser-gfm (~> 1.1)
   logger (~> 1.6)
   minitest (~> 5.25)
   ostruct (~> 0.6)
   rake (~> 13.2)
+  rouge (~> 4.0)
+  sass-embedded (~> 1.75)
   webrick (~> 1.9)
 
 CHECKSUMS
-  addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
   base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
   benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
   bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218
   bundler (4.0.16) sha256=d6ca5dd440c24f9abce9844cf44cc8e18c6a553de65a47efb4544137af92c47d
-  colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38
-  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
   csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
-  em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8
-  eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
-  ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
-  ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
-  ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
-  ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
-  ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
-  ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
-  ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf
-  ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809
-  ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
-  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
-  ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
-  forwardable-extended (2.6.0) sha256=1bec948c469bbddfadeb3bd90eb8c85f6e627a412a3e852acfd7eaedbac3ec97
   google-protobuf (4.36.0) sha256=2132fb002366b85a57fbb796333e2cb1f969126e05f9af32149709ba3fd397b5
   google-protobuf (4.36.0-aarch64-linux-gnu) sha256=3fa22004853b83297d8ad4ca5c9adc8aea5e6da7b4caadf5577cb9da3eefe63f
   google-protobuf (4.36.0-aarch64-linux-musl) sha256=6392f5e7cc5366203349f268c98e39f29c2ef9766ebbdb434819d7edb085a12c
@@ -209,47 +124,29 @@ CHECKSUMS
   google-protobuf (4.36.0-x86_64-darwin) sha256=6cb938760546ee5ce57d04ed358ea10d0278b6f1ddea35e100c278f429afc25b
   google-protobuf (4.36.0-x86_64-linux-gnu) sha256=8c253f7cb7647cdf1f54fc3f215e151206b5159e03d74fe29b8599264903de2b
   google-protobuf (4.36.0-x86_64-linux-musl) sha256=d6c510ff9743b3dcde6acdea06512e4e4ecf53246e5c5b689879861e05d4738a
-  http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a
-  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
-  jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1
-  jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443
-  jekyll-seo-tag (2.9.0) sha256=0260015a8e1df9bf195cdfb0c675b7b2883fd8cbf12556e1c1cbe36a831c6852
-  jekyll-sitemap (1.4.0) sha256=0de08c5debc185ea5a8f980e1025c7cd3f8e0c35c8b6ef592f15c46235cf4218
-  jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1
-  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
   kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
   kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
-  liquid (4.0.4) sha256=4fcfebb1a045e47918388dbb7a0925e7c3893e58d2bd6c3b3c73ec17a2d8fdb3
-  listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
   logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
-  mercenary (0.4.0) sha256=b25a1e4a59adca88665e08e24acf0af30da5b5d859f7d8f38fba52c28f405138
   minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
   ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
-  pathutil (0.16.2) sha256=e43b74365631cab4f6d5e4228f812927efc9cb2c71e62976edcb252ee948d589
-  public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
   rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
-  rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
-  rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
   rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
   rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739
-  safe_yaml (1.0.5) sha256=a6ac2d64b7eb027bdeeca1851fe7e7af0d668e133e8a88066a0c6f7087d9f848
-  sass-embedded (1.103.0) sha256=4071783511b144db6643234e462a8396537874520fe241b911028b3798865a72
-  sass-embedded (1.103.0-aarch64-linux-android) sha256=10024f82a49db48bcc9cd04400f1995db6a06ccfe1b33cd272e8d9c7095cb671
-  sass-embedded (1.103.0-aarch64-linux-gnu) sha256=b8d87a98de409798914680386e6555c60b449505924dbf6d650a33c83e211022
-  sass-embedded (1.103.0-aarch64-linux-musl) sha256=428cfa9cad8603b21f74b6a9836a7c81cd78cc2c3a383052829c177f802bf203
-  sass-embedded (1.103.0-arm-linux-androideabi) sha256=ec686a406ee1c344ccbdfdcfe9964d3aee7360ef65178781027fe852461068b9
-  sass-embedded (1.103.0-arm-linux-gnueabihf) sha256=1437f8ab92b3aede8db4faed290e6d8e1647eba6e264a9321d64996b8e38129e
-  sass-embedded (1.103.0-arm-linux-musleabihf) sha256=94f865a9ea659069ddacb1e385c6d8a7dd50dc0ea692d610b1029084b4de2713
-  sass-embedded (1.103.0-arm64-darwin) sha256=fc0d97ccede1558839cab2c03c15c4dfd8f273090cdbdc5b1f44061f15618c38
-  sass-embedded (1.103.0-riscv64-linux-android) sha256=748c4586008e7e375954e24bebb5065030814117aa19dc0ef2d9d6772007bfd4
-  sass-embedded (1.103.0-riscv64-linux-gnu) sha256=2f5ce754310c863aa733428e5462a70bebd804be6ba4d99a131bae676e9cffe8
-  sass-embedded (1.103.0-riscv64-linux-musl) sha256=d0e020734c3a0722855b99c456275280efbfa50eacdc1d9a83b3e1046506d582
-  sass-embedded (1.103.0-x86_64-darwin) sha256=1ae138f2695b395d01ef207942a32c94ad99d9e161248bdbac75dac8947cdf2a
-  sass-embedded (1.103.0-x86_64-linux-android) sha256=2dd0b26810111bc934a9ed4ae4ad7501cd46f5d31d6f574f550d1dca3f547aec
-  sass-embedded (1.103.0-x86_64-linux-gnu) sha256=5f544e536424aff1d70eafd7676ad93b3f52664634f664dc4bd3bacef780945a
-  sass-embedded (1.103.0-x86_64-linux-musl) sha256=bdb741aa2c879371af9109917d2aeca6a259f4f50760398da05807473f6733f0
-  terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
-  unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
+  sass-embedded (1.103.1) sha256=a47bdc1dc3de2a8181c68f8da1396748eb8776dde71df08daa080dccc38f5cf0
+  sass-embedded (1.103.1-aarch64-linux-android) sha256=5905fb8567192c8b391e5aa2db8d3db9f7e866c5eff32ec3be96ac79a389085d
+  sass-embedded (1.103.1-aarch64-linux-gnu) sha256=48790fb0be3c57177121f28626a7c5cfc258a06aa499f09fd80a55054cc46c09
+  sass-embedded (1.103.1-aarch64-linux-musl) sha256=fb0bfcbab2e7baf8e6a59890e2729037cd1651859718e7c7a5878d5f9e2a6e8c
+  sass-embedded (1.103.1-arm-linux-androideabi) sha256=a2edbc498e324c51fc5b3b7a4168b75295d33cc95e49cb1dbf2aeea7e4150752
+  sass-embedded (1.103.1-arm-linux-gnueabihf) sha256=1388f692fd7c76d3fe03fb8d79a45ca89586a1fa19a8718faf875f457bed4169
+  sass-embedded (1.103.1-arm-linux-musleabihf) sha256=9e2a3cf86576d96fd16ebd1fbd4333e72c3df6b1990e5273d5a3b4d2e1eec4ac
+  sass-embedded (1.103.1-arm64-darwin) sha256=b4afa0515a8bdb9949675bc85a3625b676bd11080b4d5b5134282729762a2261
+  sass-embedded (1.103.1-riscv64-linux-android) sha256=3de9d13ba48e93fd8c361c576cbadbc89d60b98223cbb932bf5b7ce4167ebd03
+  sass-embedded (1.103.1-riscv64-linux-gnu) sha256=291f37e6d7317dc9fc07e264b8f86a7d156d68e2d3ef55b9014bb2d88d0b5234
+  sass-embedded (1.103.1-riscv64-linux-musl) sha256=49f72d4b31d668f0b90f8c02e507840078a66a91fbc96570254a9a3b3163b2b9
+  sass-embedded (1.103.1-x86_64-darwin) sha256=75514c8f62a849e5e2e98fe89268176278fb0900751208a7ae3322451a0b5285
+  sass-embedded (1.103.1-x86_64-linux-android) sha256=23e4545427223ceb3c9b293e10e5eaf60f2499151d2abdc647897a086e410775
+  sass-embedded (1.103.1-x86_64-linux-gnu) sha256=1056cb8031fbceee992a3d07c39e9118864aa280e1ba03d85bc5397bd4479333
+  sass-embedded (1.103.1-x86_64-linux-musl) sha256=2f52d9f70030a2d8ddbff8289049df1d38f20f55ba05a87a0049d99fbaa1108c
   webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
 
 BUNDLED WITH
diff --git a/README.md b/README.md
index 9c9b0a6..4db07c3 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
 
 A personal design system for self-hosted, single-user tools — synthesized by
 reading three real, running personal projects, not written from a spec.
-Sixteen components and five foundations, documented by a Jekyll site that
-renders every swatch, table, and demo from the same YAML the stylesheet is
-compiled from.
+Sixteen components and five foundations, documented by a small Ruby/ERB site
+(no Jekyll) that renders every swatch, table, and demo from the same YAML the
+stylesheet is compiled from.
 
 **Site:** https://kejadlen.github.io/arbitrary-definitions-design-system/
 
@@ -28,16 +28,18 @@ for the full story.
 So this repository has exactly one source of truth — four YAML files under
 `_data/tokens/` — and everything else is derived from it at build time:
 
-- `assets/css/tokens.css` is generated by `_plugins/token_generator.rb`: a
-  `:root` block, both dark-theme selectors, and a `prefers-reduced-motion`
-  override that zeroes every duration token.
-- The documentation tables and swatches loop over the same YAML through Liquid.
+- `assets/css/tokens.css` is generated by
+  `lib/arbitrary_definitions/token_set.rb`: a `:root` block, both dark-theme
+  selectors, and a `prefers-reduced-motion` override that zeroes every
+  duration token.
+- The documentation tables and swatches loop over the same YAML in plain ERB.
 - Contrast ratios on the colour page are computed during the build by
   `lib/arbitrary_definitions/color_math.rb`, so a number on the page cannot
   disagree with the hex it describes.
-- Component demos are rendered by a `{% raw %}{% example %}{% endraw %}` Liquid block that outputs
-  the live component and its highlighted source from one string, so the
-  snippet can never drift from the demo.
+- Component demos are rendered by an `example` ERB helper
+  (`lib/arbitrary_definitions/example_helper.rb`) that captures its block's
+  markup and outputs the live component and its highlighted source from that
+  one captured string, so the snippet can never drift from the demo.
 
 No component stylesheet contains a colour literal or a duration — every one is a `var(--ad-*)`, with one flagged exception (Select's chevron — see `_sass/components/_field.scss`).
 
@@ -53,9 +55,8 @@ $ bundle exec rake serve    # http://127.0.0.1:4000
 
 ## Tests
 
-`test/` is plain Minitest against `lib/`, which has no Jekyll dependency. The
-suite is the deploy gate in CI, and it asserts things that are easy to break
-by accident:
+`test/` is plain Minitest against `lib/`. The suite is the deploy gate in CI,
+and it asserts things that are easy to break by accident:
 
 - every pairing in `_data/tokens/contrast.yml` holds in **both** themes at
   WCAG AA, and every `color-text-*` token except the one documented,
@@ -77,18 +78,21 @@ $ bundle exec rake test
 | Path | What it is |
 | --- | --- |
 | `_data/tokens/` | Every value in the system: ramps, semantic aliases, scales, and the contrast contract. |
-| `lib/arbitrary_definitions/` | Token compiler and WCAG colour maths. No Jekyll dependency. |
-| `_plugins/` | Jekyll wiring: the generator, Liquid filters, the `example` block. |
+| `lib/arbitrary_definitions/` | The token compiler, WCAG colour maths, and the whole site builder (front matter, ERB, Kramdown, layouts, the `example` helper). No Jekyll anywhere. |
+| `_layouts/`, `_includes/` | Plain ERB templates for the page chrome. |
 | `_sass/` | Base, components (core/forms/feedback/navigation), and the documentation site's own chrome. |
-| `_foundations/`, `_components/`, `_patterns/` | The documentation collections. |
+| `_foundations/`, `_components/`, `_patterns/` | The documentation pages, in Markdown with embedded ERB. |
 | `test/` | Minitest suite. |
 | `.github/workflows/pages.yml` | Build on Ruby 4.0, test, deploy to Pages. |
 
 ## Deployment
 
-GitHub's hosted Jekyll build cannot run this site — it ignores `_plugins/`
-and pins its own Ruby — so `.github/workflows/pages.yml` builds it on Actions
-with `ruby/setup-ruby` and uploads the result with `actions/deploy-pages`.
+There is no Jekyll to run here — `Rakefile`'s `build` task drives
+`lib/arbitrary_definitions/site_builder.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
+uploads the result with `actions/deploy-pages`.
 
 Every branch is built and tested; only the repository's default branch
 deploys.
diff --git a/Rakefile b/Rakefile
index 5929f19..b008b09 100644
--- a/Rakefile
+++ b/Rakefile
@@ -2,6 +2,8 @@
 
 require "rake/testtask"
 
+$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+
 Rake::TestTask.new(:test) do |t|
   t.libs << "lib" << "test"
   t.test_files = FileList["test/test_*.rb"]
@@ -10,12 +12,29 @@ end
 
 desc "Build the site into _site"
 task :build do
-  sh "bundle exec jekyll build"
+  require "arbitrary_definitions/site_builder"
+  ArbitraryDefinitions::SiteBuilder.new.build
 end
 
-desc "Serve the site with live reload on http://127.0.0.1:4000"
+desc "Serve the already-built site at http://127.0.0.1:4000"
 task :serve do
-  sh "bundle exec jekyll serve --livereload"
+  require "webrick"
+  require "yaml"
+
+  site_dir = File.expand_path("_site", __dir__)
+  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.
+  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)
+  server.mount(baseurl.empty? ? "/" : baseurl, WEBrick::HTTPServlet::FileHandler, site_dir)
+  puts "http://127.0.0.1:4000#{baseurl}/"
+
+  trap("INT") { server.shutdown }
+  server.start
 end
 
 desc "Build the Claude Design upload bundle into design-bundle/"
diff --git a/_components/badge.md b/_components/badge.md
index 259ead3..5e238ad 100644
--- a/_components/badge.md
+++ b/_components/badge.md
@@ -10,22 +10,22 @@ Labels are catalog-style: short, mono, uppercase — `SAVED`, `RECOMMENDED`,
 
 ## Tones
 
-{% example title="Tones" open %}
+<% example(title: "Tones", open: true) do %>
 <span class="ad-badge">Unassigned</span>
 <span class="ad-badge ad-badge--accent">Draft</span>
 <span class="ad-badge ad-badge--success">Saved</span>
 <span class="ad-badge ad-badge--danger">Overdue</span>
-{% endexample %}
+<% end %>
 
 ## In a set
 
-{% example title="Badge set" %}
+<% example(title: "Badge set") do %>
 <div class="ad-badge-set">
   <span class="ad-badge ad-badge--accent">Recommended</span>
   <span class="ad-badge">Every 2 weeks</span>
   <span class="ad-badge ad-badge--danger">3d overdue</span>
 </div>
-{% endexample %}
+<% end %>
 
 ## Rules
 
diff --git a/_components/button.md b/_components/button.md
index e6e2ad6..ed35e94 100644
--- a/_components/button.md
+++ b/_components/button.md
@@ -13,34 +13,34 @@ committing action in a screen, typically a final Save; it should be rare.
 `ghost` has no border or fill, for dismissals and tertiary actions. `danger`
 is destructive and irreversible only.
 
-{% example title="Variants" open %}
+<% example(title: "Variants", open: true) do %>
 <button type="button" class="ad-btn">Cancel</button>
 <button type="button" class="ad-btn ad-btn--primary">Save</button>
 <button type="button" class="ad-btn ad-btn--dark">Finish setup</button>
 <button type="button" class="ad-btn ad-btn--ghost">Dismiss</button>
 <button type="button" class="ad-btn ad-btn--danger">Delete</button>
-{% endexample %}
+<% end %>
 
 ## Sizes
 
 Sizes adjust padding only — the font stays on the type scale, it does not
 grow a step of its own.
 
-{% example title="Sizes" %}
+<% example(title: "Sizes") do %>
 <button type="button" class="ad-btn ad-btn--primary ad-btn--sm">Small</button>
 <button type="button" class="ad-btn ad-btn--primary">Medium</button>
 <button type="button" class="ad-btn ad-btn--primary ad-btn--lg">Large</button>
-{% endexample %}
+<% end %>
 
 ## States
 
 Disabled buttons drop to 50% opacity and keep their variant colours, so a
 disabled primary still reads as the primary.
 
-{% example title="States" %}
+<% example(title: "States") do %>
 <button type="button" class="ad-btn ad-btn--primary" disabled>Disabled</button>
 <button type="button" class="ad-btn" aria-disabled="true">Not yet available</button>
-{% endexample %}
+<% end %>
 
 <div class="ad-callout">
   <p class="type-label" style="margin-bottom: var(--ad-space-2xs); display:block;">disabled vs. aria-disabled</p>
@@ -58,38 +58,38 @@ disabled primary still reads as the primary.
 Pass an icon as the first child. The gap is `--ad-space-2xs`, set by the
 button itself.
 
-{% example title="Icon" %}
+<% example(title: "Icon") do %>
 <button type="button" class="ad-btn ad-btn--primary">
   <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
   New
 </button>
-{% endexample %}
+<% end %>
 
 ## Groups
 
-{% example title="Button group" %}
+<% example(title: "Button group") do %>
 <div class="ad-btn-group" role="group" aria-label="View">
   <button type="button" class="ad-btn ad-btn--sm">List</button>
   <button type="button" class="ad-btn ad-btn--sm">Board</button>
   <button type="button" class="ad-btn ad-btn--sm">Calendar</button>
 </div>
-{% endexample %}
+<% end %>
 
 ## Full width
 
-{% example title="Block" layout="stack" %}
+<% example(title: "Block", layout: "stack") do %>
 <button type="button" class="ad-btn ad-btn--primary ad-btn--block">Take a photo</button>
-{% endexample %}
+<% end %>
 
 ## Links that act like buttons
 
 An `<a>` with `.ad-btn` is fine when the thing it does is navigate. A
 `<button>` is required when it changes something.
 
-{% example title="Anchor as button" %}
-<a class="ad-btn ad-btn--primary" href="{{ '/components/icon-button/' | relative_url }}">Next: IconButton</a>
-<a class="ad-btn ad-btn--ghost" href="{{ '/foundations/color/' | relative_url }}">Back to colour</a>
-{% endexample %}
+<% example(title: "Anchor as button") do %>
+<a class="ad-btn ad-btn--primary" href="<%= relative_url('/components/icon-button/') %>">Next: IconButton</a>
+<a class="ad-btn ad-btn--ghost" href="<%= relative_url('/foundations/color/') %>">Back to colour</a>
+<% end %>
 
 ## API
 
diff --git a/_components/card-row.md b/_components/card-row.md
index 82d642e..4da391b 100644
--- a/_components/card-row.md
+++ b/_components/card-row.md
@@ -7,25 +7,25 @@ summary: >-
 
 ## Basic
 
-{% example title="Rows" open %}
+<% example(title: "Rows", open: true) do %>
 <div class="ad-card" style="max-width: 22rem;">
   <div class="ad-card-row">Renew passport</div>
   <div class="ad-card-row">Dentist checkup</div>
   <div class="ad-card-row">Rotate mattress</div>
 </div>
-{% endexample %}
+<% end %>
 
 ## Clickable
 
 `--clickable` adds a hover fill and a pointer cursor. Use a real `<button>`
 element when the row navigates or opens something.
 
-{% example title="Clickable rows" %}
+<% example(title: "Clickable rows") do %>
 <div class="ad-card" style="max-width: 22rem;">
   <button type="button" class="ad-card-row ad-card-row--clickable">Tax return 2025.pdf</button>
   <button type="button" class="ad-card-row ad-card-row--clickable">Warranty card.pdf</button>
 </div>
-{% endexample %}
+<% end %>
 
 ## With content on both ends
 
@@ -33,7 +33,7 @@ The row is a flex container — a leading icon, a two-line middle column, and a
 trailing mono value is the most common shape (see the Loop and Archive
 patterns for it in context).
 
-{% example title="Leading icon, trailing meta" %}
+<% example(title: "Leading icon, trailing meta") do %>
 <div class="ad-card" style="max-width: 24rem;">
   <div class="ad-card-row">
     <span class="ad-avatar ad-avatar--square">
@@ -46,7 +46,7 @@ patterns for it in context).
     <span class="type-mono" style="color: var(--ad-color-text-tertiary);">2h</span>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/card.md b/_components/card.md
index 7714ac3..03b9506 100644
--- a/_components/card.md
+++ b/_components/card.md
@@ -10,14 +10,14 @@ summary: >-
 Flat is the default everywhere. `--elevated` is for the single most-elevated
 element on screen — never a whole grid of cards at once.
 
-{% example title="Flat and elevated" surface="sunken" %}
+<% example(title: "Flat and elevated", surface: "sunken") do %>
 <div class="ad-card" style="width: 14rem; padding: var(--ad-space-s);">Flat — the default.</div>
 <div class="ad-card ad-card--elevated" style="width: 14rem; padding: var(--ad-space-s);">Elevated — one per view.</div>
-{% endexample %}
+<% end %>
 
 ## As a content tile
 
-{% example title="Content tile" %}
+<% example(title: "Content tile") do %>
 <article class="ad-card" style="max-width: 18rem;">
   <div class="ad-card__body">
     <p class="type-label">Recommended</p>
@@ -29,21 +29,21 @@ element on screen — never a whole grid of cards at once.
     <button type="button" class="ad-btn ad-btn--ghost ad-btn--sm">Dismiss</button>
   </div>
 </article>
-{% endexample %}
+<% end %>
 
 ## As a list — with CardRow
 
 Card's most common job in this system is holding a list of
-[CardRow]({{ '/components/card-row/' | relative_url }})s: entry lists, task
+[CardRow](<%= relative_url('/components/card-row/') %>)s: entry lists, task
 lists, settings rows.
 
-{% example title="Card of rows" %}
+<% example(title: "Card of rows") do %>
 <div class="ad-card" style="max-width: 22rem;">
   <div class="ad-card-row">Dishwasher manual</div>
   <div class="ad-card-row">Sofa receipt</div>
   <div class="ad-card-row">Passport scan</div>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/checkbox.md b/_components/checkbox.md
index 73da0ab..0181f02 100644
--- a/_components/checkbox.md
+++ b/_components/checkbox.md
@@ -5,7 +5,7 @@ summary: Flat checkbox, fills with the accent when checked. No bounce.
 
 ## Basic
 
-{% example title="Checkbox" open %}
+<% example(title: "Checkbox", open: true) do %>
 <label class="ad-choice ad-choice--checkbox">
   <input type="checkbox">
   <span class="ad-choice__box"></span>
@@ -18,21 +18,21 @@ summary: Flat checkbox, fills with the accent when checked. No bounce.
   </span>
   Ship on merge
 </label>
-{% endexample %}
+<% end %>
 
 ## Disabled
 
-{% example title="Disabled" %}
+<% example(title: "Disabled") do %>
 <label class="ad-choice ad-choice--checkbox" aria-disabled="true">
   <input type="checkbox" disabled>
   <span class="ad-choice__box"></span>
   Not available on this plan
 </label>
-{% endexample %}
+<% end %>
 
 ## In a fieldset
 
-{% example title="Fieldset" %}
+<% example(title: "Fieldset") do %>
 <fieldset class="ad-fieldset">
   <legend>Notify me about</legend>
   <label class="ad-choice ad-choice--checkbox">
@@ -46,7 +46,7 @@ summary: Flat checkbox, fills with the accent when checked. No bounce.
     Weekly digest
   </label>
 </fieldset>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/dialog.md b/_components/dialog.md
index be9b63a..0ca41d0 100644
--- a/_components/dialog.md
+++ b/_components/dialog.md
@@ -11,7 +11,7 @@ documentation page. Wire real open/close state — and a `position: fixed`
 overlay — in the consuming app; this is a pixel recreation for documentation,
 not a functioning modal.
 
-{% example title="Dialog" open %}
+<% example(title: "Dialog", open: true) do %>
 <div class="ad-dialog-demo">
   <div class="ad-dialog__overlay">
     <div class="ad-dialog">
@@ -24,13 +24,13 @@ not a functioning modal.
     </div>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## Without a footer
 
 A body-only dialog for a notice rather than a decision.
 
-{% example title="No footer" %}
+<% example(title: "No footer") do %>
 <div class="ad-dialog-demo" style="min-height: 14rem;">
   <div class="ad-dialog__overlay">
     <div class="ad-dialog" style="width: 20rem;">
@@ -39,7 +39,7 @@ A body-only dialog for a notice rather than a decision.
     </div>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/field.md b/_components/field.md
index 722f1af..d2502fc 100644
--- a/_components/field.md
+++ b/_components/field.md
@@ -7,21 +7,21 @@ summary: >-
 
 ## Basic
 
-{% example title="Field" open %}
+<% example(title: "Field", open: true) do %>
 <div class="ad-field" style="max-width: 20rem;">
   <label class="ad-field__label" for="field-name">Name</label>
   <input class="ad-input" id="field-name" type="text" placeholder="Call Mom">
   <span class="ad-field__hint">First line becomes the display name.</span>
 </div>
-{% endexample %}
+<% end %>
 
 ## Holding any control
 
-Field wraps [Input]({{ '/components/input/' | relative_url }}),
-[Select]({{ '/components/select/' | relative_url }}), or anything else — it
+Field wraps [Input](<%= relative_url('/components/input/') %>),
+[Select](<%= relative_url('/components/select/') %>), or anything else — it
 only owns the label and the hint line, never the control's own styling.
 
-{% example title="Field with a select" %}
+<% example(title: "Field with a select") do %>
 <div class="ad-field" style="max-width: 20rem;">
   <label class="ad-field__label" for="field-unit">Unit</label>
   <select class="ad-select" id="field-unit">
@@ -31,11 +31,11 @@ only owns the label and the hint line, never the control's own styling.
     <option>Year</option>
   </select>
 </div>
-{% endexample %}
+<% end %>
 
 ## In a row
 
-{% example title="Two fields in a row" %}
+<% example(title: "Two fields in a row") do %>
 <div class="ad-form" style="max-width: none;">
   <div class="ad-form__row">
     <div class="ad-field">
@@ -50,7 +50,7 @@ only owns the label and the hint line, never the control's own styling.
     </div>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/icon-button.md b/_components/icon-button.md
index ab81a4f..c8e0204 100644
--- a/_components/icon-button.md
+++ b/_components/icon-button.md
@@ -12,7 +12,7 @@ an icon button sitting alone rather than in a toolbar. `accent` is a soft
 accent tint, for a toggled or active state — the segmented-tabs pattern this
 system does not otherwise reach for.
 
-{% example title="Variants" open %}
+<% example(title: "Variants", open: true) do %>
 <button type="button" class="ad-icon-btn" aria-label="Delete">
   <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>
 </button>
@@ -22,28 +22,28 @@ system does not otherwise reach for.
 <button type="button" class="ad-icon-btn ad-icon-btn--accent" aria-label="Filter (active)">
   <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 8v5l-4 2v-7z"/></svg>
 </button>
-{% endexample %}
+<% end %>
 
 ## Sizes
 
-{% example title="Sizes" %}
+<% example(title: "Sizes") do %>
 <button type="button" class="ad-icon-btn ad-icon-btn--outline ad-icon-btn--sm" aria-label="Back"><svg width="14" height="14" 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>
 <button type="button" class="ad-icon-btn ad-icon-btn--outline" aria-label="Back"><svg width="16" height="16" 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>
 <button type="button" class="ad-icon-btn ad-icon-btn--outline ad-icon-btn--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>
-{% endexample %}
+<% end %>
 
 ## Paired with Tooltip
 
-An icon-only control almost always wants a [Tooltip]({{ '/components/tooltip/' | relative_url }}) too — the `aria-label` covers accessibility, the tooltip covers a sighted mouse user glancing for a hint.
+An icon-only control almost always wants a [Tooltip](<%= relative_url('/components/tooltip/') %>) too — the `aria-label` covers accessibility, the tooltip covers a sighted mouse user glancing for a hint.
 
-{% example title="With a tooltip" %}
+<% example(title: "With a tooltip") do %>
 <span class="ad-tooltip">
   <button type="button" class="ad-icon-btn" aria-label="Delete">
     <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>
   </button>
   <span class="ad-tooltip__bubble">Delete</span>
 </span>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/input.md b/_components/input.md
index 1049483..81db812 100644
--- a/_components/input.md
+++ b/_components/input.md
@@ -7,29 +7,29 @@ summary: >-
 
 ## Basic
 
-{% example title="Input" open %}
+<% example(title: "Input", open: true) do %>
 <input class="ad-input" type="text" placeholder="Dishwasher manual" style="max-width: 20rem;">
-{% endexample %}
+<% end %>
 
 ## Focus and disabled
 
-{% example title="States" layout="stack" %}
+<% example(title: "States", layout: "stack") do %>
 <input class="ad-input" type="text" value="Focused" style="max-width: 20rem; border-color: var(--ad-color-accent); box-shadow: 0 0 0 2px var(--ad-color-accent-soft);">
 <input class="ad-input" type="text" value="Disabled" disabled style="max-width: 20rem;">
-{% endexample %}
+<% end %>
 
 ## Invalid
 
 Invalid state is expressed on the control itself, so it works with native
 form validation, plus an error line below.
 
-{% example title="Invalid" %}
+<% example(title: "Invalid") do %>
 <div class="ad-field" style="max-width: 20rem;">
   <label class="ad-field__label" for="input-accent">Accent hex</label>
   <input class="ad-input" id="input-accent" type="text" value="not-a-colour" aria-invalid="true" aria-describedby="input-accent-error">
   <p class="ad-field__error" id="input-accent-error">Not a valid hex colour.</p>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/radio.md b/_components/radio.md
index 9322632..9e6d97a 100644
--- a/_components/radio.md
+++ b/_components/radio.md
@@ -5,7 +5,7 @@ summary: Flat radio button, the same visual language as Checkbox.
 
 ## Basic
 
-{% example title="Radio" open %}
+<% example(title: "Radio", open: true) do %>
 <label class="ad-choice ad-choice--radio">
   <input type="radio" name="demo-radio-1" checked>
   <span class="ad-choice__box"></span>
@@ -16,11 +16,11 @@ summary: Flat radio button, the same visual language as Checkbox.
   <span class="ad-choice__box"></span>
   Monthly
 </label>
-{% endexample %}
+<% end %>
 
 ## As a group
 
-{% example title="Interval" %}
+<% example(title: "Interval") do %>
 <fieldset class="ad-fieldset">
   <legend>Repeat</legend>
   <label class="ad-choice ad-choice--radio">
@@ -39,7 +39,7 @@ summary: Flat radio button, the same visual language as Checkbox.
     Every year
   </label>
 </fieldset>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/select.md b/_components/select.md
index d82dc21..18383a1 100644
--- a/_components/select.md
+++ b/_components/select.md
@@ -5,20 +5,20 @@ summary: Native select, styled to match Input exactly.
 
 ## Basic
 
-{% example title="Select" open %}
+<% example(title: "Select", open: true) do %>
 <select class="ad-select" style="max-width: 20rem;">
   <option>Day</option>
   <option selected>Week</option>
   <option>Month</option>
   <option>Year</option>
 </select>
-{% endexample %}
+<% end %>
 
 ## Disabled
 
-{% example title="Disabled" %}
+<% example(title: "Disabled") do %>
 <select class="ad-select" disabled style="max-width: 20rem;"><option>Week</option></select>
-{% endexample %}
+<% end %>
 
 <div class="ad-callout">
   <p style="margin:0; font-size: var(--ad-step--1);">
diff --git a/_components/switch.md b/_components/switch.md
index 494586e..5df8b0e 100644
--- a/_components/switch.md
+++ b/_components/switch.md
@@ -7,7 +7,7 @@ summary: >-
 
 ## Basic
 
-{% example title="Switch" open %}
+<% example(title: "Switch", open: true) do %>
 <label class="ad-switch">
   <input type="checkbox" checked>
   <span class="ad-switch__track"></span>
@@ -18,17 +18,17 @@ summary: >-
   <span class="ad-switch__track"></span>
   Weekly digest
 </label>
-{% endexample %}
+<% end %>
 
 ## Disabled
 
-{% example title="Disabled" %}
+<% example(title: "Disabled") do %>
 <label class="ad-switch">
   <input type="checkbox" checked disabled>
   <span class="ad-switch__track"></span>
   Always on for this plan
 </label>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/tabs.md b/_components/tabs.md
index 4a99577..5291016 100644
--- a/_components/tabs.md
+++ b/_components/tabs.md
@@ -11,7 +11,7 @@ summary: >-
 Mono, with an accent underline. For primary section navigation — a project's
 own tabs, never a filter.
 
-{% example title="Underline" layout="stack" open %}
+<% example(title: "Underline", layout: "stack", open: true) do %>
 <div class="ad-tabs ad-tabs--underline" data-tabs>
   <div class="ad-tabs__list" role="tablist" aria-label="Repository">
     <button class="ad-tabs__tab" role="tab" id="tabs-code-tab" aria-controls="tabs-code-panel" aria-selected="true" tabindex="0">Code</button>
@@ -22,14 +22,14 @@ own tabs, never a filter.
   <div class="ad-tabs__panel" role="tabpanel" id="tabs-ci-panel" aria-labelledby="tabs-ci-tab" tabindex="0" hidden>Build history.</div>
   <div class="ad-tabs__panel" role="tabpanel" id="tabs-issues-panel" aria-labelledby="tabs-issues-tab" tabindex="0" hidden>Open issues.</div>
 </div>
-{% endexample %}
+<% end %>
 
 ## Segmented
 
 A pill track; the active segment lifts to the surface. For filters — *All ·
 Overdue · Upcoming* — never primary navigation.
 
-{% example title="Segmented" layout="stack" %}
+<% example(title: "Segmented", layout: "stack") do %>
 <div class="ad-tabs ad-tabs--segmented" data-tabs>
   <div class="ad-tabs__list" role="tablist" aria-label="Filter">
     <button class="ad-tabs__tab" role="tab" id="tabs-all-tab" aria-controls="tabs-all-panel" aria-selected="true" tabindex="0">All</button>
@@ -40,7 +40,7 @@ Overdue · Upcoming* — never primary navigation.
   <div class="ad-tabs__panel" role="tabpanel" id="tabs-overdue-panel" aria-labelledby="tabs-overdue-tab" tabindex="0" hidden>2 series.</div>
   <div class="ad-tabs__panel" role="tabpanel" id="tabs-upcoming-panel" aria-labelledby="tabs-upcoming-tab" tabindex="0" hidden>5 series.</div>
 </div>
-{% endexample %}
+<% end %>
 
 ## Rules
 
diff --git a/_components/tag.md b/_components/tag.md
index cc48e11..381208d 100644
--- a/_components/tag.md
+++ b/_components/tag.md
@@ -7,23 +7,23 @@ summary: >-
 
 ## Default and removable
 
-{% example title="Tags" open %}
+<% example(title: "Tags", open: true) do %>
 <span class="ad-tag">location:garage</span>
 <span class="ad-tag">category:appliance</span>
 <span class="ad-tag">
   category:receipt
   <button type="button" class="ad-tag__remove" aria-label="Remove tag">×</button>
 </span>
-{% endexample %}
+<% end %>
 
 ## Dashed — the "add tag" affordance
 
-{% example title="Add tag" %}
+<% example(title: "Add tag") do %>
 <div class="ad-tag-set">
   <span class="ad-tag">location:garage</span>
   <span class="ad-tag ad-tag--dashed">+ add tag</span>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/toast.md b/_components/toast.md
index 513e45e..93f9068 100644
--- a/_components/toast.md
+++ b/_components/toast.md
@@ -11,22 +11,22 @@ summary: >-
 The example below stays in document flow so it does not cover the page;
 `.ad-toast--fixed` is what actually pins it bottom-right in a real app.
 
-{% example title="Toast" open %}
+<% example(title: "Toast", open: true) do %>
 <div class="ad-toast">
   <span class="ad-toast__message">Deleted "Call Mom"</span>
   <button type="button" class="ad-toast__action">Undo</button>
   <button type="button" class="ad-toast__dismiss" aria-label="Dismiss">×</button>
 </div>
-{% endexample %}
+<% end %>
 
 ## Without an action
 
-{% example title="No action" %}
+<% example(title: "No action") do %>
 <div class="ad-toast">
   <span class="ad-toast__message">Series created</span>
   <button type="button" class="ad-toast__dismiss" aria-label="Dismiss">×</button>
 </div>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_components/tooltip.md b/_components/tooltip.md
index b74cc94..c702f64 100644
--- a/_components/tooltip.md
+++ b/_components/tooltip.md
@@ -9,25 +9,25 @@ summary: >-
 
 Hover or tab to the button below.
 
-{% example title="Tooltip" open %}
+<% example(title: "Tooltip", open: true) do %>
 <span class="ad-tooltip">
   <button type="button" class="ad-icon-btn" aria-label="Delete">
     <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>
   </button>
   <span class="ad-tooltip__bubble">Delete</span>
 </span>
-{% endexample %}
+<% end %>
 
 ## On any element
 
-A Tooltip wraps whatever it labels — most often an [IconButton]({{ '/components/icon-button/' | relative_url }}), since that is the control with no visible text of its own.
+A Tooltip wraps whatever it labels — most often an [IconButton](<%= relative_url('/components/icon-button/') %>), since that is the control with no visible text of its own.
 
-{% example title="On a badge" %}
+<% example(title: "On a badge") do %>
 <span class="ad-tooltip">
   <span class="ad-badge ad-badge--danger" tabindex="0">3d overdue</span>
   <span class="ad-tooltip__bubble">Due Tuesday, was Saturday</span>
 </span>
-{% endexample %}
+<% end %>
 
 ## API
 
diff --git a/_config.yml b/_config.yml
index 547bb15..70ef174 100644
--- a/_config.yml
+++ b/_config.yml
@@ -4,69 +4,13 @@ description: >-
   Arbitrary Definitions is a personal design system for self-hosted,
   single-user tools — synthesized from three real running projects, not
   written from a spec. Sixteen components and five foundations, rendered from
-  a single set of YAML token files by a Jekyll site running on Ruby 4.0.
+  a single set of YAML token files by a small Ruby/ERB site builder running
+  on Ruby 4.0.
 
 url: https://kejadlen.github.io
 baseurl: /arbitrary-definitions-design-system
 repository: kejadlen/arbitrary-definitions-design-system
 lang: en
 
-sass:
-  style: compressed
-  sass_dir: _sass
-
-markdown: kramdown
-kramdown:
-  input: GFM
-  syntax_highlighter: rouge
-  syntax_highlighter_opts:
-    default_lang: html
-
 # The prefix on every custom property and class this system emits.
 token_prefix: ad
-
-collections:
-  foundations:
-    output: true
-    permalink: /foundations/:name/
-  components:
-    output: true
-    permalink: /components/:name/
-  patterns:
-    output: true
-    permalink: /patterns/:name/
-
-defaults:
-  - scope: { path: "" }
-    values:
-      layout: page
-  - scope: { path: "", type: foundations }
-    values:
-      layout: page
-      section: Foundations
-  - scope: { path: "", type: components }
-    values:
-      layout: page
-      section: Components
-  - scope: { path: "", type: patterns }
-    values:
-      layout: page
-      section: Patterns
-
-exclude:
-  - Gemfile
-  - Gemfile.lock
-  - README.md
-  - CONTRIBUTING.md
-  - Rakefile
-  - test
-  - vendor
-  - lib
-  # Build artifacts. .gitignore does not exclude these from Jekyll, and
-  # design-bundle/ is built *from* _site, so leaving it in would fold a copy
-  # of the whole site back into the next build.
-  - design-bundle
-  - .ruby-version
-  - script
-
-plugins: []
diff --git a/_foundations/color.md b/_foundations/color.md
index 6414a60..8d51352 100644
--- a/_foundations/color.md
+++ b/_foundations/color.md
@@ -22,7 +22,7 @@ at different values — which is exactly what the dark block does.
   <p style="margin:0; font-size: var(--ad-step--1);">
     The neutral ramps were authored in <code>oklch()</code> by the three
     projects this system was synthesized from (see
-    <a href="{{ '/rationale/' | relative_url }}">Why arbitrary</a>). This
+    <a href="<%= relative_url('/rationale/') %>">Why arbitrary</a>). This
     site's contrast-testing suite only understands flat hex, so every value
     below is that same colour converted to sRGB — not re-picked, not rounded.
     Contrast figures on this page are computed from those hex values during
@@ -36,36 +36,34 @@ Low-chroma warm-gray — the intersection of ketchup's plain grays, quire's
 warm cream/dark palette, and domus's warm paper. Ten steps, not twelve or
 sixteen: the source defines exactly these.
 
-{% assign neutral = site.data.tokens.color.ramps | where: "name", "neutral" | first %}
-<p class="ad-muted">{{ neutral.description }}</p>
+<% neutral = site.data.tokens.color.ramps.find { |r| r.name == "neutral" } -%>
+<p class="ad-muted"><%= neutral.description %></p>
 <ul class="ad-swatch-grid">
-  {%- assign steps = neutral.steps | sort: "order" %}
-  {%- for step in steps %}
-  {%- assign on = step.value | readable_on %}
+  <% neutral.steps.sort_by { |s| s.order }.each do |step| -%>
+  <% on = readable_on(step.value) -%>
   <li class="ad-swatch">
-    <div class="ad-swatch__chip" style="background: {{ step.value }}; color: {{ on }};">n-{{ step.step }}</div>
+    <div class="ad-swatch__chip" style="background: <%= step.value %>; color: <%= on %>;">n-<%= step.step %></div>
     <div class="ad-swatch__meta">
-      <span class="ad-swatch__hex">{{ step.value }}</span>
-      <span class="ad-swatch__ratio">{{ step.value | contrast_with: '#ffffff' }} on white</span>
+      <span class="ad-swatch__hex"><%= step.value %></span>
+      <span class="ad-swatch__ratio"><%= contrast_with(step.value, '#ffffff') %> on white</span>
     </div>
   </li>
-  {%- endfor %}
+  <% end -%>
 </ul>
 
-{% assign neutral_dark = site.data.tokens.color.ramps | where: "name", "neutral_dark" | first %}
-<p class="ad-muted">{{ neutral_dark.description }}</p>
+<% neutral_dark = site.data.tokens.color.ramps.find { |r| r.name == "neutral_dark" } -%>
+<p class="ad-muted"><%= neutral_dark.description %></p>
 <ul class="ad-swatch-grid">
-  {%- assign dsteps = neutral_dark.steps | sort: "order" %}
-  {%- for step in dsteps %}
-  {%- assign on = step.value | readable_on %}
+  <% neutral_dark.steps.sort_by { |s| s.order }.each do |step| -%>
+  <% on = readable_on(step.value) -%>
   <li class="ad-swatch">
-    <div class="ad-swatch__chip" style="background: {{ step.value }}; color: {{ on }};">nd-{{ step.step }}</div>
+    <div class="ad-swatch__chip" style="background: <%= step.value %>; color: <%= on %>;">nd-<%= step.step %></div>
     <div class="ad-swatch__meta">
-      <span class="ad-swatch__hex">{{ step.value }}</span>
-      <span class="ad-swatch__ratio">{{ step.value | contrast_with: '#000000' }} on black</span>
+      <span class="ad-swatch__hex"><%= step.value %></span>
+      <span class="ad-swatch__ratio"><%= contrast_with(step.value, '#000000') %> on black</span>
     </div>
   </li>
-  {%- endfor %}
+  <% end -%>
 </ul>
 
 ## Accent
@@ -73,15 +71,15 @@ sixteen: the source defines exactly these.
 One swappable accent per project — never two in the same view. Signal Teal is
 the default; the other four are the sanctioned menu a project can switch to.
 
-{% assign accent = site.data.tokens.color.ramps | where: "name", "accent" | first %}
+<% accent = site.data.tokens.color.ramps.find { |r| r.name == "accent" } -%>
 <ul class="ad-accent-grid">
-  {%- for step in accent.steps %}
+  <% accent.steps.each do |step| -%>
   <li>
-    <div class="ad-accent-demo" style="background: {{ step.value }};">{{ step.step }}{% if step.step == "teal" %} (default){% endif %}</div>
-    <div class="ad-specimen__meta"><span class="ad-token-name">{{ step.value }}</span><span>{{ step.value | contrast_with: '#ffffff' }}:1 on white</span></div>
-    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;">{{ step.usage }}</p>
+    <div class="ad-accent-demo" style="background: <%= step.value %>;"><%= step.step %><% if step.step == "teal" %> (default)<% end %></div>
+    <div class="ad-specimen__meta"><span class="ad-token-name"><%= step.value %></span><span><%= contrast_with(step.value, '#ffffff') %>:1 on white</span></div>
+    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;"><%= step.usage %></p>
   </li>
-  {%- endfor %}
+  <% end -%>
 </ul>
 
 <div class="ad-callout">
@@ -101,11 +99,10 @@ the default; the other four are the sanctioned menu a project can switch to.
 Both themes, side by side. The left half of each chip is the light value and
 the right half is the dark one.
 
-{% assign groups = site.data.tokens.semantic.groups %}
-{% for group in groups %}
-### {{ group.name }}
+<% site.data.tokens.semantic.groups.each do |group| -%>
+### <%= group.name %>
 
-<p class="ad-muted">{{ group.description }}</p>
+<p class="ad-muted"><%= group.description %></p>
 
 <div class="ad-table-wrap">
   <table class="ad-table ad-table--compact">
@@ -118,31 +115,31 @@ the right half is the dark one.
       </tr>
     </thead>
     <tbody>
-      {%- for token in group.tokens %}
-      {%- assign flat = site.data.semantic_flat | where: "token", token.token | first %}
+      <% group.tokens.each do |token| -%>
+      <% flat = site.data.semantic_flat.find { |f| f.token == token.token } -%>
       <tr>
         <td>
           <div class="ad-alias-row">
-            {%- if flat.light contains "color-mix" %}
+            <% if flat.light.include?("color-mix") -%>
             <span class="ad-alias-chip" aria-hidden="true" style="background: repeating-linear-gradient(45deg, var(--ad-color-surface-fill) 0 4px, var(--ad-color-surface-card) 4px 8px);"></span>
-            {%- else %}
+            <% else -%>
             <span class="ad-alias-chip ad-alias-chip--pair" aria-hidden="true">
-              <span style="background: {{ flat.light }}"></span>
-              <span style="background: {{ flat.dark }}"></span>
+              <span style="background: <%= flat.light %>"></span>
+              <span style="background: <%= flat.dark %>"></span>
             </span>
-            {%- endif %}
-            <span class="ad-token-name">{{ flat.name }}</span>
+            <% end -%>
+            <span class="ad-token-name"><%= flat.name %></span>
           </div>
         </td>
-        <td class="ad-table__code">{{ token.light }}</td>
-        <td class="ad-table__code">{{ token.dark }}</td>
-        <td>{{ token.usage }}</td>
+        <td class="ad-table__code"><%= token.light %></td>
+        <td class="ad-table__code"><%= token.dark %></td>
+        <td><%= token.usage %></td>
       </tr>
-      {%- endfor %}
+      <% end -%>
     </tbody>
   </table>
 </div>
-{% endfor %}
+<% end -%>
 
 ## Contrast that has to hold
 
@@ -152,14 +149,13 @@ palette wants. The pairings below live in `_data/tokens/contrast.yml`,
 `test/test_token_set.rb` asserts every one of them in both themes, and this
 table is rendered from that same file.
 
-{% assign contract = site.data.tokens.contrast %}
-{% assign flat = site.data.semantic_flat %}
+<% contract = site.data.tokens.contrast -%>
+<% flat = site.data.semantic_flat -%>
 
-{% for section in contract %}
-{% assign spec = section[1] %}
-### {{ spec.label }} — {{ spec.minimum }}:1 minimum
+<% contract.each do |_key, spec| -%>
+### <%= spec.label %> — <%= spec.minimum %>:1 minimum
 
-<p class="ad-muted">{{ spec.standard }}</p>
+<p class="ad-muted"><%= spec.standard %></p>
 
 <div class="ad-table-wrap">
   <table class="ad-table ad-table--compact">
@@ -172,28 +168,28 @@ table is rendered from that same file.
       </tr>
     </thead>
     <tbody>
-      {%- for pair in spec.pairs %}
-      {%- assign fg = flat | where: "token", pair.foreground | first %}
-      {%- assign bg = flat | where: "token", pair.background | first %}
+      <% spec.pairs.each do |pair| -%>
+      <% fg = flat.find { |f| f.token == pair.foreground } -%>
+      <% bg = flat.find { |f| f.token == pair.background } -%>
       <tr>
         <td>
           <span class="ad-alias-row">
             <span class="ad-alias-chip" aria-hidden="true"
-                  style="background: {{ bg.light }}; color: {{ fg.light }}; display: grid; place-items: center; font-size: 10px;">Aa</span>
-            <span class="ad-token-name">{{ pair.foreground }}<br><span class="ad-subtle">on {{ pair.background }}</span></span>
+                  style="background: <%= bg.light %>; color: <%= fg.light %>; display: grid; place-items: center; font-size: 10px;">Aa</span>
+            <span class="ad-token-name"><%= pair.foreground %><br><span class="ad-subtle">on <%= pair.background %></span></span>
           </span>
         </td>
-        <td>{{ pair.note }}</td>
-        {%- assign light_ratio = fg.light | contrast_with: bg.light %}
-        {%- assign dark_ratio = fg.dark | contrast_with: bg.dark %}
-        <td class="ad-table__num">{{ light_ratio }}</td>
-        <td class="ad-table__num">{{ dark_ratio }}</td>
+        <td><%= pair.note %></td>
+        <% light_ratio = contrast_with(fg.light, bg.light) -%>
+        <% dark_ratio = contrast_with(fg.dark, bg.dark) -%>
+        <td class="ad-table__num"><%= light_ratio %></td>
+        <td class="ad-table__num"><%= dark_ratio %></td>
       </tr>
-      {%- endfor %}
+      <% end -%>
     </tbody>
   </table>
 </div>
-{% endfor %}
+<% end -%>
 
 Every figure above was computed during this build from the token value
 itself — none of it was typed by a person.
diff --git a/_foundations/elevation.md b/_foundations/elevation.md
index b267769..3eba4ca 100644
--- a/_foundations/elevation.md
+++ b/_foundations/elevation.md
@@ -8,22 +8,22 @@ summary: >-
 ## Borders do the work
 
 The source's own rule: "hairline (1px) borders and dividers do most of the
-elevation work". A flat, bordered [Card]({{ '/components/card/' | relative_url }})
+elevation work". A flat, bordered [Card](<%= relative_url('/components/card/') %>)
 is the default everywhere. Shadow is the exception, not the baseline.
 
 ## The two levels
 
 <ul class="ad-elevation-grid">
-  {%- for step in site.data.tokens.scale.elevation.scale %}
+  <% site.data.tokens.scale.elevation.scale.each do |step| -%>
   <li>
-    <div class="ad-elevation-demo" style="box-shadow: {{ step.value }};"></div>
-    <div class="ad-specimen__meta"><span class="ad-token-name">{{ step.token }}</span></div>
-    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;">{{ step.usage }}</p>
+    <div class="ad-elevation-demo" style="box-shadow: <%= step.value %>;"></div>
+    <div class="ad-specimen__meta"><span class="ad-token-name"><%= step.token %></span></div>
+    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;"><%= step.usage %></p>
   </li>
-  {%- endfor %}
+  <% end -%>
 </ul>
 
-{% example title="Flat vs. float" layout="grid" surface="sunken" %}
+<% example(title: "Flat vs. float", layout: "grid", surface: "sunken") do %>
 <article class="ad-card">
   <div class="ad-card__body">
     <h3 class="ad-card__title" style="font-size: var(--ad-step--1);">Flat</h3>
@@ -36,7 +36,7 @@ is the default everywhere. Shadow is the exception, not the baseline.
     <p class="ad-card__text">shadow-float. Reserved for one element per view — a Dialog, a Toast.</p>
   </div>
 </article>
-{% endexample %}
+<% end %>
 
 ## Elevation in the dark theme
 
diff --git a/_foundations/motion.md b/_foundations/motion.md
index f1d4804..32208f4 100644
--- a/_foundations/motion.md
+++ b/_foundations/motion.md
@@ -15,13 +15,13 @@ easing, no page-transition choreography.
   <table class="ad-table ad-table--compact">
     <thead><tr><th scope="col">Token</th><th scope="col">Value</th><th scope="col">Use for</th></tr></thead>
     <tbody>
-      {%- for row in site.data.tokens.scale.motion.durations %}
+      <% site.data.tokens.scale.motion.durations.each do |row| -%>
       <tr>
-        <td class="ad-token-name">{{ row.token | custom_property }}</td>
-        <td class="ad-table__code">{{ row.value }}</td>
-        <td>{{ row.usage }}</td>
+        <td class="ad-token-name"><%= custom_property(row.token) %></td>
+        <td class="ad-table__code"><%= row.value %></td>
+        <td><%= row.usage %></td>
       </tr>
-      {%- endfor %}
+      <% end -%>
     </tbody>
   </table>
 </div>
@@ -31,15 +31,15 @@ easing, no page-transition choreography.
 There is exactly one curve in the system. Hover the tile below to run it.
 
 <ul class="ad-motion-grid">
-  {%- for row in site.data.tokens.scale.motion.easings %}
+  <% site.data.tokens.scale.motion.easings.each do |row| -%>
   <li>
     <div class="ad-motion-demo" tabindex="0">
       <span class="ad-motion-demo__dot"></span>
     </div>
-    <div class="ad-specimen__meta"><span class="ad-token-name">{{ row.token | custom_property }}</span></div>
-    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;">{{ row.usage }}</p>
+    <div class="ad-specimen__meta"><span class="ad-token-name"><%= custom_property(row.token) %></span></div>
+    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;"><%= row.usage %></p>
   </li>
-  {%- endfor %}
+  <% end -%>
 </ul>
 
 ## Reduced motion is not a downgrade
@@ -61,7 +61,7 @@ do not have to opt in, and cannot opt out.
 The state change still happens. The Switch still moves. It simply arrives
 instead of travelling.
 
-{% example title="Motion in practice" %}
+<% example(title: "Motion in practice") do %>
 <label class="ad-switch">
   <input type="checkbox" checked>
   <span class="ad-switch__track"></span>
@@ -72,7 +72,7 @@ instead of travelling.
   <span class="ad-switch__track"></span>
   <span>Weekly digest</span>
 </label>
-{% endexample %}
+<% end %>
 
 ## Rules
 
diff --git a/_foundations/space.md b/_foundations/space.md
index 6522e78..3c1adf5 100644
--- a/_foundations/space.md
+++ b/_foundations/space.md
@@ -8,13 +8,13 @@ summary: >-
 ## The scale
 
 <div class="ad-ruler">
-  {%- for step in site.data.tokens.scale.space.scale %}
+  <% site.data.tokens.scale.space.scale.each do |step| -%>
   <div class="ad-ruler__row">
-    <span class="ad-token-name">{{ step.token }}</span>
-    <span class="ad-ruler__num">{{ step.px }}px</span>
-    <span class="ad-ruler__bar" style="width: {{ step.value }}; min-width: 1px;"></span>
+    <span class="ad-token-name"><%= step.token %></span>
+    <span class="ad-ruler__num"><%= step.px %>px</span>
+    <span class="ad-ruler__bar" style="width: <%= step.value %>; min-width: 1px;"></span>
   </div>
-  {%- endfor %}
+  <% end -%>
 </div>
 
 ## How to pick one
@@ -30,7 +30,7 @@ summary: >-
 | Major section spacing | `--ad-space-xl` / `--ad-space-2xl` |
 | Hero and empty-state breathing room | `--ad-space-3xl` |
 
-{% example title="The same card at three densities" layout="grid" %}
+<% example(title: "The same card at three densities", layout: "grid") do %>
 <article class="ad-card">
   <div class="ad-card__body" style="gap: var(--ad-space-3xs); padding: var(--ad-space-xs);">
     <h3 class="ad-card__title" style="font-size: var(--ad-step--1);">Compact</h3>
@@ -49,7 +49,7 @@ summary: >-
     <p class="ad-card__text">space-l padding, space-s gap. For a card that is the whole screen.</p>
   </div>
 </article>
-{% endexample %}
+<% end %>
 
 ## Radius
 
@@ -58,18 +58,18 @@ rather than any one source project's specific softness. Nothing pill-shaped
 except true pills.
 
 <ul class="ad-radius-grid">
-  {%- for step in site.data.tokens.scale.radius.scale %}
-  {%- unless step.token == "border-w" %}
+  <% site.data.tokens.scale.radius.scale.each do |step| -%>
+  <% unless step.token == "border-w" -%>
   <li>
-    <div class="ad-radius-demo" style="border-radius: {{ step.value }};"></div>
+    <div class="ad-radius-demo" style="border-radius: <%= step.value %>;"></div>
     <div class="ad-specimen__meta">
-      <span class="ad-token-name">{{ step.token }}</span>
-      <span>{{ step.value }}</span>
+      <span class="ad-token-name"><%= step.token %></span>
+      <span><%= step.value %></span>
     </div>
-    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;">{{ step.usage }}</p>
+    <p class="ad-muted" style="font-size: var(--ad-step--2); margin: var(--ad-space-3xs) 0 0;"><%= step.usage %></p>
   </li>
-  {%- endunless %}
-  {%- endfor %}
+  <% end -%>
+  <% end -%>
 </ul>
 
 ## Rules
diff --git a/_foundations/typography.md b/_foundations/typography.md
index 2284330..89603eb 100644
--- a/_foundations/typography.md
+++ b/_foundations/typography.md
@@ -8,22 +8,22 @@ summary: >-
 
 ## Families
 
-{% for family in site.data.tokens.scale.typography.families %}
+<% site.data.tokens.scale.typography.families.each do |family| -%>
 <div class="ad-specimen">
   <div class="ad-specimen__meta">
-    <span class="ad-token-name">{{ family.token | custom_property }}</span>
-    <span>{{ family.usage }}</span>
+    <span class="ad-token-name"><%= custom_property(family.token) %></span>
+    <span><%= family.usage %></span>
   </div>
-  <div class="ad-specimen__sample" style="font-family: var({{ family.token | custom_property }}); font-size: 1.5rem;">
+  <div class="ad-specimen__sample" style="font-family: var(<%= custom_property(family.token) %>); font-size: 1.5rem;">
     Arbitrary Definitions — 0123456789
   </div>
 </div>
-{% endfor %}
+<% end -%>
 
 Only one webfont is loaded — IBM Plex Mono, at 400/500/600:
 
 ```css
-{{ site.data.tokens.scale.typography.webfont_import }}
+<%= site.data.tokens.scale.typography.webfont_import %>
 ```
 
 Sans is a web-safe system stack; serif is Georgia-based and opt-in, for a
@@ -35,18 +35,18 @@ Eight steps, `clamp()`-based (Utopia, 320→1240px viewport). The body sits at
 `--ad-step-0` — 18px on a phone, 20px on a wide screen — with no fixed
 breakpoint in between.
 
-{% for step in site.data.tokens.scale.typography.steps %}
+<% site.data.tokens.scale.typography.steps.each do |step| -%>
 <div class="ad-specimen">
   <div class="ad-specimen__meta">
-    <span class="ad-token-name">{{ step.token | custom_property }}</span>
-    <span>{{ step.px }}px</span>
-    <span>{{ step.usage }}</span>
+    <span class="ad-token-name"><%= custom_property(step.token) %></span>
+    <span><%= step.px %>px</span>
+    <span><%= step.usage %></span>
   </div>
-  <div class="ad-specimen__sample" style="font-size: var({{ step.token | custom_property }});">
+  <div class="ad-specimen__sample" style="font-size: var(<%= custom_property(step.token) %>);">
     The quick brown fox jumps over the lazy dog
   </div>
 </div>
-{% endfor %}
+<% end -%>
 
 ## The eight compound styles
 
@@ -58,21 +58,21 @@ source specifies one) letter-spacing in a single declaration.
   <table class="ad-table ad-table--compact">
     <thead><tr><th scope="col">Class</th><th scope="col">Weight</th><th scope="col">Size</th><th scope="col">Line-height</th><th scope="col">Tracking</th><th scope="col">Use for</th></tr></thead>
     <tbody>
-      {%- for style in site.data.tokens.scale.typography.styles %}
+      <% site.data.tokens.scale.typography.styles.each do |style| -%>
       <tr>
-        <td class="ad-token-name">.{{ style.class }}</td>
-        <td>{{ style.weight }}</td>
-        <td class="ad-table__code">{{ style.size | custom_property }}</td>
-        <td>{{ style.line_height }}</td>
-        <td>{{ style.tracking }}</td>
-        <td>{{ style.usage }}</td>
+        <td class="ad-token-name">.<%= style["class"] %></td>
+        <td><%= style.weight %></td>
+        <td class="ad-table__code"><%= custom_property(style.size) %></td>
+        <td><%= style.line_height %></td>
+        <td><%= style.tracking %></td>
+        <td><%= style.usage %></td>
       </tr>
-      {%- endfor %}
+      <% end -%>
     </tbody>
   </table>
 </div>
 
-{% example title="All eight, in order" layout="stack" %}
+<% example(title: "All eight, in order", layout: "stack") do %>
 <div class="type-display">Display</div>
 <h1 class="type-h1">Heading one</h1>
 <h2 class="type-h2">Heading two</h2>
@@ -81,7 +81,7 @@ source specifies one) letter-spacing in a single declaration.
 <p class="type-body-sm">Secondary copy, one step down.</p>
 <span class="type-label">Catalog label</span>
 <span class="type-mono">technical.value / 2026-08-21 / #3a6a63</span>
-{% endexample %}
+<% end %>
 
 ## Rules
 
diff --git a/_includes/head.html b/_includes/head.erb
similarity index 81%
rename from _includes/head.html
rename to _includes/head.erb
index 796de97..a6b60e0 100644
--- a/_includes/head.html
+++ b/_includes/head.erb
@@ -3,7 +3,7 @@
 <meta name="theme-color" content="#faf9f7" media="(prefers-color-scheme: light)">
 <meta name="theme-color" content="#0e0d0b" media="(prefers-color-scheme: dark)">
 
-{% seo %}
+<%= 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
@@ -13,10 +13,10 @@
 <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="{{ '/assets/css/tokens.css' | relative_url }}">
-<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
+<link rel="stylesheet" href="<%= relative_url('/assets/css/tokens.css') %>">
+<link rel="stylesheet" href="<%= relative_url('/assets/css/style.css') %>">
 
-<link rel="icon" href="{{ '/assets/favicon.svg' | relative_url }}" type="image/svg+xml">
+<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>
diff --git a/_includes/seo.erb b/_includes/seo.erb
new file mode 100644
index 0000000..29b40f2
--- /dev/null
+++ b/_includes/seo.erb
@@ -0,0 +1,10 @@
+<%
+  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.html b/_includes/sidebar.erb
similarity index 55%
rename from _includes/sidebar.html
rename to _includes/sidebar.erb
index 7babe9c..2fa2c61 100644
--- a/_includes/sidebar.html
+++ b/_includes/sidebar.erb
@@ -6,15 +6,15 @@
   </button>
 
   <nav class="ad-doc-nav" id="ad-doc-nav" aria-label="Design system">
-    {%- for group in site.data.nav %}
+    <% site.data.nav.each do |group| -%>
     <div class="ad-doc-nav__group">
-      <h2 class="ad-doc-nav__heading">{{ group.title }}</h2>
-      {%- for link in group.links %}
-      {%- assign href = link.url | relative_url %}
-      <a class="ad-doc-nav__link" href="{{ href }}"
-         {% if page.url == link.url %}aria-current="page"{% endif %}>{{ link.text }}</a>
-      {%- endfor %}
+      <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>
-    {%- endfor %}
+    <% end -%>
   </nav>
 </aside>
diff --git a/_includes/theme-toggle.html b/_includes/theme-toggle.erb
similarity index 100%
rename from _includes/theme-toggle.html
rename to _includes/theme-toggle.erb
diff --git a/_includes/wordmark.html b/_includes/wordmark.erb
similarity index 76%
rename from _includes/wordmark.html
rename to _includes/wordmark.erb
index b5a6fea..c881e7f 100644
--- a/_includes/wordmark.html
+++ b/_includes/wordmark.erb
@@ -1,9 +1,9 @@
-<a class="ad-doc-wordmark" href="{{ '/' | relative_url }}">
+<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>
+  <span class="ad-doc-wordmark__version">v<%= site.data.meta.version %></span>
 </a>
diff --git a/_layouts/default.html b/_layouts/default.erb
similarity index 50%
rename from _layouts/default.html
rename to _layouts/default.erb
index 00c4c4b..214775c 100644
--- a/_layouts/default.html
+++ b/_layouts/default.erb
@@ -1,39 +1,39 @@
 <!DOCTYPE html>
-<html lang="{{ site.lang | default: 'en' }}">
+<html lang="<%= site.lang || 'en' %>">
 <head>
-  {% include head.html %}
+  <%= 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">
-      {% include wordmark.html %}
+      <%= render("wordmark") %>
       <div class="ad-doc-header__actions">
-        {% include theme-toggle.html %}
+        <%= render("theme-toggle") %>
         <a class="ad-btn ad-btn--secondary ad-btn--sm"
-           href="https://github.com/{{ site.repository }}">Source</a>
+           href="https://github.com/<%= site.repository %>">Source</a>
       </div>
     </div>
   </header>
 
   <div class="ad-doc-layout">
-    {% include sidebar.html %}
+    <%= render("sidebar") %>
     <main class="ad-doc-main" id="main">
-      {{ content }}
+      <%= content %>
     </main>
   </div>
 
   <footer class="ad-doc-footer">
     <div class="ad-doc-footer__inner">
       <p>
-        {{ site.title }} v{{ site.data.meta.version }} — built with Jekyll on Ruby
-        {{ site.data.build.ruby }}.
+        <%= site.title %> v<%= site.data.meta.version %> — built with Ruby
+        <%= site.data.build.ruby %>.
       </p>
-      <p><a href="https://github.com/{{ site.repository }}">View the source</a></p>
+      <p><a href="https://github.com/<%= site.repository %>">View the source</a></p>
     </div>
   </footer>
 
-  <script src="{{ '/assets/js/system.js' | relative_url }}" defer></script>
+  <script src="<%= relative_url('/assets/js/system.js') %>" defer></script>
 </body>
 </html>
diff --git a/_layouts/page.erb b/_layouts/page.erb
new file mode 100644
index 0000000..b067e11
--- /dev/null
+++ b/_layouts/page.erb
@@ -0,0 +1,12 @@
+---
+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 %>
diff --git a/_layouts/page.html b/_layouts/page.html
deleted file mode 100644
index 9692257..0000000
--- a/_layouts/page.html
+++ /dev/null
@@ -1,13 +0,0 @@
----
-layout: default
----
-<header class="ad-doc-title">
-  {%- if page.section %}<span class="ad-doc-title__section">{{ page.section }}</span>{% endif %}
-  <h1>{{ page.title }}</h1>
-</header>
-
-{%- if page.summary %}
-<p class="ad-lead">{{ page.summary }}</p>
-{%- endif %}
-
-{{ content }}
diff --git a/_patterns/archive.md b/_patterns/archive.md
index 9bc576b..58047a7 100644
--- a/_patterns/archive.md
+++ b/_patterns/archive.md
@@ -11,7 +11,7 @@ mobile capture flow: the shape `kejadlen/domus` (a household inventory and
 document archive) would take if it were rebuilt on this token set, at its
 390×760 frame. Three screens, abbreviated to what each one adds.
 
-{% example title="Home — recent captures" layout="row" no-source %}
+<% example(title: "Home — recent captures", layout: "row", no_source: true) do %>
 <div style="width: 300px; height: 560px; background: var(--ad-color-surface-page); border-radius: var(--ad-radius-lg); box-shadow: var(--ad-shadow-float); overflow:hidden; display:flex; flex-direction:column; position:relative;">
   <header style="padding: 16px var(--ad-space-m) var(--ad-space-2xs);">
     <span style="font-weight:600; font-size: var(--ad-step-1); letter-spacing:-0.01em;">Archive</span>
@@ -99,7 +99,7 @@ document archive) would take if it were rebuilt on this token set, at its
     <p style="font-size: var(--ad-step--1); color: var(--ad-color-text-secondary); line-height:1.55; margin:0;">No notes yet.</p>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## What this screen is testing
 
diff --git a/_patterns/loop.md b/_patterns/loop.md
index 2d0804d..19f8b81 100644
--- a/_patterns/loop.md
+++ b/_patterns/loop.md
@@ -12,7 +12,7 @@ tracker) would take if it were rebuilt on this token set. Every visible value
 below is a component from this system; nothing here is page-specific CSS
 beyond a couple of inline widths.
 
-{% example title="Loop" layout="stack" no-source %}
+<% example(title: "Loop", layout: "stack", no_source: true) do %>
 <div style="border: 1px solid var(--ad-color-border-hairline); border-radius: var(--ad-radius-lg); overflow: hidden; background: var(--ad-color-surface-page);">
 
   <header style="display:flex; align-items:center; gap: var(--ad-space-s); padding: var(--ad-space-s) var(--ad-space-l); border-bottom: 1px solid var(--ad-color-border-hairline);">
@@ -83,14 +83,14 @@ beyond a couple of inline widths.
     <aside style="width: 15rem; flex-shrink:0; background: var(--ad-color-surface-fill); border-left: 1px solid var(--ad-color-border-hairline); padding: var(--ad-space-l);">
       <div class="type-label" style="margin-bottom: var(--ad-space-s);">This week</div>
       <div style="display:flex; background: var(--ad-color-surface-card); border: 1px solid var(--ad-color-border-hairline); border-radius: var(--ad-radius-md); overflow:hidden; margin-bottom: var(--ad-space-l);">
-        {%- assign week = "S,M,T,W,T,F,S" | split: "," %}
-        {%- assign nums = "0,1,0,2,0,1,0" | split: "," %}
-        {%- for d in week %}
-        <div style="flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; padding: 8px 0; {% if forloop.index0 == 3 %}background: var(--ad-color-accent-soft);{% endif %} {% unless forloop.last %}border-right: 1px solid var(--ad-color-border-hairline);{% endunless %}">
-          <span class="type-mono" style="font-size:9px; color: var(--ad-color-text-tertiary); text-transform:uppercase;">{{ d }}</span>
-          <span style="font-size:13px; font-weight:600; color: {% if forloop.index0 == 3 %}var(--ad-color-accent-ink){% else %}var(--ad-color-text-primary){% endif %};">{{ nums[forloop.index0] }}</span>
+        <% week = %w[S M T W T F S] -%>
+        <% nums = [0, 1, 0, 2, 0, 1, 0] -%>
+        <% week.each_with_index do |d, index0| -%>
+        <div style="flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; padding: 8px 0; <% if index0 == 3 %>background: var(--ad-color-accent-soft);<% end %> <% unless index0 == week.length - 1 %>border-right: 1px solid var(--ad-color-border-hairline);<% end %>">
+          <span class="type-mono" style="font-size:9px; color: var(--ad-color-text-tertiary); text-transform:uppercase;"><%= d %></span>
+          <span style="font-size:13px; font-weight:600; color: <% if index0 == 3 %>var(--ad-color-accent-ink)<% else %>var(--ad-color-text-primary)<% end %>;"><%= nums[index0] %></span>
         </div>
-        {%- endfor %}
+        <% end -%>
       </div>
       <div class="type-label" style="margin-bottom: var(--ad-space-2xs);">Series</div>
       <div>
@@ -101,11 +101,11 @@ beyond a couple of inline widths.
     </aside>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## The New series dialog
 
-{% example title="New series" no-source %}
+<% example(title: "New series", no_source: true) do %>
 <div class="ad-dialog-demo" style="min-height: 18rem;">
   <div class="ad-dialog__overlay">
     <div class="ad-dialog" style="width: 22rem;">
@@ -136,16 +136,16 @@ beyond a couple of inline widths.
     </div>
   </div>
 </div>
-{% endexample %}
+<% end %>
 
 ## After completing a task
 
-{% example title="Toast" no-source %}
+<% example(title: "Toast", no_source: true) do %>
 <div class="ad-toast">
   <span class="ad-toast__message">Completed "Call Mom"</span>
   <button type="button" class="ad-toast__dismiss" aria-label="Dismiss">×</button>
 </div>
-{% endexample %}
+<% end %>
 
 ## What this screen is testing
 
diff --git a/_plugins/color_filters.rb b/_plugins/color_filters.rb
deleted file mode 100644
index 9843b06..0000000
--- a/_plugins/color_filters.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
-
-require "arbitrary_definitions/color_math"
-
-module ArbitraryDefinitions
-  # Liquid filters that let the documentation compute facts about its own
-  # colours instead of restating them. Every contrast figure on the Colour
-  # page is produced here, at build time, from the token value itself.
-  module ColorFilters
-    # Contrast ratio against a second colour, rounded for display: "8.42".
-    def contrast_with(value, other)
-      ratio = ColorMath.contrast_ratio(value, other)
-      return "—" unless ratio
-
-      format("%.2f", ratio)
-    end
-
-    # "AAA" / "AA" / "AA Large" / "Fail" for normal-size body text.
-    def contrast_grade(value, other)
-      ColorMath.grade(ColorMath.contrast_ratio(value, other))
-    end
-
-    # Whichever of white or near-black stays legible on this swatch.
-    def readable_on(value)
-      ColorMath.readable_on(value)
-    end
-
-    # 0–100, used to sort or annotate a ramp.
-    def luminance_percent(value)
-      luminance = ColorMath.relative_luminance(value)
-      return "—" unless luminance
-
-      (luminance * 100).round
-    end
-
-    def hex_color?(value)
-      ColorMath.hex?(value)
-    end
-
-    # "color-accent" -> "--ad-color-accent"
-    def custom_property(value)
-      prefix = @context.registers[:site].config.fetch("token_prefix", "ad")
-      "--#{prefix}-#{value}"
-    end
-  end
-end
-
-Liquid::Template.register_filter(ArbitraryDefinitions::ColorFilters)
diff --git a/_plugins/example_tag.rb b/_plugins/example_tag.rb
deleted file mode 100644
index 3b4c293..0000000
--- a/_plugins/example_tag.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-# frozen_string_literal: true
-
-require "cgi/escape"
-require "rouge"
-
-module ArbitraryDefinitions
-  # {% example title="Variants" align="center" %} ... {% endexample %}
-  #
-  # Renders the block twice: once live, so the reader sees the component the
-  # browser actually paints, and once as highlighted source, so they can copy
-  # the exact markup. Writing the demo and the snippet separately is how
-  # design system docs start lying; this makes that impossible.
-  #
-  # Options
-  #   title="…"     caption above the preview
-  #   align=…       start | center | end   (default start)
-  #   surface=…     canvas | sunken | plain  (default canvas)
-  #   layout=…      row | stack | grid      (default row)
-  #   open          expand the source panel on load
-  #   no-source     preview only, for demos whose markup is noise
-  class ExampleTag < Liquid::Block
-    OPTION = /(\w[\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/
-
-    ALIGNMENTS = %w[start center end].freeze
-    SURFACES   = %w[canvas sunken plain].freeze
-    LAYOUTS    = %w[row stack grid].freeze
-
-    def initialize(tag_name, markup, tokens)
-      super
-      @options = parse_options(markup)
-    end
-
-    def render(context)
-      source = dedent(super.to_s)
-      return "" if source.strip.empty?
-
-      preview = <<~HTML.strip
-        <div class="ad-example__preview #{preview_classes}">#{source}</div>
-      HTML
-
-      parts = []
-      parts << %(<figure class="ad-example">)
-      parts << %(<figcaption class="ad-example__title">#{escape(@options['title'])}</figcaption>) if @options["title"]
-      parts << preview
-      parts << source_panel(source) unless @options.key?("no-source")
-      parts << "</figure>"
-      parts.join("\n")
-    end
-
-    private
-
-    def preview_classes
-      align   = ALIGNMENTS.include?(@options["align"]) ? @options["align"] : "start"
-      surface = SURFACES.include?(@options["surface"]) ? @options["surface"] : "canvas"
-      layout  = LAYOUTS.include?(@options["layout"]) ? @options["layout"] : "row"
-
-      "ad-example__preview--#{align} ad-example__preview--#{surface} ad-example__preview--#{layout}"
-    end
-
-    def source_panel(source)
-      lexer     = Rouge::Lexers::HTML.new
-      formatter = Rouge::Formatters::HTML.new
-      highlighted = formatter.format(lexer.lex(source))
-
-      <<~HTML.strip
-        <details class="ad-example__source"#{@options.key?('open') ? ' open' : ''}>
-        <summary class="ad-example__summary"><span>Markup</span></summary>
-        <div class="ad-example__code highlight">
-        <button type="button" class="ad-example__copy" data-copy aria-label="Copy markup to clipboard">Copy</button>
-        <pre><code class="language-html">#{highlighted}</code></pre>
-        </div>
-        </details>
-      HTML
-    end
-
-    # Liquid hands back the block body with whatever indentation the Markdown
-    # file used. Strip the common prefix so the copied snippet is not
-    # accidentally nested four levels deep.
-    def dedent(text)
-      lines  = text.sub(/\A\n+/, "").rstrip.lines
-      widths = lines.reject { |line| line.strip.empty? }
-                    .map { |line| line[/\A[ \t]*/].length }
-      indent = widths.min || 0
-
-      lines.map { |line| line.strip.empty? ? line.lstrip : line[indent..] }.join
-    end
-
-    def parse_options(markup)
-      markup.to_s.scan(OPTION).each_with_object({}) do |(key, dq, sq, bare), options|
-        options[key] = dq || sq || bare || true
-      end
-    end
-
-    def escape(value)
-      CGI.escapeHTML(value.to_s)
-    end
-  end
-end
-
-Liquid::Template.register_tag("example", ArbitraryDefinitions::ExampleTag)
diff --git a/_plugins/token_generator.rb b/_plugins/token_generator.rb
deleted file mode 100644
index 7742202..0000000
--- a/_plugins/token_generator.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
-
-require "arbitrary_definitions/token_set"
-
-module ArbitraryDefinitions
-  # Compiles _data/tokens/*.yml into assets/css/tokens.css during the build,
-  # and hands the resolved token set back to Liquid as `site.data.tokens.*`
-  # so the documentation tables and the stylesheet cannot disagree.
-  class TokenGenerator < Jekyll::Generator
-    safe true
-    priority :highest
-
-    def generate(site)
-      token_set = TokenSet.new(
-        dir: File.join(site.source, "_data", "tokens"),
-        prefix: site.config.fetch("token_prefix", "ad"),
-      )
-
-      site.pages << stylesheet(site, token_set)
-
-      site.data["token_prefix"]   = token_set.prefix
-      site.data["semantic_flat"]  = token_set.semantic_tokens
-      site.data["tokens_css_size"] = token_set.to_css.bytesize
-      site.data["build"] = {
-        "ruby" => RUBY_VERSION,
-        "jekyll" => Jekyll::VERSION,
-        "ramps" => token_set.ramps.size,
-        "aliases" => token_set.semantic_tokens.size,
-        "generated_at" => Time.now.utc.strftime("%Y-%m-%d"),
-      }
-
-      Jekyll.logger.info "Tokens:", "compiled #{token_set.semantic_tokens.size} aliases " \
-                                    "from #{token_set.ramps.size} ramps"
-    end
-
-    private
-
-    def stylesheet(site, token_set)
-      page = Jekyll::PageWithoutAFile.new(site, site.source, "assets/css", "tokens.css")
-      page.content = token_set.to_css
-      page.data["layout"] = nil
-      page
-    end
-  end
-end
diff --git a/assets/css/style.scss b/assets/css/style.scss
index dfface1..6d4db8c 100644
--- a/assets/css/style.scss
+++ b/assets/css/style.scss
@@ -1,8 +1,7 @@
----
-# Front matter tells Jekyll to run this through the Sass converter.
-# The custom properties it consumes are generated separately by
-# _plugins/token_generator.rb into assets/css/tokens.css.
----
+// The Sass entry point compiled by lib/arbitrary_definitions/site_builder.rb
+// into assets/css/style.css. The custom properties it consumes are generated
+// separately, by lib/arbitrary_definitions/token_set.rb, into
+// assets/css/tokens.css.
 
 @use "base/reset";
 @use "base/typography";
diff --git a/index.md b/index.md
index 209d08b..0f1c0bc 100644
--- a/index.md
+++ b/index.md
@@ -3,8 +3,8 @@ layout: default
 title: Overview
 description: >-
   A personal design system for self-hosted, single-user tools — synthesized
-  from three real running projects and rendered from YAML tokens by Jekyll on
-  Ruby 4.0.
+  from three real running projects and rendered from YAML tokens by a small
+  Ruby/ERB static-site generator on Ruby 4.0.
 ---
 
 <div class="ad-hero">
@@ -17,22 +17,22 @@ description: >-
     typographic mood; this system is what they all share underneath.
   </p>
   <div class="ad-hero__actions">
-    <a class="ad-btn ad-btn--primary ad-btn--lg" href="{{ '/foundations/color/' | relative_url }}">Start with colour</a>
-    <a class="ad-btn ad-btn--lg" href="{{ '/components/button/' | relative_url }}">Jump to components</a>
+    <a class="ad-btn ad-btn--primary ad-btn--lg" href="<%= relative_url('/foundations/color/') %>">Start with colour</a>
+    <a class="ad-btn ad-btn--lg" href="<%= relative_url('/components/button/') %>">Jump to components</a>
   </div>
 </div>
 
 <div class="ad-stat-row">
   <div class="ad-stat">
-    <span class="ad-stat__value">{{ site.data.build.ramps }}</span>
+    <span class="ad-stat__value"><%= site.data.build.ramps %></span>
     <span class="ad-stat__label">Colour ramps</span>
   </div>
   <div class="ad-stat">
-    <span class="ad-stat__value">{{ site.data.build.aliases }}</span>
+    <span class="ad-stat__value"><%= site.data.build.aliases %></span>
     <span class="ad-stat__label">Semantic aliases</span>
   </div>
   <div class="ad-stat">
-    <span class="ad-stat__value">{{ site.components | size }}</span>
+    <span class="ad-stat__value"><%= site.components.size %></span>
     <span class="ad-stat__label">Components</span>
   </div>
   <div class="ad-stat">
@@ -52,16 +52,16 @@ recurring-task tracker — all-mono, plain grays, a flash-bar toast), and
 sans and mono, a full dark palette, underline tabs). The one literal constant
 across all three is an identical Utopia fluid type/space scale — adopted
 verbatim as this system's spine. Everything else is a synthesized middle
-ground. See [Why arbitrary]({{ '/rationale/' | relative_url }}) for the full
+ground. See [Why arbitrary](<%= relative_url('/rationale/') %>) for the full
 story, including where this system knowingly guesses.
 
 ## How it is put together
 
-Three YAML files under `_data/tokens/` hold every value in the system. A
-Jekyll generator written in Ruby reads them at build time and emits
-`assets/css/tokens.css` — one flat block of custom properties, plus the dark
-theme and a `prefers-reduced-motion` override. The component stylesheets read
-`var(--ad-*)` and nothing else.
+Three YAML files under `_data/tokens/` hold every value in the system. Plain
+Ruby (`lib/arbitrary_definitions/token_set.rb`) reads them at build time and
+emits `assets/css/tokens.css` — one flat block of custom properties, plus the
+dark theme and a `prefers-reduced-motion` override. The component stylesheets
+read `var(--ad-*)` and nothing else.
 
 The same YAML feeds the tables and swatches on these pages, so the
 documentation cannot drift from the stylesheet. If a hex changes in the YAML,
@@ -71,7 +71,7 @@ of them do.
 <div class="ad-card-grid">
   <article class="ad-card ad-card--link">
     <div class="ad-card__body">
-      <h3 class="ad-card__title"><a href="{{ '/foundations/color/' | relative_url }}">Foundations</a></h3>
+      <h3 class="ad-card__title"><a href="<%= relative_url('/foundations/color/') %>">Foundations</a></h3>
       <p class="ad-card__text">
         Colour, type, space, elevation, and motion — with contrast ratios
         computed from the tokens during the build rather than typed by hand.
@@ -80,7 +80,7 @@ of them do.
   </article>
   <article class="ad-card ad-card--link">
     <div class="ad-card__body">
-      <h3 class="ad-card__title"><a href="{{ '/components/button/' | relative_url }}">Components</a></h3>
+      <h3 class="ad-card__title"><a href="<%= relative_url('/components/button/') %>">Components</a></h3>
       <p class="ad-card__text">
         Sixteen components across core, forms, feedback, and navigation, each
         rendered live next to the exact markup that produced it.
@@ -89,7 +89,7 @@ of them do.
   </article>
   <article class="ad-card ad-card--link">
     <div class="ad-card__body">
-      <h3 class="ad-card__title"><a href="{{ '/patterns/loop/' | relative_url }}">Patterns</a></h3>
+      <h3 class="ad-card__title"><a href="<%= relative_url('/patterns/loop/') %>">Patterns</a></h3>
       <p class="ad-card__text">
         Loop and Archive — a task dashboard and a capture app — put every
         component family in one screen at once.
@@ -100,7 +100,7 @@ of them do.
 
 ## A taste of it
 
-{% example title="Everything on one card" layout="stack" %}
+<% example(title: "Everything on one card", layout: "stack") do %>
 <article class="ad-card ad-card--elevated" style="max-width: 26rem;">
   <div class="ad-card__body">
     <div class="ad-badge-set">
@@ -119,7 +119,7 @@ of them do.
     <button type="button" class="ad-btn ad-btn--ghost ad-btn--sm">Dismiss</button>
   </div>
 </article>
-{% endexample %}
+<% end %>
 
 ## Voice
 
@@ -138,6 +138,6 @@ JavaScript at all.
 <link rel="stylesheet" href="style.css">
 ```
 
-See [Using the CSS]({{ '/install/' | relative_url }}) for the details,
+See [Using the CSS](<%= relative_url('/install/') %>) for the details,
 including how to retheme the whole thing by overriding a dozen custom
 properties.
diff --git a/install.md b/install.md
index 508a834..ebc2b6b 100644
--- a/install.md
+++ b/install.md
@@ -10,8 +10,9 @@ summary: >-
 ## The two files
 
 The system compiles to exactly two stylesheets. `tokens.css` is generated
-from YAML by a Jekyll plugin; `style.css` is the components. The order
-matters — the components read the custom properties the tokens define.
+from YAML by `lib/arbitrary_definitions/token_set.rb`; `style.css` is the
+components. The order matters — the components read the custom properties the
+tokens define.
 
 ```html
 <link rel="stylesheet" href="tokens.css">
@@ -19,19 +20,19 @@ matters — the components read the custom properties the tokens define.
 ```
 
 Both are on this site at
-[`/assets/css/tokens.css`]({{ '/assets/css/tokens.css' | relative_url }}) and
-[`/assets/css/style.css`]({{ '/assets/css/style.css' | relative_url }}). The
-generated token file is {{ site.data.tokens_css_size }} bytes uncompressed.
+[`/assets/css/tokens.css`](<%= relative_url('/assets/css/tokens.css') %>) and
+[`/assets/css/style.css`](<%= relative_url('/assets/css/style.css') %>). The
+generated token file is <%= site.data.tokens_css_size %> bytes uncompressed.
 
 Load IBM Plex Mono separately — it is the system's one webfont, and nothing
 else is fetched:
 
 ```css
-{{ site.data.tokens.scale.typography.webfont_import }}
+<%= site.data.tokens.scale.typography.webfont_import %>
 ```
 
 Almost nothing here needs JavaScript. `assets/js/system.js` adds keyboard
-handling for [Tabs]({{ '/components/tabs/' | relative_url }}), the theme
+handling for [Tabs](<%= relative_url('/components/tabs/') %>), the theme
 toggle, and the copy buttons on this site — Tooltip, Checkbox, Radio, and
 Switch are pure CSS, and Dialog/Toast are shown as static states for
 documentation rather than wired up as production widgets.
@@ -84,9 +85,11 @@ a handful of custom properties after the token file loads — most of all,
 
 ## Building this site
 
-The plugins in `_plugins/` mean the GitHub-hosted Jekyll build will not run
-this site, so it builds on GitHub Actions and uploads the result to Pages.
-Locally:
+There is no Jekyll here — the whole site is a Rakefile driving plain ERB
+templates and Kramdown (see `lib/arbitrary_definitions/site_builder.rb`). It
+still builds on GitHub Actions and uploads the result to Pages, mainly so the
+build always runs on the pinned Ruby 4.0 rather than whatever GitHub's Pages
+runner happens to ship. Locally:
 
 ```console
 $ rbenv install 4.0.6      # or however you get Ruby 4.0
@@ -102,8 +105,8 @@ $ bundle exec rake serve   # http://127.0.0.1:4000
     <thead><tr><th scope="col">Path</th><th scope="col">What it is</th></tr></thead>
     <tbody>
       <tr><td class="ad-table__code">_data/tokens/</td><td>The four YAML files. Every value in the system.</td></tr>
-      <tr><td class="ad-table__code">lib/arbitrary_definitions/</td><td>Token compiler and WCAG colour maths. No Jekyll dependency, so it is unit tested on its own.</td></tr>
-      <tr><td class="ad-table__code">_plugins/</td><td>The Jekyll wiring: a generator, Liquid filters, and the <code>{% raw %}{% example %}{% endraw %}</code> block.</td></tr>
+      <tr><td class="ad-table__code">lib/arbitrary_definitions/</td><td>The token compiler, WCAG colour maths, and the whole site builder. No Jekyll dependency anywhere, so the first two are unit tested on their own.</td></tr>
+      <tr><td class="ad-table__code">_layouts/, _includes/</td><td>Plain ERB templates — the page chrome and the <code>example</code> helper's two-up demo/source rendering.</td></tr>
       <tr><td class="ad-table__code">_sass/components/</td><td>One stylesheet per component group. No hexes, no pixels, no durations.</td></tr>
       <tr><td class="ad-table__code">test/</td><td>Minitest. Contrast floors, ramp monotonicity, CSS well-formedness.</td></tr>
     </tbody>
@@ -112,18 +115,19 @@ $ bundle exec rake serve   # http://127.0.0.1:4000
 
 ## How the example blocks work
 
-Every demo on this site is one Liquid block. The plugin renders its body
-twice — live into the page, and Rouge-highlighted into the source panel
-underneath.
+Every demo on this site is one call to an `example` ERB helper
+(`lib/arbitrary_definitions/example_helper.rb`). It captures its block's
+rendered output and renders that captured markup twice — live into the page,
+and Rouge-highlighted into the source panel underneath.
 
-{% raw %}
-```liquid
-{% example title="Variants" open %}
+<!--verbatim-->
+```erb
+<% example(title: "Variants", open: true) do %>
 <button type="button" class="ad-btn">Cancel</button>
 <button type="button" class="ad-btn ad-btn--primary">Save</button>
-{% endexample %}
+<% end %>
 ```
-{% endraw %}
+<!--/verbatim-->
 
 There is no second copy of the markup to keep in sync, which is the usual way
 component documentation starts lying about the component.
diff --git a/lib/arbitrary_definitions/dot.rb b/lib/arbitrary_definitions/dot.rb
new file mode 100644
index 0000000..ff99736
--- /dev/null
+++ b/lib/arbitrary_definitions/dot.rb
@@ -0,0 +1,69 @@
+# 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
+    # behaviour 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/example_helper.rb b/lib/arbitrary_definitions/example_helper.rb
new file mode 100644
index 0000000..bd96475
--- /dev/null
+++ b/lib/arbitrary_definitions/example_helper.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require "cgi/escape"
+require "rouge"
+
+module ArbitraryDefinitions
+  # `<%= example(title: "Variants", align: :center, ...) do %> ... <% end %>`
+  #
+  # Renders its block twice: once live, so the reader sees the component the
+  # browser actually paints, and once as highlighted source, so they can copy
+  # the exact markup. Writing the demo and the snippet separately is how
+  # design system docs start lying; this makes that impossible.
+  #
+  # This is the same logic that used to live in the Liquid `{% example %}`
+  # block (`_plugins/example_tag.rb`) — ported to a plain method that takes
+  # real keyword arguments and captures a real ERB block, since there is no
+  # Liquid markup string to parse any more.
+  #
+  # Options
+  #   title: "…"            caption above the preview
+  #   align: "start"|"center"|"end"      (default "start")
+  #   surface: "canvas"|"sunken"|"plain" (default "canvas")
+  #   layout: "row"|"stack"|"grid"       (default "row")
+  #   open: true             expand the source panel on load
+  #   no_source: true        preview only, for demos whose markup is noise
+  module ExampleHelper
+    ALIGNMENTS = %w[start center end].freeze
+    SURFACES   = %w[canvas sunken plain].freeze
+    LAYOUTS    = %w[row stack grid].freeze
+
+    # Called as `<% example(...) do %> ... <% end %>` — a statement, not
+    # `<%= ... %>`, because stock ERB (unlike Rails' Erubi) cannot compile an
+    # interpolated expression whose `do ... end` block spans template tags:
+    # the `<%=` tag closes its own `(...)` before the block body appears, so
+    # `_erbout << (example(...) do ).to_s` is what ERB would generate and it
+    # is a syntax error. Instead this writes its result directly into the
+    # same output buffer `capture` already knows how to reach.
+    def example(title: nil, align: nil, surface: nil, layout: nil, open: false, no_source: false, &block)
+      buffer = eval("_erbout", block.binding)
+      source = dedent(capture(&block).to_s)
+      return if source.strip.empty?
+
+      preview = <<~HTML.strip
+        <div class="ad-example__preview #{preview_classes(align, surface, layout)}">#{source}</div>
+      HTML
+
+      parts = []
+      parts << %(<figure class="ad-example">)
+      parts << %(<figcaption class="ad-example__title">#{escape_html(title)}</figcaption>) if title
+      parts << preview
+      parts << source_panel(source, open: open) unless no_source
+      parts << "</figure>"
+
+      buffer << parts.join("\n")
+      nil
+    end
+
+    # The well-known manual-capture pattern for plain ERB: the output buffer
+    # is a local variable (`_erbout`, ERB's default `eoutvar`) in whatever
+    # binding the surrounding template is being evaluated in, and a block
+    # literal written inside that template shares that same binding — so
+    # reaching into it lets a helper method capture exactly what its block
+    # wrote, the way Rails' `capture` does without Rails.
+    def capture(&block)
+      buffer = eval("_erbout", block.binding)
+      pos = buffer.length
+      block.call
+      result = buffer[pos..]
+      buffer.slice!(pos..)
+      result
+    end
+
+    private
+
+    def preview_classes(align, surface, layout)
+      align   = ALIGNMENTS.include?(align.to_s) ? align.to_s : "start"
+      surface = SURFACES.include?(surface.to_s) ? surface.to_s : "canvas"
+      layout  = LAYOUTS.include?(layout.to_s) ? layout.to_s : "row"
+
+      "ad-example__preview--#{align} ad-example__preview--#{surface} ad-example__preview--#{layout}"
+    end
+
+    def source_panel(source, open:)
+      lexer       = Rouge::Lexers::HTML.new
+      formatter   = Rouge::Formatters::HTML.new
+      highlighted = formatter.format(lexer.lex(source))
+
+      <<~HTML.strip
+        <details class="ad-example__source"#{open ? ' open' : ''}>
+        <summary class="ad-example__summary"><span>Markup</span></summary>
+        <div class="ad-example__code highlight">
+        <button type="button" class="ad-example__copy" data-copy aria-label="Copy markup to clipboard">Copy</button>
+        <pre><code class="language-html">#{highlighted}</code></pre>
+        </div>
+        </details>
+      HTML
+    end
+
+    # The captured block body carries whatever indentation the Markdown file
+    # used. Strip the common prefix so the copied snippet is not accidentally
+    # nested four levels deep.
+    def dedent(text)
+      lines  = text.sub(/\A\n+/, "").rstrip.lines
+      widths = lines.reject { |line| line.strip.empty? }
+                    .map { |line| line[/\A[ \t]*/].length }
+      indent = widths.min || 0
+
+      lines.map { |line| line.strip.empty? ? line.lstrip : line[indent..] }.join
+    end
+
+    def escape_html(value)
+      CGI.escapeHTML(value.to_s)
+    end
+  end
+end
diff --git a/lib/arbitrary_definitions/page_context.rb b/lib/arbitrary_definitions/page_context.rb
new file mode 100644
index 0000000..09a6985
--- /dev/null
+++ b/lib/arbitrary_definitions/page_context.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require "cgi/escape"
+require "erb"
+require_relative "color_math"
+require_relative "example_helper"
+
+module ArbitraryDefinitions
+  # Everything an ERB template can reach: `site`/`page` data (standing in for
+  # Liquid's `{{ site.* }}` / `{{ page.* }}`), the colour-maths helpers that
+  # used to be Liquid filters (`_plugins/color_filters.rb`), `relative_url`
+  # (Jekyll's `relative_url` filter), `render` for includes/partials, and the
+  # `example` block helper (`_plugins/example_tag.rb`).
+  #
+  # 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
+    include ExampleHelper
+
+    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
+
+    # Stands in for Jekyll's `relative_url` filter: prefixes `baseurl`,
+    # without doubling slashes.
+    def relative_url(path)
+      path = path.to_s
+      path = "/#{path}" unless path.start_with?("/")
+      "#{site.baseurl.to_s.chomp('/')}#{path}"
+    end
+
+    # -- former Liquid filters in _plugins/color_filters.rb -----------------
+
+    def contrast_with(value, other)
+      ratio = ColorMath.contrast_ratio(value, other)
+      return "—" unless ratio
+
+      format("%.2f", ratio)
+    end
+
+    def contrast_grade(value, other)
+      ColorMath.grade(ColorMath.contrast_ratio(value, other))
+    end
+
+    def readable_on(value)
+      ColorMath.readable_on(value)
+    end
+
+    def luminance_percent(value)
+      luminance = ColorMath.relative_luminance(value)
+      return "—" unless luminance
+
+      (luminance * 100).round
+    end
+
+    def hex_color?(value)
+      ColorMath.hex?(value)
+    end
+
+    def custom_property(value)
+      "--#{site.token_prefix}-#{value}"
+    end
+
+    def escape(value)
+      CGI.escapeHTML(value.to_s)
+    end
+
+    # -- includes -------------------------------------------------------
+
+    INCLUDES_DIR = File.expand_path("../../_includes", __dir__)
+
+    # Stands in for `{% include name.html %}`. `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
new file mode 100644
index 0000000..551b233
--- /dev/null
+++ b/lib/arbitrary_definitions/site_builder.rb
@@ -0,0 +1,241 @@
+# frozen_string_literal: true
+
+require "yaml"
+require "erb"
+require "fileutils"
+require "time"
+
+# 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 "token_set"
+require_relative "dot"
+require_relative "page_context"
+
+module ArbitraryDefinitions
+  # The whole static site build: reads `_config.yml` and `_data/`, compiles
+  # the tokens and Sass, 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")
+
+    # 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"))
+      @token_set = TokenSet.new(dir: File.join(ROOT, "_data", "tokens"),
+                                 prefix: @config.fetch("token_prefix", "ad"))
+      @site      = build_site
+    end
+
+    def build
+      FileUtils.rm_rf(SITE_DIR)
+      FileUtils.mkdir_p(SITE_DIR)
+
+      write_tokens_css
+      compile_sass
+      copy_assets
+      collect_pages.each { |page_source| render_page(page_source) }
+    end
+
+    private
+
+    def build_site
+      nav  = YAML.safe_load_file(File.join(ROOT, "_data", "nav.yml"))
+      meta = YAML.safe_load_file(File.join(ROOT, "_data", "meta.yml"))
+
+      component_names = Dir.glob(File.join(ROOT, "_components", "*.md")).map { |path| File.basename(path, ".md") }
+
+      data = {
+        "meta" => meta,
+        "nav" => nav,
+        "tokens" => {
+          "color" => { "ramps" => @token_set.ramps },
+          "semantic" => { "groups" => @token_set.semantic },
+          "scale" => @token_set.scale,
+          "contrast" => @token_set.contrast,
+        },
+        "semantic_flat" => @token_set.semantic_tokens,
+        "tokens_css_size" => @token_set.to_css.bytesize,
+        "build" => {
+          "ruby" => RUBY_VERSION,
+          "ramps" => @token_set.ramps.size,
+          "aliases" => @token_set.semantic_tokens.size,
+          "generated_at" => Time.now.utc.strftime("%Y-%m-%d"),
+        },
+      }
+
+      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"),
+        token_prefix: @token_set.prefix,
+        components: component_names,
+        data: Dot.wrap(data),
+      )
+    end
+
+    Site = Struct.new(:title, :tagline, :description, :url, :baseurl, :repository, :lang,
+                       :token_prefix, :components, :data, keyword_init: true)
+
+    def write_tokens_css
+      write(File.join(SITE_DIR, "assets", "css", "tokens.css"), @token_set.to_css)
+    end
+
+    def compile_sass
+      require "sass-embedded"
+
+      source = File.read(File.join(ROOT, "assets", "css", "style.scss"))
+      result = Sass.compile_string(source, load_paths: [SASS_DIR], style: :compressed)
+      write(File.join(SITE_DIR, "assets", "css", "style.css"), result.css)
+    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
+
+    # A page that wants to *show* `<%= ... %>` syntax as documentation text —
+    # `install.md` demonstrating the `example` helper — needs it kept out of
+    # ERB's own evaluation, the same job Liquid's `{% raw %}` used to do.
+    # `<!--verbatim--> ... <!--/verbatim-->` marks such a span; its contents
+    # are stashed before the ERB pass and spliced back in afterwards.
+    VERBATIM = /<!--verbatim-->(.*?)<!--\/verbatim-->/m
+
+    def extract_verbatim(body)
+      blocks = []
+      protected_body = body.gsub(VERBATIM) do
+        blocks << Regexp.last_match(1)
+        " VERBATIM#{blocks.size - 1} "
+      end
+      [protected_body, blocks]
+    end
+
+    def restore_verbatim(text, blocks)
+      text.gsub(/ VERBATIM(\d+) /) { blocks[Regexp.last_match(1).to_i] }
+    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)
+
+      protected_body, verbatim_blocks = extract_verbatim(page_source.body)
+      rendered = ERB.new(protected_body, trim_mode: "-").result(context.erb_binding)
+      rendered = restore_verbatim(rendered, verbatim_blocks)
+
+      html_body = Kramdown::Document.new(
+        rendered,
+        input: "GFM",
+        syntax_highlighter: "rouge",
+        syntax_highlighter_opts: { default_lang: "html" },
+      ).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/rationale.md b/rationale.md
index a430122..4830b3c 100644
--- a/rationale.md
+++ b/rationale.md
@@ -100,10 +100,10 @@ and it does not care what the brand guidelines say.
 
 So it is the one thing the build refuses to take on trust.
 `_data/tokens/contrast.yml` names
-{{ site.data.tokens.contrast.text.pairs | size }} pairings that have to hold;
+<%= site.data.tokens.contrast.text.pairs.size %> pairings that have to hold;
 `lib/arbitrary_definitions/color_math.rb` computes WCAG relative luminance
 from the token values, and `test/test_token_set.rb` asserts every one of them
-in both themes. The [contract table]({{ '/foundations/color/' | relative_url }}#contrast-that-has-to-hold)
+in both themes. The [contract table](<%= relative_url('/foundations/color/') %>#contrast-that-has-to-hold)
 is rendered from that same file. If a token change drops any pairing below
 its floor, `rake test` fails and the site does not deploy.
 
@@ -117,7 +117,7 @@ That contract is deliberately narrower than "every colour in the system".
 about 3.46:1 against the page in light mode, under the 4.5:1 floor for normal
 text. That is not a bug the build should paper over by darkening a real brand
 value; it is a documented, intentional property of the palette (see the
-[Colour page]({{ '/foundations/color/' | relative_url }})), so the contract
+[Colour page](<%= relative_url('/foundations/color/') %>)), so the contract
 excludes it rather than silently forcing it to pass. Borders get the same
 treatment for the same reason: the source's own rule is that hairline borders
 do the elevation work, not high contrast, so this system does not invent a
diff --git a/script/build_design_bundle.rb b/script/build_design_bundle.rb
index 797bc19..856489d 100644
--- a/script/build_design_bundle.rb
+++ b/script/build_design_bundle.rb
@@ -9,7 +9,7 @@
 # same reason the docs are — a second, hand-maintained copy of the system would
 # start disagreeing with the first one immediately.
 #
-#   bundle exec jekyll build && ruby script/build_design_bundle.rb
+#   bundle exec rake build && ruby script/build_design_bundle.rb
 #
 # Output lands in design-bundle/, which is gitignored: it is a build artifact.
 
@@ -55,7 +55,7 @@ CARDS = [
 
 def read_site(path)
   full = File.join(SITE, path)
-  abort "missing #{full} — run `bundle exec jekyll build` first" unless File.file?(full)
+  abort "missing #{full} — run `bundle exec rake build` first" unless File.file?(full)
   File.read(full, encoding: "UTF-8")
 end