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