Generate Claude Design cards, and move the contrast contract into the tokens
Adds `rake design`, which renders design-bundle/ for Claude Design's Design
System pane: one card per documentation page, each a standalone HTML file with
the compiled tokens and component CSS inlined and a first-line `@dsCard` marker.
The cards are extracted from the built _site by script/build_design_bundle.rb
rather than hand-written, so they cannot describe a system different from the
one the stylesheet compiles to.
Pushing the bundle needs DesignSync, which requires `/design-login` and an
interactive terminal, so it cannot run from Claude Code on the web. README
documents the local path; design-bundle/ is gitignored as a build artifact.
Lifting the pages out of the site chrome surfaced that several of them quoted
counts by hand — "ten text pairings", "23 runs, 199 assertions", "32 / 32
aliases" — every one of them already stale, in a project whose whole argument is
that re-typed documentation goes wrong by Thursday.
So the contrast contract moves to _data/tokens/contrast.yml. The suite iterates
it instead of carrying its own copy, the Colour page renders the full table from
the same file with every ratio computed at build time, and the prose derives its
numbers through Liquid. A new assertion closes the gap the danger-button bug came
through: no `color-text-*` or `color-on-*` token may sit outside the contract.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TL4uoCCvX2CqHWT8p3tLd7
diff --git a/.gitignore b/.gitignore
index 2597211..18c5402 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
_site/
+design-bundle/
.jekyll-cache/
.jekyll-metadata
.sass-cache/
diff --git a/README.md b/README.md
index 23db9c4..52e7dfc 100644
--- a/README.md
+++ b/README.md
@@ -45,8 +45,9 @@ $ bundle exec rake serve # http://127.0.0.1:4000
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 pairing in `_data/tokens/contrast.yml` holds in **both** themes — text
+ at WCAG AA, interface boundaries at 3:1 — and no `color-text-*` or
+ `color-on-*` token is allowed to sit outside that contract;
- 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
@@ -54,7 +55,7 @@ accident:
```console
$ bundle exec rake test
-23 runs, 199 assertions, 0 failures, 0 errors, 0 skips
+0 failures, 0 errors, 0 skips
```
Those assertions are not decorative. The accent started at `persimmon.600` and
@@ -66,7 +67,7 @@ reason.
| Path | What it is |
| --- | --- |
-| `_data/tokens/` | Every value in the system: ramps, semantic aliases, scales. |
+| `_data/tokens/` | Every value in the system: ramps, semantic aliases, scales, and the contrast contract. |
| `lib/arbitrary_definitions/` | Token compiler and WCAG colour maths. No Jekyll dependency. |
| `_plugins/` | Jekyll wiring: the generator, Liquid filters, the `example` block. |
| `_sass/` | Base, components, and the documentation site's own chrome. |
@@ -91,6 +92,39 @@ The site's `baseurl` lives in `_config.yml` rather than coming from
`actions/configure-pages`, which keeps the build independent of the Pages API.
Change it there if the site ever moves off a project page.
+## Claude Design
+
+`rake design` renders the bundle that Claude Design's Design System pane wants,
+into `design-bundle/`:
+
+```console
+$ bundle exec rake design
+design-bundle: 15 cards, 17 files, 966 KiB
+```
+
+One card per documentation page — five foundations, eight components, two
+patterns — each a standalone HTML file with the compiled tokens and component
+CSS inlined and a first-line `<!-- @dsCard group="…" -->` marker for the pane's
+card index. `design-bundle/css/` carries the two compiled stylesheets as well,
+so the project holds the artifact a consumer would link and not only the
+previews.
+
+The cards are generated from `_site` by `script/build_design_bundle.rb` rather
+than written by hand, for the same reason the docs are: a second copy of the
+system maintained separately starts disagreeing with the first one immediately.
+Rebuild them whenever a token changes.
+
+Pushing the bundle needs `DesignSync`, which requires design-system
+authorization via `/design-login` — an interactive terminal, so it cannot run in
+Claude Code on the web. From a local Claude Code session:
+
+```console
+$ bundle exec rake design
+$ claude # then: /design-login, and ask it to sync design-bundle/
+```
+
+`design-bundle/` is gitignored; it is a build artifact.
+
## Using the CSS elsewhere
Two files, in this order, and no JavaScript:
diff --git a/Rakefile b/Rakefile
index 772f2fd..5929f19 100644
--- a/Rakefile
+++ b/Rakefile
@@ -18,5 +18,10 @@ task :serve do
sh "bundle exec jekyll serve --livereload"
end
+desc "Build the Claude Design upload bundle into design-bundle/"
+task design: :build do
+ sh "ruby script/build_design_bundle.rb"
+end
+
desc "Run the token and colour tests, then build"
task default: %i[test build]
diff --git a/_components/callout.md b/_components/callout.md
index 08f8160..5840b7e 100644
--- a/_components/callout.md
+++ b/_components/callout.md
@@ -26,7 +26,7 @@ summary: >-
</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>
+ <p style="margin:0">All {{ site.data.tokens.contrast.text.pairs | size }} text pairings clear WCAG AA in both themes.</p>
</div>
</div>
diff --git a/_data/tokens/contrast.yml b/_data/tokens/contrast.yml
new file mode 100644
index 0000000..93551c6
--- /dev/null
+++ b/_data/tokens/contrast.yml
@@ -0,0 +1,36 @@
+# The contrast contract.
+#
+# Colour is the one part of a design system that is not arbitrary: a ratio is a
+# fact about two colours and a pair of eyes. So these pairings are asserted by
+# test/test_token_set.rb in both themes, and the same list renders the contract
+# table on the Colour page. Adding a pair here adds it to the deploy gate.
+text:
+ label: Text
+ minimum: 4.5
+ standard: WCAG 2.1 SC 1.4.3 (normal-size text)
+ pairs:
+ - { foreground: color-text, background: color-canvas, note: Body copy on the page. }
+ - { foreground: color-text, background: color-surface, note: Body copy on a card or table. }
+ - { foreground: color-text-muted, background: color-canvas, note: Helper text and captions. }
+ - { foreground: color-text-subtle, background: color-canvas, note: Placeholders and metadata. }
+ - { foreground: color-accent, background: color-canvas, note: Links in running text. }
+ - { foreground: color-on-accent, background: color-accent, note: The label on a primary button. }
+ - { foreground: color-positive, background: color-positive-subtle, note: Positive callouts and badges. }
+ - { foreground: color-caution, background: color-caution-subtle, note: Caution callouts and badges. }
+ - { foreground: color-critical, background: color-critical-subtle, note: Critical callouts and badges. }
+ - { foreground: color-info, background: color-info-subtle, note: Informational callouts and badges. }
+ - { foreground: color-on-critical, background: color-critical-solid, note: The label on a destructive button. }
+ - { foreground: color-text-inverse, background: color-accent, note: A solid accent badge. }
+ - { foreground: color-text-inverse, background: color-positive, note: A solid positive badge. }
+ - { foreground: color-text-inverse, background: color-caution, note: A solid caution badge. }
+ - { foreground: color-text-inverse, background: color-critical, note: A solid critical badge. }
+ - { foreground: color-text-inverse, background: color-info, note: A solid info badge. }
+ - { foreground: color-text-inverse, background: color-text-muted, note: A solid neutral badge. }
+
+non_text:
+ label: Non-text
+ minimum: 3.0
+ standard: WCAG 2.1 SC 1.4.11 (user interface components)
+ pairs:
+ - { foreground: color-border-strong, background: color-surface, note: The edge of a control a reader has to find. }
+ - { foreground: color-focus, background: color-canvas, note: The focus ring against the page. }
diff --git a/_foundations/color.md b/_foundations/color.md
index 38dd020..8e1de9c 100644
--- a/_foundations/color.md
+++ b/_foundations/color.md
@@ -97,72 +97,60 @@ group usually means somebody forgot to think about dark mode.
## 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.
+Colour is the one part of this system that is not arbitrary: a ratio is a fact
+about two colours and a pair of eyes, and it does not care what the palette
+wants. So the pairings below are a contract. They live in
+`_data/tokens/contrast.yml`, `test/test_token_set.rb` asserts every one of them
+in both themes, and the table is rendered from that same file — the deploy
+fails before a regression can reach this page.
-{% 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 %}
+{% assign contract = site.data.tokens.contrast %}
+{% assign flat = site.data.semantic_flat %}
+
+{% for section in contract %}
+{% assign spec = section[1] %}
+### {{ spec.label }} — {{ spec.minimum }}:1 minimum
+
+<p class="ad-muted">{{ spec.standard }}</p>
<div class="ad-table-wrap">
- <table class="ad-table">
- <caption>Ratios computed at build time from the token values themselves.</caption>
+ <table class="ad-table ad-table--compact">
<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>
+ <th scope="col">Pairing</th>
+ <th scope="col">Where it shows up</th>
+ <th scope="col" class="ad-table__num">Light</th>
+ <th scope="col" class="ad-table__num">Dark</th>
</tr>
</thead>
<tbody>
+ {%- for pair in spec.pairs %}
+ {%- assign fg = flat | where: "token", pair.foreground | first %}
+ {%- assign bg = flat | where: "token", pair.background | first %}
<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>
+ <td>
+ <span class="ad-alias-row">
+ <span class="ad-alias-chip" aria-hidden="true"
+ style="background: {{ bg.light }}; color: {{ fg.light }}; display: grid; place-items: center; font-size: 10px;">Aa</span>
+ <span class="ad-token-name">{{ pair.foreground }}<br><span class="ad-subtle">on {{ pair.background }}</span></span>
+ </span>
+ </td>
+ <td>{{ pair.note }}</td>
+ {%- assign light_ratio = fg.light | contrast_with: bg.light %}
+ {%- assign dark_ratio = fg.dark | contrast_with: bg.dark %}
+ <td class="ad-table__num">{{ light_ratio }}</td>
+ <td class="ad-table__num">{{ dark_ratio }}</td>
</tr>
+ {%- endfor %}
</tbody>
</table>
</div>
+{% endfor %}
+
+Every figure in those two tables was computed during this build by
+`lib/arbitrary_definitions/color_math.rb`, from the token value itself. None of
+them was typed by a person, which is the only way a number in documentation
+stays true.
<div class="ad-callout ad-callout--caution">
<div class="ad-callout__body">
diff --git a/_foundations/elevation.md b/_foundations/elevation.md
index e0f0c92..faf6f53 100644
--- a/_foundations/elevation.md
+++ b/_foundations/elevation.md
@@ -59,8 +59,8 @@ 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.
+ Switch themes and watch the row above. In light, the three cards separate
+ by shadow. In dark, they separate by surface. Same markup either way.
</p>
</div>
</div>
diff --git a/_patterns/settings-form.md b/_patterns/settings-form.md
index 2e086cd..5bea81f 100644
--- a/_patterns/settings-form.md
+++ b/_patterns/settings-form.md
@@ -80,7 +80,7 @@ hold together when a real screen puts nine of them side by side.
<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>
+ <span class="ad-progress__value">{{ site.data.build.aliases }} / {{ site.data.build.aliases }} aliases documented</span>
</div>
<div class="ad-progress__track" role="progressbar" aria-labelledby="pattern-progress"
aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
@@ -112,5 +112,5 @@ hold together when a real screen puts nine of them side by side.
- **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.
+- **The dark theme.** Nothing in this pattern has a colour of its own — every
+ value is an alias, and the dark block repoints all of them at once.
diff --git a/lib/arbitrary_definitions/token_set.rb b/lib/arbitrary_definitions/token_set.rb
index ca007a3..759187b 100644
--- a/lib/arbitrary_definitions/token_set.rb
+++ b/lib/arbitrary_definitions/token_set.rb
@@ -17,16 +17,33 @@ module ArbitraryDefinitions
# 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
+ attr_reader :prefix, :ramps, :semantic, :scale, :contrast
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"))
+ @contrast = load_yaml(File.join(dir, "contrast.yml"))
@index = build_ramp_index
end
+ # The contrast contract, flattened into one list of
+ # {foreground, background, minimum, …} rows. The test suite asserts these
+ # and the Colour page renders them, so neither can quietly fall behind the
+ # other.
+ def contrast_pairs
+ contrast.flat_map do |_key, section|
+ section.fetch("pairs").map do |pair|
+ pair.merge(
+ "kind" => section.fetch("label"),
+ "minimum" => section.fetch("minimum"),
+ "standard" => section.fetch("standard"),
+ )
+ end
+ end
+ end
+
# "ink.900" -> "#26231f"; a literal colour passes straight through.
def resolve(value)
value = value.to_s
diff --git a/rationale.md b/rationale.md
index e67538d..613b508 100644
--- a/rationale.md
+++ b/rationale.md
@@ -71,25 +71,30 @@ 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.
+`_data/tokens/contrast.yml` names
+{{ site.data.tokens.contrast.text.pairs | size }} text pairings and
+{{ site.data.tokens.contrast.non_text.pairs | size }} non-text pairings that
+have to hold; `lib/arbitrary_definitions/color_math.rb` computes WCAG relative
+luminance from the token values, and `test/test_token_set.rb` asserts every one
+of them in both themes. The [contract table]({{ '/foundations/color/' | relative_url }}#contrast-that-has-to-hold)
+is rendered from that same file. If a token change drops any pairing below its
+floor, `rake test` fails and the site does not deploy.
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.
+The dark theme caught a second class of mistake that no single 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, more usefully,
+a rule in the suite that no `color-text-*` or `color-on-*` token may sit outside
+the contract at all, so the next filled surface cannot be forgotten.
```console
$ bundle exec rake test
-23 runs, 199 assertions, 0 failures, 0 errors, 0 skips
+0 failures, 0 errors, 0 skips
```
## The four rules
diff --git a/script/build_design_bundle.rb b/script/build_design_bundle.rb
new file mode 100644
index 0000000..b8e802d
--- /dev/null
+++ b/script/build_design_bundle.rb
@@ -0,0 +1,124 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Builds the Claude Design upload bundle from the already-built _site.
+#
+# One card per documentation page: the page's <main> lifted out of the site
+# chrome and dropped into a standalone HTML file with the compiled tokens and
+# component CSS inlined. Cards are generated rather than hand-written for the
+# same reason the docs are — a second, hand-maintained copy of the system would
+# start disagreeing with the first one immediately.
+#
+# bundle exec jekyll build && ruby script/build_design_bundle.rb
+#
+# Output lands in design-bundle/, which is gitignored: it is a build artifact.
+
+require "fileutils"
+
+# The site is UTF-8 regardless of the locale the build happens to run under,
+# and a runner with LANG unset would otherwise read it as US-ASCII.
+Encoding.default_external = Encoding::UTF_8
+Encoding.default_internal = Encoding::UTF_8
+
+ROOT = File.expand_path("..", __dir__)
+SITE = File.join(ROOT, "_site")
+OUT = File.join(ROOT, "design-bundle")
+BASE = "/arbitrary-definitions-design-system"
+CANON = "https://kejadlen.github.io#{BASE}"
+
+# page path in _site => [bundle path, Design System pane group, card title]
+CARDS = [
+ ["foundations/color", "foundations/color.html", "Foundations", "Colour"],
+ ["foundations/typography", "foundations/typography.html", "Foundations", "Typography"],
+ ["foundations/space", "foundations/space.html", "Foundations", "Space"],
+ ["foundations/elevation", "foundations/elevation.html", "Foundations", "Elevation"],
+ ["foundations/motion", "foundations/motion.html", "Foundations", "Motion"],
+ ["components/button", "components/button.html", "Components", "Button"],
+ ["components/field", "components/field.html", "Components", "Field"],
+ ["components/badge", "components/badge.html", "Components", "Badge"],
+ ["components/callout", "components/callout.html", "Components", "Callout"],
+ ["components/card", "components/card.html", "Components", "Card"],
+ ["components/table", "components/table.html", "Components", "Table"],
+ ["components/tabs", "components/tabs.html", "Components", "Tabs"],
+ ["components/progress", "components/progress.html", "Components", "Progress"],
+ ["patterns/settings-form", "patterns/settings-form.html", "Patterns", "Settings form"],
+ ["patterns/kitchen-sink", "patterns/kitchen-sink.html", "Patterns", "Kitchen sink"],
+].freeze
+
+def read_site(path)
+ full = File.join(SITE, path)
+ abort "missing #{full} — run `bundle exec jekyll build` first" unless File.file?(full)
+ File.read(full, encoding: "UTF-8")
+end
+
+# The site emits exactly one <main>, with no nesting, so this is unambiguous.
+def extract_main(html, page)
+ body = html[%r{<main[^>]*id="main"[^>]*>(.*?)</main>}m, 1]
+ abort "no <main> found in #{page}" unless body
+
+ body
+ # Site-relative links would dead-end inside the Design System pane.
+ .gsub(%r{(href|src)="#{Regexp.escape(BASE)}/}, "\\1=\"#{CANON}/")
+ # The copy button needs the site's clipboard handler; without it the
+ # control is a lie, so the card ships without it.
+ .gsub(%r{<button[^>]*data-copy[^>]*>.*?</button>}m, "")
+ .strip
+end
+
+def card_html(group:, title:, body:, tokens:, styles:, script:)
+ <<~HTML
+ <!-- @dsCard group="#{group}" -->
+ <!doctype html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>#{title}</title>
+ <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">
+ <style>
+ #{tokens}
+ #{styles}
+ /* Card chrome. The pane supplies the frame; this just insets the page. */
+ body { padding: var(--ad-space-6) var(--ad-space-5); }
+ .ad-doc-main { max-width: var(--ad-width-content); margin-inline: auto; }
+ .ad-doc-main > * { max-width: none; }
+ </style>
+ </head>
+ <body>
+ <main class="ad-doc-main">
+ #{body}
+ </main>
+ <script>
+ #{script}
+ </script>
+ </body>
+ </html>
+ HTML
+end
+
+FileUtils.rm_rf(OUT)
+FileUtils.mkdir_p(OUT)
+
+tokens = read_site("assets/css/tokens.css")
+styles = read_site("assets/css/style.css")
+script = read_site("assets/js/system.js")
+
+# The compiled CSS goes in as-is too, so the project carries the artifact a
+# consumer would actually link, not only the rendered previews.
+FileUtils.mkdir_p(File.join(OUT, "css"))
+File.write(File.join(OUT, "css", "tokens.css"), tokens)
+File.write(File.join(OUT, "css", "style.css"), styles)
+
+CARDS.each do |page, out_path, group, title|
+ body = extract_main(read_site(File.join(page, "index.html")), page)
+ target = File.join(OUT, out_path)
+ FileUtils.mkdir_p(File.dirname(target))
+ File.write(target, card_html(group: group, title: title, body: body,
+ tokens: tokens, styles: styles, script: script))
+end
+
+total = Dir.glob(File.join(OUT, "**", "*")).count { |f| File.file?(f) }
+bytes = Dir.glob(File.join(OUT, "**", "*")).sum { |f| File.file?(f) ? File.size(f) : 0 }
+puts "design-bundle: #{CARDS.size} cards, #{total} files, #{(bytes / 1024.0).round} KiB"
diff --git a/test/test_token_set.rb b/test/test_token_set.rb
index 7575755..3745d48 100644
--- a/test/test_token_set.rb
+++ b/test/test_token_set.rb
@@ -60,43 +60,54 @@ class TestTokenSet < Minitest::Test
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)
+ # The contrast contract lives in _data/tokens/contrast.yml so that this
+ # suite and the Colour page read the same list. Adding a pair there adds it
+ # to the deploy gate here, with no second copy to keep in step.
+ def test_the_contrast_contract_holds_in_both_themes
+ by_name = @tokens.semantic_tokens.to_h { |token| [token["token"], token] }
+ failures = []
+
+ @tokens.contrast_pairs.each do |pair|
+ %w[light dark].each do |theme|
+ foreground = by_name.fetch(pair.fetch("foreground"))
+ background = by_name.fetch(pair.fetch("background"))
+ ratio = ArbitraryDefinitions::ColorMath.contrast_ratio(foreground[theme], background[theme])
+
+ next if ratio && ratio >= pair.fetch("minimum")
+
+ failures << "#{pair['foreground']} on #{pair['background']} is " \
+ "#{ratio ? ratio.round(2) : 'unmeasurable'}:1 in the #{theme} theme, " \
+ "below the #{pair['minimum']}:1 floor"
+ end
+ end
+
+ assert_empty failures
+ end
+
+ def test_the_contract_only_names_tokens_that_exist
+ known = @tokens.semantic_tokens.map { |token| token["token"] }
+ named = @tokens.contrast_pairs.flat_map { |pair| pair.values_at("foreground", "background") }
+
+ assert_empty named.uniq - known, "the contrast contract names tokens the system does not define"
+ end
+
+ def test_the_contract_covers_the_pairings_that_matter
+ covered = @tokens.contrast_pairs.map { |pair| pair.fetch("foreground") }
+
+ # Every text colour and every "on-" colour is something a reader decodes,
+ # so none of them may sit outside the contract.
+ required = @tokens.semantic_tokens
+ .map { |token| token["token"] }
+ .select { |name| name.start_with?("color-text", "color-on-") }
+ .reject { |name| name == "color-text-inverse" }
+
+ assert_empty required - covered, "a text token is not covered by the contrast contract"
+ end
+
+ def test_every_contract_pair_explains_itself
+ undocumented = @tokens.contrast_pairs.reject { |pair| pair["note"].to_s.strip.length.positive? }
+
+ assert_empty undocumented
end
def test_css_emits_a_root_block_and_both_dark_selectors
@@ -128,22 +139,4 @@ class TestTokenSet < Minitest::Test
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