Add the Arbitrary Definitions design system and its Pages site
Builds a small design system — six colour ramps, 34 semantic aliases, ten
type sizes, and eight components — plus a Jekyll site that documents it,
deployed to GitHub Pages from Actions on Ruby 4.0.
Everything derives from one source. `_data/tokens/*.yml` holds every value in
the system; `_plugins/token_generator.rb` compiles it into
`assets/css/tokens.css` (a `:root` block, both dark-theme selectors, and a
`prefers-reduced-motion` override), and the documentation tables and swatches
loop over the same YAML. Contrast figures 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 come from
a `{% example %}` Liquid block that emits the live component and its
highlighted source from one string.
`lib/` has no Jekyll dependency, so the token compiler and the colour maths are
unit tested on their own. The suite is the deploy gate: it asserts WCAG AA on
sixteen text pairings and 3:1 on two non-text pairings in both themes, that
every ramp darkens monotonically, that every semantic token resolves in both
themes and documents its usage, and that no unresolved token reference reaches
the stylesheet.
Those assertions earned their keep while the system was being built. The accent
started at persimmon 600 and failed at 4.17:1, so it moved to 700 and ink
500–800 were darkened. The dark theme exposed two more: the destructive button
was filled with `--ad-color-critical`, a text colour that goes pale in dark
mode, which needed a separate `--ad-color-critical-solid`; and
`.ad-badge--solid` set `--_bg: var(--_fg)` in the same rule, which resolves
against the final value of `--_fg` and rendered solid badges invisible.
Also fixes a column-wrap flex container sizing its single line against the
container rather than its content, which left every stacked example with a few
hundred pixels of dead space.
The `_plugins/` directory rules out GitHub's hosted Jekyll build, so
.github/workflows/pages.yml builds with ruby/setup-ruby and uploads via
actions/deploy-pages. Enabling it needs Settings -> Pages -> Source: GitHub
Actions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TL4uoCCvX2CqHWT8p3tLd7
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..209b1f7
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,77 @@
+name: Build and deploy to Pages
+
+on:
+ push:
+ branches: [main]
+ 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.
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Never cancel a deploy midway; queue the next one instead.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Ruby 4.0
+ uses: ruby/setup-ruby@v1
+ with:
+ # Resolves to the latest 4.0.x the runner has, so a patch release
+ # does not need a commit here.
+ ruby-version: "4.0"
+ bundler-cache: true
+
+ - name: Report the toolchain
+ run: |
+ ruby -v
+ bundle exec jekyll -v
+
+ # The token tests are the deploy gate: they assert the contrast floors,
+ # that every ramp darkens monotonically, and that no unresolved token
+ # reference reaches the stylesheet.
+ - name: Test the token compiler
+ run: bundle exec rake test
+
+ - name: Configure Pages
+ id: pages
+ uses: actions/configure-pages@v5
+
+ - name: Build
+ env:
+ JEKYLL_ENV: production
+ run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
+
+ - name: Check the build output
+ run: |
+ test -s _site/index.html
+ test -s _site/assets/css/tokens.css
+ grep -q -- '--ad-color-accent:' _site/assets/css/tokens.css
+ # A reference that failed to resolve would appear verbatim in the CSS.
+ ! grep -Eq ':[[:space:]]*[a-z]+\.[a-z0-9]+;' _site/assets/css/tokens.css
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+
+ deploy:
+ needs: build
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2597211
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+_site/
+.jekyll-cache/
+.jekyll-metadata
+.sass-cache/
+vendor/bundle/
+.bundle/
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..d13e837
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4.0.6
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..e9fd616
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+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.
+ruby "~> 4.0"
+
+gem "jekyll", "~> 4.4"
+
+group :jekyll_plugins do
+ gem "jekyll-seo-tag", "~> 2.8"
+ gem "jekyll-sitemap", "~> 1.4"
+end
+
+# 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.
+gem "base64", "~> 0.2"
+gem "benchmark", "~> 0.4"
+gem "bigdecimal", "~> 3.1"
+gem "csv", "~> 3.3"
+gem "logger", "~> 1.6"
+gem "ostruct", "~> 0.6"
+
+group :development, :test do
+ gem "minitest", "~> 5.25"
+ gem "rake", "~> 13.2"
+ gem "webrick", "~> 1.9"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..2e8072f
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,258 @@
+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)
+ google-protobuf (4.36.0-aarch64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-aarch64-linux-musl)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-arm64-darwin)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86-linux-musl)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86_64-darwin)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.0-x86_64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ 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)
+ google-protobuf (~> 4.31)
+ rake (>= 13)
+ sass-embedded (1.103.0-aarch64-linux-android)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-aarch64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-aarch64-linux-musl)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-arm-linux-androideabi)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-arm-linux-gnueabihf)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-arm-linux-musleabihf)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-arm64-darwin)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-riscv64-linux-android)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-riscv64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-riscv64-linux-musl)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-x86_64-darwin)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-x86_64-linux-android)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-x86_64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.103.0-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
+ aarch64-linux-android
+ 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
+ riscv64-linux-gnu
+ riscv64-linux-musl
+ ruby
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
+ x86_64-linux-android
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ base64 (~> 0.2)
+ benchmark (~> 0.4)
+ bigdecimal (~> 3.1)
+ csv (~> 3.3)
+ jekyll (~> 4.4)
+ jekyll-seo-tag (~> 2.8)
+ jekyll-sitemap (~> 1.4)
+ logger (~> 1.6)
+ minitest (~> 5.25)
+ ostruct (~> 0.6)
+ rake (~> 13.2)
+ 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
+ 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
+ google-protobuf (4.36.0-arm64-darwin) sha256=be182aac1cf55ccbbd27ec5a94d5b012069075f5c879d65423d33e6b2dec86f5
+ google-protobuf (4.36.0-x86-linux-gnu) sha256=34c327aaf29e51db012e537cce9d2e38359d9ec96cb4a699eaf47231db6020ab
+ google-protobuf (4.36.0-x86-linux-musl) sha256=621b53fd93f88cb68d3b3d25e5ecda8f9ea2977f86759c1badb701de5ae371d6
+ 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
+ webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
+
+RUBY VERSION
+ ruby 4.0.6
+
+BUNDLED WITH
+ 4.0.16
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6772805
--- /dev/null
+++ b/README.md
@@ -0,0 +1,94 @@
+# Arbitrary Definitions
+
+A small design system — colour, type, space, elevation, motion, and eight
+components — documented by a Jekyll site 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/
+
+## The idea
+
+Almost every value in a design system is arbitrary. The base unit is 4px because
+4 divides nicely; the accent is this orange because somebody liked it. What makes
+it a *system* is not that the choices are correct but that they are written down
+once, given names, actually used, and therefore changeable.
+
+So this repository has exactly one source of truth — three 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.
+- 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 `{% example %}` Liquid block that outputs the
+ live component and its highlighted source from one 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-*)`.
+
+## Requirements
+
+Ruby 4.0 (`.ruby-version` pins 4.0.6) and Bundler.
+
+```console
+$ bundle install
+$ bundle exec rake # tests, then build
+$ 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:
+
+- ten text pairings clear WCAG AA in **both** themes, and two non-text pairings
+ clear 3:1;
+- every ramp darkens monotonically from step to step;
+- every semantic token resolves in both themes and documents its usage;
+- no unresolved `ramp.step` reference reaches the generated CSS, and its braces
+ balance.
+
+```console
+$ bundle exec rake test
+23 runs, 199 assertions, 0 failures, 0 errors, 0 skips
+```
+
+Those assertions are not decorative. The accent started at `persimmon.600` and
+the suite caught it at 4.17:1 against white — under the 4.5:1 floor — so it moved
+to `persimmon.700`, and steps 500–800 of the ink ramp were darkened for the same
+reason.
+
+## Layout
+
+| Path | What it is |
+| --- | --- |
+| `_data/tokens/` | Every value in the system: ramps, semantic aliases, scales. |
+| `lib/arbitrary_definitions/` | Token compiler and WCAG colour maths. No Jekyll dependency. |
+| `_plugins/` | Jekyll wiring: the generator, Liquid filters, the `example` block. |
+| `_sass/` | Base, components, and the documentation site's own chrome. |
+| `_foundations/`, `_components/`, `_patterns/` | The documentation collections. |
+| `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`. Enable it
+under **Settings → Pages → Build and deployment → Source: GitHub Actions**.
+
+## Using the CSS elsewhere
+
+Two files, in this order, and no JavaScript:
+
+```html
+<link rel="stylesheet" href="tokens.css">
+<link rel="stylesheet" href="style.css">
+```
+
+Retheming means overriding a dozen custom properties after `tokens.css` loads —
+no recompile, no fork. See [Using the CSS](https://kejadlen.github.io/arbitrary-definitions-design-system/install/).
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..772f2fd
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "rake/testtask"
+
+Rake::TestTask.new(:test) do |t|
+ t.libs << "lib" << "test"
+ t.test_files = FileList["test/test_*.rb"]
+ t.warning = false
+end
+
+desc "Build the site into _site"
+task :build do
+ sh "bundle exec jekyll build"
+end
+
+desc "Serve the site with live reload on http://127.0.0.1:4000"
+task :serve do
+ sh "bundle exec jekyll serve --livereload"
+end
+
+desc "Run the token and colour tests, then build"
+task default: %i[test build]
diff --git a/_components/badge.md b/_components/badge.md
new file mode 100644
index 0000000..c128f3a
--- /dev/null
+++ b/_components/badge.md
@@ -0,0 +1,83 @@
+---
+title: Badge
+summary: >-
+ A label with a background. Six tones, two shapes, and a dot for the times when
+ colour alone will not do.
+---
+
+## Tones
+
+{% example title="Tones" open %}
+<span class="ad-badge">Neutral</span>
+<span class="ad-badge ad-badge--accent">Accent</span>
+<span class="ad-badge ad-badge--positive">Positive</span>
+<span class="ad-badge ad-badge--caution">Caution</span>
+<span class="ad-badge ad-badge--critical">Critical</span>
+<span class="ad-badge ad-badge--info">Info</span>
+{% endexample %}
+
+Each tone pulls the whole status triple — foreground, tint, edge — from the same
+tokens the callout uses. A caution badge and a caution callout on the same page
+cannot drift apart, because there is only one definition of caution.
+
+## Shapes
+
+{% example title="Square and pill, outline and solid" %}
+<span class="ad-badge ad-badge--positive">Passing</span>
+<span class="ad-badge ad-badge--positive ad-badge--pill">Passing</span>
+<span class="ad-badge ad-badge--positive ad-badge--solid">Passing</span>
+<span class="ad-badge ad-badge--positive ad-badge--pill ad-badge--solid">Passing</span>
+{% endexample %}
+
+Square badges read as metadata attached to something. Pills read as free-standing
+tags. Pick one per surface and stay with it.
+
+## Status with a dot
+
+Colour on its own excludes anyone who cannot distinguish the hues, and it also
+fails on a greyscale print. The dot is not decoration — it is a second channel
+that a text label can attach to.
+
+{% example title="Status" %}
+<span class="ad-badge ad-badge--positive"><span class="ad-badge__dot"></span>Deployed</span>
+<span class="ad-badge ad-badge--caution"><span class="ad-badge__dot"></span>Degraded</span>
+<span class="ad-badge ad-badge--critical"><span class="ad-badge__dot"></span>Down</span>
+<span class="ad-badge"><span class="ad-badge__dot"></span>Unknown</span>
+{% endexample %}
+
+## In context
+
+{% example title="Badges in a table" layout="stack" %}
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--hover">
+ <thead>
+ <tr><th scope="col">Token</th><th scope="col">Status</th><th scope="col">Since</th></tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td class="ad-table__code">--ad-color-accent</td>
+ <td><span class="ad-badge ad-badge--positive"><span class="ad-badge__dot"></span>Stable</span></td>
+ <td>v1.0.0</td>
+ </tr>
+ <tr>
+ <td class="ad-table__code">--ad-color-surface-hover</td>
+ <td><span class="ad-badge ad-badge--caution"><span class="ad-badge__dot"></span>Experimental</span></td>
+ <td>v1.0.0</td>
+ </tr>
+ <tr>
+ <td class="ad-table__code">--ad-shadow-soft</td>
+ <td><span class="ad-badge ad-badge--critical"><span class="ad-badge__dot"></span>Removed</span></td>
+ <td>v0.9.0</td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+{% endexample %}
+
+## Rules
+
+- **A badge is never interactive.** No click handlers, no `<button>`, no cursor
+ change. If it does something, it is a button that happens to be small.
+- **Two words at most.** A badge that wraps is a sentence in the wrong clothes.
+- **Solid badges are for counts and hard states**, outline for everything else.
+ A page of solid badges is a page of alarms.
diff --git a/_components/button.md b/_components/button.md
new file mode 100644
index 0000000..d7b0aed
--- /dev/null
+++ b/_components/button.md
@@ -0,0 +1,127 @@
+---
+title: Button
+summary: >-
+ Four variants, three sizes, one geometry. Every variant sets three private
+ custom properties and inherits everything else, so a mixed row still aligns.
+---
+
+## Variants
+
+Rank them by how much you want the reader to do them. One primary per view, at
+most. If a screen has two primary buttons, one of them is lying.
+
+{% example title="Variants" open %}
+<button type="button" class="ad-btn ad-btn--primary">Publish</button>
+<button type="button" class="ad-btn ad-btn--secondary">Save draft</button>
+<button type="button" class="ad-btn ad-btn--ghost">Cancel</button>
+<button type="button" class="ad-btn ad-btn--danger">Delete</button>
+{% endexample %}
+
+## Sizes
+
+{% example title="Sizes" %}
+<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 %}
+
+The three sizes exist for three contexts: `sm` inside a table row or a card
+footer, the default in a form, `lg` for a single call to action in a hero. They
+are not a way to signal importance — that is what variants are for.
+
+## States
+
+Disabled buttons drop to 45% opacity and keep their variant colours, so a
+disabled primary still reads as the primary. The loading state replaces the
+label with a spinner without changing the button's width, which stops the row
+from reflowing mid-click.
+
+{% example title="States" %}
+<button type="button" class="ad-btn ad-btn--primary" disabled>Disabled</button>
+<button type="button" class="ad-btn ad-btn--secondary" aria-disabled="true">Not yet available</button>
+<button type="button" class="ad-btn ad-btn--primary" data-demo-loading>Click to load</button>
+{% endexample %}
+
+<div class="ad-callout ad-callout--caution">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title"><code>disabled</code> versus <code>aria-disabled</code></p>
+ <p style="margin:0">
+ A <code>disabled</code> button is removed from the tab order, so a reader
+ navigating by keyboard never learns it is there. When the button is
+ temporarily unavailable and the reason matters, use
+ <code>aria-disabled="true"</code> instead: it stays focusable, still
+ announces as disabled, and can carry an explanation.
+ </p>
+ </div>
+</div>
+
+## With an icon
+
+Icons sit before the label. The gap is `--ad-space-2`, set by the button itself,
+so no icon needs its own margin.
+
+{% example title="Icons" %}
+<button type="button" class="ad-btn ad-btn--secondary">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
+ </svg>
+ Add token
+</button>
+<button type="button" class="ad-btn ad-btn--secondary ad-btn--icon" aria-label="Open settings">
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
+ <path d="M12 2.5v3M12 18.5v3M3.6 7.2l2.6 1.5M17.8 15.3l2.6 1.5M3.6 16.8l2.6-1.5M17.8 8.7l2.6-1.5"
+ stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+ </svg>
+</button>
+{% endexample %}
+
+An icon-only button always needs an `aria-label`. There is no visible text to
+fall back on, and a tooltip is not an accessible name.
+
+## Groups
+
+{% example title="Button group" %}
+<div class="ad-btn-group" role="group" aria-label="Text alignment">
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm">Left</button>
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm">Centre</button>
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm">Right</button>
+</div>
+{% endexample %}
+
+## Full width
+
+{% example title="Block" layout="stack" %}
+<button type="button" class="ad-btn ad-btn--primary ad-btn--block">Continue</button>
+<button type="button" class="ad-btn ad-btn--ghost ad-btn--block">Use a different account</button>
+{% endexample %}
+
+## Links that look like buttons
+
+An `<a>` with `.ad-btn` is fine when the thing it does is navigate. A `<button>`
+is required when it changes something. Getting this backwards breaks
+middle-click, Cmd-click, and the reader's expectations all at once.
+
+{% example title="Anchor as button" %}
+<a class="ad-btn ad-btn--primary" href="{{ '/components/field/' | relative_url }}">Next: Field</a>
+<a class="ad-btn ad-btn--ghost" href="{{ '/foundations/color/' | relative_url }}">Back to colour</a>
+{% endexample %}
+
+## API
+
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--compact">
+ <thead><tr><th scope="col">Class</th><th scope="col">Effect</th></tr></thead>
+ <tbody>
+ <tr><td class="ad-token-name">.ad-btn</td><td>Required base. Sets geometry, focus, and transitions.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--primary</td><td>Filled accent. One per view.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--secondary</td><td>Bordered surface. The workhorse.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--ghost</td><td>No border, muted label. For dismissals and tertiary actions.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--danger</td><td>Filled critical. Only for destructive, irreversible actions.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--sm / --lg</td><td>Height, padding, and font size in one step.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--icon</td><td>Square footprint for a lone glyph. Needs an <code>aria-label</code>.</td></tr>
+ <tr><td class="ad-token-name">.ad-btn--block</td><td>Full width of its container.</td></tr>
+ <tr><td class="ad-token-name">[data-loading="true"]</td><td>Hides the label and shows a spinner at the same width.</td></tr>
+ </tbody>
+ </table>
+</div>
diff --git a/_components/callout.md b/_components/callout.md
new file mode 100644
index 0000000..08f8160
--- /dev/null
+++ b/_components/callout.md
@@ -0,0 +1,105 @@
+---
+title: Callout
+summary: >-
+ Block-level messaging with an icon, a title, and a body. Five tones, the same
+ five the badge uses.
+---
+
+## Tones
+
+{% example title="Tones" layout="stack" open %}
+<div class="ad-callout ad-callout--info">
+ <svg class="ad-callout__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <circle cx="12" cy="12" r="9.5" stroke="currentColor" stroke-width="2"/>
+ <path d="M12 11v6M12 7.4v.2" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
+ </svg>
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Tokens rebuild on every save</p>
+ <p style="margin:0">Editing a hex in <code>_data/tokens/color.yml</code> updates the stylesheet, the swatch, and the contrast figure together.</p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--positive">
+ <svg class="ad-callout__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <circle cx="12" cy="12" r="9.5" stroke="currentColor" stroke-width="2"/>
+ <path d="m7.8 12.3 2.7 2.7 5.7-5.7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Contrast contract holding</p>
+ <p style="margin:0">All ten text pairings clear WCAG AA in both themes.</p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--caution">
+ <svg class="ad-callout__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <path d="M12 3.6 21.4 20H2.6L12 3.6Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
+ <path d="M12 10v4M12 17.2v.2" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
+ </svg>
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">This ramp has no 150 step</p>
+ <p style="margin:0">Ink defines one because its light end needed the extra resolution. The other ramps do not.</p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--critical" role="alert">
+ <svg class="ad-callout__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <circle cx="12" cy="12" r="9.5" stroke="currentColor" stroke-width="2"/>
+ <path d="m8.6 8.6 6.8 6.8M15.4 8.6l-6.8 6.8" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
+ </svg>
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Build failed: unknown token reference</p>
+ <p style="margin:0"><code>persimmon.55</code> is not a step in the persimmon ramp.</p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--neutral">
+ <div class="ad-callout__body">
+ <p style="margin:0">A neutral callout with no icon and no title — for an aside that is not news, just context.</p>
+ </div>
+</div>
+{% endexample %}
+
+## Without an icon
+
+The icon column collapses on its own when there is no icon, so a bare callout
+needs no extra class.
+
+{% example title="Body only" layout="stack" %}
+<div class="ad-callout ad-callout--accent">
+ <div class="ad-callout__body">
+ <p style="margin:0">
+ Every number on the colour page is computed during the build by
+ <code>lib/arbitrary_definitions/color_math.rb</code>. Nothing on that page
+ was typed by a person.
+ </p>
+ </div>
+</div>
+{% endexample %}
+
+## Announcing a callout
+
+A callout that appears in response to something the reader did needs
+`role="alert"` (for errors) or `role="status"` (for confirmations), or a screen
+reader will never mention it. A callout that was on the page from the start
+needs neither, and adding one makes the page noisier for no benefit.
+
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--compact">
+ <thead><tr><th scope="col">Situation</th><th scope="col">Role</th></tr></thead>
+ <tbody>
+ <tr><td>Static guidance in a document</td><td class="ad-table__code">none</td></tr>
+ <tr><td>Confirmation after a successful action</td><td class="ad-table__code">status</td></tr>
+ <tr><td>Error after a failed action</td><td class="ad-table__code">alert</td></tr>
+ <tr><td>A warning present before the reader acts</td><td class="ad-table__code">none</td></tr>
+ </tbody>
+ </table>
+</div>
+
+## Rules
+
+- **One callout per section.** Three stacked callouts are a wall, and readers
+ skip walls.
+- **The title is the message.** If the reader only reads the title, they should
+ still know what happened.
+- **Critical is for things that failed**, not for things that are merely
+ important. Importance is what the surrounding prose is for.
diff --git a/_components/card.md b/_components/card.md
new file mode 100644
index 0000000..d7697da
--- /dev/null
+++ b/_components/card.md
@@ -0,0 +1,103 @@
+---
+title: Card
+summary: >-
+ A bordered sheet with an optional media area, a body, and a footer. Elevation
+ is opt-in.
+---
+
+## Anatomy
+
+{% example title="Card" layout="stack" open %}
+<article class="ad-card" style="max-width: 22rem;">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">Persimmon 700</h3>
+ <p class="ad-card__text">
+ The light theme's accent. Clears AA against both the canvas and white,
+ which the 600 step did not.
+ </p>
+ </div>
+ <div class="ad-card__footer">
+ <span class="ad-badge ad-badge--accent">Accent</span>
+ <span class="ad-subtle" style="font-size: var(--ad-text-xs);">#c2260c</span>
+ </div>
+</article>
+{% endexample %}
+
+## Grids
+
+`.ad-card-grid` is `repeat(auto-fill, minmax(15rem, 1fr))`. Cards in it stretch
+to equal height, and `margin-top: auto` on the footer pins footers to the bottom
+regardless of how much body copy each card has.
+
+{% example title="A grid of uneven cards" layout="stack" surface="sunken" %}
+<div class="ad-card-grid">
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">Short</h3>
+ <p class="ad-card__text">One line.</p>
+ </div>
+ <div class="ad-card__footer"><button type="button" class="ad-btn ad-btn--ghost ad-btn--sm">Open</button></div>
+ </article>
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">Longer</h3>
+ <p class="ad-card__text">
+ Enough copy here to push this card taller than its neighbour, which is
+ exactly the case that breaks a grid built on floats.
+ </p>
+ </div>
+ <div class="ad-card__footer"><button type="button" class="ad-btn ad-btn--ghost ad-btn--sm">Open</button></div>
+ </article>
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">Middling</h3>
+ <p class="ad-card__text">Two lines of copy, which lands between the other two.</p>
+ </div>
+ <div class="ad-card__footer"><button type="button" class="ad-btn ad-btn--ghost ad-btn--sm">Open</button></div>
+ </article>
+</div>
+{% endexample %}
+
+## Clickable cards
+
+Do not wrap a whole card in an `<a>`. It makes the entire block one enormous
+link target, which is miserable to read with a screen reader and impossible to
+select text inside. Put the link on the heading and let `.ad-card--link` handle
+the hover and focus affordance for the card around it.
+
+{% example title="Linked card" layout="stack" %}
+<article class="ad-card ad-card--link" style="max-width: 22rem;">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">
+ <a href="{{ '/foundations/elevation/' | relative_url }}">Elevation</a>
+ </h3>
+ <p class="ad-card__text">
+ The heading is the link. The card lights up on hover and shows a focus ring
+ when the link inside it is focused, via <code>:focus-within</code>.
+ </p>
+ </div>
+</article>
+{% endexample %}
+
+## Avatars
+
+{% example title="Avatars" %}
+<span class="ad-avatar ad-avatar--sm">ad</span>
+<span class="ad-avatar">ad</span>
+<span class="ad-avatar ad-avatar--lg">ad</span>
+<span class="ad-avatar-stack">
+ <span class="ad-avatar ad-avatar--sm">ka</span>
+ <span class="ad-avatar ad-avatar--sm">mr</span>
+ <span class="ad-avatar ad-avatar--sm">tp</span>
+</span>
+{% endexample %}
+
+Stacked avatars overlap by half a step and carry a 2px ring in the surface
+colour, so the stack still reads on any background the card can sit on.
+
+## Rules
+
+- **A card is a container, not a component.** It has no opinion about what goes
+ inside it beyond spacing.
+- **Border or shadow, not both** — see [Elevation]({{ '/foundations/elevation/' | relative_url }}).
+- **The heading is the link.** Never the whole card.
diff --git a/_components/field.md b/_components/field.md
new file mode 100644
index 0000000..b29b684
--- /dev/null
+++ b/_components/field.md
@@ -0,0 +1,166 @@
+---
+title: Field
+summary: >-
+ Label, control, hint, error. The wrapper owns the spacing so that a text input,
+ a select, and a group of checkboxes all line up in the same form.
+---
+
+## Anatomy
+
+Every field is a `<label>` (or a `<fieldset>` with a `<legend>`), a control, and
+optionally a hint and an error. The wrapper is a flex column with
+`--ad-space-2` between the parts, which is why controls of different heights
+still produce an even form.
+
+{% example title="A field with a hint" layout="stack" open %}
+<div class="ad-field" style="max-width: 22rem;">
+ <label class="ad-field__label" for="token-name">
+ Token name <span class="ad-field__required" aria-hidden="true">*</span>
+ </label>
+ <input class="ad-input" id="token-name" name="token-name" type="text"
+ placeholder="color-surface-sunken" aria-describedby="token-name-hint" required>
+ <p class="ad-field__hint" id="token-name-hint">
+ Lowercase, hyphenated, no prefix — the build adds <code>--ad-</code>.
+ </p>
+</div>
+{% endexample %}
+
+## Controls
+
+{% example title="Text, textarea, select" layout="stack" %}
+<div class="ad-form">
+ <div class="ad-field">
+ <label class="ad-field__label" for="demo-text">Display name</label>
+ <input class="ad-input" id="demo-text" type="text" value="Persimmon">
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="demo-select">Ramp</label>
+ <select class="ad-select" id="demo-select">
+ <option>ink</option>
+ <option selected>persimmon</option>
+ <option>verdant</option>
+ <option>amber</option>
+ </select>
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="demo-textarea">Rationale</label>
+ <textarea class="ad-textarea" id="demo-textarea"
+ placeholder="Why this colour and not the one next to it?"></textarea>
+ <p class="ad-field__hint">Optional, but the next person will thank you.</p>
+ </div>
+</div>
+{% endexample %}
+
+## Validation
+
+Invalid state lives on the control via `aria-invalid`, not on the wrapper. That
+way it works with native constraint validation and it is announced, not merely
+coloured. The error message is wired up with `aria-describedby`.
+
+{% example title="Invalid" layout="stack" %}
+<div class="ad-field" style="max-width: 22rem;">
+ <label class="ad-field__label" for="demo-invalid">Hex value</label>
+ <input class="ad-input" id="demo-invalid" type="text" value="persimmon-ish"
+ aria-invalid="true" aria-describedby="demo-invalid-error">
+ <p class="ad-field__error" id="demo-invalid-error">
+ Enter a six-digit hex, like <code>#ea360c</code>.
+ </p>
+</div>
+{% endexample %}
+
+## Disabled
+
+{% example title="Disabled" layout="stack" %}
+<div class="ad-form">
+ <div class="ad-field">
+ <label class="ad-field__label" for="demo-disabled">Inherited from the parent theme</label>
+ <input class="ad-input" id="demo-disabled" type="text" value="--ad-color-canvas" disabled>
+ </div>
+ <label class="ad-choice">
+ <input type="checkbox" disabled checked>
+ <span class="ad-choice__text">Locked by the theme<span class="ad-choice__hint">Unlock the theme to edit this.</span></span>
+ </label>
+</div>
+{% endexample %}
+
+## Choices
+
+Checkboxes and radios share `.ad-choice`, which puts the box on the first line
+of a multi-line label instead of centring it against the whole block. Grouped
+choices go in a `<fieldset>` so the group has a name.
+
+{% example title="Checkbox and radio groups" layout="stack" %}
+<fieldset class="ad-fieldset">
+ <legend>Emit</legend>
+ <label class="ad-choice">
+ <input type="checkbox" checked>
+ <span class="ad-choice__text">CSS custom properties<span class="ad-choice__hint">One <code>:root</code> block plus both dark selectors.</span></span>
+ </label>
+ <label class="ad-choice">
+ <input type="checkbox">
+ <span class="ad-choice__text">JSON<span class="ad-choice__hint">For tooling that cannot read CSS.</span></span>
+ </label>
+</fieldset>
+
+<fieldset class="ad-fieldset">
+ <legend>Default theme</legend>
+ <label class="ad-choice"><input type="radio" name="demo-theme" checked> <span class="ad-choice__text">Follow the system</span></label>
+ <label class="ad-choice"><input type="radio" name="demo-theme"> <span class="ad-choice__text">Always light</span></label>
+ <label class="ad-choice"><input type="radio" name="demo-theme"> <span class="ad-choice__text">Always dark</span></label>
+</fieldset>
+{% endexample %}
+
+## Switch
+
+A switch takes effect immediately. A checkbox waits for a submit. If your switch
+needs a Save button, it should have been a checkbox.
+
+{% example title="Switch" layout="stack" %}
+<label class="ad-switch">
+ <input type="checkbox" checked>
+ <span class="ad-switch__track"></span>
+ <span>Rebuild tokens on save</span>
+</label>
+<label class="ad-switch">
+ <input type="checkbox">
+ <span class="ad-switch__track"></span>
+ <span>Warn on contrast regressions</span>
+</label>
+<label class="ad-switch">
+ <input type="checkbox" disabled>
+ <span class="ad-switch__track"></span>
+ <span class="ad-subtle">Publish to npm (not configured)</span>
+</label>
+{% endexample %}
+
+## Rows
+
+`.ad-form__row` is a `repeat(auto-fit, minmax(11rem, 1fr))` grid, so fields sit
+side by side when there is room and stack when there is not. No breakpoint
+needed.
+
+{% example title="Two up" layout="stack" %}
+<div class="ad-form" style="max-width: none;">
+ <div class="ad-form__row">
+ <div class="ad-field">
+ <label class="ad-field__label" for="demo-from">Ramp</label>
+ <input class="ad-input" id="demo-from" type="text" value="persimmon">
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="demo-to">Step</label>
+ <input class="ad-input" id="demo-to" type="text" value="600">
+ </div>
+ </div>
+</div>
+{% endexample %}
+
+## Rules
+
+- **Every control has a real `<label>`.** A placeholder is not a label; it
+ disappears the moment someone types.
+- **Hints go in `aria-describedby`.** Otherwise a screen reader announces the
+ label and nothing else.
+- **Never mark a field invalid before the reader has finished with it.** Validate
+ on blur or on submit.
+- **The required marker is decorative.** `aria-hidden` on the asterisk, `required`
+ on the input.
diff --git a/_components/progress.md b/_components/progress.md
new file mode 100644
index 0000000..b1bbe86
--- /dev/null
+++ b/_components/progress.md
@@ -0,0 +1,78 @@
+---
+title: Progress
+summary: >-
+ A determinate bar for work that will finish, and a shuttle for work whose
+ finish you cannot predict. Never a fake percentage.
+---
+
+## Determinate
+
+{% example title="Progress" layout="stack" open %}
+<div class="ad-progress" style="max-width: 24rem;">
+ <div class="ad-progress__label">
+ <span id="demo-progress-label">Compiling tokens</span>
+ <span class="ad-progress__value">68%</span>
+ </div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="demo-progress-label"
+ aria-valuenow="68" aria-valuemin="0" aria-valuemax="100">
+ <div class="ad-progress__fill" style="width: 68%;"></div>
+ </div>
+</div>
+{% endexample %}
+
+The `role="progressbar"` and its three `aria-value*` attributes go on the track,
+not the fill. The fill is a decoration; the track is the control.
+
+## Tones
+
+{% example title="Tones" layout="stack" %}
+<div class="ad-progress ad-progress--positive" style="max-width: 24rem;">
+ <div class="ad-progress__label"><span id="p-1">Contrast checks</span><span class="ad-progress__value">10 / 10</span></div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="p-1" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
+ <div class="ad-progress__fill" style="width: 100%;"></div>
+ </div>
+</div>
+<div class="ad-progress ad-progress--caution" style="max-width: 24rem;">
+ <div class="ad-progress__label"><span id="p-2">Storage used</span><span class="ad-progress__value">81%</span></div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="p-2" aria-valuenow="81" aria-valuemin="0" aria-valuemax="100">
+ <div class="ad-progress__fill" style="width: 81%;"></div>
+ </div>
+</div>
+<div class="ad-progress ad-progress--critical" style="max-width: 24rem;">
+ <div class="ad-progress__label"><span id="p-3">Budget remaining</span><span class="ad-progress__value">4%</span></div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="p-3" aria-valuenow="4" aria-valuemin="0" aria-valuemax="100">
+ <div class="ad-progress__fill" style="width: 4%;"></div>
+ </div>
+</div>
+{% endexample %}
+
+Tone is about the meaning of the number, not the size of it. A bar at 100% is
+positive when it is a task completing and critical when it is a quota filling.
+
+## Indeterminate
+
+{% example title="Indeterminate" layout="stack" %}
+<div class="ad-progress ad-progress--indeterminate" style="max-width: 24rem;">
+ <div class="ad-progress__label"><span id="p-4">Waiting for the build</span></div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="p-4">
+ <div class="ad-progress__fill"></div>
+ </div>
+</div>
+{% endexample %}
+
+No `aria-valuenow` — its absence is what tells assistive technology the value is
+unknown. Inventing a number here would be a lie told twice: once to the reader
+and once to the screen reader.
+
+Under `prefers-reduced-motion` the shuttle stops moving. The bar stays, the label
+stays, and the reader still knows something is happening because the label says so.
+
+## Rules
+
+- **`role="progressbar"` on the track**, with `aria-labelledby` pointing at a
+ visible label.
+- **Omit `aria-valuenow` when you do not know it.** Do not pass `0`.
+- **Show the number in text as well as in the bar.** A bar alone is unreadable
+ at a glance and unusable in greyscale.
+- **Under about two seconds, show nothing.** A progress bar that flashes is worse
+ than no feedback at all.
diff --git a/_components/table.md b/_components/table.md
new file mode 100644
index 0000000..33a493f
--- /dev/null
+++ b/_components/table.md
@@ -0,0 +1,82 @@
+---
+title: Table
+summary: >-
+ Left-aligned by default, numeric columns opt into tabular figures, and the
+ whole thing scrolls inside its own wrapper instead of pushing the page sideways.
+---
+
+## Default
+
+{% example title="Table" layout="stack" open %}
+<div class="ad-table-wrap">
+ <table class="ad-table">
+ <caption>Ramps and their step counts.</caption>
+ <thead>
+ <tr>
+ <th scope="col">Ramp</th>
+ <th scope="col">Role</th>
+ <th scope="col" class="ad-table__num">Steps</th>
+ </tr>
+ </thead>
+ <tbody>
+ {%- for ramp in site.data.tokens.color.ramps %}
+ <tr>
+ <td class="ad-table__code">{{ ramp.name }}</td>
+ <td>{{ ramp.description | truncate: 64 }}</td>
+ <td class="ad-table__num">{{ ramp.steps | size }}</td>
+ </tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+</div>
+{% endexample %}
+
+That table is not hand-written. It loops over the same YAML the stylesheet is
+compiled from, so adding a ramp adds a row.
+
+## Density and emphasis
+
+{% example title="Compact, striped, hoverable" layout="stack" %}
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--compact ad-table--striped ad-table--hover">
+ <thead>
+ <tr>
+ <th scope="col">Token</th>
+ <th scope="col" class="ad-table__num">Light</th>
+ <th scope="col" class="ad-table__num">Dark</th>
+ </tr>
+ </thead>
+ <tbody>
+ {%- assign shown = site.data.tokens.scale.space.scale | slice: 1, 6 %}
+ {%- for step in shown %}
+ <tr>
+ <td class="ad-table__code">{{ step.token }}</td>
+ <td class="ad-table__num">{{ step.px }}px</td>
+ <td class="ad-table__num">{{ step.px }}px</td>
+ </tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+</div>
+{% endexample %}
+
+Stripes and hover are alternatives, not partners. Stripes help the eye track a
+long row across many columns; hover helps when rows are clickable. Together they
+fight.
+
+## Overflow
+
+`.ad-table-wrap` owns `overflow-x: auto`, so a wide table scrolls inside its own
+box and the page body never scrolls horizontally. This is the single most
+common way a documentation site breaks on a phone.
+
+## Rules
+
+- **Every header cell gets `scope`.** `scope="col"` on column headers,
+ `scope="row"` on the first cell of a row when it identifies the row.
+- **Numbers right, text left, and `tabular-nums` on the numbers** so digits
+ stack into columns.
+- **A `<caption>` beats a heading above the table.** It is announced as part of
+ the table.
+- **Never nest a table in a table.** If the data is that shaped, it is two
+ tables.
diff --git a/_components/tabs.md b/_components/tabs.md
new file mode 100644
index 0000000..93dc6cd
--- /dev/null
+++ b/_components/tabs.md
@@ -0,0 +1,79 @@
+---
+title: Tabs
+summary: >-
+ The WAI-ARIA tabs pattern, wired up in about forty lines of vanilla JavaScript.
+ Arrows move between tabs, Home and End jump to the ends.
+---
+
+## Tabs
+
+{% example title="Tabs" layout="stack" open %}
+<div class="ad-tabs" data-tabs>
+ <div class="ad-tabs__list" role="tablist" aria-label="Token formats">
+ <button class="ad-tabs__tab" role="tab" id="tab-css" aria-controls="panel-css" aria-selected="true" type="button">CSS</button>
+ <button class="ad-tabs__tab" role="tab" id="tab-yaml" aria-controls="panel-yaml" aria-selected="false" tabindex="-1" type="button">YAML</button>
+ <button class="ad-tabs__tab" role="tab" id="tab-ruby" aria-controls="panel-ruby" aria-selected="false" tabindex="-1" type="button">Ruby</button>
+ </div>
+
+ <div class="ad-tabs__panel" role="tabpanel" id="panel-css" aria-labelledby="tab-css" tabindex="0">
+ <p style="margin:0">What the build emits — one flat block of custom properties.</p>
+ <pre style="margin: var(--ad-space-3) 0 0; padding: var(--ad-space-3); background: var(--ad-color-surface-sunken); border-radius: var(--ad-radius-sm); font-size: var(--ad-text-xs); overflow-x: auto;"><code>:root {
+ --ad-color-accent: #c2260c;
+ --ad-color-accent-hover: #9a2012;
+}</code></pre>
+ </div>
+
+ <div class="ad-tabs__panel" role="tabpanel" id="panel-yaml" aria-labelledby="tab-yaml" tabindex="0" hidden>
+ <p style="margin:0">What a person edits — a reference into a ramp, never a hex.</p>
+ <pre style="margin: var(--ad-space-3) 0 0; padding: var(--ad-space-3); background: var(--ad-color-surface-sunken); border-radius: var(--ad-radius-sm); font-size: var(--ad-text-xs); overflow-x: auto;"><code>- token: color-accent
+ light: persimmon.700
+ dark: persimmon.400</code></pre>
+ </div>
+
+ <div class="ad-tabs__panel" role="tabpanel" id="panel-ruby" aria-labelledby="tab-ruby" tabindex="0" hidden>
+ <p style="margin:0">What connects them — a generator that runs before Jekyll renders a page.</p>
+ <pre style="margin: var(--ad-space-3) 0 0; padding: var(--ad-space-3); background: var(--ad-color-surface-sunken); border-radius: var(--ad-radius-sm); font-size: var(--ad-text-xs); overflow-x: auto;"><code>site.pages << stylesheet(site, token_set)</code></pre>
+ </div>
+</div>
+{% endexample %}
+
+## The contract
+
+The JavaScript looks for `[data-tabs]` and takes over from there, but it will
+only do the right thing if the markup is right:
+
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--compact">
+ <thead><tr><th scope="col">Element</th><th scope="col">Needs</th></tr></thead>
+ <tbody>
+ <tr><td>Wrapper</td><td class="ad-table__code">data-tabs</td></tr>
+ <tr><td>List</td><td class="ad-table__code">role="tablist"</td></tr>
+ <tr><td>Tab</td><td class="ad-table__code">role="tab", id, aria-controls, aria-selected</td></tr>
+ <tr><td>Panel</td><td class="ad-table__code">role="tabpanel", id, aria-labelledby, tabindex="0"</td></tr>
+ </tbody>
+ </table>
+</div>
+
+Only the selected tab stays in the tab order; the rest get `tabindex="-1"` and
+are reached with the arrow keys. That is one Tab stop for the whole tablist,
+which is the point of the pattern.
+
+<div class="ad-callout ad-callout--caution">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Tabs are not navigation</p>
+ <p style="margin:0">
+ If each panel deserves its own URL — if a reader might want to link to one,
+ or hit Back to leave it — it is a set of pages with a nav, not a tablist.
+ This site's sidebar is nav; the block above is tabs.
+ </p>
+ </div>
+</div>
+
+## Rules
+
+- **The selected panel is the only one not `hidden`.** Do not hide panels with
+ CSS alone; `hidden` keeps them out of the accessibility tree.
+- **Never more than about six tabs.** Past that the labels stop fitting and the
+ arrow keys stop being faster than reading.
+- **Keep panel heights close.** A tablist that resizes the page on every switch
+ makes the reader lose their place.
diff --git a/_config.yml b/_config.yml
new file mode 100644
index 0000000..db2f425
--- /dev/null
+++ b/_config.yml
@@ -0,0 +1,67 @@
+title: Arbitrary Definitions
+tagline: A small design system that admits what it is
+description: >-
+ Arbitrary Definitions is a documented design system — colour, type, space,
+ motion, and a dozen components — rendered from a single set of YAML token
+ files by a Jekyll site 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
+ - .ruby-version
+ - script
+
+plugins: []
diff --git a/_data/meta.yml b/_data/meta.yml
new file mode 100644
index 0000000..71e3fd1
--- /dev/null
+++ b/_data/meta.yml
@@ -0,0 +1 @@
+version: "1.0.0"
diff --git a/_data/nav.yml b/_data/nav.yml
new file mode 100644
index 0000000..f60ad73
--- /dev/null
+++ b/_data/nav.yml
@@ -0,0 +1,26 @@
+- title: Start here
+ links:
+ - { text: Overview, url: / }
+ - { text: Why arbitrary, url: /rationale/ }
+ - { text: Using the CSS, url: /install/ }
+- title: Foundations
+ links:
+ - { text: Colour, url: /foundations/color/ }
+ - { text: Typography, url: /foundations/typography/ }
+ - { text: Space, url: /foundations/space/ }
+ - { text: Elevation, url: /foundations/elevation/ }
+ - { text: Motion, url: /foundations/motion/ }
+- title: Components
+ links:
+ - { text: Button, url: /components/button/ }
+ - { text: Field, url: /components/field/ }
+ - { text: Badge, url: /components/badge/ }
+ - { text: Callout, url: /components/callout/ }
+ - { text: Card, url: /components/card/ }
+ - { text: Table, url: /components/table/ }
+ - { text: Tabs, url: /components/tabs/ }
+ - { text: Progress, url: /components/progress/ }
+- title: Patterns
+ links:
+ - { text: Settings form, url: /patterns/settings-form/ }
+ - { text: Kitchen sink, url: /patterns/kitchen-sink/ }
diff --git a/_data/tokens/color.yml b/_data/tokens/color.yml
new file mode 100644
index 0000000..fb1f617
--- /dev/null
+++ b/_data/tokens/color.yml
@@ -0,0 +1,111 @@
+# Raw palette ramps. These are the arbitrary part: hand-picked hexes that,
+# once written down, stop being arbitrary and start being the rule.
+#
+# Nothing in the site references a ramp step directly. Components only ever
+# reach for the semantic aliases defined in `semantic.yml`, which is what
+# makes retheming a one-file change.
+ramps:
+ - name: ink
+ label: Ink
+ description: >-
+ A warm neutral ramp with a faint olive cast. Used for text, borders,
+ and every surface that is not trying to say anything.
+ steps:
+ - { step: "50", value: "#faf9f7" }
+ - { step: "100", value: "#f2f0ec" }
+ - { step: "150", value: "#e8e5df" }
+ - { step: "200", value: "#dcd8d0" }
+ - { step: "300", value: "#c3bdb2" }
+ - { step: "400", value: "#a29a8c" }
+ - { step: "500", value: "#78705f" }
+ - { step: "600", value: "#625b50" }
+ - { step: "700", value: "#4b463d" }
+ - { step: "800", value: "#35312b" }
+ - { step: "900", value: "#26231f" }
+ - { step: "950", value: "#171512" }
+
+ - name: persimmon
+ label: Persimmon
+ description: >-
+ The one loud hue in the system. Reserved for primary actions, the
+ current page marker, and links. If everything is persimmon, nothing is.
+ steps:
+ - { step: "50", value: "#fff4ed" }
+ - { step: "100", value: "#ffe6d5" }
+ - { step: "200", value: "#fec9aa" }
+ - { step: "300", value: "#fda474" }
+ - { step: "400", value: "#fb743c" }
+ - { step: "500", value: "#f95116" }
+ - { step: "600", value: "#ea360c" }
+ - { step: "700", value: "#c2260c" }
+ - { step: "800", value: "#9a2012" }
+ - { step: "900", value: "#7c1d12" }
+ - { step: "950", value: "#430b07" }
+
+ - name: verdant
+ label: Verdant
+ description: A mossy green, desaturated enough to sit next to persimmon without arguing.
+ steps:
+ - { step: "50", value: "#f1faf2" }
+ - { step: "100", value: "#ddf2e0" }
+ - { step: "150", value: "#cdebd2" }
+ - { step: "200", value: "#bce4c3" }
+ - { step: "300", value: "#8ecf9c" }
+ - { step: "400", value: "#5bb16f" }
+ - { step: "500", value: "#389350" }
+ - { step: "600", value: "#27763f" }
+ - { step: "700", value: "#205e34" }
+ - { step: "800", value: "#1c4b2c" }
+ - { step: "900", value: "#183e26" }
+ - { step: "950", value: "#0a2313" }
+
+ - name: amber
+ label: Amber
+ description: Caution. Warm enough to read as a warning, dark enough to hold white text from 600 up.
+ steps:
+ - { step: "50", value: "#fef8ec" }
+ - { step: "100", value: "#fbecc9" }
+ - { step: "150", value: "#f9e2ac" }
+ - { step: "200", value: "#f7d78e" }
+ - { step: "300", value: "#f2bc52" }
+ - { step: "400", value: "#eea32b" }
+ - { step: "500", value: "#e08211" }
+ - { step: "600", value: "#c45f0c" }
+ - { step: "700", value: "#a3410e" }
+ - { step: "800", value: "#853312" }
+ - { step: "900", value: "#6f2b12" }
+ - { step: "950", value: "#401405" }
+
+ - name: crimson
+ label: Crimson
+ description: Destructive and error states. Deliberately cooler than persimmon so the two never blur.
+ steps:
+ - { step: "50", value: "#fef2f3" }
+ - { step: "100", value: "#fde3e5" }
+ - { step: "150", value: "#fcd7db" }
+ - { step: "200", value: "#fbccd1" }
+ - { step: "300", value: "#f7a5ae" }
+ - { step: "400", value: "#f17385" }
+ - { step: "500", value: "#e6455f" }
+ - { step: "600", value: "#d22548" }
+ - { step: "700", value: "#b01a3c" }
+ - { step: "800", value: "#931939" }
+ - { step: "900", value: "#7e1936" }
+ - { step: "950", value: "#470819" }
+
+ - name: azure
+ label: Azure
+ description: Informational states and the focus ring. The only blue in the building.
+ steps:
+ - { step: "50", value: "#eff7ff" }
+ - { step: "100", value: "#dbecfe" }
+ - { step: "150", value: "#cde5fe" }
+ - { step: "200", value: "#bfdffe" }
+ - { step: "300", value: "#93cbfd" }
+ - { step: "400", value: "#60affa" }
+ - { step: "500", value: "#3b8ef6" }
+ - { step: "600", value: "#2571eb" }
+ - { step: "700", value: "#1d5bd8" }
+ - { step: "800", value: "#1e4baf" }
+ - { step: "900", value: "#1e428a" }
+ - { step: "950", value: "#172a54" }
diff --git a/_data/tokens/scale.yml b/_data/tokens/scale.yml
new file mode 100644
index 0000000..51350fa
--- /dev/null
+++ b/_data/tokens/scale.yml
@@ -0,0 +1,121 @@
+# Non-colour tokens. Same deal: `_plugins/token_generator.rb` turns each
+# group into a block of CSS custom properties under `:root`.
+
+typography:
+ label: Typography
+ families:
+ - token: font-display
+ value: "\"Fraunces\", \"Iowan Old Style\", Georgia, serif"
+ usage: Wordmark and page titles. Optical sizing does the heavy lifting.
+ - token: font-sans
+ value: "\"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif"
+ usage: Everything else. Body copy, UI labels, tables.
+ - token: font-mono
+ value: "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, monospace"
+ usage: Code, token names, and any value the reader might need to copy.
+ sizes:
+ - { token: text-2xs, value: "0.6875rem", px: 11, usage: Overlines and table micro-labels. Uppercase only. }
+ - { token: text-xs, value: "0.75rem", px: 12, usage: Badges, captions, keyboard hints. }
+ - { token: text-sm, value: "0.875rem", px: 14, usage: Dense UI — small buttons, helper text, table cells. }
+ - { token: text-md, value: "1rem", px: 16, usage: The default. Body copy and standard controls. }
+ - { token: text-lg, value: "1.125rem", px: 18, usage: Lead paragraphs and large controls. }
+ - { token: text-xl, value: "1.375rem", px: 22, usage: Section headings (h3). }
+ - { token: text-2xl, value: "1.75rem", px: 28, usage: Subsection headings (h2). }
+ - { token: text-3xl, value: "2.25rem", px: 36, usage: Page titles (h1). }
+ - { token: text-4xl, value: "3rem", px: 48, usage: Hero headline. }
+ - { token: text-5xl, value: "3.75rem", px: 60, usage: One per site, at most. }
+ line_heights:
+ - { token: leading-tight, value: "1.15", usage: Display sizes, 28px and up. }
+ - { token: leading-snug, value: "1.3", usage: Headings and multi-line labels. }
+ - { token: leading-normal, value: "1.55", usage: Body copy. }
+ - { token: leading-relaxed, value: "1.75", usage: Long-form prose columns. }
+ weights:
+ - { token: weight-regular, value: "400", usage: Body copy. }
+ - { token: weight-medium, value: "500", usage: UI labels and table headers. }
+ - { token: weight-semibold, value: "600", usage: Buttons and headings. }
+ - { token: weight-bold, value: "700", usage: Emphasis inside running text. }
+ tracking:
+ - { token: tracking-tight, value: "-0.02em", usage: Sizes 28px and up, where default spacing looks loose. }
+ - { token: tracking-normal, value: "0", usage: Everything from 12px to 22px. }
+ - { token: tracking-wide, value: "0.08em", usage: Uppercase overlines only. }
+
+space:
+ label: Space
+ description: >-
+ A 4px base with a ramp that thins out as it grows. Small increments matter
+ inside a control; nobody can see the difference between 76px and 80px.
+ scale:
+ - { token: space-0, value: "0", px: 0 }
+ - { token: space-1, value: "0.25rem", px: 4 }
+ - { token: space-2, value: "0.5rem", px: 8 }
+ - { token: space-3, value: "0.75rem", px: 12 }
+ - { token: space-4, value: "1rem", px: 16 }
+ - { token: space-5, value: "1.5rem", px: 24 }
+ - { token: space-6, value: "2rem", px: 32 }
+ - { token: space-7, value: "2.5rem", px: 40 }
+ - { token: space-8, value: "3rem", px: 48 }
+ - { token: space-9, value: "4rem", px: 64 }
+ - { token: space-10, value: "6rem", px: 96 }
+ - { token: space-11, value: "8rem", px: 128 }
+
+radius:
+ label: Radius
+ description: Corner rounding. The system tops out at 16px; anything softer reads as a toy.
+ scale:
+ - { token: radius-none, value: "0", usage: Tables, full-bleed bands. }
+ - { token: radius-xs, value: "3px", usage: Badges and inline code. }
+ - { token: radius-sm, value: "5px", usage: Inputs, buttons, checkboxes. }
+ - { token: radius-md, value: "8px", usage: Cards and callouts. }
+ - { token: radius-lg, value: "12px", usage: Panels and modals. }
+ - { token: radius-xl, value: "16px", usage: Hero surfaces. }
+ - { token: radius-full, value: "999px", usage: Pills, avatars, switches. }
+
+elevation:
+ label: Elevation
+ description: >-
+ Five levels, each a two-shadow stack: a tight contact shadow plus a wider
+ ambient one. Levels are about hierarchy, not distance.
+ scale:
+ - token: elevation-0
+ value: "none"
+ usage: Flush with the canvas. Bordered cards live here.
+ - token: elevation-1
+ value: "0 1px 2px rgba(23, 21, 18, 0.06), 0 1px 3px rgba(23, 21, 18, 0.08)"
+ usage: Resting cards that need to separate from a busy background.
+ - token: elevation-2
+ value: "0 2px 4px rgba(23, 21, 18, 0.06), 0 4px 12px rgba(23, 21, 18, 0.08)"
+ usage: Dropdowns, popovers, sticky headers once they detach.
+ - token: elevation-3
+ value: "0 4px 8px rgba(23, 21, 18, 0.07), 0 12px 28px rgba(23, 21, 18, 0.12)"
+ usage: Dialogs and side sheets.
+ - token: elevation-4
+ value: "0 8px 16px rgba(23, 21, 18, 0.08), 0 24px 56px rgba(23, 21, 18, 0.16)"
+ usage: Full-screen overlays. One per view.
+
+motion:
+ label: Motion
+ description: >-
+ Durations are short because this is an interface, not a title sequence.
+ Every one of these is muted to 0ms under `prefers-reduced-motion`.
+ durations:
+ - { token: duration-instant, value: "0ms", usage: State changes that must feel like they already happened. }
+ - { token: duration-fast, value: "120ms", usage: Hover, focus, and colour transitions. }
+ - { token: duration-base, value: "200ms", usage: Expanding, revealing, switching. }
+ - { token: duration-slow, value: "320ms", usage: Anything that crosses more than half the viewport. }
+ - { token: duration-spin, value: "700ms", usage: One rotation of the button spinner. }
+ - { token: duration-loop, value: "1400ms", usage: One pass of an indeterminate indicator. }
+ easings:
+ - { token: ease-standard, value: "cubic-bezier(0.2, 0, 0, 1)", usage: The default. Fast out, settles gently. }
+ - { token: ease-entrance, value: "cubic-bezier(0, 0, 0.2, 1)", usage: Elements arriving on screen. }
+ - { token: ease-exit, value: "cubic-bezier(0.4, 0, 1, 1)", usage: Elements leaving. Quicker than arriving. }
+ - { token: ease-spring, value: "cubic-bezier(0.34, 1.4, 0.64, 1)", usage: Toggles and switches. The only overshoot in the system. }
+
+layout:
+ label: Layout
+ description: Container widths and the one breakpoint that actually changes the page.
+ scale:
+ - { token: measure-prose, value: "68ch", usage: Max width for running text. Roughly 75 characters. }
+ - { token: width-content, value: "50rem", usage: Documentation column. }
+ - { token: width-wide, value: "72rem", usage: Full site shell including the sidebar. }
+ - { token: sidebar-width, value: "16rem", usage: Persistent navigation rail. }
+ - { token: header-height, value: "3.5rem", usage: Sticky masthead. }
diff --git a/_data/tokens/semantic.yml b/_data/tokens/semantic.yml
new file mode 100644
index 0000000..542f9cc
--- /dev/null
+++ b/_data/tokens/semantic.yml
@@ -0,0 +1,176 @@
+# Semantic colour aliases. Every component reads from this file and never
+# from a raw ramp. Values are either a `ramp.step` reference or a literal
+# CSS colour; `_plugins/token_generator.rb` resolves references at build time.
+#
+# `light` and `dark` are emitted as `:root` and `[data-theme="dark"]` blocks.
+groups:
+ - name: Surfaces
+ id: surface
+ description: Backgrounds, from the page itself up to the topmost floating layer.
+ tokens:
+ - token: color-canvas
+ light: ink.50
+ dark: ink.950
+ usage: The page. Nothing sits behind it.
+ - token: color-surface
+ light: "#ffffff"
+ dark: ink.900
+ usage: Cards, panels, table bodies — anything that reads as a sheet on the canvas.
+ - token: color-surface-sunken
+ light: ink.100
+ dark: "#100e0c"
+ usage: Wells, code blocks, inset examples. Recessed relative to the canvas.
+ - token: color-surface-raised
+ light: "#ffffff"
+ dark: ink.800
+ usage: Menus, popovers, tooltips. Pairs with elevation 2 or higher.
+ - token: color-surface-hover
+ light: ink.100
+ dark: ink.800
+ usage: The wash under a hovered row or list item.
+
+ - name: Borders
+ id: border
+ description: Three weights, because two was never enough and four was too many.
+ tokens:
+ - token: color-border-subtle
+ light: ink.150
+ dark: ink.800
+ usage: Hairlines between rows and inside dense tables.
+ - token: color-border
+ light: ink.200
+ dark: ink.700
+ usage: The default. Card edges, input outlines, dividers.
+ - token: color-border-strong
+ light: ink.500
+ dark: ink.400
+ usage: Hovered inputs and anything that needs to look grabbable.
+
+ - name: Text
+ id: text
+ description: Four levels of emphasis. Past four, readers stop perceiving a hierarchy.
+ tokens:
+ - token: color-text
+ light: ink.900
+ dark: ink.100
+ usage: Body copy and headings.
+ - token: color-text-muted
+ light: ink.600
+ dark: ink.300
+ usage: Supporting copy, table captions, helper text.
+ - token: color-text-subtle
+ light: ink.500
+ dark: ink.400
+ usage: Placeholders, disabled labels, metadata nobody reads on purpose.
+ - token: color-text-inverse
+ light: ink.50
+ dark: ink.950
+ usage: Text on a filled accent or inverted surface.
+
+ - name: Accent
+ id: accent
+ description: Persimmon, rationed.
+ tokens:
+ - token: color-accent
+ light: persimmon.700
+ dark: persimmon.400
+ usage: Primary buttons, links, the active nav marker.
+ - token: color-accent-hover
+ light: persimmon.800
+ dark: persimmon.300
+ usage: Hover and active states for accent surfaces.
+ - token: color-accent-subtle
+ light: persimmon.50
+ dark: persimmon.950
+ usage: Tinted backgrounds — selected rows, accent callouts.
+ - token: color-accent-border
+ light: persimmon.200
+ dark: persimmon.800
+ usage: Edges on accent-tinted surfaces.
+ - token: color-on-accent
+ light: "#ffffff"
+ dark: persimmon.950
+ usage: Text and icons sitting on a filled accent.
+
+ - name: Status
+ id: status
+ description: >-
+ Four states, each with a foreground, a tint, and an edge. Components take
+ the whole triple so a callout and a badge can never drift apart.
+ tokens:
+ - token: color-positive
+ light: verdant.700
+ dark: verdant.300
+ usage: Success messaging and healthy indicators.
+ - token: color-positive-subtle
+ light: verdant.50
+ dark: verdant.950
+ usage: Tint behind positive content.
+ - token: color-positive-border
+ light: verdant.200
+ dark: verdant.800
+ usage: Edge on positive surfaces.
+ - token: color-caution
+ light: amber.700
+ dark: amber.300
+ usage: Warnings that do not block the user.
+ - token: color-caution-subtle
+ light: amber.50
+ dark: amber.950
+ usage: Tint behind caution content.
+ - token: color-caution-border
+ light: amber.200
+ dark: amber.800
+ usage: Edge on caution surfaces.
+ - token: color-critical
+ light: crimson.700
+ dark: crimson.300
+ usage: Errors, validation failures, destructive actions.
+ - token: color-critical-subtle
+ light: crimson.50
+ dark: crimson.950
+ usage: Tint behind critical content.
+ - token: color-critical-border
+ light: crimson.200
+ dark: crimson.800
+ usage: Edge on critical surfaces.
+ - token: color-critical-solid
+ light: crimson.700
+ dark: crimson.600
+ usage: >-
+ Fill for a destructive button. Distinct from `color-critical`, which is
+ a text colour and goes light in the dark theme — filling a button with
+ it would put white on pink.
+ - token: color-on-critical
+ light: "#ffffff"
+ dark: "#ffffff"
+ usage: Label on a filled destructive button.
+ - token: color-info
+ light: azure.700
+ dark: azure.300
+ usage: Neutral notices and tips.
+ - token: color-info-subtle
+ light: azure.50
+ dark: azure.950
+ usage: Tint behind informational content.
+ - token: color-info-border
+ light: azure.200
+ dark: azure.800
+ usage: Edge on informational surfaces.
+
+ - name: Interaction
+ id: interaction
+ description: The bits that only exist while a finger or a caret is involved.
+ tokens:
+ - token: color-focus
+ light: azure.500
+ dark: azure.400
+ usage: The focus ring. One colour, system-wide, never restyled per component.
+ - token: color-selection
+ light: persimmon.100
+ dark: persimmon.900
+ usage: Text selection highlight.
+ - token: color-overlay
+ light: "rgba(38, 35, 31, 0.5)"
+ dark: "rgba(0, 0, 0, 0.65)"
+ usage: Scrim behind modal surfaces.
diff --git a/_foundations/color.md b/_foundations/color.md
new file mode 100644
index 0000000..38dd020
--- /dev/null
+++ b/_foundations/color.md
@@ -0,0 +1,189 @@
+---
+title: Colour
+summary: >-
+ Six ramps and thirty-two semantic aliases. Components never touch a ramp
+ directly, which is the only reason the dark theme is one file and not a rewrite.
+---
+
+## The two layers
+
+The system separates *what a colour is* from *what a colour is for*.
+
+The **ramps** below are raw material: twelve steps of ink, eleven of persimmon,
+and four supporting hues. They exist so that the next decision has something to
+choose from. No component references them.
+
+The **aliases** underneath are the working vocabulary — `--ad-color-text`,
+`--ad-color-critical-subtle`, `--ad-color-focus`. Every rule in every component
+stylesheet reads from these. Swapping a theme means repointing aliases at
+different ramp steps, which is exactly what the dark theme does.
+
+<div class="ad-callout ad-callout--neutral">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Contrast figures on this page are computed, not typed</p>
+ <p style="margin:0">
+ Each ratio comes from <code>lib/arbitrary_definitions/color_math.rb</code>, run
+ against the token value during the Jekyll build. A hex cannot change without its
+ contrast number changing with it.
+ </p>
+ </div>
+</div>
+
+## Ramps
+
+{% for ramp in site.data.tokens.color.ramps %}
+### {{ ramp.label }}
+
+<p class="ad-muted">{{ ramp.description }}</p>
+
+<ul class="ad-swatch-grid">
+ {%- for step in ramp.steps %}
+ {%- assign on = step.value | readable_on %}
+ <li class="ad-swatch">
+ <div class="ad-swatch__chip" style="background: {{ step.value }}; color: {{ on }};">{{ ramp.name }} {{ 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>
+ </div>
+ </li>
+ {%- endfor %}
+</ul>
+{% endfor %}
+
+## Semantic aliases
+
+Both themes, side by side. The left half of each chip is the light value and the
+right half is the dark one — a token whose halves look identical across a whole
+group usually means somebody forgot to think about dark mode.
+
+{% assign groups = site.data.tokens.semantic.groups %}
+{% for group in groups %}
+### {{ group.name }}
+
+<p class="ad-muted">{{ group.description }}</p>
+
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--compact">
+ <thead>
+ <tr>
+ <th scope="col">Token</th>
+ <th scope="col">Light</th>
+ <th scope="col">Dark</th>
+ <th scope="col">Use for</th>
+ </tr>
+ </thead>
+ <tbody>
+ {%- for token in group.tokens %}
+ {%- assign flat = site.data.semantic_flat | where: "token", token.token | first %}
+ <tr>
+ <td>
+ <div class="ad-alias-row">
+ <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>
+ <span class="ad-token-name">{{ flat.name }}</span>
+ </div>
+ </td>
+ <td class="ad-table__code">{{ token.light }}<br><span class="ad-subtle">{{ flat.light }}</span></td>
+ <td class="ad-table__code">{{ token.dark }}<br><span class="ad-subtle">{{ flat.dark }}</span></td>
+ <td>{{ token.usage }}</td>
+ </tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+</div>
+{% endfor %}
+
+## Contrast that has to hold
+
+Three pairings are load-bearing. The build fails if any of them drops below WCAG
+AA — `test/test_token_set.rb` asserts it on the light and dark themes both.
+
+{% assign text = site.data.semantic_flat | where: "token", "color-text" | first %}
+{% assign muted = site.data.semantic_flat | where: "token", "color-text-muted" | first %}
+{% assign canvas = site.data.semantic_flat | where: "token", "color-canvas" | first %}
+{% assign accent = site.data.semantic_flat | where: "token", "color-accent" | first %}
+{% assign on_accent = site.data.semantic_flat | where: "token", "color-on-accent" | first %}
+
+<div class="ad-table-wrap">
+ <table class="ad-table">
+ <caption>Ratios computed at build time from the token values themselves.</caption>
+ <thead>
+ <tr>
+ <th scope="col">Pair</th>
+ <th scope="col">Theme</th>
+ <th scope="col" class="ad-table__num">Ratio</th>
+ <th scope="col">Grade</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>Body text on canvas</td>
+ <td>Light</td>
+ <td class="ad-table__num">{{ text.light | contrast_with: canvas.light }}</td>
+ {%- assign grade = text.light | contrast_grade: canvas.light %}
+ <td><span class="ad-grade ad-grade--{{ grade | replace: ' ', '-' }}">{{ grade }}</span></td>
+ </tr>
+ <tr>
+ <td>Body text on canvas</td>
+ <td>Dark</td>
+ <td class="ad-table__num">{{ text.dark | contrast_with: canvas.dark }}</td>
+ {%- assign grade = text.dark | contrast_grade: canvas.dark %}
+ <td><span class="ad-grade ad-grade--{{ grade | replace: ' ', '-' }}">{{ grade }}</span></td>
+ </tr>
+ <tr>
+ <td>Muted text on canvas</td>
+ <td>Light</td>
+ <td class="ad-table__num">{{ muted.light | contrast_with: canvas.light }}</td>
+ {%- assign grade = muted.light | contrast_grade: canvas.light %}
+ <td><span class="ad-grade ad-grade--{{ grade | replace: ' ', '-' }}">{{ grade }}</span></td>
+ </tr>
+ <tr>
+ <td>Muted text on canvas</td>
+ <td>Dark</td>
+ <td class="ad-table__num">{{ muted.dark | contrast_with: canvas.dark }}</td>
+ {%- assign grade = muted.dark | contrast_grade: canvas.dark %}
+ <td><span class="ad-grade ad-grade--{{ grade | replace: ' ', '-' }}">{{ grade }}</span></td>
+ </tr>
+ <tr>
+ <td>Label on a filled accent</td>
+ <td>Light</td>
+ <td class="ad-table__num">{{ on_accent.light | contrast_with: accent.light }}</td>
+ {%- assign grade = on_accent.light | contrast_grade: accent.light %}
+ <td><span class="ad-grade ad-grade--{{ grade | replace: ' ', '-' }}">{{ grade }}</span></td>
+ </tr>
+ <tr>
+ <td>Label on a filled accent</td>
+ <td>Dark</td>
+ <td class="ad-table__num">{{ on_accent.dark | contrast_with: accent.dark }}</td>
+ {%- assign grade = on_accent.dark | contrast_grade: accent.dark %}
+ <td><span class="ad-grade ad-grade--{{ grade | replace: ' ', '-' }}">{{ grade }}</span></td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+
+<div class="ad-callout ad-callout--caution">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Where the system knowingly stops short</p>
+ <p style="margin:0">
+ <code>--ad-color-border</code> sits around 1.3:1 against the surfaces it divides.
+ That is well under the 3:1 that WCAG 1.4.11 asks of a boundary a user has to
+ find. It is deliberate — a table gridded in 3:1 lines is unreadable — but it
+ means the default border is decorative. Any edge that is the <em>only</em> way
+ to locate a control uses <code>--ad-color-border-strong</code>, which is tested
+ at 3:1 in both themes.
+ </p>
+ </div>
+</div>
+
+## Rules
+
+- **Never write a hex outside `_data/tokens/color.yml`.** If a component needs a
+ colour that does not exist yet, the colour does not exist yet. Add the alias.
+- **Status colour is never the only signal.** Every status component carries an
+ icon, a label, or a dot alongside the hue.
+- **One focus colour.** `--ad-color-focus` is azure everywhere, including on
+ persimmon surfaces, because a focus ring that changes per component is a focus
+ ring nobody learns.
diff --git a/_foundations/elevation.md b/_foundations/elevation.md
new file mode 100644
index 0000000..e0f0c92
--- /dev/null
+++ b/_foundations/elevation.md
@@ -0,0 +1,76 @@
+---
+title: Elevation
+summary: >-
+ Five levels, each a two-shadow stack. Elevation is a claim about hierarchy,
+ not about distance — level 3 does not mean "12 pixels above the page".
+---
+
+## The levels
+
+Every level pairs a tight contact shadow with a wider ambient one. That is what
+keeps a shadow from reading as a grey smudge: the near shadow anchors the edge,
+the far shadow suggests the light.
+
+<ul class="ad-elevation-grid">
+ {%- for step in site.data.tokens.scale.elevation.scale %}
+ <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-text-xs); margin: var(--ad-space-1) 0 0;">{{ step.usage }}</p>
+ </li>
+ {%- endfor %}
+</ul>
+
+## Border or shadow, not both
+
+A card can separate itself from the canvas with an edge or with a shadow. Doing
+both makes it look like a sticker. The system's default card is bordered and
+flat; `--ad-card--raised` swaps in elevation 1 and `--ad-card--floating` drops
+the border entirely.
+
+{% example title="Three ways to lift a card" layout="grid" surface="sunken" %}
+<article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title" style="font-size: var(--ad-text-md);">Bordered</h3>
+ <p class="ad-card__text">The default. Works on any background.</p>
+ </div>
+</article>
+<article class="ad-card ad-card--raised">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title" style="font-size: var(--ad-text-md);">Raised</h3>
+ <p class="ad-card__text">Border plus elevation 1, for a busy canvas.</p>
+ </div>
+</article>
+<article class="ad-card ad-card--floating">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title" style="font-size: var(--ad-text-md);">Floating</h3>
+ <p class="ad-card__text">Elevation 2, no border. For things that overlay.</p>
+ </div>
+</article>
+{% endexample %}
+
+## Elevation in the dark theme
+
+Shadows barely register on a near-black canvas. The dark theme leans on surface
+lightness instead: `--ad-color-surface` is one ink step lighter than
+`--ad-color-canvas`, and `--ad-color-surface-raised` is lighter again. The
+shadow tokens still apply — they just stop being the primary signal.
+
+<div class="ad-callout ad-callout--neutral">
+ <div class="ad-callout__body">
+ <p style="margin:0">
+ Toggle the theme in the header and watch the row above. In light, the three
+ cards separate by shadow. In dark, they separate by surface. Same markup.
+ </p>
+ </div>
+</div>
+
+## Rules
+
+- **One level per layer, and no skipping.** A dropdown inside a dialog is not
+ elevation 4; the dialog owns the elevation and the dropdown inherits the
+ context.
+- **At most one level 4 on screen.** If two things both claim to be the topmost
+ layer, neither is.
+- **Never animate a shadow's blur.** Animate `opacity` on a pseudo-element, or
+ do not animate it at all.
diff --git a/_foundations/motion.md b/_foundations/motion.md
new file mode 100644
index 0000000..e96e06c
--- /dev/null
+++ b/_foundations/motion.md
@@ -0,0 +1,89 @@
+---
+title: Motion
+summary: >-
+ Four durations, four easings, and one rule that overrides all of them: if the
+ reader asked for less motion, they get none.
+---
+
+## Durations
+
+Interface motion is short. Anything over a third of a second stops feeling like
+a response and starts feeling like a wait.
+
+<div class="ad-table-wrap">
+ <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 %}
+ <tr>
+ <td class="ad-token-name">{{ row.token | custom_property }}</td>
+ <td class="ad-table__code">{{ row.value }}</td>
+ <td>{{ row.usage }}</td>
+ </tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+</div>
+
+## Easings
+
+Hover or focus a tile to run its curve. All four move the same distance over the
+same 320ms, so the only difference you are seeing is the shape.
+
+<ul class="ad-motion-grid">
+ {%- for row in site.data.tokens.scale.motion.easings %}
+ <li>
+ <div class="ad-motion-demo" tabindex="0"
+ style="--_easing: {{ row.value }}; --_duration: var(--ad-duration-slow);">
+ <span class="ad-motion-demo__dot"></span>
+ </div>
+ <div class="ad-specimen__meta"><span class="ad-token-name">{{ row.token }}</span></div>
+ <p class="ad-muted" style="font-size: var(--ad-text-xs); margin: var(--ad-space-1) 0 0;">{{ row.usage }}</p>
+ </li>
+ {%- endfor %}
+</ul>
+
+## Reduced motion is not a downgrade
+
+`_plugins/token_generator.rb` emits a `prefers-reduced-motion: reduce` block that
+sets every duration token to `0ms`, and the base stylesheet clamps any remaining
+transition or animation to a hundredth of a millisecond. Components do not have
+to opt in, and cannot opt out.
+
+```css
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --ad-duration-instant: 0ms;
+ --ad-duration-fast: 0ms;
+ --ad-duration-base: 0ms;
+ --ad-duration-slow: 0ms;
+ }
+}
+```
+
+The state change still happens. The switch still moves. It simply arrives
+instead of travelling.
+
+{% example title="The one component allowed to overshoot" %}
+<label class="ad-switch">
+ <input type="checkbox" checked>
+ <span class="ad-switch__track"></span>
+ <span>Ship on merge</span>
+</label>
+<label class="ad-switch">
+ <input type="checkbox">
+ <span class="ad-switch__track"></span>
+ <span>Notify the channel</span>
+</label>
+{% endexample %}
+
+## Rules
+
+- **Transition named properties, never `all`.** `transition: all` animates
+ things you did not think about, including layout.
+- **Prefer `opacity` and `transform`.** They are the two properties the
+ compositor can animate without a layout pass.
+- **Exit faster than you enter.** `--ad-ease-exit` is steeper than
+ `--ad-ease-entrance` for exactly this reason.
+- **`--ad-ease-spring` is for toggles only.** Overshoot on a panel or a dialog
+ reads as a bug.
diff --git a/_foundations/space.md b/_foundations/space.md
new file mode 100644
index 0000000..8010229
--- /dev/null
+++ b/_foundations/space.md
@@ -0,0 +1,96 @@
+---
+title: Space
+summary: >-
+ A 4px base that thins out as it grows, because nobody can see the difference
+ between 76px and 80px but everybody sees the difference between 4px and 8px.
+---
+
+## The ramp
+
+Twelve steps. The gaps are tight and linear at the small end, where spacing
+inside a control is doing real work, and geometric at the large end, where it is
+just separating sections.
+
+<div class="ad-ruler">
+ {%- for step in site.data.tokens.scale.space.scale %}
+ <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>
+ </div>
+ {%- endfor %}
+</div>
+
+## How to pick one
+
+| Distance between | Reach for |
+| --- | --- |
+| A glyph and its own border | `--ad-space-2` / `--ad-space-3` |
+| Two controls in a row | `--ad-space-3` |
+| A label and its input | `--ad-space-2` |
+| Two fields in a form | `--ad-space-5` |
+| Two sections of a page | `--ad-space-8` |
+| A page title and its first section | `--ad-space-6` |
+
+{% example title="The same card at three densities" layout="grid" %}
+<article class="ad-card">
+ <div class="ad-card__body" style="gap: var(--ad-space-1); padding: var(--ad-space-3);">
+ <h3 class="ad-card__title" style="font-size: var(--ad-text-md);">Compact</h3>
+ <p class="ad-card__text">space-3 padding, space-1 gap. For dense tables and lists.</p>
+ </div>
+</article>
+<article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title" style="font-size: var(--ad-text-md);">Default</h3>
+ <p class="ad-card__text">space-4 padding, space-2 gap. What the component ships with.</p>
+ </div>
+</article>
+<article class="ad-card">
+ <div class="ad-card__body" style="gap: var(--ad-space-4); padding: var(--ad-space-6);">
+ <h3 class="ad-card__title" style="font-size: var(--ad-text-md);">Roomy</h3>
+ <p class="ad-card__text">space-6 padding, space-4 gap. For a card that is the whole page.</p>
+ </div>
+</article>
+{% endexample %}
+
+## Radius
+
+<ul class="ad-radius-grid">
+ {%- for step in site.data.tokens.scale.radius.scale %}
+ <li>
+ <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>
+ </div>
+ <p class="ad-muted" style="font-size: var(--ad-text-xs); margin: var(--ad-space-1) 0 0;">{{ step.usage }}</p>
+ </li>
+ {%- endfor %}
+</ul>
+
+## Layout
+
+<div class="ad-table-wrap">
+ <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.layout.scale %}
+ <tr>
+ <td class="ad-token-name">{{ row.token | custom_property }}</td>
+ <td class="ad-table__code">{{ row.value }}</td>
+ <td>{{ row.usage }}</td>
+ </tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+</div>
+
+## Rules
+
+- **Space comes from the parent.** A component sets its own internal padding and
+ never its external margin; the layout that contains it owns the gap. This is
+ what stops margins from collapsing into arguments.
+- **Use `gap`.** Flex and grid gaps do not collapse and do not need a
+ `:last-child` reset.
+- **A value not on the ramp is a bug**, not a nudge. If 14px is truly right, the
+ ramp is wrong and should be changed for everybody.
diff --git a/_foundations/typography.md b/_foundations/typography.md
new file mode 100644
index 0000000..2ec4fd5
--- /dev/null
+++ b/_foundations/typography.md
@@ -0,0 +1,95 @@
+---
+title: Typography
+summary: >-
+ Two families, ten sizes, four weights. The specimens below are rendered with
+ the same custom properties the components use, at the sizes the tokens define.
+---
+
+## Families
+
+Fraunces carries the wordmark and page titles; its optical sizing axis keeps
+large settings from looking spindly. Inter does everything else. Code is set in
+JetBrains Mono, with a system monospace fallback that will not shift the layout
+if the webfont never arrives.
+
+{% for family in site.data.tokens.scale.typography.families %}
+<div class="ad-specimen">
+ <div class="ad-specimen__meta">
+ <span class="ad-token-name">{{ family.token | custom_property }}</span>
+ <span>{{ family.usage }}</span>
+ </div>
+ <div class="ad-specimen__sample" style="font-family: var({{ family.token | custom_property }}); font-size: 1.75rem;">
+ Arbitrary Definitions — 0123456789
+ </div>
+</div>
+{% endfor %}
+
+## Scale
+
+A minor-third-ish ramp, hand-adjusted at the small end where a strict ratio
+produces sizes nobody can tell apart. Sizes are declared in `rem` so a reader who
+has changed their browser's base font size gets a system that scales with them.
+
+{% for size in site.data.tokens.scale.typography.sizes %}
+<div class="ad-specimen">
+ <div class="ad-specimen__meta">
+ <span class="ad-token-name">{{ size.token | custom_property }}</span>
+ <span>{{ size.value }} · {{ size.px }}px</span>
+ <span>{{ size.usage }}</span>
+ </div>
+ <div class="ad-specimen__sample" style="font-size: var({{ size.token | custom_property }});">
+ The quick brown fox jumps over the lazy dog
+ </div>
+</div>
+{% endfor %}
+
+## Line height, weight, tracking
+
+<div class="ad-table-wrap">
+ <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>
+ {%- assign type = site.data.tokens.scale.typography %}
+ {%- for row in type.line_heights %}
+ <tr><td class="ad-token-name">{{ row.token | custom_property }}</td><td class="ad-table__code">{{ row.value }}</td><td>{{ row.usage }}</td></tr>
+ {%- endfor %}
+ {%- for row in type.weights %}
+ <tr><td class="ad-token-name">{{ row.token | custom_property }}</td><td class="ad-table__code">{{ row.value }}</td><td>{{ row.usage }}</td></tr>
+ {%- endfor %}
+ {%- for row in type.tracking %}
+ <tr><td class="ad-token-name">{{ row.token | custom_property }}</td><td class="ad-table__code">{{ row.value }}</td><td>{{ row.usage }}</td></tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+</div>
+
+## In practice
+
+{% example title="A heading, a lead, and body copy" layout="stack" %}
+<div style="max-width: 34rem;">
+ <p class="ad-overline">Foundations</p>
+ <h2 style="margin: var(--ad-space-2) 0 var(--ad-space-3);">Type is the cheapest hierarchy you have</h2>
+ <p class="ad-lead">
+ Before reaching for a border, a background, or a badge, try moving one step
+ up the size ramp and one step down the colour ramp.
+ </p>
+ <p>
+ Body copy sits at <code>--ad-text-md</code> with <code>--ad-leading-normal</code>,
+ capped at <code>--ad-measure-prose</code> so a line never runs past about
+ seventy-five characters. Long measures are the single most common readability
+ failure in documentation, and the fix costs one declaration.
+ </p>
+</div>
+{% endexample %}
+
+## Rules
+
+- **Two families, and no third.** A new typeface needs a reason that a weight or
+ a size cannot supply.
+- **Uppercase only at `--ad-text-2xs` with `--ad-tracking-wide`.** Uppercase at
+ body size is shouting and it is also slower to read.
+- **Never set a size in `px`.** The ramp is in `rem` on purpose.
+- **`text-wrap: balance` on headings, `pretty` on lead paragraphs.** Both are in
+ the base stylesheet; components do not need to repeat them.
diff --git a/_includes/head.html b/_includes/head.html
new file mode 100644
index 0000000..aedd0f0
--- /dev/null
+++ b/_includes/head.html
@@ -0,0 +1,30 @@
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="theme-color" content="#faf9f7" media="(prefers-color-scheme: light)">
+<meta name="theme-color" content="#171512" media="(prefers-color-scheme: dark)">
+
+{% seo %}
+
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&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="icon" href="{{ '/assets/favicon.svg' | relative_url }}" type="image/svg+xml">
+
+<!-- Applied before first paint so an explicit theme choice never flashes. -->
+<script>
+ (function () {
+ try {
+ var stored = localStorage.getItem("ad-theme");
+ if (stored === "dark" || stored === "light") {
+ document.documentElement.setAttribute("data-theme", stored);
+ }
+ } catch (error) {
+ /* Private windows and blocked storage are fine; the media query covers us. */
+ }
+ })();
+</script>
diff --git a/_includes/sidebar.html b/_includes/sidebar.html
new file mode 100644
index 0000000..7babe9c
--- /dev/null
+++ b/_includes/sidebar.html
@@ -0,0 +1,20 @@
+<aside class="ad-doc-sidebar" data-sidebar data-collapsed="true">
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm ad-doc-nav-toggle"
+ data-nav-toggle aria-expanded="false" aria-controls="ad-doc-nav">
+ <span>Browse the system</span>
+ <span aria-hidden="true">☰</span>
+ </button>
+
+ <nav class="ad-doc-nav" id="ad-doc-nav" aria-label="Design system">
+ {%- for group in site.data.nav %}
+ <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 %}
+ </div>
+ {%- endfor %}
+ </nav>
+</aside>
diff --git a/_includes/theme-toggle.html b/_includes/theme-toggle.html
new file mode 100644
index 0000000..12b408c
--- /dev/null
+++ b/_includes/theme-toggle.html
@@ -0,0 +1,12 @@
+<button type="button" class="ad-btn ad-btn--ghost ad-btn--sm" data-theme-toggle
+ aria-label="Switch colour theme" title="Switch colour theme">
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="light">
+ <circle cx="12" cy="12" r="4.5" stroke="currentColor" stroke-width="2"/>
+ <path d="M12 1.8v2.4M12 19.8v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M1.8 12h2.4M19.8 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7"
+ stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+ </svg>
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="dark" hidden>
+ <path d="M20 14.2A8.4 8.4 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
+ </svg>
+ <span class="ad-visually-hidden" data-theme-label>Theme</span>
+</button>
diff --git a/_includes/wordmark.html b/_includes/wordmark.html
new file mode 100644
index 0000000..b5a6fea
--- /dev/null
+++ b/_includes/wordmark.html
@@ -0,0 +1,9 @@
+<a class="ad-doc-wordmark" href="{{ '/' | relative_url }}">
+ <svg class="ad-doc-wordmark__mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <rect x="1.5" y="1.5" width="21" height="21" rx="5" stroke="currentColor" stroke-width="2"/>
+ <path d="M7 16.5 12 7l5 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M9.2 13.4h5.6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
+ </svg>
+ <span>Arbitrary Definitions</span>
+ <span class="ad-doc-wordmark__version">v{{ site.data.meta.version }}</span>
+</a>
diff --git a/_layouts/default.html b/_layouts/default.html
new file mode 100644
index 0000000..00c4c4b
--- /dev/null
+++ b/_layouts/default.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html lang="{{ site.lang | default: 'en' }}">
+<head>
+ {% include head.html %}
+</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 %}
+ <div class="ad-doc-header__actions">
+ {% include theme-toggle.html %}
+ <a class="ad-btn ad-btn--secondary ad-btn--sm"
+ href="https://github.com/{{ site.repository }}">Source</a>
+ </div>
+ </div>
+ </header>
+
+ <div class="ad-doc-layout">
+ {% include sidebar.html %}
+ <main class="ad-doc-main" id="main">
+ {{ content }}
+ </main>
+ </div>
+
+ <footer class="ad-doc-footer">
+ <div class="ad-doc-footer__inner">
+ <p>
+ {{ site.title }} v{{ site.data.meta.version }} — built with Jekyll on Ruby
+ {{ site.data.build.ruby }}.
+ </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>
+</body>
+</html>
diff --git a/_layouts/page.html b/_layouts/page.html
new file mode 100644
index 0000000..9692257
--- /dev/null
+++ b/_layouts/page.html
@@ -0,0 +1,13 @@
+---
+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/kitchen-sink.md b/_patterns/kitchen-sink.md
new file mode 100644
index 0000000..49116b3
--- /dev/null
+++ b/_patterns/kitchen-sink.md
@@ -0,0 +1,131 @@
+---
+title: Kitchen sink
+summary: >-
+ Every component the system ships, on one page, in both themes. This is the page
+ to open after changing a token.
+---
+
+A kitchen sink is not documentation — nobody learns a system from this page. It
+is a regression test you can look at. Change a token, open this, toggle the
+theme, and see what broke.
+
+## Buttons
+
+{% example no-source %}
+<button type="button" class="ad-btn ad-btn--primary">Primary</button>
+<button type="button" class="ad-btn ad-btn--secondary">Secondary</button>
+<button type="button" class="ad-btn ad-btn--ghost">Ghost</button>
+<button type="button" class="ad-btn ad-btn--danger">Danger</button>
+<button type="button" class="ad-btn ad-btn--primary" disabled>Disabled</button>
+<button type="button" class="ad-btn ad-btn--primary ad-btn--sm">Small</button>
+<button type="button" class="ad-btn ad-btn--primary ad-btn--lg">Large</button>
+<div class="ad-btn-group" role="group" aria-label="Alignment">
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm">One</button>
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm">Two</button>
+ <button type="button" class="ad-btn ad-btn--secondary ad-btn--sm">Three</button>
+</div>
+{% endexample %}
+
+## Badges
+
+{% example no-source %}
+<span class="ad-badge">Neutral</span>
+<span class="ad-badge ad-badge--accent">Accent</span>
+<span class="ad-badge ad-badge--positive"><span class="ad-badge__dot"></span>Positive</span>
+<span class="ad-badge ad-badge--caution"><span class="ad-badge__dot"></span>Caution</span>
+<span class="ad-badge ad-badge--critical"><span class="ad-badge__dot"></span>Critical</span>
+<span class="ad-badge ad-badge--info">Info</span>
+<span class="ad-badge ad-badge--pill ad-badge--solid ad-badge--positive">Solid pill</span>
+<span class="ad-avatar ad-avatar--sm">ad</span>
+<span class="ad-avatar">ad</span>
+{% endexample %}
+
+## Controls
+
+{% example no-source layout="stack" %}
+<div class="ad-form" style="max-width: none;">
+ <div class="ad-form__row">
+ <div class="ad-field">
+ <label class="ad-field__label" for="ks-text">Text</label>
+ <input class="ad-input" id="ks-text" type="text" placeholder="Placeholder">
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="ks-select">Select</label>
+ <select class="ad-select" id="ks-select"><option>One</option><option>Two</option></select>
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="ks-invalid">Invalid</label>
+ <input class="ad-input" id="ks-invalid" type="text" value="nope" aria-invalid="true" aria-describedby="ks-invalid-error">
+ <p class="ad-field__error" id="ks-invalid-error">Something is wrong.</p>
+ </div>
+ </div>
+ <div class="ad-form__row">
+ <label class="ad-choice"><input type="checkbox" checked> <span class="ad-choice__text">Checkbox</span></label>
+ <label class="ad-choice"><input type="radio" name="ks-radio" checked> <span class="ad-choice__text">Radio</span></label>
+ <label class="ad-switch"><input type="checkbox" checked><span class="ad-switch__track"></span><span>Switch</span></label>
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="ks-textarea">Textarea</label>
+ <textarea class="ad-textarea" id="ks-textarea">Multi-line value.</textarea>
+ </div>
+</div>
+{% endexample %}
+
+## Callouts
+
+{% example no-source layout="stack" %}
+<div class="ad-callout ad-callout--info" style="margin:0"><div class="ad-callout__body"><p class="ad-callout__title">Info</p><p style="margin:0">Neutral notice.</p></div></div>
+<div class="ad-callout ad-callout--positive" style="margin:0"><div class="ad-callout__body"><p class="ad-callout__title">Positive</p><p style="margin:0">It worked.</p></div></div>
+<div class="ad-callout ad-callout--caution" style="margin:0"><div class="ad-callout__body"><p class="ad-callout__title">Caution</p><p style="margin:0">It might not work.</p></div></div>
+<div class="ad-callout ad-callout--critical" style="margin:0"><div class="ad-callout__body"><p class="ad-callout__title">Critical</p><p style="margin:0">It did not work.</p></div></div>
+<div class="ad-callout ad-callout--accent" style="margin:0"><div class="ad-callout__body"><p class="ad-callout__title">Accent</p><p style="margin:0">Read this one.</p></div></div>
+<div class="ad-callout ad-callout--neutral" style="margin:0"><div class="ad-callout__body"><p style="margin:0">Neutral aside with no title.</p></div></div>
+{% endexample %}
+
+## Cards, table, progress
+
+{% example no-source layout="stack" surface="sunken" %}
+<div class="ad-card-grid">
+ <article class="ad-card"><div class="ad-card__body"><h3 class="ad-card__title">Bordered</h3><p class="ad-card__text">Flat.</p></div></article>
+ <article class="ad-card ad-card--raised"><div class="ad-card__body"><h3 class="ad-card__title">Raised</h3><p class="ad-card__text">Elevation 1.</p></div></article>
+ <article class="ad-card ad-card--floating"><div class="ad-card__body"><h3 class="ad-card__title">Floating</h3><p class="ad-card__text">Elevation 2.</p></div></article>
+</div>
+
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--hover ad-table--compact">
+ <thead><tr><th scope="col">Token</th><th scope="col">Tone</th><th scope="col" class="ad-table__num">Steps</th></tr></thead>
+ <tbody>
+ <tr><td class="ad-table__code">--ad-color-accent</td><td><span class="ad-badge ad-badge--accent">accent</span></td><td class="ad-table__num">11</td></tr>
+ <tr><td class="ad-table__code">--ad-color-positive</td><td><span class="ad-badge ad-badge--positive">positive</span></td><td class="ad-table__num">11</td></tr>
+ <tr><td class="ad-table__code">--ad-color-critical</td><td><span class="ad-badge ad-badge--critical">critical</span></td><td class="ad-table__num">11</td></tr>
+ </tbody>
+ </table>
+</div>
+
+<div class="ad-progress" style="max-width: none;">
+ <div class="ad-progress__label"><span id="ks-progress">Determinate</span><span class="ad-progress__value">42%</span></div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="ks-progress" aria-valuenow="42" aria-valuemin="0" aria-valuemax="100">
+ <div class="ad-progress__fill" style="width: 42%;"></div>
+ </div>
+</div>
+<div class="ad-progress ad-progress--indeterminate" style="max-width: none;">
+ <div class="ad-progress__label"><span id="ks-progress-2">Indeterminate</span></div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="ks-progress-2"><div class="ad-progress__fill"></div></div>
+</div>
+{% endexample %}
+
+## Typography
+
+{% example no-source layout="stack" %}
+<div>
+ <p class="ad-overline">Overline</p>
+ <h1 style="font-size: var(--ad-text-3xl);">Heading one</h1>
+ <h2 style="border:0; padding:0; margin: var(--ad-space-3) 0 0;">Heading two</h2>
+ <h3 style="margin: var(--ad-space-3) 0 0;">Heading three</h3>
+ <p class="ad-lead" style="margin-top: var(--ad-space-3);">A lead paragraph, one step up the ramp and one step down the colour scale.</p>
+ <p>Body copy with <a href="#main">a link</a>, <strong>bold text</strong>, <code>inline code</code>, and a <kbd>⌘K</kbd> key.</p>
+ <blockquote>A blockquote, marked by an accent rule rather than an indent.</blockquote>
+ <p class="ad-muted">Muted text.</p>
+ <p class="ad-subtle">Subtle text.</p>
+</div>
+{% endexample %}
diff --git a/_patterns/settings-form.md b/_patterns/settings-form.md
new file mode 100644
index 0000000..2e086cd
--- /dev/null
+++ b/_patterns/settings-form.md
@@ -0,0 +1,116 @@
+---
+title: Settings form
+summary: >-
+ Ten components in one realistic screen. Everything below is system markup with
+ no page-specific CSS beyond a couple of inline widths.
+---
+
+A pattern is what a design system is actually judged on. Individual components
+looking tidy on a white background proves nothing; the question is whether they
+hold together when a real screen puts nine of them side by side.
+
+{% example title="Theme settings" layout="stack" surface="sunken" %}
+<form class="ad-card ad-card--raised" style="max-width: 40rem;" onsubmit="return false;">
+ <div class="ad-card__body" style="gap: var(--ad-space-5); padding: var(--ad-space-6);">
+
+ <div>
+ <p class="ad-overline">Workspace</p>
+ <h2 style="font-size: var(--ad-text-xl); margin: var(--ad-space-1) 0 var(--ad-space-1);">Theme settings</h2>
+ <p class="ad-card__text">How this workspace resolves design tokens at build time.</p>
+ </div>
+
+ <div class="ad-callout ad-callout--info" style="margin: 0;">
+ <svg class="ad-callout__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+ <circle cx="12" cy="12" r="9.5" stroke="currentColor" stroke-width="2"/>
+ <path d="M12 11v6M12 7.4v.2" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
+ </svg>
+ <div class="ad-callout__body">
+ <p style="margin:0">Changes here rebuild <code>tokens.css</code> for every project in the workspace.</p>
+ </div>
+ </div>
+
+ <div class="ad-form" style="max-width: none;">
+ <div class="ad-form__row">
+ <div class="ad-field">
+ <label class="ad-field__label" for="pattern-prefix">Custom property prefix</label>
+ <input class="ad-input" id="pattern-prefix" type="text" value="ad" aria-describedby="pattern-prefix-hint">
+ <p class="ad-field__hint" id="pattern-prefix-hint">Emitted as <code>--ad-*</code>.</p>
+ </div>
+ <div class="ad-field">
+ <label class="ad-field__label" for="pattern-base">Base unit</label>
+ <select class="ad-select" id="pattern-base">
+ <option>2px</option>
+ <option selected>4px</option>
+ <option>8px</option>
+ </select>
+ <p class="ad-field__hint">Changing this rewrites the whole space ramp.</p>
+ </div>
+ </div>
+
+ <div class="ad-field">
+ <label class="ad-field__label" for="pattern-accent">Accent ramp</label>
+ <input class="ad-input" id="pattern-accent" type="text" value="persimmon" aria-invalid="true" aria-describedby="pattern-accent-error">
+ <p class="ad-field__error" id="pattern-accent-error">
+ Ramp <code>persimmon</code> has no step that clears 4.5:1 on white below 700. Pick a step explicitly.
+ </p>
+ </div>
+
+ <fieldset class="ad-fieldset">
+ <legend>Emit</legend>
+ <label class="ad-choice">
+ <input type="checkbox" checked>
+ <span class="ad-choice__text">CSS custom properties<span class="ad-choice__hint">One <code>:root</code> block plus both dark selectors.</span></span>
+ </label>
+ <label class="ad-choice">
+ <input type="checkbox" checked>
+ <span class="ad-choice__text">Reduced-motion overrides<span class="ad-choice__hint">Zeroes every duration token.</span></span>
+ </label>
+ <label class="ad-choice">
+ <input type="checkbox">
+ <span class="ad-choice__text">JSON<span class="ad-choice__hint">For tooling that cannot read CSS.</span></span>
+ </label>
+ </fieldset>
+
+ <label class="ad-switch">
+ <input type="checkbox" checked>
+ <span class="ad-switch__track"></span>
+ <span>Fail the build on a contrast regression</span>
+ </label>
+
+ <div class="ad-progress" style="max-width: none;">
+ <div class="ad-progress__label">
+ <span id="pattern-progress">Token coverage</span>
+ <span class="ad-progress__value">32 / 32 aliases documented</span>
+ </div>
+ <div class="ad-progress__track" role="progressbar" aria-labelledby="pattern-progress"
+ aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
+ <div class="ad-progress__fill" style="width: 100%;"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="ad-card__footer" style="justify-content: space-between; padding: var(--ad-space-4) var(--ad-space-6);">
+ <span class="ad-badge ad-badge--caution"><span class="ad-badge__dot"></span>Unsaved changes</span>
+ <span style="display: flex; gap: var(--ad-space-3);">
+ <button type="button" class="ad-btn ad-btn--ghost">Discard</button>
+ <button type="submit" class="ad-btn ad-btn--primary">Save and rebuild</button>
+ </span>
+ </div>
+</form>
+{% endexample %}
+
+## What this screen is testing
+
+- **Vertical rhythm across mixed control heights.** An input, a select, a
+ fieldset of checkboxes, a switch, and a progress bar in one column, all spaced
+ by `--ad-space-5` from the form and never by margins of their own.
+- **The error state in a real form.** The invalid field sits between two valid
+ ones. It has to be findable without being the loudest thing on screen.
+- **A footer that carries both status and actions.** Status left, actions right,
+ and the destructive-ish action (`Discard`) as a ghost so it does not compete.
+- **Two tones at once.** A caution badge and an info callout coexist without the
+ page turning into a traffic light, because both draw from the same restrained
+ status tints.
+- **The dark theme.** Toggle it in the header. Nothing in this pattern has a
+ colour of its own.
diff --git a/_plugins/color_filters.rb b/_plugins/color_filters.rb
new file mode 100644
index 0000000..9843b06
--- /dev/null
+++ b/_plugins/color_filters.rb
@@ -0,0 +1,50 @@
+# 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
new file mode 100644
index 0000000..3b4c293
--- /dev/null
+++ b/_plugins/example_tag.rb
@@ -0,0 +1,100 @@
+# 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
new file mode 100644
index 0000000..7742202
--- /dev/null
+++ b/_plugins/token_generator.rb
@@ -0,0 +1,47 @@
+# 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/_sass/base/_reset.scss b/_sass/base/_reset.scss
new file mode 100644
index 0000000..a61a830
--- /dev/null
+++ b/_sass/base/_reset.scss
@@ -0,0 +1,79 @@
+/* A short reset. Everything it sets is something the system would otherwise
+ have to override in every component. */
+
+*,
+*::before,
+*::after { box-sizing: border-box; }
+
+html {
+ -webkit-text-size-adjust: 100%;
+ scroll-behavior: smooth;
+ scroll-padding-top: calc(var(--ad-header-height) + var(--ad-space-5));
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+body {
+ margin: 0;
+ background: var(--ad-color-canvas);
+ color: var(--ad-color-text);
+ font-family: var(--ad-font-sans);
+ font-size: var(--ad-text-md);
+ line-height: var(--ad-leading-normal);
+ font-synthesis-weight: none;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+::selection {
+ background: var(--ad-color-selection);
+ color: var(--ad-color-text);
+}
+
+h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; }
+ul[class], ol[class] { list-style: none; margin: 0; padding: 0; }
+
+img, svg, video { display: block; max-width: 100%; height: auto; }
+
+button, input, select, textarea {
+ font: inherit;
+ color: inherit;
+}
+
+hr {
+ height: 1px;
+ margin: var(--ad-space-6) 0;
+ border: 0;
+ background: var(--ad-color-border-subtle);
+}
+
+/* One focus ring, defined once. No component is allowed its own. */
+:focus-visible {
+ outline: 2px solid var(--ad-color-focus);
+ outline-offset: 2px;
+ border-radius: var(--ad-radius-xs);
+}
+:focus:not(:focus-visible) { outline: none; }
+
+[hidden] { display: none !important; }
+
+.ad-visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/_sass/base/_typography.scss b/_sass/base/_typography.scss
new file mode 100644
index 0000000..8b1419f
--- /dev/null
+++ b/_sass/base/_typography.scss
@@ -0,0 +1,97 @@
+/* Every rule here reads from a type token. If a size appears in this file as
+ a raw value, it is a bug. */
+
+h1, h2, h3, h4 {
+ font-weight: var(--ad-weight-semibold);
+ line-height: var(--ad-leading-snug);
+ text-wrap: balance;
+}
+
+h1 {
+ font-family: var(--ad-font-display);
+ font-size: var(--ad-text-3xl);
+ font-weight: var(--ad-weight-semibold);
+ line-height: var(--ad-leading-tight);
+ letter-spacing: var(--ad-tracking-tight);
+ font-optical-sizing: auto;
+}
+
+h2 {
+ font-size: var(--ad-text-2xl);
+ letter-spacing: var(--ad-tracking-tight);
+}
+
+h3 { font-size: var(--ad-text-xl); }
+h4 { font-size: var(--ad-text-lg); }
+
+p, ul, ol { margin: 0 0 var(--ad-space-4); }
+li + li { margin-top: var(--ad-space-1); }
+
+a {
+ color: var(--ad-color-accent);
+ text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
+ text-underline-offset: 0.18em;
+ text-decoration-thickness: 1px;
+ transition: color var(--ad-duration-fast) var(--ad-ease-standard),
+ text-decoration-color var(--ad-duration-fast) var(--ad-ease-standard);
+}
+a:hover {
+ color: var(--ad-color-accent-hover);
+ text-decoration-color: currentColor;
+}
+
+strong, b { font-weight: var(--ad-weight-semibold); }
+
+small { font-size: var(--ad-text-sm); }
+
+.ad-overline {
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ color: var(--ad-color-text-muted);
+}
+
+.ad-lead {
+ font-size: var(--ad-text-lg);
+ line-height: var(--ad-leading-relaxed);
+ color: var(--ad-color-text-muted);
+ max-width: var(--ad-measure-prose);
+ text-wrap: pretty;
+}
+
+.ad-muted { color: var(--ad-color-text-muted); }
+.ad-subtle { color: var(--ad-color-text-subtle); }
+
+code, kbd, samp, pre {
+ font-family: var(--ad-font-mono);
+ font-feature-settings: "liga" 0;
+}
+
+:not(pre) > code {
+ padding: 0.15em 0.4em;
+ font-size: 0.875em;
+ background: var(--ad-color-surface-sunken);
+ border: 1px solid var(--ad-color-border-subtle);
+ border-radius: var(--ad-radius-xs);
+ white-space: nowrap;
+}
+
+kbd {
+ display: inline-block;
+ padding: 0.1em 0.45em;
+ font-size: var(--ad-text-xs);
+ line-height: 1.6;
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border);
+ border-bottom-width: 2px;
+ border-radius: var(--ad-radius-xs);
+ color: var(--ad-color-text-muted);
+}
+
+blockquote {
+ margin-block: var(--ad-space-4);
+ padding-left: var(--ad-space-4);
+ border-left: 2px solid var(--ad-color-accent-border);
+ color: var(--ad-color-text-muted);
+}
diff --git a/_sass/components/_badge.scss b/_sass/components/_badge.scss
new file mode 100644
index 0000000..6eb0aa7
--- /dev/null
+++ b/_sass/components/_badge.scss
@@ -0,0 +1,49 @@
+/* Badge
+ A label, not a button. Never put a click handler on one. */
+
+.ad-badge {
+ /* --_tone is the badge's hue; --_fg is where that hue lands. Keeping them
+ apart is what lets --solid swap background and foreground without a
+ custom property referring to itself, which resolves to the wrong value. */
+ --_tone: var(--ad-color-text-muted);
+ --_bg: var(--ad-color-surface-sunken);
+ --_fg: var(--_tone);
+ --_border: var(--ad-color-border);
+
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ad-space-1);
+ padding: 0.125rem var(--ad-space-2);
+ border: 1px solid var(--_border);
+ border-radius: var(--ad-radius-xs);
+ background: var(--_bg);
+ color: var(--_fg);
+ font-size: var(--ad-text-xs);
+ font-weight: var(--ad-weight-medium);
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.ad-badge--accent { --_bg: var(--ad-color-accent-subtle); --_tone: var(--ad-color-accent); --_border: var(--ad-color-accent-border); }
+.ad-badge--positive { --_bg: var(--ad-color-positive-subtle); --_tone: var(--ad-color-positive); --_border: var(--ad-color-positive-border); }
+.ad-badge--caution { --_bg: var(--ad-color-caution-subtle); --_tone: var(--ad-color-caution); --_border: var(--ad-color-caution-border); }
+.ad-badge--critical { --_bg: var(--ad-color-critical-subtle); --_tone: var(--ad-color-critical); --_border: var(--ad-color-critical-border); }
+.ad-badge--info { --_bg: var(--ad-color-info-subtle); --_tone: var(--ad-color-info); --_border: var(--ad-color-info-border); }
+
+.ad-badge--pill { border-radius: var(--ad-radius-full); padding-inline: var(--ad-space-3); }
+.ad-badge--solid { --_bg: var(--_tone); --_fg: var(--ad-color-text-inverse); --_border: transparent; }
+
+/* A dot lets a badge carry status without relying on colour alone… */
+.ad-badge__dot {
+ width: 0.4rem;
+ height: 0.4rem;
+ border-radius: var(--ad-radius-full);
+ background: currentColor;
+ flex: none;
+}
+
+.ad-badge-set {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ad-space-2);
+}
diff --git a/_sass/components/_button.scss b/_sass/components/_button.scss
new file mode 100644
index 0000000..56faf22
--- /dev/null
+++ b/_sass/components/_button.scss
@@ -0,0 +1,111 @@
+/* Button
+ Variants: primary, secondary, ghost, danger.
+ Sizes: sm, md (default), lg.
+ Every variant shares one geometry so a row of mixed buttons still lines up. */
+
+.ad-btn {
+ --_bg: transparent;
+ --_fg: var(--ad-color-text);
+ --_border: transparent;
+ --_bg-hover: var(--ad-color-surface-hover);
+ --_height: 2.25rem;
+ --_padding: var(--ad-space-4);
+ --_font: var(--ad-text-sm);
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--ad-space-2);
+ min-height: var(--_height);
+ padding-inline: var(--_padding);
+ border: 1px solid var(--_border);
+ border-radius: var(--ad-radius-sm);
+ background: var(--_bg);
+ color: var(--_fg);
+ font-size: var(--_font);
+ font-weight: var(--ad-weight-semibold);
+ line-height: 1;
+ text-align: center;
+ text-decoration: none;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background-color var(--ad-duration-fast) var(--ad-ease-standard),
+ border-color var(--ad-duration-fast) var(--ad-ease-standard),
+ color var(--ad-duration-fast) var(--ad-ease-standard);
+}
+
+.ad-btn:hover:not(:disabled):not([aria-disabled="true"]) { background: var(--_bg-hover); }
+.ad-btn:active:not(:disabled):not([aria-disabled="true"]) { transform: translateY(0.5px); }
+
+.ad-btn:disabled,
+.ad-btn[aria-disabled="true"] {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.ad-btn--primary {
+ --_bg: var(--ad-color-accent);
+ --_fg: var(--ad-color-on-accent);
+ --_border: var(--ad-color-accent);
+ --_bg-hover: var(--ad-color-accent-hover);
+}
+.ad-btn--primary:hover:not(:disabled) { border-color: var(--ad-color-accent-hover); }
+
+.ad-btn--secondary {
+ --_bg: var(--ad-color-surface);
+ --_fg: var(--ad-color-text);
+ --_border: var(--ad-color-border);
+ --_bg-hover: var(--ad-color-surface-hover);
+}
+.ad-btn--secondary:hover:not(:disabled) { border-color: var(--ad-color-border-strong); }
+
+.ad-btn--ghost {
+ --_fg: var(--ad-color-text-muted);
+}
+.ad-btn--ghost:hover:not(:disabled) { --_fg: var(--ad-color-text); }
+
+/* Filled danger reads from the solid critical fill, not from
+ --ad-color-critical: that one is a text colour and goes light in the dark
+ theme, which would leave a white label on a pink button. */
+.ad-btn--danger {
+ --_bg: var(--ad-color-critical-solid);
+ --_fg: var(--ad-color-on-critical);
+ --_border: var(--ad-color-critical-solid);
+ --_bg-hover: color-mix(in srgb, var(--ad-color-critical-solid) 82%, black);
+}
+
+.ad-btn--sm { --_height: 1.75rem; --_padding: var(--ad-space-3); --_font: var(--ad-text-xs); }
+.ad-btn--lg { --_height: 2.75rem; --_padding: var(--ad-space-5); --_font: var(--ad-text-md); }
+
+/* Square footprint for a lone glyph. */
+.ad-btn--icon { --_padding: 0; width: var(--_height); }
+
+.ad-btn--block { width: 100%; }
+
+/* Grouped buttons collapse into one control with shared edges. */
+.ad-btn-group {
+ display: inline-flex;
+ isolation: isolate;
+}
+.ad-btn-group > .ad-btn { border-radius: 0; }
+.ad-btn-group > .ad-btn:first-child { border-start-start-radius: var(--ad-radius-sm); border-end-start-radius: var(--ad-radius-sm); }
+.ad-btn-group > .ad-btn:last-child { border-start-end-radius: var(--ad-radius-sm); border-end-end-radius: var(--ad-radius-sm); }
+.ad-btn-group > .ad-btn + .ad-btn { margin-inline-start: -1px; }
+.ad-btn-group > .ad-btn:hover,
+.ad-btn-group > .ad-btn:focus-visible { z-index: 1; }
+
+/* A spinner that replaces the label without changing the button's width. */
+.ad-btn[data-loading="true"] { color: transparent; pointer-events: none; position: relative; }
+.ad-btn[data-loading="true"]::after {
+ content: "";
+ position: absolute;
+ width: 0.875em;
+ height: 0.875em;
+ border: 2px solid color-mix(in srgb, var(--_fg) 30%, transparent);
+ border-top-color: var(--_fg);
+ border-radius: var(--ad-radius-full);
+ animation: ad-spin var(--ad-duration-spin) linear infinite;
+}
+
+@keyframes ad-spin { to { transform: rotate(360deg); } }
diff --git a/_sass/components/_callout.scss b/_sass/components/_callout.scss
new file mode 100644
index 0000000..ad86cf8
--- /dev/null
+++ b/_sass/components/_callout.scss
@@ -0,0 +1,42 @@
+/* Callout
+ Block-level messaging. Takes the full status triple — foreground, tint,
+ edge — so it can never half-match a badge of the same status. */
+
+.ad-callout {
+ --_fg: var(--ad-color-info);
+ --_bg: var(--ad-color-info-subtle);
+ --_border: var(--ad-color-info-border);
+
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--ad-space-3);
+ padding: var(--ad-space-4);
+ margin-block: var(--ad-space-5);
+ background: var(--_bg);
+ border: 1px solid var(--_border);
+ border-inline-start: 3px solid var(--_fg);
+ border-radius: var(--ad-radius-md);
+ color: var(--ad-color-text);
+ font-size: var(--ad-text-sm);
+ line-height: var(--ad-leading-normal);
+}
+
+.ad-callout--positive { --_fg: var(--ad-color-positive); --_bg: var(--ad-color-positive-subtle); --_border: var(--ad-color-positive-border); }
+.ad-callout--caution { --_fg: var(--ad-color-caution); --_bg: var(--ad-color-caution-subtle); --_border: var(--ad-color-caution-border); }
+.ad-callout--critical { --_fg: var(--ad-color-critical); --_bg: var(--ad-color-critical-subtle); --_border: var(--ad-color-critical-border); }
+.ad-callout--accent { --_fg: var(--ad-color-accent); --_bg: var(--ad-color-accent-subtle); --_border: var(--ad-color-accent-border); }
+.ad-callout--neutral { --_fg: var(--ad-color-border-strong); --_bg: var(--ad-color-surface-sunken); --_border: var(--ad-color-border); }
+
+.ad-callout__icon {
+ flex: none;
+ width: 1.25rem;
+ height: 1.25rem;
+ color: var(--_fg);
+}
+.ad-callout__body > :last-child { margin-bottom: 0; }
+.ad-callout__title {
+ margin: 0 0 var(--ad-space-1);
+ font-size: var(--ad-text-sm);
+ font-weight: var(--ad-weight-semibold);
+ color: var(--_fg);
+}
diff --git a/_sass/components/_card.scss b/_sass/components/_card.scss
new file mode 100644
index 0000000..be2f8c5
--- /dev/null
+++ b/_sass/components/_card.scss
@@ -0,0 +1,80 @@
+/* Card
+ A bordered sheet by default. Elevation is opt-in, because a page of
+ floating cards has no hierarchy at all. */
+
+.ad-card {
+ display: flex;
+ flex-direction: column;
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-md);
+ overflow: hidden;
+}
+
+.ad-card--raised { box-shadow: var(--ad-elevation-1); }
+.ad-card--floating { box-shadow: var(--ad-elevation-2); border-color: transparent; }
+
+.ad-card__media { background: var(--ad-color-surface-sunken); aspect-ratio: 16 / 9; }
+
+.ad-card__body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ad-space-2);
+ padding: var(--ad-space-4);
+}
+
+.ad-card__title {
+ font-size: var(--ad-text-lg);
+ font-weight: var(--ad-weight-semibold);
+ line-height: var(--ad-leading-snug);
+}
+.ad-card__text {
+ margin: 0;
+ color: var(--ad-color-text-muted);
+ font-size: var(--ad-text-sm);
+}
+
+.ad-card__footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--ad-space-3);
+ margin-top: auto;
+ padding: var(--ad-space-3) var(--ad-space-4);
+ border-top: 1px solid var(--ad-color-border-subtle);
+ background: var(--ad-color-surface-sunken);
+}
+
+/* A whole card that behaves as one link target. */
+.ad-card--link { transition: border-color var(--ad-duration-fast) var(--ad-ease-standard), box-shadow var(--ad-duration-fast) var(--ad-ease-standard); }
+.ad-card--link:hover { border-color: var(--ad-color-border-strong); box-shadow: var(--ad-elevation-1); }
+.ad-card--link:focus-within { outline: 2px solid var(--ad-color-focus); outline-offset: 2px; }
+
+.ad-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+ gap: var(--ad-space-4);
+}
+
+/* Avatar lives here because it almost never appears outside a card or a row. */
+.ad-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ flex: none;
+ border-radius: var(--ad-radius-full);
+ background: var(--ad-color-accent-subtle);
+ color: var(--ad-color-accent);
+ font-size: var(--ad-text-xs);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ overflow: hidden;
+}
+.ad-avatar--sm { width: 1.5rem; height: 1.5rem; font-size: var(--ad-text-2xs); }
+.ad-avatar--lg { width: 3rem; height: 3rem; font-size: var(--ad-text-md); }
+
+.ad-avatar-stack { display: flex; }
+.ad-avatar-stack > .ad-avatar + .ad-avatar { margin-inline-start: -0.5rem; box-shadow: 0 0 0 2px var(--ad-color-surface); }
diff --git a/_sass/components/_field.scss b/_sass/components/_field.scss
new file mode 100644
index 0000000..00a71a3
--- /dev/null
+++ b/_sass/components/_field.scss
@@ -0,0 +1,180 @@
+/* Field
+ The label / control / description / error quartet. The wrapper owns the
+ spacing so that every control type lines up in a form regardless of height. */
+
+.ad-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ad-space-2);
+ min-width: 0;
+}
+
+.ad-field__label {
+ font-size: var(--ad-text-sm);
+ font-weight: var(--ad-weight-medium);
+ color: var(--ad-color-text);
+}
+
+.ad-field__required {
+ color: var(--ad-color-critical);
+ margin-inline-start: 0.15em;
+}
+
+.ad-field__hint,
+.ad-field__error {
+ font-size: var(--ad-text-sm);
+ line-height: var(--ad-leading-snug);
+}
+.ad-field__hint { color: var(--ad-color-text-muted); }
+.ad-field__error { color: var(--ad-color-critical); font-weight: var(--ad-weight-medium); }
+
+.ad-input,
+.ad-textarea,
+.ad-select {
+ width: 100%;
+ min-height: 2.25rem;
+ padding: var(--ad-space-2) var(--ad-space-3);
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-sm);
+ color: var(--ad-color-text);
+ font-size: var(--ad-text-sm);
+ transition: border-color var(--ad-duration-fast) var(--ad-ease-standard),
+ background-color var(--ad-duration-fast) var(--ad-ease-standard);
+}
+
+.ad-input::placeholder,
+.ad-textarea::placeholder { color: var(--ad-color-text-subtle); }
+
+.ad-input:hover:not(:disabled),
+.ad-textarea:hover:not(:disabled),
+.ad-select:hover:not(:disabled) { border-color: var(--ad-color-border-strong); }
+
+.ad-input:disabled,
+.ad-textarea:disabled,
+.ad-select:disabled {
+ background: var(--ad-color-surface-sunken);
+ color: var(--ad-color-text-subtle);
+ cursor: not-allowed;
+}
+
+.ad-textarea { min-height: 5.5rem; resize: vertical; line-height: var(--ad-leading-normal); }
+
+.ad-select {
+ appearance: none;
+ padding-inline-end: var(--ad-space-7);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%2378705f' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right var(--ad-space-3) center;
+}
+
+/* Invalid is expressed on the control, so it works with native validation. */
+.ad-input[aria-invalid="true"],
+.ad-textarea[aria-invalid="true"],
+.ad-select[aria-invalid="true"] {
+ border-color: var(--ad-color-critical);
+ background: var(--ad-color-critical-subtle);
+}
+
+/* Checkbox and radio share a box; only the radius differs. */
+.ad-choice {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--ad-space-2);
+ font-size: var(--ad-text-sm);
+ cursor: pointer;
+}
+.ad-choice input[type="checkbox"],
+.ad-choice input[type="radio"] {
+ flex: none;
+ width: 1rem;
+ height: 1rem;
+ margin: 0.2rem 0 0;
+ accent-color: var(--ad-color-accent);
+ cursor: pointer;
+}
+.ad-choice__text { display: block; }
+.ad-choice__hint {
+ display: block;
+ color: var(--ad-color-text-muted);
+ font-size: var(--ad-text-xs);
+}
+.ad-choice input:disabled,
+.ad-choice:has(input:disabled) { cursor: not-allowed; color: var(--ad-color-text-subtle); }
+
+/* Switch — the one place in the system that overshoots on the way out. */
+.ad-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--ad-space-3);
+ cursor: pointer;
+ font-size: var(--ad-text-sm);
+}
+.ad-switch input {
+ position: absolute;
+ opacity: 0;
+ width: 2.25rem;
+ height: 1.25rem;
+ margin: 0;
+ cursor: pointer;
+}
+.ad-switch__track {
+ position: relative;
+ flex: none;
+ width: 2.25rem;
+ height: 1.25rem;
+ background: var(--ad-color-border-strong);
+ border-radius: var(--ad-radius-full);
+ transition: background-color var(--ad-duration-base) var(--ad-ease-standard);
+}
+.ad-switch__track::after {
+ content: "";
+ position: absolute;
+ inset-block-start: 2px;
+ inset-inline-start: 2px;
+ width: 1rem;
+ height: 1rem;
+ background: var(--ad-color-surface);
+ border-radius: var(--ad-radius-full);
+ box-shadow: var(--ad-elevation-1);
+ transition: translate var(--ad-duration-base) var(--ad-ease-spring);
+}
+.ad-switch input:checked + .ad-switch__track { background: var(--ad-color-accent); }
+.ad-switch input:checked + .ad-switch__track::after { translate: 1rem 0; }
+.ad-switch input:focus-visible + .ad-switch__track {
+ outline: 2px solid var(--ad-color-focus);
+ outline-offset: 2px;
+}
+.ad-switch input:disabled + .ad-switch__track { opacity: 0.45; }
+
+/* Rows of fields. Collapses to one column on narrow viewports. */
+.ad-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ad-space-5);
+ max-width: 32rem;
+}
+.ad-form__row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
+ gap: var(--ad-space-4);
+}
+.ad-form__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ad-space-3);
+ padding-top: var(--ad-space-2);
+}
+.ad-fieldset {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ad-space-3);
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+.ad-fieldset > legend {
+ padding: 0 0 var(--ad-space-2);
+ font-size: var(--ad-text-sm);
+ font-weight: var(--ad-weight-medium);
+}
diff --git a/_sass/components/_progress.scss b/_sass/components/_progress.scss
new file mode 100644
index 0000000..a19bb56
--- /dev/null
+++ b/_sass/components/_progress.scss
@@ -0,0 +1,45 @@
+/* Progress and meter
+ `progress` is for a task that will finish; `meter` is for a level that
+ simply is what it is. The system keeps them visually distinct on purpose. */
+
+.ad-progress {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ad-space-2);
+ min-width: 12rem;
+}
+.ad-progress__label {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--ad-space-3);
+ font-size: var(--ad-text-sm);
+ color: var(--ad-color-text-muted);
+}
+.ad-progress__value { font-variant-numeric: tabular-nums; color: var(--ad-color-text); }
+
+.ad-progress__track {
+ height: 0.5rem;
+ background: var(--ad-color-surface-sunken);
+ border: 1px solid var(--ad-color-border-subtle);
+ border-radius: var(--ad-radius-full);
+ overflow: hidden;
+}
+.ad-progress__fill {
+ height: 100%;
+ background: var(--ad-color-accent);
+ border-radius: inherit;
+ transition: width var(--ad-duration-slow) var(--ad-ease-standard);
+}
+.ad-progress--positive .ad-progress__fill { background: var(--ad-color-positive); }
+.ad-progress--caution .ad-progress__fill { background: var(--ad-color-caution); }
+.ad-progress--critical .ad-progress__fill { background: var(--ad-color-critical); }
+
+/* Indeterminate: a shuttle rather than a fill, because a fake percentage is a lie. */
+.ad-progress--indeterminate .ad-progress__fill {
+ width: 35%;
+ animation: ad-shuttle var(--ad-duration-loop) var(--ad-ease-standard) infinite;
+}
+@keyframes ad-shuttle {
+ 0% { margin-inline-start: -35%; }
+ 100% { margin-inline-start: 100%; }
+}
diff --git a/_sass/components/_table.scss b/_sass/components/_table.scss
new file mode 100644
index 0000000..2ac115b
--- /dev/null
+++ b/_sass/components/_table.scss
@@ -0,0 +1,62 @@
+/* Table
+ Left-aligned by default; numeric columns opt into tabular figures and
+ right alignment so digits stack. */
+
+.ad-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-md);
+ background: var(--ad-color-surface);
+}
+
+.ad-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--ad-text-sm);
+ text-align: left;
+}
+
+.ad-table caption {
+ padding: var(--ad-space-3) var(--ad-space-4);
+ color: var(--ad-color-text-muted);
+ font-size: var(--ad-text-xs);
+ text-align: left;
+ border-bottom: 1px solid var(--ad-color-border-subtle);
+}
+
+.ad-table th {
+ padding: var(--ad-space-3) var(--ad-space-4);
+ background: var(--ad-color-surface-sunken);
+ border-bottom: 1px solid var(--ad-color-border);
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ color: var(--ad-color-text-muted);
+ white-space: nowrap;
+}
+
+.ad-table td {
+ padding: var(--ad-space-3) var(--ad-space-4);
+ border-bottom: 1px solid var(--ad-color-border-subtle);
+ vertical-align: baseline;
+}
+
+.ad-table tbody tr:last-child td { border-bottom: 0; }
+.ad-table--hover tbody tr { transition: background-color var(--ad-duration-fast) var(--ad-ease-standard); }
+.ad-table--hover tbody tr:hover { background: var(--ad-color-surface-hover); }
+.ad-table--striped tbody tr:nth-child(even) { background: color-mix(in srgb, var(--ad-color-surface-sunken) 50%, transparent); }
+.ad-table--compact th,
+.ad-table--compact td { padding: var(--ad-space-2) var(--ad-space-3); }
+
+.ad-table__num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: "tnum";
+}
+.ad-table__code {
+ font-family: var(--ad-font-mono);
+ font-size: var(--ad-text-xs);
+ color: var(--ad-color-text-muted);
+ white-space: nowrap;
+}
diff --git a/_sass/components/_tabs.scss b/_sass/components/_tabs.scss
new file mode 100644
index 0000000..4655a46
--- /dev/null
+++ b/_sass/components/_tabs.scss
@@ -0,0 +1,37 @@
+/* Tabs
+ Markup follows the WAI-ARIA tabs pattern: a `tablist` of `tab` buttons
+ controlling `tabpanel`s. The keyboard wiring lives in assets/js/system.js. */
+
+.ad-tabs { display: flex; flex-direction: column; gap: var(--ad-space-4); }
+
+.ad-tabs__list {
+ display: flex;
+ gap: var(--ad-space-1);
+ overflow-x: auto;
+ border-bottom: 1px solid var(--ad-color-border);
+ scrollbar-width: none;
+}
+.ad-tabs__list::-webkit-scrollbar { display: none; }
+
+.ad-tabs__tab {
+ position: relative;
+ padding: var(--ad-space-2) var(--ad-space-3);
+ margin-bottom: -1px;
+ background: none;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ color: var(--ad-color-text-muted);
+ font-size: var(--ad-text-sm);
+ font-weight: var(--ad-weight-medium);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: color var(--ad-duration-fast) var(--ad-ease-standard),
+ border-color var(--ad-duration-fast) var(--ad-ease-standard);
+}
+.ad-tabs__tab:hover { color: var(--ad-color-text); }
+.ad-tabs__tab[aria-selected="true"] {
+ color: var(--ad-color-accent);
+ border-bottom-color: var(--ad-color-accent);
+}
+.ad-tabs__panel[hidden] { display: none; }
+.ad-tabs__panel:focus-visible { outline-offset: 4px; }
diff --git a/_sass/site/_example.scss b/_sass/site/_example.scss
new file mode 100644
index 0000000..ab07cf2
--- /dev/null
+++ b/_sass/site/_example.scss
@@ -0,0 +1,133 @@
+/* The live-example figure emitted by _plugins/example_tag.rb. */
+
+.ad-example {
+ margin: var(--ad-space-5) 0 var(--ad-space-6);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-md);
+ background: var(--ad-color-surface);
+ overflow: hidden;
+}
+
+.ad-example__title {
+ padding: var(--ad-space-2) var(--ad-space-4);
+ border-bottom: 1px solid var(--ad-color-border-subtle);
+ background: var(--ad-color-surface-sunken);
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ color: var(--ad-color-text-muted);
+}
+
+.ad-example__preview {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--ad-space-3);
+ padding: var(--ad-space-5);
+}
+.ad-example__preview--center { justify-content: center; }
+.ad-example__preview--end { justify-content: flex-end; }
+/* nowrap is load-bearing: a column-direction flex container with wrap enabled
+ sizes its single line against the container rather than the content, which
+ left every stacked example with a few hundred pixels of dead space. */
+.ad-example__preview--stack { flex-direction: column; flex-wrap: nowrap; align-items: stretch; }
+.ad-example__preview--stack.ad-example__preview--center { align-items: center; }
+.ad-example__preview--grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
+ align-items: start;
+}
+.ad-example__preview--sunken { background: var(--ad-color-surface-sunken); }
+.ad-example__preview--plain { padding: 0; }
+.ad-example__preview > * { min-width: 0; }
+
+.ad-example__source {
+ border-top: 1px solid var(--ad-color-border-subtle);
+ background: var(--ad-color-surface-sunken);
+}
+.ad-example__summary {
+ padding: var(--ad-space-2) var(--ad-space-4);
+ font-size: var(--ad-text-xs);
+ font-weight: var(--ad-weight-medium);
+ color: var(--ad-color-text-muted);
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ gap: var(--ad-space-2);
+}
+.ad-example__summary::-webkit-details-marker { display: none; }
+.ad-example__summary::before {
+ content: "";
+ width: 0.5rem;
+ height: 0.5rem;
+ border-inline-end: 1.5px solid currentColor;
+ border-block-end: 1.5px solid currentColor;
+ rotate: -45deg;
+ transition: rotate var(--ad-duration-fast) var(--ad-ease-standard);
+}
+.ad-example__source[open] > .ad-example__summary::before { rotate: 45deg; }
+.ad-example__summary:hover { color: var(--ad-color-text); }
+
+.ad-example__code {
+ position: relative;
+ border-top: 1px solid var(--ad-color-border-subtle);
+}
+.ad-example__copy {
+ position: absolute;
+ inset-block-start: var(--ad-space-2);
+ inset-inline-end: var(--ad-space-2);
+ padding: 0.125rem var(--ad-space-2);
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-xs);
+ color: var(--ad-color-text-muted);
+ font-size: var(--ad-text-2xs);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--ad-duration-fast) var(--ad-ease-standard);
+}
+.ad-example__code:hover .ad-example__copy,
+.ad-example__copy:focus-visible { opacity: 1; }
+.ad-example__copy[data-copied="true"] { color: var(--ad-color-positive); border-color: var(--ad-color-positive-border); }
+
+.ad-example pre {
+ margin: 0;
+ padding: var(--ad-space-4);
+ overflow-x: auto;
+ font-size: var(--ad-text-xs);
+ line-height: var(--ad-leading-normal);
+ tab-size: 2;
+}
+.ad-example pre code { background: none; border: 0; padding: 0; white-space: pre; }
+
+/* Standalone fenced code blocks in the prose. */
+.ad-doc-main > .highlighter-rouge,
+.ad-doc-main > .highlight {
+ margin-block: var(--ad-space-4);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-md);
+ background: var(--ad-color-surface-sunken);
+ overflow: hidden;
+}
+.ad-doc-main > .highlighter-rouge pre {
+ margin: 0;
+ padding: var(--ad-space-4);
+ overflow-x: auto;
+ font-size: var(--ad-text-xs);
+ line-height: var(--ad-leading-normal);
+}
+.ad-doc-main > .highlighter-rouge pre code { background: none; border: 0; padding: 0; white-space: pre; }
+
+/* Rouge tokens, mapped onto system colours so highlighting themes with the page. */
+.highlight { color: var(--ad-color-text); }
+.highlight .c, .highlight .c1, .highlight .cm, .highlight .cd { color: var(--ad-color-text-subtle); font-style: italic; }
+.highlight .nt { color: var(--ad-color-accent); }
+.highlight .na { color: var(--ad-color-info); }
+.highlight .s, .highlight .s1, .highlight .s2, .highlight .dl { color: var(--ad-color-positive); }
+.highlight .k, .highlight .kd, .highlight .kn, .highlight .kr { color: var(--ad-color-critical); }
+.highlight .p, .highlight .o { color: var(--ad-color-text-muted); }
+.highlight .m, .highlight .mi, .highlight .mf { color: var(--ad-color-caution); }
+.highlight .nc, .highlight .nf, .highlight .nn { color: var(--ad-color-info); }
diff --git a/_sass/site/_shell.scss b/_sass/site/_shell.scss
new file mode 100644
index 0000000..1050e91
--- /dev/null
+++ b/_sass/site/_shell.scss
@@ -0,0 +1,173 @@
+/* Documentation site chrome. Everything below the `.ad-doc-` prefix belongs
+ to this site rather than to the design system it documents. */
+
+.ad-doc-skip {
+ position: absolute;
+ inset-inline-start: var(--ad-space-4);
+ inset-block-start: -4rem;
+ z-index: 40;
+ padding: var(--ad-space-2) var(--ad-space-4);
+ background: var(--ad-color-surface-raised);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-sm);
+ box-shadow: var(--ad-elevation-2);
+ transition: inset-block-start var(--ad-duration-fast) var(--ad-ease-standard);
+}
+.ad-doc-skip:focus { inset-block-start: var(--ad-space-3); }
+
+.ad-doc-header {
+ position: sticky;
+ inset-block-start: 0;
+ z-index: 30;
+ height: var(--ad-header-height);
+ background: color-mix(in srgb, var(--ad-color-canvas) 88%, transparent);
+ backdrop-filter: saturate(1.6) blur(10px);
+ border-bottom: 1px solid var(--ad-color-border-subtle);
+}
+.ad-doc-header__inner {
+ display: flex;
+ align-items: center;
+ gap: var(--ad-space-4);
+ height: 100%;
+ max-width: var(--ad-width-wide);
+ margin-inline: auto;
+ padding-inline: var(--ad-space-4);
+}
+
+.ad-doc-wordmark {
+ display: flex;
+ align-items: baseline;
+ gap: var(--ad-space-2);
+ color: var(--ad-color-text);
+ text-decoration: none;
+ font-family: var(--ad-font-display);
+ font-size: var(--ad-text-lg);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-tight);
+ white-space: nowrap;
+}
+.ad-doc-wordmark:hover { color: var(--ad-color-text); }
+.ad-doc-wordmark__mark {
+ width: 1.5rem;
+ height: 1.5rem;
+ flex: none;
+ align-self: center;
+ color: var(--ad-color-accent);
+}
+.ad-doc-wordmark__version {
+ font-family: var(--ad-font-mono);
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-regular);
+ color: var(--ad-color-text-subtle);
+}
+
+.ad-doc-header__actions { margin-inline-start: auto; display: flex; align-items: center; gap: var(--ad-space-2); }
+
+.ad-doc-layout {
+ display: grid;
+ grid-template-columns: var(--ad-sidebar-width) minmax(0, 1fr);
+ gap: var(--ad-space-8);
+ max-width: var(--ad-width-wide);
+ margin-inline: auto;
+ padding: var(--ad-space-6) var(--ad-space-4) var(--ad-space-10);
+}
+
+.ad-doc-sidebar {
+ position: sticky;
+ inset-block-start: calc(var(--ad-header-height) + var(--ad-space-6));
+ align-self: start;
+ max-height: calc(100vh - var(--ad-header-height) - var(--ad-space-8));
+ overflow-y: auto;
+ padding-inline-end: var(--ad-space-2);
+}
+.ad-doc-nav__group + .ad-doc-nav__group { margin-top: var(--ad-space-5); }
+.ad-doc-nav__heading {
+ margin-bottom: var(--ad-space-2);
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ color: var(--ad-color-text-subtle);
+}
+.ad-doc-nav__link {
+ display: block;
+ padding: var(--ad-space-1) var(--ad-space-3);
+ margin-inline-start: calc(var(--ad-space-3) * -1);
+ border-inline-start: 2px solid transparent;
+ border-radius: 0 var(--ad-radius-xs) var(--ad-radius-xs) 0;
+ color: var(--ad-color-text-muted);
+ font-size: var(--ad-text-sm);
+ text-decoration: none;
+ transition: color var(--ad-duration-fast) var(--ad-ease-standard),
+ background-color var(--ad-duration-fast) var(--ad-ease-standard);
+}
+.ad-doc-nav__link:hover { color: var(--ad-color-text); background: var(--ad-color-surface-hover); }
+.ad-doc-nav__link[aria-current="page"] {
+ color: var(--ad-color-accent);
+ border-inline-start-color: var(--ad-color-accent);
+ background: var(--ad-color-accent-subtle);
+ font-weight: var(--ad-weight-medium);
+}
+
+.ad-doc-main { min-width: 0; }
+.ad-doc-main > * { max-width: var(--ad-width-content); }
+
+.ad-doc-title { margin-bottom: var(--ad-space-3); }
+.ad-doc-title__section {
+ display: block;
+ margin-bottom: var(--ad-space-2);
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-semibold);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ color: var(--ad-color-accent);
+}
+
+.ad-doc-main h2 {
+ margin-block: var(--ad-space-8) var(--ad-space-3);
+ padding-bottom: var(--ad-space-2);
+ border-bottom: 1px solid var(--ad-color-border-subtle);
+}
+.ad-doc-main h3 { margin-block: var(--ad-space-6) var(--ad-space-2); }
+.ad-doc-main h4 { margin-block: var(--ad-space-5) var(--ad-space-2); }
+.ad-doc-main p,
+.ad-doc-main ul,
+.ad-doc-main ol { max-width: var(--ad-measure-prose); }
+.ad-doc-main ul:not([class]),
+.ad-doc-main ol:not([class]) { padding-inline-start: var(--ad-space-5); }
+
+.ad-doc-footer {
+ max-width: var(--ad-width-wide);
+ margin-inline: auto;
+ padding: var(--ad-space-5) var(--ad-space-4) var(--ad-space-8);
+ border-top: 1px solid var(--ad-color-border-subtle);
+ color: var(--ad-color-text-subtle);
+ font-size: var(--ad-text-sm);
+}
+.ad-doc-footer__inner { display: flex; flex-wrap: wrap; gap: var(--ad-space-4); justify-content: space-between; }
+
+.ad-doc-nav-toggle { display: none; }
+
+@media (max-width: 60rem) {
+ .ad-doc-layout { grid-template-columns: minmax(0, 1fr); gap: var(--ad-space-5); }
+ .ad-doc-sidebar {
+ position: static;
+ max-height: none;
+ padding: var(--ad-space-4);
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-md);
+ }
+ .ad-doc-sidebar[data-collapsed="true"] > .ad-doc-nav { display: none; }
+ .ad-doc-nav-toggle {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ }
+ .ad-doc-nav-toggle + .ad-doc-nav { margin-top: var(--ad-space-4); }
+}
+
+@media (max-width: 34rem) {
+ .ad-doc-wordmark__version { display: none; }
+ .ad-doc-layout { padding-inline: var(--ad-space-3); }
+}
diff --git a/_sass/site/_tokens-docs.scss b/_sass/site/_tokens-docs.scss
new file mode 100644
index 0000000..e5fcdc9
--- /dev/null
+++ b/_sass/site/_tokens-docs.scss
@@ -0,0 +1,168 @@
+/* Presentation for the token reference tables and swatch grids. */
+
+.ad-swatch-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
+ gap: var(--ad-space-3);
+ margin-block: var(--ad-space-4) var(--ad-space-6);
+ padding: 0;
+ list-style: none;
+}
+.ad-swatch {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-sm);
+ overflow: hidden;
+ background: var(--ad-color-surface);
+}
+.ad-swatch + .ad-swatch { margin: 0; }
+.ad-swatch__chip {
+ display: flex;
+ align-items: flex-end;
+ justify-content: flex-end;
+ height: 4rem;
+ padding: var(--ad-space-2);
+ font-size: var(--ad-text-2xs);
+ font-weight: var(--ad-weight-semibold);
+}
+.ad-swatch__meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ padding: var(--ad-space-2);
+ border-top: 1px solid var(--ad-color-border-subtle);
+ font-family: var(--ad-font-mono);
+ font-size: var(--ad-text-2xs);
+}
+.ad-swatch__hex { color: var(--ad-color-text); text-transform: uppercase; }
+.ad-swatch__ratio { color: var(--ad-color-text-subtle); }
+
+.ad-alias-row { display: flex; align-items: center; gap: var(--ad-space-2); }
+.ad-alias-chip {
+ width: 1.5rem;
+ height: 1.5rem;
+ flex: none;
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-xs);
+}
+.ad-alias-chip--pair { display: flex; }
+.ad-alias-chip--pair > span { flex: 1; }
+
+.ad-grade { font-weight: var(--ad-weight-semibold); }
+.ad-grade--AAA, .ad-grade--AA { color: var(--ad-color-positive); }
+.ad-grade--AA-Large { color: var(--ad-color-caution); }
+.ad-grade--Fail { color: var(--ad-color-critical); }
+
+/* Type specimen rows. */
+.ad-specimen {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--ad-space-1);
+ padding-block: var(--ad-space-3);
+ border-bottom: 1px solid var(--ad-color-border-subtle);
+}
+.ad-specimen__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ad-space-3);
+ font-family: var(--ad-font-mono);
+ font-size: var(--ad-text-2xs);
+ color: var(--ad-color-text-subtle);
+}
+.ad-specimen__sample {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--ad-color-text);
+}
+
+/* Space and radius rulers. */
+.ad-ruler { display: flex; flex-direction: column; gap: var(--ad-space-2); }
+.ad-ruler__row { display: grid; grid-template-columns: 5.5rem 3rem 1fr; align-items: center; gap: var(--ad-space-3); font-family: var(--ad-font-mono); font-size: var(--ad-text-2xs); }
+.ad-ruler__bar { height: 0.75rem; background: var(--ad-color-accent); border-radius: var(--ad-radius-xs); }
+.ad-ruler__num { color: var(--ad-color-text-subtle); text-align: right; }
+
+.ad-radius-grid, .ad-elevation-grid, .ad-motion-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
+ gap: var(--ad-space-4);
+ margin-block: var(--ad-space-4);
+ padding: 0;
+ list-style: none;
+}
+.ad-radius-demo {
+ height: 4.5rem;
+ background: var(--ad-color-accent-subtle);
+ border: 1px solid var(--ad-color-accent-border);
+ margin-bottom: var(--ad-space-2);
+}
+.ad-elevation-demo {
+ height: 4.5rem;
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border-subtle);
+ border-radius: var(--ad-radius-md);
+ margin-bottom: var(--ad-space-2);
+}
+.ad-motion-demo {
+ height: 4.5rem;
+ display: grid;
+ place-items: center;
+ background: var(--ad-color-surface-sunken);
+ border: 1px solid var(--ad-color-border-subtle);
+ border-radius: var(--ad-radius-md);
+ margin-bottom: var(--ad-space-2);
+ cursor: pointer;
+ overflow: hidden;
+}
+.ad-motion-demo__dot {
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: var(--ad-radius-full);
+ background: var(--ad-color-accent);
+ translate: -3rem 0;
+ transition-property: translate;
+ transition-duration: var(--_duration, 200ms);
+ transition-timing-function: var(--_easing, ease);
+}
+.ad-motion-demo:hover .ad-motion-demo__dot,
+.ad-motion-demo:focus-visible .ad-motion-demo__dot { translate: 3rem 0; }
+
+.ad-token-name {
+ font-family: var(--ad-font-mono);
+ font-size: var(--ad-text-xs);
+ color: var(--ad-color-text);
+ white-space: nowrap;
+}
+
+/* Home page. */
+.ad-hero { padding-block: var(--ad-space-6) var(--ad-space-7); }
+.ad-hero__title {
+ font-size: var(--ad-text-4xl);
+ margin-bottom: var(--ad-space-4);
+}
+.ad-hero__actions { display: flex; flex-wrap: wrap; gap: var(--ad-space-3); margin-top: var(--ad-space-5); }
+.ad-stat-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
+ gap: var(--ad-space-4);
+ margin-block: var(--ad-space-6);
+ padding: var(--ad-space-4);
+ background: var(--ad-color-surface);
+ border: 1px solid var(--ad-color-border);
+ border-radius: var(--ad-radius-md);
+}
+.ad-stat__value {
+ display: block;
+ font-family: var(--ad-font-display);
+ font-size: var(--ad-text-2xl);
+ font-weight: var(--ad-weight-semibold);
+ line-height: var(--ad-leading-tight);
+ font-variant-numeric: tabular-nums;
+}
+.ad-stat__label {
+ font-size: var(--ad-text-2xs);
+ letter-spacing: var(--ad-tracking-wide);
+ text-transform: uppercase;
+ color: var(--ad-color-text-subtle);
+}
diff --git a/assets/css/style.scss b/assets/css/style.scss
new file mode 100644
index 0000000..ed2f5b9
--- /dev/null
+++ b/assets/css/style.scss
@@ -0,0 +1,21 @@
+---
+# 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.
+---
+
+@use "base/reset";
+@use "base/typography";
+
+@use "components/button";
+@use "components/field";
+@use "components/badge";
+@use "components/callout";
+@use "components/card";
+@use "components/table";
+@use "components/tabs";
+@use "components/progress";
+
+@use "site/shell";
+@use "site/example";
+@use "site/tokens-docs";
diff --git a/assets/favicon.svg b/assets/favicon.svg
new file mode 100644
index 0000000..aa09e89
--- /dev/null
+++ b/assets/favicon.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
+ <rect width="24" height="24" rx="5" fill="#ea360c"/>
+ <path d="M6.5 17.5 12 6l5.5 11.5" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M9 14h6" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>
+</svg>
diff --git a/assets/js/system.js b/assets/js/system.js
new file mode 100644
index 0000000..5456635
--- /dev/null
+++ b/assets/js/system.js
@@ -0,0 +1,162 @@
+// Behaviour for the pieces of the system that need it. Everything else is
+// CSS and semantic HTML on purpose — a design system that only works with
+// JavaScript running is a component library with extra steps.
+(function () {
+ "use strict";
+
+ var STORAGE_KEY = "ad-theme";
+
+ function readStored() {
+ try {
+ return localStorage.getItem(STORAGE_KEY);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function writeStored(value) {
+ try {
+ localStorage.setItem(STORAGE_KEY, value);
+ } catch (error) {
+ /* Nothing to do — the media query still gives a sensible default. */
+ }
+ }
+
+ function systemPrefersDark() {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
+ }
+
+ function currentTheme() {
+ var explicit = document.documentElement.getAttribute("data-theme");
+ if (explicit) return explicit;
+ return systemPrefersDark() ? "dark" : "light";
+ }
+
+ function paintToggle(theme) {
+ document.querySelectorAll("[data-theme-toggle]").forEach(function (toggle) {
+ var next = theme === "dark" ? "light" : "dark";
+ toggle.querySelectorAll("[data-theme-icon]").forEach(function (icon) {
+ icon.hidden = icon.dataset.themeIcon !== theme;
+ });
+ toggle.setAttribute("aria-label", "Switch to the " + next + " theme");
+ toggle.setAttribute("title", "Switch to the " + next + " theme");
+ var label = toggle.querySelector("[data-theme-label]");
+ if (label) label.textContent = "Switch to the " + next + " theme";
+ });
+ }
+
+ function setUpTheme() {
+ paintToggle(currentTheme());
+
+ document.addEventListener("click", function (event) {
+ var toggle = event.target.closest("[data-theme-toggle]");
+ if (!toggle) return;
+
+ var next = currentTheme() === "dark" ? "light" : "dark";
+ document.documentElement.setAttribute("data-theme", next);
+ writeStored(next);
+ paintToggle(next);
+ });
+
+ // Follow the OS while the reader has not made an explicit choice.
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function () {
+ if (!readStored()) paintToggle(currentTheme());
+ });
+ }
+
+ // Tabs, wired to the WAI-ARIA authoring practices: arrows move, Home/End
+ // jump, and only the selected tab stays in the tab order.
+ function setUpTabs() {
+ document.querySelectorAll("[data-tabs]").forEach(function (root) {
+ var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
+ if (!tabs.length) return;
+
+ function select(tab, focus) {
+ tabs.forEach(function (candidate) {
+ var selected = candidate === tab;
+ candidate.setAttribute("aria-selected", String(selected));
+ candidate.tabIndex = selected ? 0 : -1;
+ var panel = document.getElementById(candidate.getAttribute("aria-controls"));
+ if (panel) panel.hidden = !selected;
+ });
+ if (focus) tab.focus();
+ }
+
+ root.addEventListener("click", function (event) {
+ var tab = event.target.closest('[role="tab"]');
+ if (tab) select(tab, false);
+ });
+
+ root.addEventListener("keydown", function (event) {
+ var index = tabs.indexOf(document.activeElement);
+ if (index === -1) return;
+
+ var next = null;
+ if (event.key === "ArrowRight") next = tabs[(index + 1) % tabs.length];
+ else if (event.key === "ArrowLeft") next = tabs[(index - 1 + tabs.length) % tabs.length];
+ else if (event.key === "Home") next = tabs[0];
+ else if (event.key === "End") next = tabs[tabs.length - 1];
+
+ if (next) {
+ event.preventDefault();
+ select(next, true);
+ }
+ });
+
+ select(tabs.filter(function (tab) {
+ return tab.getAttribute("aria-selected") === "true";
+ })[0] || tabs[0], false);
+ });
+ }
+
+ // Copy button on every example's source panel.
+ function setUpCopy() {
+ document.addEventListener("click", function (event) {
+ var button = event.target.closest("[data-copy]");
+ if (!button) return;
+
+ var code = button.parentElement.querySelector("code");
+ if (!code || !navigator.clipboard) return;
+
+ navigator.clipboard.writeText(code.textContent).then(function () {
+ button.dataset.copied = "true";
+ button.textContent = "Copied";
+ window.setTimeout(function () {
+ delete button.dataset.copied;
+ button.textContent = "Copy";
+ }, 1600);
+ });
+ });
+ }
+
+ function setUpNavToggle() {
+ document.addEventListener("click", function (event) {
+ var toggle = event.target.closest("[data-nav-toggle]");
+ if (!toggle) return;
+
+ var sidebar = toggle.closest("[data-sidebar]");
+ var collapsed = sidebar.dataset.collapsed !== "false";
+ sidebar.dataset.collapsed = String(!collapsed);
+ toggle.setAttribute("aria-expanded", String(collapsed));
+ });
+ }
+
+ // Demo-only: the buttons page shows a loading state that resolves itself.
+ function setUpLoadingDemo() {
+ document.addEventListener("click", function (event) {
+ var button = event.target.closest("[data-demo-loading]");
+ if (!button || button.dataset.loading === "true") return;
+
+ button.dataset.loading = "true";
+ window.setTimeout(function () {
+ button.dataset.loading = "false";
+ }, 1800);
+ });
+ }
+
+ setUpTheme();
+ setUpTabs();
+ setUpCopy();
+ setUpNavToggle();
+ setUpLoadingDemo();
+})();
diff --git a/index.md b/index.md
new file mode 100644
index 0000000..fb72785
--- /dev/null
+++ b/index.md
@@ -0,0 +1,115 @@
+---
+layout: default
+title: Overview
+description: A small, documented design system rendered from YAML tokens by Jekyll on Ruby 4.0.
+---
+
+<div class="ad-hero">
+ <p class="ad-overline">Design system</p>
+ <h1 class="ad-hero__title">Every design system is a pile of arbitrary definitions. This one says so out loud.</h1>
+ <p class="ad-lead">
+ Somebody decided that the gap is 16px, that the accent is this orange and not that one,
+ and that a warning is amber. None of it was inevitable. What makes it a system is that
+ the decisions got written down in one place, given names, and then actually used.
+ </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--secondary ad-btn--lg" href="{{ '/components/button/' | relative_url }}">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__label">Colour ramps</span>
+ </div>
+ <div class="ad-stat">
+ <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__label">Components</span>
+ </div>
+ <div class="ad-stat">
+ <span class="ad-stat__value">{{ site.data.build.ruby }}</span>
+ <span class="ad-stat__label">Ruby</span>
+ </div>
+</div>
+
+## 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.
+
+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, the swatch, the
+contrast figure, and the button all change together or none 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>
+ <p class="ad-card__text">
+ Colour, type, space, elevation, and motion — with contrast ratios computed
+ from the tokens during the build rather than typed in by hand.
+ </p>
+ </div>
+ </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>
+ <p class="ad-card__text">
+ Eight components, each rendered live next to the exact markup that produced
+ it. The demo and the snippet are the same string.
+ </p>
+ </div>
+ </article>
+ <article class="ad-card ad-card--link">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title"><a href="{{ '/rationale/' | relative_url }}">Why arbitrary</a></h3>
+ <p class="ad-card__text">
+ The argument for naming a decision you cannot justify, and the four rules
+ this system actually enforces.
+ </p>
+ </div>
+ </article>
+</div>
+
+## A taste of it
+
+{% example title="Everything on one card" layout="stack" %}
+<article class="ad-card ad-card--raised" style="max-width: 26rem;">
+ <div class="ad-card__body">
+ <div class="ad-badge-set">
+ <span class="ad-badge ad-badge--accent">Draft</span>
+ <span class="ad-badge"><span class="ad-badge__dot"></span>Unassigned</span>
+ </div>
+ <h3 class="ad-card__title">Rename the caution colour</h3>
+ <p class="ad-card__text">
+ Three people have called it “yellow” in review this month. The token is
+ <code>--ad-color-caution</code>; the ramp is amber.
+ </p>
+ </div>
+ <div class="ad-card__footer">
+ <button type="button" class="ad-btn ad-btn--primary ad-btn--sm">Approve</button>
+ <button type="button" class="ad-btn ad-btn--ghost ad-btn--sm">Dismiss</button>
+ </div>
+</article>
+{% endexample %}
+
+## Using it elsewhere
+
+Everything the system needs is two stylesheets and no JavaScript. Copy them into
+any project and the class names work as documented.
+
+```html
+<link rel="stylesheet" href="tokens.css">
+<link rel="stylesheet" href="style.css">
+```
+
+See [Using the CSS]({{ '/install/' | relative_url }}) for the details, including
+how to retheme the whole thing by overriding a dozen custom properties.
diff --git a/install.md b/install.md
new file mode 100644
index 0000000..6134376
--- /dev/null
+++ b/install.md
@@ -0,0 +1,124 @@
+---
+title: Using the CSS
+permalink: /install/
+section: Start here
+summary: >-
+ Two stylesheets, no JavaScript, no build step. Then a dozen custom properties
+ if you want it to stop looking like this site.
+---
+
+## 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.
+
+```html
+<link rel="stylesheet" href="tokens.css">
+<link rel="stylesheet" href="style.css">
+```
+
+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.
+
+Nothing in the components needs JavaScript. `assets/js/system.js` adds keyboard
+handling for [Tabs]({{ '/components/tabs/' | relative_url }}), the theme toggle,
+and the copy buttons on this site — that is all it is for.
+
+## Dark theme
+
+The token file emits the dark palette twice: once under
+`@media (prefers-color-scheme: dark)`, so a reader who has never touched your UI
+still gets the right theme, and once under `:root[data-theme="dark"]`, so an
+explicit choice beats the media query in both directions.
+
+```css
+:root { --ad-color-canvas: #faf9f7; }
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) { --ad-color-canvas: #171512; }
+}
+
+:root[data-theme="dark"] { --ad-color-canvas: #171512; }
+```
+
+To wire up a toggle, set or clear `data-theme` on `<html>` and persist the
+choice. Apply it in a blocking inline script in `<head>` or the page will flash
+the wrong theme before your bundle runs.
+
+## Retheming
+
+Because every component reads an alias, retheming is a matter of repointing a
+dozen custom properties after the token file loads. No recompile, no fork.
+
+```css
+:root {
+ --ad-color-accent: #1d5bd8;
+ --ad-color-accent-hover: #1e4baf;
+ --ad-color-accent-subtle: #eff7ff;
+ --ad-color-accent-border: #bfdffe;
+ --ad-color-on-accent: #ffffff;
+
+ --ad-radius-sm: 2px;
+ --ad-radius-md: 3px;
+ --ad-font-sans: "Söhne", system-ui, sans-serif;
+}
+```
+
+<div class="ad-callout ad-callout--caution">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">Re-check contrast after retheming</p>
+ <p style="margin:0">
+ The system's contrast guarantees are about the system's own values. Swap the
+ accent and you own the result. The checker is a hundred lines of dependency-free
+ Ruby in <code>lib/arbitrary_definitions/color_math.rb</code> if you want to
+ run the same assertions against your palette.
+ </p>
+ </div>
+</div>
+
+## 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:
+
+```console
+$ rbenv install 4.0.6 # or however you get Ruby 4.0
+$ bundle install
+$ bundle exec rake # runs the token tests, then builds
+$ bundle exec rake serve # http://127.0.0.1:4000
+```
+
+## What is where
+
+<div class="ad-table-wrap">
+ <table class="ad-table ad-table--compact">
+ <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 three 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">_sass/components/</td><td>One stylesheet per component. No hexes, no pixels.</td></tr>
+ <tr><td class="ad-table__code">test/</td><td>Minitest. Contrast floors, ramp monotonicity, CSS well-formedness.</td></tr>
+ </tbody>
+ </table>
+</div>
+
+## 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.
+
+{% raw %}
+```liquid
+{% example title="Variants" open %}
+<button type="button" class="ad-btn ad-btn--primary">Publish</button>
+<button type="button" class="ad-btn ad-btn--ghost">Cancel</button>
+{% endexample %}
+```
+{% endraw %}
+
+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/color_math.rb b/lib/arbitrary_definitions/color_math.rb
new file mode 100644
index 0000000..fcac378
--- /dev/null
+++ b/lib/arbitrary_definitions/color_math.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module ArbitraryDefinitions
+ # WCAG 2.1 colour maths, kept free of any Jekyll dependency so it can be
+ # unit tested on its own (see test/test_color_math.rb).
+ #
+ # The swatch tables on the Colour page call into this at build time, which
+ # is the whole point: a contrast number that is computed from the token
+ # cannot drift away from the token.
+ module ColorMath
+ module_function
+
+ HEX_PATTERN = /\A#?(\h{3}|\h{6})\z/
+ # Values that are not a flat hex — rgba(), colour functions, `none` —
+ # have no single luminance, so we decline rather than guess.
+ UNMEASURABLE = nil
+
+ # "#f95116" or "f95116" or "#abc" -> [249, 81, 22]
+ def to_rgb(hex)
+ match = HEX_PATTERN.match(hex.to_s.strip)
+ return UNMEASURABLE unless match
+
+ digits = match[1]
+ digits = digits.chars.flat_map { |c| [c, c] }.join if digits.length == 3
+ digits.scan(/\h{2}/).map { |pair| pair.to_i(16) }
+ end
+
+ def hex?(value)
+ !to_rgb(value).nil?
+ end
+
+ # WCAG relative luminance, 0.0 (black) to 1.0 (white).
+ def relative_luminance(hex)
+ rgb = to_rgb(hex)
+ return UNMEASURABLE unless rgb
+
+ r, g, b = rgb.map do |channel|
+ srgb = channel / 255.0
+ srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055)**2.4
+ end
+
+ (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
+ end
+
+ # Contrast ratio between two colours, 1.0 to 21.0.
+ def contrast_ratio(one, two)
+ a = relative_luminance(one)
+ b = relative_luminance(two)
+ return UNMEASURABLE unless a && b
+
+ lighter, darker = a > b ? [a, b] : [b, a]
+ (lighter + 0.05) / (darker + 0.05)
+ end
+
+ # The WCAG grade a pair earns for normal-size body text.
+ # Large text (>= 18.66px bold or 24px) passes AA at 3.0, which the
+ # `large:` keyword accounts for.
+ def grade(ratio, large: false)
+ return "n/a" unless ratio
+
+ aa = large ? 3.0 : 4.5
+ aaa = large ? 4.5 : 7.0
+
+ if ratio >= aaa then "AAA"
+ elsif ratio >= aa then "AA"
+ elsif ratio >= 3.0 then "AA Large"
+ else "Fail"
+ end
+ end
+
+ # Whichever of the two candidates reads more clearly on `background`.
+ def readable_on(background, light: "#ffffff", dark: "#171512")
+ on_light = contrast_ratio(background, light)
+ on_dark = contrast_ratio(background, dark)
+ return dark unless on_light && on_dark
+
+ on_light >= on_dark ? light : dark
+ end
+ end
+end
diff --git a/lib/arbitrary_definitions/token_set.rb b/lib/arbitrary_definitions/token_set.rb
new file mode 100644
index 0000000..ca007a3
--- /dev/null
+++ b/lib/arbitrary_definitions/token_set.rb
@@ -0,0 +1,192 @@
+# frozen_string_literal: true
+
+require "yaml"
+
+module ArbitraryDefinitions
+ # Reads the YAML under `_data/tokens/` and compiles it into one stylesheet
+ # of CSS custom properties.
+ #
+ # The YAML is the source of truth. Nothing else in the repository is allowed
+ # to write a raw hex, a raw pixel value, or a raw duration — the SCSS reads
+ # `var(--ad-*)` and the docs read the same YAML through Liquid, so the
+ # swatch on the page and the colour in the button are the same fact rendered
+ # twice.
+ class TokenSet
+ class UnknownReference < StandardError; end
+
+ # A `ramp.step` reference, e.g. `persimmon.600`.
+ REFERENCE = /\A([a-z][a-z0-9_]*)\.([a-z0-9]+)\z/
+
+ attr_reader :prefix, :ramps, :semantic, :scale
+
+ def initialize(dir:, prefix: "ad")
+ @prefix = prefix
+ @ramps = load_yaml(File.join(dir, "color.yml")).fetch("ramps")
+ @semantic = load_yaml(File.join(dir, "semantic.yml")).fetch("groups")
+ @scale = load_yaml(File.join(dir, "scale.yml"))
+ @index = build_ramp_index
+ end
+
+ # "ink.900" -> "#26231f"; a literal colour passes straight through.
+ def resolve(value)
+ value = value.to_s
+ match = REFERENCE.match(value)
+ return value unless match
+
+ @index.fetch(value) do
+ raise UnknownReference, "no token #{value.inspect} in ramp #{match[1].inspect}"
+ end
+ end
+
+ # Every semantic token, flattened, with both themes resolved. The docs use
+ # this to render the alias tables.
+ def semantic_tokens
+ semantic.flat_map do |group|
+ group.fetch("tokens").map do |token|
+ {
+ "group" => group.fetch("name"),
+ "token" => token.fetch("token"),
+ "name" => custom_property(token.fetch("token")),
+ "light" => resolve(token.fetch("light")),
+ "dark" => resolve(token.fetch("dark")),
+ "light_ref" => token.fetch("light"),
+ "dark_ref" => token.fetch("dark"),
+ "usage" => token["usage"],
+ }
+ end
+ end
+ end
+
+ def custom_property(name)
+ "--#{prefix}-#{name}"
+ end
+
+ def to_css
+ [
+ header,
+ root_block,
+ dark_block,
+ forced_dark_block,
+ reduced_motion_block,
+ ].join("\n\n") << "\n"
+ end
+
+ private
+
+ def load_yaml(path)
+ YAML.safe_load_file(path, aliases: true)
+ end
+
+ def build_ramp_index
+ ramps.each_with_object({}) do |ramp, index|
+ ramp.fetch("steps").each do |step|
+ index["#{ramp.fetch('name')}.#{step.fetch('step')}"] = step.fetch("value")
+ end
+ end
+ end
+
+ def header
+ <<~CSS.strip
+ /* Generated by _plugins/token_generator.rb from _data/tokens/*.yml.
+ Do not edit by hand — edit the YAML and rebuild. */
+ CSS
+ end
+
+ def root_block
+ lines = []
+ lines << " /* Palette ramps — reference only. Components use the aliases below. */"
+ ramps.each do |ramp|
+ ramp.fetch("steps").each do |step|
+ lines << declaration("#{ramp.fetch('name')}-#{step.fetch('step')}", step.fetch("value"))
+ end
+ end
+
+ semantic.each do |group|
+ lines << ""
+ lines << " /* #{group.fetch('name')} */"
+ group.fetch("tokens").each do |token|
+ lines << declaration(token.fetch("token"), resolve(token.fetch("light")))
+ end
+ end
+
+ scale_groups.each do |label, entries|
+ lines << ""
+ lines << " /* #{label} */"
+ entries.each { |entry| lines << declaration(entry.fetch("token"), entry.fetch("value")) }
+ end
+
+ wrap(":root", lines)
+ end
+
+ def dark_block
+ lines = [' color-scheme: dark;']
+ semantic.each do |group|
+ lines << ""
+ lines << " /* #{group.fetch('name')} */"
+ group.fetch("tokens").each do |token|
+ lines << declaration(token.fetch("token"), resolve(token.fetch("dark")))
+ end
+ end
+
+ body = wrap(" :root:not([data-theme=\"light\"])", lines, indent: " ")
+ <<~CSS.rstrip
+ @media (prefers-color-scheme: dark) {
+ #{body}
+ }
+ CSS
+ end
+
+ # An explicit choice from the theme toggle has to beat the media query in
+ # both directions, so the dark declarations are emitted a second time
+ # under the attribute selector.
+ def forced_dark_block
+ lines = [' color-scheme: dark;']
+ semantic.each do |group|
+ group.fetch("tokens").each do |token|
+ lines << declaration(token.fetch("token"), resolve(token.fetch("dark")))
+ end
+ end
+
+ wrap(':root[data-theme="dark"]', lines)
+ end
+
+ def reduced_motion_block
+ durations = scale.dig("motion", "durations") || []
+ lines = durations.map { |entry| declaration(entry.fetch("token"), "0ms") }
+
+ <<~CSS.rstrip
+ @media (prefers-reduced-motion: reduce) {
+ #{wrap(' :root', lines, indent: ' ')}
+ }
+ CSS
+ end
+
+ # The non-colour token groups, in the order they should appear, paired
+ # with a human label for the comment banner.
+ def scale_groups
+ groups = []
+ typography = scale.fetch("typography")
+ groups << ["Type families", typography.fetch("families")]
+ groups << ["Type sizes", typography.fetch("sizes")]
+ groups << ["Line heights", typography.fetch("line_heights")]
+ groups << ["Weights", typography.fetch("weights")]
+ groups << ["Tracking", typography.fetch("tracking")]
+ groups << ["Space", scale.dig("space", "scale")]
+ groups << ["Radius", scale.dig("radius", "scale")]
+ groups << ["Elevation", scale.dig("elevation", "scale")]
+ groups << ["Durations", scale.dig("motion", "durations")]
+ groups << ["Easings", scale.dig("motion", "easings")]
+ groups << ["Layout", scale.dig("layout", "scale")]
+ groups.reject { |(_, entries)| entries.nil? || entries.empty? }
+ end
+
+ def declaration(name, value)
+ " #{custom_property(name)}: #{value};"
+ end
+
+ def wrap(selector, lines, indent: "")
+ body = lines.map { |line| line.empty? ? "" : "#{indent}#{line}" }.join("\n")
+ "#{selector} {\n#{body}\n#{indent}}"
+ end
+ end
+end
diff --git a/rationale.md b/rationale.md
new file mode 100644
index 0000000..e67538d
--- /dev/null
+++ b/rationale.md
@@ -0,0 +1,153 @@
+---
+title: Why arbitrary
+permalink: /rationale/
+section: Start here
+summary: >-
+ The case for writing down a decision you cannot fully justify, and the four
+ rules this system actually enforces.
+---
+
+## The uncomfortable part
+
+Almost nothing in a design system is derived. The spacing base is 4px because
+4 divides nicely and screens used to be 1x, not because 4 is correct. The accent
+is persimmon because somebody liked it. The type ramp is a minor third until it
+isn't, at the small end, where a strict ratio produces 13.3px and 11.1px and
+nobody can tell those apart anyway.
+
+Design systems that pretend otherwise end up in an unwinnable argument. Somebody
+asks *why* the gap is 16px and not 14px, and the honest answer — "it isn't
+16 because it's right, it's right because it's 16" — sounds like a dodge. So the
+team invents a justification, and then defends the justification instead of the
+decision, and a year later nobody can change the number because the reasoning
+has calcified around it.
+
+## What actually makes it a system
+
+Not the quality of the individual choices. The properties below:
+
+<div class="ad-card-grid">
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">It is written down once</h3>
+ <p class="ad-card__text">
+ Three YAML files. Not a Figma library that disagrees with the CSS, and
+ not a spreadsheet somebody exports by hand.
+ </p>
+ </div>
+ </article>
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">It has names</h3>
+ <p class="ad-card__text">
+ <code>--ad-color-critical</code> survives a change of mind about which
+ red. <code>#b01a3c</code> does not.
+ </p>
+ </div>
+ </article>
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">It is actually used</h3>
+ <p class="ad-card__text">
+ Every colour, type, spacing, radius, elevation, and motion value in the
+ components is a <code>var(--ad-*)</code>. Not one is a literal.
+ </p>
+ </div>
+ </article>
+ <article class="ad-card">
+ <div class="ad-card__body">
+ <h3 class="ad-card__title">It can be changed</h3>
+ <p class="ad-card__text">
+ Because of the three above, moving the accent one ramp step is a
+ one-line diff — which is exactly what happened while this site was built.
+ </p>
+ </div>
+ </article>
+</div>
+
+## The one thing that is not arbitrary
+
+Contrast. A ratio is a physical fact about two colours and a pair of eyes, and
+it does not care what the brand guidelines say.
+
+So it is the one thing the build refuses to take on trust.
+`lib/arbitrary_definitions/color_math.rb` computes WCAG relative luminance from
+the token values, and `test/test_token_set.rb` asserts a floor on ten text
+pairings and two non-text pairings, in both themes. If a token change drops any
+of them below the line, `rake test` fails and the site does not deploy.
+
+That is not a formality. The accent in this system started at persimmon 600 —
+a nicer orange — and the test caught it at 4.17:1, under the 4.5:1 floor for
+normal text. It moved to 700. The ink ramp got darker at steps 500 through 800
+for the same reason.
+
+The dark theme caught a second class of mistake that no ratio would have found:
+the destructive button was filled with `--ad-color-critical`, which is a *text*
+colour and therefore goes pale in dark mode. White label, pink button. The fix
+was a separate `--ad-color-critical-solid` token, and a pair of assertions that
+now check every filled surface in the system.
+
+```console
+$ bundle exec rake test
+23 runs, 199 assertions, 0 failures, 0 errors, 0 skips
+```
+
+## The four rules
+
+<div class="ad-callout ad-callout--accent">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">1. No colour and no motion outside the token files</p>
+ <p style="margin:0">
+ A hex or a duration in a component stylesheet is a decision made in the
+ dark, and it is the kind that breaks the moment somebody adds a theme. No
+ component stylesheet in this repository contains either; the single
+ exception is the chevron inside the select's data-URI, which CSS cannot
+ reach with a custom property, and it is commented as such. Geometry is
+ looser: a 1px hairline or a control's own height is the component's
+ business. Its <em>colour</em> never is.
+ </p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--accent">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">2. Components read aliases, never ramps</p>
+ <p style="margin:0">
+ <code>--ad-color-text</code>, not <code>--ad-ink-900</code>. The ramp is
+ raw material; the alias is the decision. This is the whole reason the dark
+ theme is a block of repointed aliases rather than a second stylesheet.
+ </p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--accent">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">3. Every token says what it is for</p>
+ <p style="margin:0">
+ A <code>usage</code> line on every semantic token, enforced by a test. A
+ token nobody can describe is a token nobody will use correctly.
+ </p>
+ </div>
+</div>
+
+<div class="ad-callout ad-callout--accent">
+ <div class="ad-callout__body">
+ <p class="ad-callout__title">4. The docs are generated from the tokens</p>
+ <p style="margin:0">
+ Every swatch, every ratio, and every table on this site is rendered from
+ the same YAML the stylesheet is compiled from. Documentation that is
+ re-typed is documentation that will be wrong by Thursday.
+ </p>
+ </div>
+</div>
+
+## What this system does not have
+
+Being honest about the arbitrary parts means being honest about the gaps, too.
+This is a small system built to be read, not a product design system. It has no
+modal, no menu, no toast, no date picker, no icon set, no RTL audit beyond using
+logical properties, and no component tests. The border colour knowingly sits
+under the 3:1 that WCAG asks of a meaningful boundary — see the note on
+[Colour]({{ '/foundations/color/' | relative_url }}).
+
+Those are all real omissions. Naming them is cheaper than being caught by them.
diff --git a/test/test_color_math.rb b/test/test_color_math.rb
new file mode 100644
index 0000000..49fbbca
--- /dev/null
+++ b/test/test_color_math.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require "minitest/autorun"
+require "arbitrary_definitions/color_math"
+
+class TestColorMath < Minitest::Test
+ CM = ArbitraryDefinitions::ColorMath
+
+ def test_parses_six_digit_hex_with_and_without_hash
+ assert_equal [249, 81, 22], CM.to_rgb("#f95116")
+ assert_equal [249, 81, 22], CM.to_rgb("f95116")
+ end
+
+ def test_expands_three_digit_shorthand
+ assert_equal [0xaa, 0xbb, 0xcc], CM.to_rgb("#abc")
+ end
+
+ def test_declines_values_that_have_no_single_luminance
+ assert_nil CM.to_rgb("rgba(38, 35, 31, 0.5)")
+ assert_nil CM.to_rgb("none")
+ assert_nil CM.relative_luminance("var(--ad-color-canvas)")
+ assert_nil CM.contrast_ratio("#ffffff", "none")
+ end
+
+ def test_luminance_endpoints
+ assert_in_delta 0.0, CM.relative_luminance("#000000"), 1e-9
+ assert_in_delta 1.0, CM.relative_luminance("#ffffff"), 1e-9
+ end
+
+ def test_black_on_white_is_the_maximum_contrast
+ assert_in_delta 21.0, CM.contrast_ratio("#000000", "#ffffff"), 1e-6
+ end
+
+ def test_contrast_is_symmetric_and_self_contrast_is_one
+ assert_in_delta CM.contrast_ratio("#ea360c", "#ffffff"),
+ CM.contrast_ratio("#ffffff", "#ea360c"), 1e-12
+ assert_in_delta 1.0, CM.contrast_ratio("#ea360c", "#ea360c"), 1e-12
+ end
+
+ def test_known_pair_matches_the_wcag_reference_value
+ # #767676 on white is the canonical "exactly AA" grey.
+ assert_in_delta 4.54, CM.contrast_ratio("#767676", "#ffffff"), 0.01
+ end
+
+ def test_grades_use_the_normal_text_thresholds
+ assert_equal "AAA", CM.grade(7.0)
+ assert_equal "AA", CM.grade(4.5)
+ assert_equal "AA Large", CM.grade(3.0)
+ assert_equal "Fail", CM.grade(2.99)
+ assert_equal "n/a", CM.grade(nil)
+ end
+
+ def test_large_text_grades_are_more_forgiving
+ assert_equal "AA", CM.grade(3.0, large: true)
+ assert_equal "AAA", CM.grade(4.5, large: true)
+ end
+
+ def test_readable_on_picks_the_higher_contrast_foreground
+ assert_equal "#171512", CM.readable_on("#faf9f7")
+ assert_equal "#ffffff", CM.readable_on("#171512")
+ end
+end
diff --git a/test/test_token_set.rb b/test/test_token_set.rb
new file mode 100644
index 0000000..7575755
--- /dev/null
+++ b/test/test_token_set.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require "minitest/autorun"
+require "arbitrary_definitions/token_set"
+require "arbitrary_definitions/color_math"
+
+class TestTokenSet < Minitest::Test
+ TOKENS_DIR = File.expand_path("../_data/tokens", __dir__)
+
+ def setup
+ @tokens = ArbitraryDefinitions::TokenSet.new(dir: TOKENS_DIR)
+ end
+
+ def test_resolves_a_ramp_reference_to_its_hex
+ assert_equal "#f95116", @tokens.resolve("persimmon.500")
+ assert_equal "#26231f", @tokens.resolve("ink.900")
+ end
+
+ def test_passes_literal_values_through_untouched
+ assert_equal "#ffffff", @tokens.resolve("#ffffff")
+ assert_equal "rgba(0, 0, 0, 0.65)", @tokens.resolve("rgba(0, 0, 0, 0.65)")
+ end
+
+ def test_raises_on_a_reference_that_does_not_exist
+ assert_raises(ArbitraryDefinitions::TokenSet::UnknownReference) do
+ @tokens.resolve("persimmon.55")
+ end
+ end
+
+ # The point of the semantic layer is that it is complete: if a token exists
+ # in light it has to exist in dark, or the theme toggle leaves holes.
+ def test_every_semantic_token_resolves_in_both_themes
+ @tokens.semantic_tokens.each do |token|
+ refute_nil token["light"], "#{token['token']} has no light value"
+ refute_nil token["dark"], "#{token['token']} has no dark value"
+ end
+ end
+
+ def test_every_semantic_token_documents_its_usage
+ undocumented = @tokens.semantic_tokens.reject { |token| token["usage"].to_s.strip.length.positive? }
+
+ assert_empty undocumented.map { |token| token["token"] },
+ "semantic tokens must say what they are for"
+ end
+
+ def test_semantic_token_names_are_unique
+ names = @tokens.semantic_tokens.map { |token| token["token"] }
+
+ assert_equal names.uniq, names, "duplicate semantic token names"
+ end
+
+ def test_ramps_run_light_to_dark_without_reversing
+ @tokens.ramps.each do |ramp|
+ luminances = ramp.fetch("steps").map do |step|
+ ArbitraryDefinitions::ColorMath.relative_luminance(step.fetch("value"))
+ end
+
+ assert_equal luminances.sort.reverse, luminances,
+ "#{ramp['name']} steps are not monotonically darkening"
+ end
+ end
+
+ # The contrast contract. Every pair here is one a reader actually has to
+ # decipher, so each owes WCAG AA in both themes. These assertions are the
+ # reason the ink ramp is as dark as it is.
+ TEXT_PAIRS = [
+ %w[color-text color-canvas],
+ %w[color-text color-surface],
+ %w[color-text-muted color-canvas],
+ %w[color-text-subtle color-canvas],
+ %w[color-accent color-canvas],
+ %w[color-on-accent color-accent],
+ %w[color-positive color-positive-subtle],
+ %w[color-caution color-caution-subtle],
+ %w[color-critical color-critical-subtle],
+ %w[color-info color-info-subtle],
+ # Filled surfaces: a label sitting directly on a status or accent fill.
+ %w[color-on-critical color-critical-solid],
+ %w[color-text-inverse color-accent],
+ %w[color-text-inverse color-positive],
+ %w[color-text-inverse color-caution],
+ %w[color-text-inverse color-critical],
+ %w[color-text-inverse color-info],
+ %w[color-text-inverse color-text-muted],
+ ].freeze
+
+ # Non-text contrast (SC 1.4.11): the border a user is told to rely on to
+ # find a control needs 3:1, even though it carries no glyphs.
+ NON_TEXT_PAIRS = [
+ %w[color-border-strong color-surface],
+ %w[color-focus color-canvas],
+ ].freeze
+
+ def test_text_pairs_clear_wcag_aa_in_both_themes
+ assert_pairs(TEXT_PAIRS, 4.5)
+ end
+
+ def test_non_text_pairs_clear_the_three_to_one_threshold
+ assert_pairs(NON_TEXT_PAIRS, 3.0)
+ end
+
+ def test_css_emits_a_root_block_and_both_dark_selectors
+ css = @tokens.to_css
+
+ assert_includes css, ":root {"
+ assert_includes css, '@media (prefers-color-scheme: dark)'
+ assert_includes css, ':root[data-theme="dark"]'
+ assert_includes css, "@media (prefers-reduced-motion: reduce)"
+ end
+
+ def test_css_contains_no_unresolved_references
+ unresolved = @tokens.to_css.scan(/:\s*([a-z][a-z0-9_]*\.[a-z0-9]+);/)
+
+ assert_empty unresolved.flatten, "token references leaked into the stylesheet"
+ end
+
+ def test_css_declares_every_semantic_token_in_root
+ root = @tokens.to_css[/:root \{(.*?)\n\}/m, 1]
+
+ @tokens.semantic_tokens.each do |token|
+ assert_includes root, "#{token['name']}:", "#{token['name']} missing from :root"
+ end
+ end
+
+ def test_braces_balance
+ css = @tokens.to_css
+
+ assert_equal css.count("{"), css.count("}")
+ end
+
+ private
+
+ def assert_pairs(pairs, threshold)
+ by_name = @tokens.semantic_tokens.to_h { |token| [token["token"], token] }
+
+ pairs.each do |(foreground, background)|
+ %w[light dark].each do |theme|
+ ratio = ArbitraryDefinitions::ColorMath.contrast_ratio(
+ by_name.fetch(foreground)[theme],
+ by_name.fetch(background)[theme],
+ )
+
+ assert_operator ratio, :>=, threshold,
+ "#{foreground} on #{background} is #{ratio.round(2)}:1 in the " \
+ "#{theme} theme, below the #{threshold}:1 floor"
+ end
+ end
+ end
+end