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 = /(?