diff --git a/.okf/build/hugo-stats-th-classes.md b/.okf/build/hugo-stats-th-classes.md
index b1595b986..28ae9cb12 100644
--- a/.okf/build/hugo-stats-th-classes.md
+++ b/.okf/build/hugo-stats-th-classes.md
@@ -33,13 +33,33 @@ Development builds hide it completely: `postcss.config.js` sets
entirely, so `bin/hugo-build` renders the styling correctly while production
ships it unstyled.
-# Why no gate catches it
+# Why the VISUAL gate cannot catch it, and what does
The visual regression suite compares a baseline against a candidate that were
**both** built the same way, so a rule that is purged in both is invisible to
-it. `css_orphan_guard_test.rb` does not help either - it asks whether a FILE
-is reachable from a template, never whether a SELECTOR survived the purge.
-This class of defect reaches production green.
+it - not at a lower tolerance, not with a bigger capture. It is unguardable
+there in principle. `css_orphan_guard_test.rb` does not help either: it asks
+whether a FILE is reachable from a template, never whether a SELECTOR survived
+the purge.
+
+A unit test does catch it, because the defect is a set relation, not a picture.
+`test/unit/next_purge_guard_test.rb` flags any class that is (a) styled in the
+register's source CSS, (b) present on an element in the rendered production
+HTML, and (c) missing from the fingerprinted bundle that page actually links.
+All three at once is a silently-purged rule every time. It is keyed on the
+MECHANISM, not on `th`, so the next element type Hugo stops recording is
+covered without an edit.
+
+Two things make it trustworthy rather than decorative:
+
+- It reads the bundle from the page's own ``, never a glob of
+ `css/` - stale fingerprinted bundles from earlier builds sit in the same
+ directory, and reading one measures a build nobody ships.
+- Broken before trusted (2026-08-22): injecting `class="rr-th-theirs"` on a
+ `
` plus a matching rule failed it with `Expected ["rr-th-theirs"] to be
+ empty`; moving the SAME class onto the `
` in the same row went green,
+ which is the control that proves it flags the purge and not merely a new
+ class.
# What to do instead
diff --git a/.okf/build/index.md b/.okf/build/index.md
index 21fed65fa..f053b936e 100644
--- a/.okf/build/index.md
+++ b/.okf/build/index.md
@@ -1,7 +1,7 @@
# Build & Test
* [Hugo build pipeline](hugo-build.md) - bin/hugo-build with the 8 course validators; also the PurgeCSS cold-start race and the minified-unquoted-attribute audit-tool trap
-* [Test gates](test-gates.md) - the local suites, when each is a commit blocker, the 0.0001 default tolerance, why the SECTION_CONFIGS shield was deleted and what measuring it cost, why a green run never refreshes a baseline, and why below-fold content is invisible at any tolerance, bin/record-baselines for accepting only the baselines you meant to move, and why a deleted source file still serves from every local _dest/ tree, plus the NULL CHANGE - a diff that passes every gate and alters nothing - and what `okf_validate` actually guards (shape, not truth; error-only conformance) with the two-spec trap
+* [Test gates](test-gates.md) - the local suites, when each is a commit blocker, the 0.0001 default tolerance, why the SECTION_CONFIGS shield was deleted and what measuring it cost, why a green run never refreshes a baseline, and why below-fold content is invisible at any tolerance, bin/record-baselines for accepting only the baselines you meant to move, and why a deleted source file still serves from every local _dest/ tree, plus the NULL CHANGE - a diff that passes every gate and alters nothing - and what `okf_validate` actually guards (shape, not truth; error-only conformance) with the two-spec trap, and why computed-style assertions beat more screenshots for below-fold coverage and for contrast (a screenshot returns a pixel delta, never a ratio)
* [CI gates](ci-gates.md) - what GitHub Actions enforces: build, unit, path-scoped link check (visual regression is report-only), and what gates a PR never sees
* [Template PDFs](pdf-templates.md) - regenerating the downloadable course PDFs
-* [Classes only on <th> get purged](hugo-stats-th-classes.md) - Hugo 0.165 writeStats records no class attrs on `
`, so PurgeCSS deletes those rules from the production bundle while dev builds look fine and the visual gate stays green; also two sibling false-greens on the /next/ rail (site-scripts in a landing baseof, computed `resources.Get` paths defeating the orphan guard)
+* [Classes only on <th> get purged](hugo-stats-th-classes.md) - Hugo 0.165 writeStats records no class attrs on `
`, so PurgeCSS deletes those rules from the production bundle while dev builds look fine and the visual gate stays green - unguardable by a screenshot, guardable by a set-diff unit test (`next_purge_guard_test.rb`); also two sibling false-greens on the /next/ rail (site-scripts in a landing baseof, computed `resources.Get` paths defeating the orphan guard)
diff --git a/.okf/build/test-gates.md b/.okf/build/test-gates.md
index f26522931..cd2d7659f 100644
--- a/.okf/build/test-gates.md
+++ b/.okf/build/test-gates.md
@@ -196,9 +196,45 @@ This is why the suite leans on per-section screenshots - each one scrolls its
section INTO the viewport first (`verify_section_for` →
`scroll_to find(css)`), which is the existing workaround, not an accident.
A page asserted only as one top-of-page shot is verified for its first fold and
-nothing else. Closing the gap properly means either more section shots or
-full-page capture; both are out of scope for the tolerance change and belong to
-the follow-up that re-records baselines.
+nothing else.
+
+# Computed styles beat more screenshots for below-fold and for contrast
+
+More captures is the tempting answer to the fold problem and the expensive one:
+each new baseline inherits the tolerance problem above (a font/SVG-heavy band
+needs its noise floor MEASURED by recording twice - the 0.0001 default is
+unsafe there), and every one is a file two platforms have to agree on.
+
+A computed-style assertion in a system test has neither cost. It does not care
+about the viewport, and it returns a NUMBER rather than a pixel delta - which
+matters because a screenshot cannot tell an intentional recolour from an
+accessibility regression. Nothing in this suite measured contrast until
+2026-08-22; a 3.33:1 black-on-ruby button shipped and was caught in human
+review.
+
+`test/system/next_pilot_contrast_test.rb` is the worked example, ~1.3s for
+three pages, no baselines, wired into `test:critical`:
+
+- The comparison table's "theirs" column must compute the register's own
+ `--*-muted` token. Asserted against the token, not a literal and not the
+ sibling cell: two registers set the "ours" column to the body colour and one
+ sets it to ink, so a sibling comparison passes on a real defect in the
+ registers where ink and body differ. That mistake was made and caught here by
+ injection, not by reading.
+- A contrast walk over every visible text run: composite each translucent layer
+ down to an opaque background, then require 4.5:1 (3:1 for large text, SC
+ 1.4.3) and 3:1 for a control's fill against its surround (SC 1.4.11). Text
+ over a background image or gradient is REPORTED as unresolvable rather than
+ measured against a guessed white.
+- Guarded against the false green it would otherwise be: the walk asserts it
+ measured more than 50 text pairs and at least one fill, because a walk that
+ finds nothing is empty and empty passes.
+
+Broken before trusted (2026-08-22), all three branches: an above-fold
+`.rr-btn-primary { color: var(--ed-ink) }` failed at `2.31:1 (needs 4.5:1)`; a
+below-fold `.rr-td-muted { color: #b5b0ab }` - the class the screenshot gate
+cannot see at all - failed at `2.15:1` on five cells; a low-contrast fill with
+a readable label failed only the fill branch at `1.12:1 (needs 3.0:1)`.
# Rake tasks and suite layout
@@ -815,10 +851,17 @@ Skipping step 1 has cost this repo repeatedly:
on a site with five real broken links, one of them a conversion path and one
a post's own canonical pointing at a 404.
-**When a gate cannot discriminate yet, write that in the test.** The derived
-tenure assertion cannot tell `derived` from `frozen` while both read "18+" in
-2026 - it starts biting on 2027-01-01. That is stated in the test body, so the
-next reader does not mistake a passing run for proof.
+**When a gate cannot discriminate yet, write that in the test - then ask what
+CAN see the defect today.** The rendered tenure assertion cannot tell `derived`
+from `frozen` while both read "18+" in 2026; it starts biting on 2027-01-01,
+and that limit is stated in the test body so a passing run is not mistaken for
+proof. But the limit is a property of the RENDERED surface, not of the defect:
+the freeze is plainly visible in the stub's frontmatter. `NextRailTest`
+therefore carries both halves, and they are one gate - the rendered assertion
+for the value, a source assertion that the tenure stat is `derived: tenure` and
+that no stat is frozen at today's derived string. This is not a config test:
+the hardcoded literal IS the defect class, the way the 2026-08-14 canon audit
+found the founding year wrong in eight places because each kept its own copy.
Full fault-injection matrix, including what nothing guards:
`docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md`.
diff --git a/.okf/log.md b/.okf/log.md
index 13f2331b0..868f42e23 100644
--- a/.okf/log.md
+++ b/.okf/log.md
@@ -51,6 +51,53 @@ make it green: restructure same-day entries under one heading, and add
`timestamp` to the 23 concepts missing it (anchored to each file's last commit
time, which is verifiable - never invented).
+## 2026-08-22 - closing three of the fault-injection gaps, each broken before it was trusted
+
+Three of the five misses above now have gates. Every one of them was injected,
+watched fail, reverted and watched pass - the procedure this bundle added the
+same day, applied to its own output.
+
+**A gate blind until 2027 needed a second half, not a note.** The rendered
+tenure assertion cannot separate a frozen `18+` from a derived one in 2026, and
+saying so in the test body is honest but does not guard anything. The freeze is
+plainly visible one layer down, in the stub's frontmatter, so `NextRailTest`
+now asserts both: the rendered value AND that every pilot's tenure stat is
+`derived: tenure` with no stat frozen at today's derived string. General
+lesson: when a gate cannot discriminate on one surface, ask which surface it
+CAN discriminate on before accepting the hole.
+
+**"Unguardable" was true only of the gate that was reached for first.** The
+`
` PurgeCSS trap really is invisible to the visual suite in principle -
+baseline and candidate are both built purged. It is trivially visible to a set
+diff: styled in source CSS ∩ present in rendered HTML − present in the built
+bundle. `test/unit/next_purge_guard_test.rb` is 60 lines, adds ~0.1s, and is
+keyed on the mechanism rather than on `th`. The control that makes it
+believable is the second injection, not the first: the same class moved onto a
+`
` goes green, so it flags the purge and not merely a new class.
+
+**The audit's own recommendation was the expensive answer.** It proposed
+per-band screenshots for the 71.7% of a pilot page below the fold. That buys
+four more baselines per pilot, each needing its noise floor measured on the
+platform that judges it, and still returns a pixel delta rather than a ratio -
+so an intentional recolour and an AA regression stay indistinguishable.
+`test/system/next_pilot_contrast_test.rb` does both jobs in ~1.3s with no
+baselines: computed styles ignore the viewport, and contrast comes out as a
+number. It closes the separately-flagged hole that nothing in this suite
+measured contrast at all - a 3.33:1 black-on-ruby button shipped this rail and
+only human review caught it.
+
+One mistake worth keeping. The muted-column assertion was first written as
+"differs from the sibling cell and from body colour". It PASSED the exact #564
+injection, because in that register the "ours" column is body colour and the
+injected ink is a third value. Rewritten to assert the register's own
+`--*-muted` token, it fails with the token it actually landed on. The test was
+wrong for two runs and green both times; only the injection said so.
+
+Gates: `test:unit` 289 runs / 6187 assertions / 0 failures in 1.42s (287 /
+6143 / 1.33s before - the purge guard reuses the build the suite already
+makes); `test:critical` 38 runs / 0 failures, 55 screenshots compared, no
+failures, no baseline rewritten; `bin/hugo-build` clean.
+
## 2026-08-22 - fault injection: the suite caught 3 of 8, and the misses were the point
Eight realistic defects planted one at a time, predictions written down BEFORE
diff --git a/Rakefile b/Rakefile
index b97575f39..04075fbfe 100644
--- a/Rakefile
+++ b/Rakefile
@@ -25,10 +25,15 @@ namespace :test do
# (2026-07-19 analysis: blog + homepage + course + site-wide dominate
# 6-month history) + the lead-gen funnel (services, contact,
# free-consultation). Full system suite = test:system; everything = test:all.
+ # NextPilotContrastTest rides along (~1.3s, no baselines): it is the only
+ # thing in this suite that measures contrast as a NUMBER rather than as a
+ # pixel delta, and the only thing that sees the 71.7% of a register pilot
+ # that sits below the 1080px capture (20.11 §"What nothing guards").
CRITICAL_TESTS =
"/homepage|blog_index|blog_post$|visit_blog_post|course_|old_blog_url|" \
"test_services($|_menu)|contact_us|free_consultation|not_found|" \
- "hamburger|color_system|mermaid|codeblock_language|friday_report/"
+ "hamburger|color_system|mermaid|codeblock_language|friday_report|" \
+ "NextPilotContrastTest/"
Rake::TestTask.new(:critical) do |t|
t.libs << "test"
diff --git a/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md b/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md
index 975a31afd..f0727b342 100644
--- a/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md
+++ b/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md
@@ -145,6 +145,30 @@ lychee --offline --root-dir \
markup survives into the built bundle, which is a bigger piece of work than
the defect has so far justified.
+## What was closed afterwards (same day)
+
+The results above are the measurement and stay as recorded. Three of the five
+misses have gates now, each injected, failed, reverted and passed before being
+believed:
+
+| # | Gate added | Where |
+|---|---|---|
+| D | Source half of the pair - every pilot's tenure stat must be `derived: tenure`, and no stat may be frozen at today's derived string. Bites now, not on 2027-01-01. | `test/unit/next_rail_test.rb` |
+| G | Set diff: styled in the register's source CSS ∩ present in the rendered HTML − present in the bundle the page links. Keyed on the mechanism, so the next blind element type is covered. | `test/unit/next_purge_guard_test.rb` |
+| B + the contrast hole | Computed styles instead of per-band screenshots: the "theirs" column must compute the register's `--*-muted` token, and every visible text pair must clear 4.5:1 (3:1 large, 3:1 for control fills). Viewport-independent, no baselines, in `test:critical`. | `test/system/next_pilot_contrast_test.rb` |
+
+Two of this document's own conclusions were wrong and are worth naming:
+
+- **Recommendation 5 - "the `
` purge trap has no cheap automated fix" -
+ was false.** It is unguardable by the VISUAL gate, which is what the analysis
+ actually established; a unit test finds it in a set operation for ~0.1s. The
+ reasoning generalised "this gate cannot" into "no gate can".
+- **Recommendation 4 - per-band screenshots for the below-fold gap - was the
+ expensive answer**, and it inherits the tolerance problem: four more
+ baselines per pilot, each needing its noise floor measured on the platform
+ that judges it, and each still reporting a pixel delta rather than a ratio.
+ Computed styles cover the whole page and return the number.
+
## Method notes
- Every injection was reverted and the tree checked `git status --porcelain`
diff --git a/test/system/next_pilot_contrast_test.rb b/test/system/next_pilot_contrast_test.rb
new file mode 100644
index 000000000..6eb8c3cbb
--- /dev/null
+++ b/test/system/next_pilot_contrast_test.rb
@@ -0,0 +1,224 @@
+# frozen_string_literal: true
+
+require "application_system_test_case"
+
+# What the screenshot gate cannot see on the register pilots.
+#
+# 1. BELOW THE FOLD. The capture is viewport-sized: measured on
+# /next/pilots/editorial/fractional-cto/ at 1920x1080 the page is 3822px
+# tall, so 71.7% of it is outside every baseline at every tolerance
+# (20.11 §"What nothing guards"). The comparison table's muted column - the
+# #564 defect - starts at y=1090, ten pixels past the frame, and an
+# injection that recoloured it shipped green.
+# 2. CONTRAST AS A NUMBER. A screenshot only knows pixels changed, so an
+# intentional recolour and an AA regression look identical to it. Defect A
+# in the same audit was caught only as a pixel delta; nothing measured the
+# ratio. A 3.33:1 black-on-ruby button shipped this rail and was caught in
+# human review.
+#
+# Computed styles answer both: they do not care about the viewport, and they
+# give a ratio instead of a pixel count. Deliberately NOT per-band
+# screenshots - those inherit the tolerance problem (font/SVG-heavy bands need
+# their noise floor MEASURED, and the 0.0001 default is unsafe for them,
+# .okf/build/test-gates.md) and cost four more baselines per pilot.
+class NextPilotContrastTest < ApplicationSystemTestCase
+ PILOTS = %w[rescue-room editorial precision].freeze
+
+ # SC 1.4.3: 4.5:1 for body text, 3:1 for large text (>=24px, or >=18.66px
+ # bold). SC 1.4.11: 3:1 for the fill of a control against its surround.
+ TEXT_MINIMUM = 4.5
+ LARGE_TEXT_MINIMUM = 3.0
+ NON_TEXT_MINIMUM = 3.0
+
+ def setup
+ Capybara.current_driver = :desktop_chrome
+ super
+ end
+
+ # The whole point of the muted column is that "theirs" reads quieter than
+ # "ours". #564 shipped it computing the ink instead - the rule was in source
+ # and looked applied; only the computed value showed it losing the cascade.
+ # Asserted against the register's own --*-muted token rather than against a
+ # literal or a sibling cell: two registers set the "ours" column to the body
+ # colour and one sets it to ink, so a sibling comparison passes on a defect
+ # in the registers where ink and body differ.
+ def test_comparison_table_theirs_column_computes_the_muted_token
+ PILOTS.each do |register|
+ visit "/next/pilots/#{register}/fractional-cto/"
+
+ assert_selector ".rr-table tbody .rr-td-muted", wait: 5
+ got, tokens = page.evaluate_script(<<~JS)
+ (function () {
+ var probe = document.createElement('span');
+ document.body.appendChild(probe);
+ var root = getComputedStyle(document.documentElement);
+ var tokens = {};
+ for (var i = 0; i < root.length; i++) {
+ var name = root[i];
+ // Surface tokens only: --ed-muted, not --ed-ink-muted (which is
+ // the muted tone ON the dark band, a different surface).
+ if (!/^--[a-z]+-(muted|ink|body)$/.test(name)) continue;
+ probe.style.color = '';
+ probe.style.color = root.getPropertyValue(name).trim();
+ tokens[name] = getComputedStyle(probe).color;
+ }
+ probe.remove();
+ var cell = document.querySelector('.rr-table tbody .rr-td-muted');
+ return [getComputedStyle(cell).color, tokens];
+ })()
+ JS
+
+ muted = tokens.find { |name, _| name.end_with?("-muted") }
+ refute_nil muted, "#{register}: no --*-muted token on :root - this gate would pass by finding nothing"
+
+ assert_equal muted.last, got,
+ "#{register}: the \"theirs\" column computes #{got}, not #{muted.first} (#{muted.last}). " \
+ "It resolves to #{tokens.select { |_, v| v == got }.keys.join(", ").then { |n| n.empty? ? "no token" : n }} - " \
+ "the muted rule lost the cascade and the comparison stopped comparing (#564)"
+ end
+ end
+
+ # Every visible text run on the WHOLE page, fold or no fold, plus the fill of
+ # every control against its surround.
+ def test_every_pilot_text_pair_clears_wcag_aa
+ PILOTS.each do |register|
+ visit "/next/pilots/#{register}/fractional-cto/"
+
+ assert_selector ".rr-hero", wait: 5
+ report = page.evaluate_script(contrast_walk)
+
+ assert_operator report["textChecked"], :>, 50,
+ "#{register}: only #{report["textChecked"]} text pairs measured - this gate would pass by " \
+ "finding nothing (the walk found no visible text, or the page did not render)"
+ assert_operator report["fillsChecked"], :>, 0,
+ "#{register}: no control fills measured - this gate would pass by finding nothing"
+
+ assert report["failures"].empty?, "#{register}: WCAG AA contrast failures\n" +
+ report["failures"].map { |f|
+ format(" %.2f:1 (needs %s) %s %s - %s on %s - %s",
+ f["ratio"], f["need"], f["kind"], f["selector"], f["fg"], f["bg"], f["sample"])
+ }.join("\n")
+ end
+ end
+
+ private
+
+ # Runs in the page. Composites every translucent layer down to an opaque
+ # background before measuring, and reports a pair it cannot resolve (a
+ # background image or gradient behind the text) rather than guessing white.
+ def contrast_walk
+ <<~JS
+ (function () {
+ var TEXT = #{TEXT_MINIMUM}, LARGE = #{LARGE_TEXT_MINIMUM}, FILL = #{NON_TEXT_MINIMUM};
+
+ function parse(value) {
+ var m = /rgba?\\(([^)]+)\\)/.exec(value || '');
+ if (!m) return null;
+ var p = m[1].split(/[\\s,\\/]+/).filter(Boolean).map(Number);
+ return {r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1};
+ }
+ function channel(v) {
+ v = v / 255;
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ }
+ function luminance(c) {
+ return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
+ }
+ function ratio(a, b) {
+ var x = luminance(a), y = luminance(b);
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
+ }
+ function over(top, bottom) {
+ return {
+ r: top.r * top.a + bottom.r * (1 - top.a),
+ g: top.g * top.a + bottom.g * (1 - top.a),
+ b: top.b * top.a + bottom.b * (1 - top.a),
+ a: 1
+ };
+ }
+ function label(el) {
+ return el.tagName.toLowerCase() + (el.className && el.className.baseVal === undefined
+ ? '.' + String(el.className).trim().split(/\\s+/).join('.') : '');
+ }
+ // Walks up compositing translucent layers. null = an image or gradient
+ // sits behind this text and no number here would mean anything.
+ function background(el) {
+ var layers = [], node = el;
+ while (node && node.nodeType === 1) {
+ var s = getComputedStyle(node);
+ if (s.backgroundImage && s.backgroundImage !== 'none') return null;
+ var c = parse(s.backgroundColor);
+ if (c && c.a > 0) {
+ layers.push(c);
+ if (c.a === 1) break;
+ }
+ node = node.parentElement;
+ }
+ var base = {r: 255, g: 255, b: 255, a: 1};
+ for (var i = layers.length - 1; i >= 0; i--) base = over(layers[i], base);
+ return base;
+ }
+ function visible(el) {
+ var s = getComputedStyle(el);
+ if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
+ // .sr-only and friends: clipped to nothing, never seen.
+ if (s.clipPath && s.clipPath.indexOf('inset(50%') === 0) return false;
+ var r = el.getBoundingClientRect();
+ return r.width >= 1 && r.height >= 1;
+ }
+ function ownText(el) {
+ var out = '';
+ for (var i = 0; i < el.childNodes.length; i++) {
+ if (el.childNodes[i].nodeType === 3) out += el.childNodes[i].nodeValue;
+ }
+ return out.trim();
+ }
+
+ var failures = [], textChecked = 0, fillsChecked = 0;
+
+ Array.prototype.forEach.call(document.body.querySelectorAll('*'), function (el) {
+ var text = ownText(el);
+ if (!text || !visible(el)) return;
+ var s = getComputedStyle(el);
+ var fg = parse(s.color), bg = background(el);
+ if (!fg) return;
+ if (!bg) {
+ failures.push({kind: 'text', selector: label(el), fg: s.color, bg: 'image/gradient',
+ ratio: 0, need: 'a resolvable background', sample: text.slice(0, 40)});
+ return;
+ }
+ var size = parseFloat(s.fontSize), weight = parseInt(s.fontWeight, 10) || 400;
+ var need = (size >= 24 || (size >= 18.66 && weight >= 700)) ? LARGE : TEXT;
+ var got = ratio(over(fg, bg), bg);
+ textChecked++;
+ if (got + 0.005 < need) {
+ failures.push({kind: 'text', selector: label(el), fg: s.color,
+ bg: 'rgb(' + Math.round(bg.r) + ', ' + Math.round(bg.g) + ', ' + Math.round(bg.b) + ')',
+ ratio: got, need: need.toFixed(1) + ':1', sample: text.slice(0, 40)});
+ }
+ });
+
+ // SC 1.4.11: a filled control has to be distinguishable from what is
+ // behind it, whatever its label does.
+ Array.prototype.forEach.call(
+ document.body.querySelectorAll('a[class*="btn"], button, input[type="submit"]'),
+ function (el) {
+ if (!visible(el)) return;
+ var fill = parse(getComputedStyle(el).backgroundColor);
+ if (!fill || fill.a === 0) return;
+ var behind = el.parentElement ? background(el.parentElement) : null;
+ if (!behind) return;
+ fillsChecked++;
+ var got = ratio(over(fill, behind), behind);
+ if (got + 0.005 < FILL) {
+ failures.push({kind: 'fill', selector: label(el), fg: getComputedStyle(el).backgroundColor,
+ bg: 'rgb(' + Math.round(behind.r) + ', ' + Math.round(behind.g) + ', ' + Math.round(behind.b) + ')',
+ ratio: got, need: FILL.toFixed(1) + ':1', sample: (el.textContent || '').trim().slice(0, 40)});
+ }
+ });
+
+ return {failures: failures, textChecked: textChecked, fillsChecked: fillsChecked};
+ })()
+ JS
+ end
+end
diff --git a/test/unit/next_purge_guard_test.rb b/test/unit/next_purge_guard_test.rb
new file mode 100644
index 000000000..63428404d
--- /dev/null
+++ b/test/unit/next_purge_guard_test.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require "base_page_test_case"
+
+# PurgeCSS keeps a rule only if the class reached hugo_stats.json, and Hugo's
+# writeStats does not record class attrs on every element - `
` is one it
+# misses (.okf/build/hugo-stats-th-classes.md). The result is a rule that
+# exists in source, markup that still carries the class, and NOTHING in the
+# bundle the browser downloads.
+#
+# No visual gate can see this even in principle: it builds with
+# ENVIRONMENT=production, so it purges exactly as production does and matches
+# its own baseline (20.11 defect G - the screenshot test reported
+# "1 screenshot compared, no failures" over a rule that had been deleted).
+# Caught by human review in #563; this is the automated half.
+#
+# Deliberately keyed on the MECHANISM, not on `
`: any element type Hugo
+# stops recording, or starts, is covered without an edit here.
+class NextPurgeGuardTest < BasePageTestCase
+ REPO_ROOT = File.expand_path("../..", __dir__)
+ SOURCE_CSS = "themes/beaver/assets/css/pages/next-%s.css"
+
+ # A class selector: a dot that is not part of a decimal, a filename, or the
+ # tail of another identifier.
+ CLASS_SELECTOR = /(?