<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Henrique Yuri</title>
    <description>The latest articles on DEV Community by Henrique Yuri (@henrique_yuri_f42f2fca47a).</description>
    <link>https://dev.to/henrique_yuri_f42f2fca47a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4070435%2Fd6d84c7e-0d48-49b9-817d-7e428b61e131.png</url>
      <title>DEV Community: Henrique Yuri</title>
      <link>https://dev.to/henrique_yuri_f42f2fca47a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/henrique_yuri_f42f2fca47a"/>
    <language>en</language>
    <item>
      <title>How to test dark mode accessibility in CI with Playwright and axe-core</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Tue, 11 Aug 2026 03:28:53 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/how-to-test-dark-mode-accessibility-in-ci-with-playwright-and-axe-core-54lg</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/how-to-test-dark-mode-accessibility-in-ci-with-playwright-and-axe-core-54lg</guid>
      <description>&lt;p&gt;If you run axe in CI, it is almost certainly testing one state: light colour scheme, no motion preference, forced colors off, desktop viewport. That is what a headless browser boots into, and axe can only evaluate what was actually rendered.&lt;/p&gt;

&lt;p&gt;Here is how to test the others with Playwright, and the four traps that make the naive version report nothing useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line part
&lt;/h2&gt;

&lt;p&gt;Playwright sets user preferences at the browser-context level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// prefers-color-scheme: dark&lt;/span&gt;
  &lt;span class="na"&gt;reducedMotion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reduce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// prefers-reduced-motion: reduce&lt;/span&gt;
  &lt;span class="na"&gt;forcedColors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// forced-colors: active (Windows High Contrast)&lt;/span&gt;
  &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// the reflow width WCAG 1.4.10 requires&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also flip it on an existing page with &lt;code&gt;page.emulateMedia({ colorScheme: 'dark' })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then inject axe and run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AXE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;require&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axe-core/axe.min.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// let webfonts swap; they move contrast values&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addScriptTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AXE&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;runOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole mechanism. Now the parts that bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the worst contrast defects are not in &lt;code&gt;violations&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I shipped a button whose label was the same colour as its own background. 1:1 contrast — literally unreadable. In dark mode only. CI stayed green even after I started rendering dark mode.&lt;/p&gt;

&lt;p&gt;Here is the probe output, same page, two schemes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;=== scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;light ===&lt;/span&gt;
&lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rgb(255,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;255,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;255)'&lt;/span&gt;   &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rgb(28,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;93,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;63)'&lt;/span&gt;
&lt;span class="na"&gt;violations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;      &lt;span class="na"&gt;incomplete&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;

&lt;span class="na"&gt;=== scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dark ===&lt;/span&gt;
&lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rgb(108,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;196,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;154)'&lt;/span&gt;   &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rgb(108,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;196,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;154)'&lt;/span&gt;
&lt;span class="na"&gt;violations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;      &lt;span class="na"&gt;incomplete&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;color-contrast&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;x1'&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Text identical to its background lands in &lt;strong&gt;&lt;code&gt;incomplete&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;violations&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;axe is right to do this. Matching foreground to background is a legitimate way to hide something — a visually-hidden label, a fade-in that has not started, print-only text. axe reports what it can prove and hands the rest to a human.&lt;/p&gt;

&lt;p&gt;It is the wrong default for a pipeline, because pipelines assert on &lt;code&gt;violations&lt;/code&gt; and drop &lt;code&gt;incomplete&lt;/code&gt;. The failure mode is asymmetric in the worst direction: &lt;strong&gt;a 4.3:1 ratio is a confident violation, a 1:1 ratio is a question.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So collect both, and keep them clearly separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;violation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incomplete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incomplete&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do not set &lt;code&gt;resultTypes: ['violations']&lt;/code&gt;.&lt;/strong&gt; It looks like a harmless optimisation. It makes axe return a single representative node for every other bucket, so your incomplete results get silently truncated before you ever see them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a strict CSP blocks the scan entirely
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;page.addScriptTag({ content })&lt;/code&gt; injects an inline script. Any site with a strict &lt;code&gt;script-src&lt;/code&gt; refuses it, and the run fails at the first page.&lt;/p&gt;

&lt;p&gt;The sites that ship a strict CSP are disproportionately the ones with a security review and a design system — which is to say, the ones worth scanning. Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;bypassCSP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trap 3: raw counts are useless, deltas are not
&lt;/h2&gt;

&lt;p&gt;Running seven states and printing 40 findings gives someone a chore. Running seven states and printing &lt;em&gt;these 3 exist in dark mode and not in your baseline&lt;/em&gt; gives them a bug report with a cause attached.&lt;/p&gt;

&lt;p&gt;Compare each state against the baseline and report only the difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baselineKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baselineFindings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newInThisState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stateFindings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;baselineKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right here, and I got both wrong first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change one variable at a time.&lt;/strong&gt; A full cross-product of six preferences is 64 runs and tells you the page is broken without telling you which preference broke it. Six single-factor runs name the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplicate across states before counting.&lt;/strong&gt; A defect caused by a narrow layout appears in your mobile run &lt;em&gt;and&lt;/em&gt; your 320px run &lt;em&gt;and&lt;/em&gt; any dark+mobile combination. Summing per-state counts reports one element three times. The tell in my own data was a page showing exactly 30 incomplete results in each of three states — not similar numbers, identical ones. My first write-up would have claimed 210 findings on a site that had 72.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: real pages are not deterministic
&lt;/h2&gt;

&lt;p&gt;Carousels, rotating promos, A/B tests and lazy-loaded media mean two identical loads do not always produce identical output. Before believing any delta, measure your noise floor: &lt;strong&gt;run the same state twice, change nothing, and see what differs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Across 68 public sites I found 62 were byte-identical between two identical runs. The other six were not — one shopping site produced 715 apparent findings and a churn of +122/−125 between two identical loads. That is a product carousel, not a defect. Without that control I would have published the 715.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually finds
&lt;/h2&gt;

&lt;p&gt;I ran this across 70 public homepages — standards bodies, framework docs, design systems, plus EU government portals, banking, transport and e-commerce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;42 of 62 stable sites (68%) had at least one finding a baseline-only run never surfaced. 26 of 62 (42%) had a violation, not merely something needing review.&lt;/strong&gt; Median one per site.&lt;/p&gt;

&lt;p&gt;Eleven were clean in all seven states, including W3C, MDN, GOV.UK and three EU government portals.&lt;/p&gt;

&lt;p&gt;Worth stating plainly: axe detects a minority of WCAG failures — &lt;a href="https://www.deque.com/blog/automated-testing-study-identifies-57-percent-of-digital-accessibility-issues/" rel="noopener noreferrer"&gt;57% by issue volume&lt;/a&gt; in Deque's own study, roughly a third by success criteria. None of this replaces manual testing, and a finding is not a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not wire it up
&lt;/h2&gt;

&lt;p&gt;All of the above is packaged in &lt;a href="https://github.com/henriqueyuri00/a11y-matrix" rel="noopener noreferrer"&gt;a11y-matrix&lt;/a&gt; — MIT, no account, no telemetry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx github:henriqueyuri00/a11y-matrix https://your-site.example
npx github:henriqueyuri00/a11y-matrix &lt;span class="nt"&gt;--sitemap&lt;/span&gt; https://your-site.example/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across multiple pages it deduplicates by element, so a header defect on 18 pages is reported once as &lt;em&gt;every page&lt;/em&gt; rather than eighteen times. The &lt;a href="https://github.com/henriqueyuri00/a11y-matrix/tree/main/study" rel="noopener noreferrer"&gt;study directory&lt;/a&gt; has the method, the control run and the raw per-site data.&lt;/p&gt;




&lt;p&gt;Your pipeline being green is worth exactly as much as the states you rendered and the buckets you read.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>testing</category>
      <category>a11y</category>
    </item>
    <item>
      <title>I scanned 70 well-known sites in seven browser states. The default one missed something on 68% of them.</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Tue, 11 Aug 2026 00:03:07 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/i-scanned-36-well-known-sites-in-seven-browser-states-the-default-one-missed-something-on-71-of-2mhb</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/i-scanned-36-well-known-sites-in-seven-browser-states-the-default-one-missed-something-on-71-of-2mhb</guid>
      <description>&lt;p&gt;Every automated accessibility check I have ever seen loads the page once, in whatever state the headless browser boots in: light colour scheme, no motion preference, forced colors off, desktop viewport.&lt;/p&gt;

&lt;p&gt;That is one user. I wanted to know what it costs, so I measured it across 70 public homepages — W3C, MDN, React, Vue, Kubernetes, GitHub, Wikipedia, the BBC, a handful of design systems, and four sectors carrying an explicit EU legal accessibility duty: government portals, banking, passenger transport and e-commerce.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(This started as 36 mostly-technical sites. I extended it afterwards, and the numbers below are the larger run — the update is described at the end.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each site was loaded seven times. One run is the baseline: light, desktop, no preferences — what an ordinary pipeline tests. The other six each change &lt;strong&gt;exactly one&lt;/strong&gt; variable away from it. A finding counts only if it exists in a non-baseline state and &lt;em&gt;not&lt;/em&gt; in the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;70 attempted. 68 loaded — Allegro and Air France did not, and are excluded rather than counted as clean. Of those 68, &lt;strong&gt;62 produced byte-identical output across two identical runs&lt;/strong&gt;, and only those 62 are counted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Had a finding the baseline never surfaced&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;42 / 62 — 68%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Had one axe classes as a &lt;strong&gt;violation&lt;/strong&gt;, not "needs review"&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26 / 62 — 42%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct findings missed by the baseline&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;348&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median per site&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first pass, on 36 mostly-technical sites, returned 71% and 47%. Nearly doubling the sample and adding regulated sectors moved it to 68% and 42%. &lt;strong&gt;The finding replicated&lt;/strong&gt;, which is worth more than either number by itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The median is the honest headline, not the 348.&lt;/strong&gt; The distribution is badly skewed: half the sites have one or none, and a handful carry most of the volume — Vercel (72), Tailwind CSS (52), Radix UI (40), Wikipedia (31). Quoting the total alone would imply everyone is sitting on dozens. They are not.&lt;/p&gt;

&lt;p&gt;Eleven sites were clean in all seven states: &lt;strong&gt;W3C, WebAIM, MDN, Playwright, Primer, GOV.UK, Service-Public (FR), Rijksoverheid (NL), Suomi.fi (FI), Fnac, SNCF Connect.&lt;/strong&gt; Several either write the standard, teach it, or are bound by a public-sector duty and appear to be meeting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control is the part that makes this a study
&lt;/h2&gt;

&lt;p&gt;Real sites carry carousels, rotating promos, A/B tests and lazy-loaded media. Two identical loads do not necessarily produce identical output. Without measuring that, "unique to dark mode" might just mean a different hero image rendered.&lt;/p&gt;

&lt;p&gt;So I ran the &lt;em&gt;same state twice&lt;/em&gt;, changing nothing, on all 36 sites:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;146 findings appeared and 156 disappeared — concentrated on six sites.&lt;/strong&gt; The other 62 were byte-identical.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those six are excluded from every figure above rather than quietly left in the denominator, and this is the part that earned the control its place. One shopping site produced &lt;strong&gt;715 apparent findings&lt;/strong&gt; and a churn of +122/−125 between two identical loads. That is a rotating product carousel, not a defect.&lt;/p&gt;

&lt;p&gt;Excluding it moved its whole sector's total from 729 findings to 14. Had I skipped the control, that one number would have been the loudest thing in the study, and it would have been wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exposed the findings
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Sites&lt;/th&gt;
&lt;th&gt;Distinct findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Narrow viewport (mobile / 320px reflow)&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;340&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dark colour scheme&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;303&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Dark scheme alone&lt;/strong&gt; — no narrow state found it&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reduced motion&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forced colors&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two overlap heavily — a lot of sites break in dark mode &lt;em&gt;and&lt;/em&gt; at 320px — which is why "dark scheme alone" is broken out. On those 9 sites the colour scheme is the only variable that can explain the finding.&lt;/p&gt;

&lt;p&gt;By rule: &lt;code&gt;color-contrast&lt;/code&gt; on 32 sites, then &lt;strong&gt;9 sites whose document is genuinely wider than a 320px viewport&lt;/strong&gt; — the WCAG 1.4.10 condition measured directly, since axe has no rule for it — then &lt;code&gt;scrollable-region-focusable&lt;/code&gt;, &lt;code&gt;link-in-text-block&lt;/code&gt; and &lt;code&gt;button-name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Forced colors found nothing at all on any stable site. Reporting that is the point: a negative result is a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  I got the counting wrong first, and the error was a factor of three
&lt;/h2&gt;

&lt;p&gt;Worth writing down, because the wrong way is the obvious way.&lt;/p&gt;

&lt;p&gt;My first pass summed the per-state counts. But a defect caused by a narrow layout appears in &lt;code&gt;mobile&lt;/code&gt;, &lt;code&gt;reflow-320&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;dark-mobile&lt;/code&gt; — three states, one element. I was counting it three times.&lt;/p&gt;

&lt;p&gt;The tell was in the raw output: Wikipedia showed exactly 30 incomplete results in each of those three states. Not similar numbers. Identical ones.&lt;/p&gt;

&lt;p&gt;Had I published, I would have claimed "210 findings on Vercel". The real figure is 72. Findings are now keyed by &lt;code&gt;(kind, rule, element)&lt;/code&gt; and deduplicated across states.&lt;/p&gt;

&lt;p&gt;The same mistake had a second half: attributing a finding to a single state when three overlapping states could equally claim it. Attribution is now by variable group.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not an audit.&lt;/strong&gt; axe detects a minority of WCAG failures — &lt;a href="https://www.deque.com/blog/automated-testing-study-identifies-57-percent-of-digital-accessibility-issues/" rel="noopener noreferrer"&gt;57% by issue volume&lt;/a&gt; in Deque's own study, roughly a third by success criteria. Every number here is a lower bound on a subset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a claim that any site is inaccessible.&lt;/strong&gt; A finding is not a failure, and an &lt;code&gt;incomplete&lt;/code&gt; result is explicitly a request for human review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a ranking.&lt;/strong&gt; The gap is a property of how pipelines are configured, not of how much any team cares. That is exactly why the sites that write the accessibility standards came out clean: they are testing the states.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The tool is &lt;a href="https://github.com/henriqueyuri00/a11y-matrix" rel="noopener noreferrer"&gt;a11y-matrix&lt;/a&gt; — MIT, no account, no telemetry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx github:henriqueyuri00/a11y-matrix https://your-site.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/henriqueyuri00/a11y-matrix/tree/main/study" rel="noopener noreferrer"&gt;study directory&lt;/a&gt; has the site list, both scripts, the raw per-site JSON and the control run. Every figure above comes from &lt;code&gt;report.js&lt;/code&gt; and nowhere else, so you can disagree with the interpretation without re-running anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the legal duty change anything?
&lt;/h2&gt;

&lt;p&gt;Only four sectors in the sample carry an explicit EU accessibility obligation — public sector under the Web Accessibility Directive, and banking, passenger transport and e-commerce under the European Accessibility Act, in force since 28 June 2025.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sector&lt;/th&gt;
&lt;th&gt;Affected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework and tool docs&lt;/td&gt;
&lt;td&gt;13 / 16 (81%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design systems&lt;/td&gt;
&lt;td&gt;3 / 4 (75%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU passenger transport&lt;/td&gt;
&lt;td&gt;3 / 4 (75%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU banking&lt;/td&gt;
&lt;td&gt;3 / 4 (75%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU public sector&lt;/td&gt;
&lt;td&gt;8 / 12 (67%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer platforms&lt;/td&gt;
&lt;td&gt;4 / 7 (57%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU e-commerce&lt;/td&gt;
&lt;td&gt;4 / 8 (50%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standards bodies&lt;/td&gt;
&lt;td&gt;2 / 4 (50%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The regulated sectors are not visibly better than the rest. They are also &lt;strong&gt;not visibly worse&lt;/strong&gt;, and I would rather say that than reach for the more quotable claim.&lt;/p&gt;

&lt;p&gt;To be explicit, because this is the kind of number people misquote: &lt;strong&gt;none of this is evidence of non-compliance by anyone.&lt;/strong&gt; Conformance is assessed across a whole service against fifty-odd success criteria, most of which no scanner can evaluate. This looks at one page with one engine and reports differences between rendering states. What it shows is narrower: the states where these defects hide are the states an automated pipeline does not render — including at organisations that have a legal duty and watch their checks pass green.&lt;/p&gt;




&lt;p&gt;If your accessibility pipeline is green, that is worth exactly as much as the states you rendered.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>testing</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Text at 1:1 contrast is not an axe violation. It is incomplete.</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Mon, 10 Aug 2026 22:07:30 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/text-at-11-contrast-is-not-an-axe-violation-it-is-incomplete-436c</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/text-at-11-contrast-is-not-an-axe-violation-it-is-incomplete-436c</guid>
      <description>&lt;p&gt;I shipped a button whose label was invisible. Same colour as its own background — 1:1 contrast, no readable text at all. It was only broken in dark mode, and my CI was green the entire time.&lt;/p&gt;

&lt;p&gt;It was green for two reasons. I expected the first one. The second one is why I am writing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason one: the headless browser boots in light mode
&lt;/h2&gt;

&lt;p&gt;axe evaluates the colour scheme that is actually rendered. It has no way to reason about a palette that was never painted. My pipeline loaded the page once, in whatever state Chromium starts in, and that state is &lt;code&gt;prefers-color-scheme: light&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That part is well known, and the fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I did that, re-ran, and the run was still green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason two: axe does not call this a violation
&lt;/h2&gt;

&lt;p&gt;Here is the probe I wrote when I stopped believing the output. Same page, same axe config, two colour schemes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matchesDark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(255, 255, 255)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(28, 93, 63)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nl"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="nx"&gt;incomplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dark&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matchesDark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(108, 196, 154)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(108, 196, 154)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nl"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="nx"&gt;incomplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color-contrast x1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In dark mode the text and its background are the &lt;em&gt;same value&lt;/em&gt;. &lt;code&gt;rgb(108, 196, 154)&lt;/code&gt; on &lt;code&gt;rgb(108, 196, 154)&lt;/code&gt;. You cannot read it, because there is nothing to read.&lt;/p&gt;

&lt;p&gt;and &lt;code&gt;violations&lt;/code&gt; is empty.&lt;/p&gt;

&lt;p&gt;It lands in &lt;code&gt;incomplete&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  axe is not wrong
&lt;/h2&gt;

&lt;p&gt;This is the part that took me a while to accept. axe declines to call it a violation because it genuinely cannot tell the difference between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;text that is invisible because someone broke the palette, and&lt;/li&gt;
&lt;li&gt;text that is invisible &lt;strong&gt;on purpose&lt;/strong&gt; — a visually-hidden label, a fade-in that has not started, a print-only string, a decorative element.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Matching foreground to background is a legitimate hiding technique. axe reports what it can prove and hands the rest to a human. That is the right call for a linter.&lt;/p&gt;

&lt;p&gt;It is the wrong call for my pipeline, because my pipeline asserts on &lt;code&gt;violations&lt;/code&gt; and throws &lt;code&gt;incomplete&lt;/code&gt; away. Almost every pipeline does. Look at your own reporter and count how many of them you have ever read.&lt;/p&gt;

&lt;p&gt;So the failure mode is asymmetric in the worst direction: &lt;strong&gt;the more severe the contrast defect, the more likely it is to be classified as needing review rather than failing.&lt;/strong&gt; A 4.3:1 ratio is a confident violation. A 1:1 ratio is a question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;Two things, and the first one matters more than the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collect &lt;code&gt;incomplete&lt;/code&gt;, and label it distinctly.&lt;/strong&gt; Not "treat it as a failure" — that will bury you. Label it, surface it, and look at the ones that are new.&lt;/p&gt;

&lt;p&gt;Also: do not set &lt;code&gt;resultTypes: ["violations"]&lt;/code&gt;. It looks like a harmless optimisation. It makes axe return a single representative node for every other bucket, so the incomplete results get silently truncated before you ever see them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diff across states instead of counting.&lt;/strong&gt; A raw count of findings is a chore that nobody triages. "These 3 findings exist in dark mode and not in your baseline" is a bug report with a cause attached.&lt;/p&gt;

&lt;p&gt;That second idea turned into a small tool: &lt;a href="https://github.com/henriqueyuri00/a11y-matrix" rel="noopener noreferrer"&gt;a11y-matrix&lt;/a&gt;. It runs axe across dark mode, reduced motion, forced colors, mobile and the 320px reflow width, changing exactly one variable at a time, and reports only what each state uniquely breaks. MIT, and it runs from &lt;code&gt;npx github:henriqueyuri00/a11y-matrix &amp;lt;url&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing it taught me
&lt;/h2&gt;

&lt;p&gt;I pointed it at my own sales page. The baseline was clean. At 320px it reported six findings: a comparison table overflowing and covering its own third column.&lt;/p&gt;

&lt;p&gt;So I fixed it the recommended way — &lt;code&gt;overflow-x: auto&lt;/code&gt;, &lt;code&gt;tabindex="0"&lt;/code&gt;, &lt;code&gt;role="region"&lt;/code&gt;, an &lt;code&gt;aria-label&lt;/code&gt; — and re-ran.&lt;/p&gt;

&lt;p&gt;Still six findings. Identical message.&lt;/p&gt;

&lt;p&gt;Because axe emits the same string for "covered by an overlay" and "scrolled out of view inside a scroll container". The first is a defect. The second is the sanctioned way to present a wide data table at the reflow width, and it is what the spec asks you to do.&lt;/p&gt;

&lt;p&gt;My own tool was failing a page for being fixed correctly. That is how a check earns its way out of a pipeline within a week — so it now walks the DOM, asks whether the element sits inside an ancestor that actually scrolls horizontally and that a keyboard can reach, and suppresses the finding while printing the count and the reason. Never silently.&lt;/p&gt;

&lt;p&gt;And since that removed the only thing watching reflow, it measures reflow directly instead: document &lt;code&gt;scrollWidth&lt;/code&gt; against viewport width. axe has no rule for WCAG 1.4.10 at all. Stating the requirement beats inferring it from a contrast side effect.&lt;/p&gt;




&lt;p&gt;If your accessibility pipeline is green, that is worth exactly as much as the states you rendered and the buckets you read.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>testing</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Five other projects have the exact same fake API key in their tests</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Mon, 10 Aug 2026 21:34:37 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/five-other-projects-have-the-exact-same-fake-api-key-in-their-tests-2i9b</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/five-other-projects-have-the-exact-same-fake-api-key-in-their-tests-2i9b</guid>
      <description>&lt;p&gt;GitHub flagged a Google API key in my repository. It was a fixture I had made up — sequential filler after the &lt;code&gt;AIza&lt;/code&gt; prefix, never a real credential.&lt;/p&gt;

&lt;p&gt;What I didn't expect was the sidebar on the alert page. Under &lt;strong&gt;Public leaks&lt;/strong&gt;, GitHub lists every other public repository where it has seen the &lt;em&gt;same string&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_pii_detector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;scanner&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;scanCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;
&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;js&lt;/span&gt;
&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;scanner&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;diagnostics&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;go&lt;/span&gt;
&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cleaner&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;leakscanner_test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;go&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five projects. Python, TypeScript, JavaScript, Go. Different authors, different years, different ecosystems.&lt;/p&gt;

&lt;p&gt;All of them secret detectors. All of them with my "invented" key in their test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was never invented
&lt;/h2&gt;

&lt;p&gt;I wrote &lt;code&gt;AIzaSyA1234567890abcdefghijklmnopqrstuv&lt;/code&gt; thinking I had made it up. What I had actually done was reconstruct the most obvious possible fixture: take the documented prefix, fill the remaining 35 characters with the alphabet and the digits in order. Anyone writing a Google API key rule reaches for the same shape, because it is the shape the format forces.&lt;/p&gt;

&lt;p&gt;Given a fixed prefix and a fixed length, the space of &lt;em&gt;lazy&lt;/em&gt; values is tiny. We all landed on the same one.&lt;/p&gt;

&lt;p&gt;There is a nice example of this in AWS's own docs: &lt;code&gt;AKIAIOSFODNN7EXAMPLE&lt;/code&gt;. That one is deliberate — Amazon publishes it so people have a safe value to paste. The interesting case is the accidental convergence, where nobody coordinated and everybody agreed anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a real problem and not a fun fact
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scanners cannot tell.&lt;/strong&gt; A string is a credential shape or it isn't. &lt;code&gt;AIzaSy&lt;/code&gt; plus 35 valid characters matches, and the scanner has no way to know that this particular one has been in five test suites since 2021. So it fires. Correctly, by its own rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which means every one of those repositories has, or had, an open alert like mine.&lt;/strong&gt; Five maintainers, at minimum, have triaged this exact false positive. Probably far more — the public-leaks list only shows repositories GitHub has indexed and surfaced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the failure mode is asymmetric.&lt;/strong&gt; If you assume it's fake and you're right, nothing happens. If you assume it's fake and you're wrong, you've just dismissed a live credential because it "looked like a test value". The convergence trains exactly the wrong reflex.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;Not the values — they still need to be realistic, because a fixture that doesn't match the pattern proves nothing. I changed where they exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// The matcher receives the identical string. The file does not contain it.&lt;/span&gt;
&lt;span class="nl"&gt;example&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AIza&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SyA1234567890abcdefghijklmnopqrstuv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then made it a build failure rather than a habit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CREDENTIAL_SHAPED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghp_[A-Za-z0-9]{36}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AKIA[0-9A-Z]{16}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AIza[0-9A-Za-z_-]{35}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_live_[0-9a-zA-Z]{24}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xox[baprs]-[0-9]{12}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hf_[A-Za-z0-9]{34}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;d{9}:[A-Za-z0-9_-]{35}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eyJ[A-Za-z0-9_-]{10,}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.eyJ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reader pointed out I had scoped that too narrowly — it covered source files but not documentation, which is backwards, since a README is exactly where a credential-shaped string gets pasted &lt;em&gt;without thinking&lt;/em&gt;. It covers docs now.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you maintain anything that detects secrets
&lt;/h2&gt;

&lt;p&gt;Three things worth checking, and none of them takes long:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do whole token literals appear anywhere in your source?&lt;/strong&gt; Including the README, the docs site, and the store listing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Would a new contributor know not to add one?&lt;/strong&gt; If the answer is "they'd know because everyone knows", you don't have a convention, you have a coincidence waiting to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can your build fail on it?&lt;/strong&gt; Twenty lines. It is the only version of this that survives you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if you get an alert for a value you're sure is fake: check the public-leaks list before you close it. Mine turned four other people's test files into the evidence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Context: I was building &lt;a href="https://henriqueyuri00.github.io/leakguard/" rel="noopener noreferrer"&gt;LeakGuard&lt;/a&gt;, which checks text for credentials and personal data before you paste it into an AI chat — 50 rules, 36 providers, entirely in your browser, nothing uploaded. The irony of it being blocked for containing secrets has not been lost on anyone. &lt;a href="https://github.com/henriqueyuri00/leakguard" rel="noopener noreferrer"&gt;Source&lt;/a&gt;, MIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>I built a secret detector. GitHub blocked my push for containing secrets.</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Mon, 10 Aug 2026 19:48:22 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/i-built-a-secret-detector-github-blocked-my-push-for-containing-secrets-29fc</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/i-built-a-secret-detector-github-blocked-my-push-for-containing-secrets-29fc</guid>
      <description>&lt;p&gt;Every rule in a secret scanner needs a fixture — a value that proves the pattern fires. Mine looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-secret&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Stripe secret key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nf"&gt;b&lt;/span&gt;&lt;span class="p"&gt;(?:&lt;/span&gt;&lt;span class="nx"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nx"&gt;rk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nx"&gt;_live_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;zA&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;]{&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,}&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_live_0123456789abcdefghijklmn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fabricated, obviously. Sequential filler after the prefix. Not a key, never was.&lt;/p&gt;

&lt;p&gt;GitHub push protection disagreed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;remote: — Stripe API Key —————————————————————————
remote:  locations:
remote:    - commit: 02345e9…
remote:      path: test/scan.test.js:37
&lt;/span&gt;&lt;span class="gp"&gt;remote: ! [remote rejected] main -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;main &lt;span class="o"&gt;(&lt;/span&gt;push declined due to repository rule violations&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is, when you think about it, the system working exactly as intended. My fixtures are realistic enough to trip a serious scanner. That is the point of them.&lt;/p&gt;

&lt;p&gt;There's an "allow this secret" link in the rejection. I didn't click it. Here's what I did instead, and the two things I got wrong on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 1: assuming push protection is the whole check
&lt;/h2&gt;

&lt;p&gt;I fixed the four types it flagged — Stripe live, Stripe test, Slack, Mailgun — by assembling the values from fragments so no whole token-shaped literal exists in the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nl"&gt;example&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live_0123456789abcdefghijklmn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime value is byte-identical. The literal in the file is not.&lt;/p&gt;

&lt;p&gt;Push succeeded. Ten minutes later, an email: &lt;strong&gt;Google API Key&lt;/strong&gt; and &lt;strong&gt;Telegram Bot Token&lt;/strong&gt; detected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push protection and secret scanning are different systems with different coverage.&lt;/strong&gt; The first blocks a subset of high-confidence provider types at push time. The second scans the repository afterwards and covers more. Clearing one tells you nothing about the other, and I had quietly assumed it did.&lt;/p&gt;

&lt;p&gt;So I stopped playing whack-a-mole and applied the fragment treatment to every realistic fixture in the corpus at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: fixing it by hand and calling it done
&lt;/h2&gt;

&lt;p&gt;A convention that lives only in someone's memory is a convention that dies at the next commit. So the build now fails on any credential-shaped literal in the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CREDENTIAL_SHAPED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghp_[A-Za-z0-9]{36}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AKIA[0-9A-Z]{16}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AIza[0-9A-Za-z_-]{35}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_live_[0-9a-zA-Z]{24}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xox[baprs]-[0-9]{12}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hf_[A-Za-z0-9]{34}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;d{9}:[A-Za-z0-9_-]{35}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eyJ[A-Za-z0-9_-]{10,}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.eyJ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified the guard by injecting a violation. The build failed with the exact file and line. A check you haven't seen fail is a check you don't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual hard part isn't detection
&lt;/h2&gt;

&lt;p&gt;While I'm here — the reason I was building this at all is that most paste guards are unusable, and it isn't because they miss things. It's because they cry wolf.&lt;/p&gt;

&lt;p&gt;Match &lt;code&gt;[A-Za-z0-9]{32}&lt;/code&gt; and call it a secret, and you flag UUIDs, git SHAs, minified bundles and build numbers. After the third false alarm the user stops reading the warning, which is strictly worse than having no warning.&lt;/p&gt;

&lt;p&gt;Three things fixed most of it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefixes beat entropy.&lt;/strong&gt; A rule keyed on a documented token format is near-zero false positive. Entropy alone is a coin flip on base64. I kept entropy, but only as a secondary gate on assignment-shaped lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate where an algorithm exists.&lt;/strong&gt; Card numbers get Luhn. Brazilian CPF and CNPJ have check digits — verify them. Roughly 99% of random 11-digit strings fail CPF's check digits, so a naive &lt;code&gt;\d{11}&lt;/code&gt; flags every order number in your logs and a validated one flags none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recognise placeholders by vocabulary, not by shape.&lt;/strong&gt; This one took two attempts. My first fix rejected anything shaped like &lt;code&gt;word-word-word&lt;/code&gt;, which killed &lt;code&gt;your-api-key-here&lt;/code&gt; nicely and also killed &lt;code&gt;correct-horse-battery-staple&lt;/code&gt; — a real password. Vocabulary works: random credentials do not contain the word "your".&lt;/p&gt;

&lt;p&gt;And a small trap inside that: &lt;code&gt;\b&lt;/code&gt; treats underscore as a word character, so &lt;code&gt;\bchange_?me\b&lt;/code&gt; never matches &lt;code&gt;CHANGE_ME_PLEASE&lt;/code&gt;. Normalising separators to spaces before matching fixed it — and then broke the multi-word patterns, which were still expecting &lt;code&gt;[-_]&lt;/code&gt;. Two bugs, one line apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The suite that matters
&lt;/h2&gt;

&lt;p&gt;The false-positive suite in this project is larger than the detection suite, deliberately. Detection tests prove the tool does something. False-positive tests prove it is worth leaving switched on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FALSE POSITIVES — the cases that make people uninstall
  PASS  UUID v4
  PASS  git commit SHA
  PASS  semver + build number
  PASS  invalid card (Luhn)
  PASS  random 11 digits
  PASS  invalid CPF
  PASS  placeholder secret

PRECISION — placeholders die, real passphrases survive
  PASS  'your-api-key-here'
  PASS  'CHANGE_ME_PLEASE'
  PASS  real passphrase kept
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;The thing I was building is &lt;a href="https://henriqueyuri00.github.io/leakguard/" rel="noopener noreferrer"&gt;LeakGuard&lt;/a&gt; — it checks text for credentials and personal data before you paste it into an AI chat. 50 rules across 36 providers. The web version runs entirely in your browser: no upload, no account, no network requests at all, which you can confirm from an empty network tab. &lt;a href="https://github.com/henriqueyuri00/leakguard" rel="noopener noreferrer"&gt;Source&lt;/a&gt;, MIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>A customer asked for our VPAT. Here's what that actually means</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:24:08 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/a-customer-asked-for-our-vpat-heres-what-that-actually-means-3edl</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/a-customer-asked-for-our-vpat-heres-what-that-actually-means-3edl</guid>
      <description>&lt;p&gt;A prospect's procurement team sends over a security-and-compliance questionnaire. Most of it you can answer. Then there's a line that says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Please provide your VPAT or Accessibility Conformance Report.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your reaction is "our what?", this is for you. It was mine too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually being asked
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;VPAT®&lt;/strong&gt; is a template — the Voluntary Product Accessibility Template, maintained by the Information Technology Industry Council. Fill it in and the completed document is called an &lt;strong&gt;ACR&lt;/strong&gt;, an Accessibility Conformance Report.&lt;/p&gt;

&lt;p&gt;The document is a table. One row per accessibility success criterion, and for each row you declare one of four things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supports&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You tested it and found no defects in scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Partially Supports&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Met in most of the product, with at least one known defect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Does Not Support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The majority of the relevant functionality fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Not Applicable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The criterion genuinely cannot apply to this product&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus a &lt;strong&gt;Remarks&lt;/strong&gt; column, which is the part that actually matters. More on that below.&lt;/p&gt;

&lt;p&gt;Which criteria? Depends on the edition. The three common ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WCAG edition&lt;/strong&gt; — the 50 WCAG 2.1 Level A and AA success criteria.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 508 edition&lt;/strong&gt; — for selling to US federal agencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EU edition&lt;/strong&gt; — maps to &lt;strong&gt;EN 301 549&lt;/strong&gt;, the harmonised European standard, whose Chapter 9 incorporates WCAG 2.1 Level AA in full.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a web application sold internationally, the WCAG/EU editions cover the substance, because all three rest on the same 50 criteria.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you're suddenly being asked
&lt;/h2&gt;

&lt;p&gt;Two things converged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Procurement made it standard.&lt;/strong&gt; Per the Seventh Annual State of Digital Accessibility Report, around three-quarters of organisations now require proof of accessibility for most digital purchases, and roughly a third require it for every purchase. Your buyer isn't being difficult — someone in their organisation made it a gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The European Accessibility Act became enforceable.&lt;/strong&gt; Enforcement started in June 2025, and 2026 is the first full year national authorities are actively supervising. Buyers in the EU manage that risk the way buyers always do: by pushing requirements down the supply chain, into contracts with vendors like you.&lt;/p&gt;

&lt;h2&gt;
  
  
  You might not be in scope, and that changes nothing
&lt;/h2&gt;

&lt;p&gt;Worth knowing, because most vendors selling into this space will not tell you: the EAA binds service providers with &lt;strong&gt;more than 10 employees or more than €2 million turnover&lt;/strong&gt;. A four-person SaaS under €2m very likely isn't in scope as a service provider.&lt;/p&gt;

&lt;p&gt;And it doesn't help you at all, because &lt;strong&gt;an exemption from the regulation is not an exemption from your customer's questionnaire&lt;/strong&gt;. The buyer asking for your ACR isn't enforcing the EAA against you. They're managing their own obligation through a contract. That's contract law, and no exemption touches it.&lt;/p&gt;

&lt;p&gt;If a deal is blocked, it's blocked either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to get one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The ITI template itself: &lt;strong&gt;free&lt;/strong&gt;. It's a blank document you can download today.&lt;/li&gt;
&lt;li&gt;A consultancy to run the audit and complete it: commonly &lt;strong&gt;$1,850–$3,100&lt;/strong&gt; for a small product — roughly $350 for completing the report on top of $1,500–$2,750 for the audit.&lt;/li&gt;
&lt;li&gt;Doing it yourself: a few days of someone's time, and the discipline to be honest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The expensive part was never the paperwork. It's knowing whether you actually conform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;The instinct is to make the report look good. Fifty rows of &lt;em&gt;Supports&lt;/em&gt;, ship it, close the deal.&lt;/p&gt;

&lt;p&gt;That instinct is exactly backwards, for a reason worth internalising: &lt;strong&gt;experienced reviewers read the Remarks column far more carefully than the conformance level.&lt;/strong&gt; A clean sweep across an entire real product is rare enough that it reads as evidence the testing never happened. It invites the follow-up question you cannot answer.&lt;/p&gt;

&lt;p&gt;Compare:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❌ &lt;em&gt;Supports. "Mostly compliant, we follow best practices."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;✅ &lt;em&gt;Partially Supports. "Tested across sign-up, dashboard and billing with NVDA 2025.1 + Firefox. All native controls expose correct name, role and state. Two defects: the saved-view combobox does not expose &lt;code&gt;aria-expanded&lt;/code&gt;, and the plan toggle does not expose a checked state. Tracked as ACME-4471/4472, remediation targeted 2026-10-15."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second one takes four minutes to write and is the difference between a report that closes a deal and one that generates a call you'll lose. It says: we tested, we know where we stand, we have a plan. That is what procurement is buying.&lt;/p&gt;

&lt;p&gt;Also worth knowing: a missing &lt;strong&gt;or inaccurate&lt;/strong&gt; accessibility statement is separately punishable in some member states — France provides for fines up to €25,000 per year. An inflated report isn't just a bad look; it's exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things to say no to
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Overlays.&lt;/strong&gt; If a vendor promises conformance from a script tag, walk. Overlays do not produce conformance with WCAG or EN 301 549, they've drawn litigation of their own, and buyers' accessibility teams increasingly treat their presence as a &lt;em&gt;negative&lt;/em&gt; signal in exactly the procurement review you're trying to pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A green automated scan as your evidence.&lt;/strong&gt; Run axe or Lighthouse — you should. But axe prints its own disclaimer on every run: &lt;em&gt;"only 20% to 50% of all accessibility issues can automatically be detected. Manual testing is always required."&lt;/em&gt; The criteria that fail most in real audits (info and relationships, name/role/value, focus visibility, keyboard operation) are precisely the ones a scanner cannot judge. A methods section listing only automated tooling will be read as untested.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest minimum
&lt;/h2&gt;

&lt;p&gt;If you want to produce a defensible report yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope it in writing.&lt;/strong&gt; List the flows in scope and the ones out, with a reason for each exclusion. Anything a customer touches is in scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated sweep.&lt;/strong&gt; axe across representative pages — and on &lt;em&gt;states&lt;/em&gt;, not just routes: open the modal, trigger the validation error, then scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard-only pass.&lt;/strong&gt; Put the mouse away and complete every primary flow. This alone finds the most serious defects you have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One screen reader session.&lt;/strong&gt; NVDA + Firefox, or VoiceOver + Safari. You need about six commands, not fluency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zoom and reflow.&lt;/strong&gt; 200% zoom, and 320 CSS pixels wide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write it down&lt;/strong&gt; — including a dated evidence log of what was tested, on what, by whom. A conformance claim you cannot reconstruct is one you cannot defend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then answer the fifty rows honestly, and give every non-conforming one a real remark and a real date.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I built a free tool for step 6: &lt;a href="https://henriqueyuri00.github.io/acr-builder/" rel="noopener noreferrer"&gt;ACR Builder&lt;/a&gt; walks the 50 WCAG 2.1 A/AA criteria and exports the report. It deliberately blocks you from filing a non-conforming criterion without an explanation, and warns you when all fifty come out as Supports. Runs entirely in your browser — no account, no upload, nothing leaves your machine. MIT licensed. Not legal advice; whether you're in scope of any of this is a question for a lawyer.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I have since written this up as a standalone reference: &lt;a href="https://henriqueyuri00.github.io/acr-builder/vpat/" rel="noopener noreferrer"&gt;what a customer actually means when they ask for your VPAT&lt;/a&gt; — the four VPAT 2.5 editions and which applies, what each conformance level signals, and why the Remarks column is the one that gets read. There are companion notes on &lt;a href="https://henriqueyuri00.github.io/acr-builder/eaa/" rel="noopener noreferrer"&gt;whether the European Accessibility Act applies to you at all&lt;/a&gt; and on &lt;a href="https://henriqueyuri00.github.io/acr-builder/statement/" rel="noopener noreferrer"&gt;why an accessibility statement has to be based on an actual evaluation&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>saas</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your axe run is green and your dark mode has 1.04:1 contrast</title>
      <dc:creator>Henrique Yuri</dc:creator>
      <pubDate>Mon, 10 Aug 2026 03:24:46 +0000</pubDate>
      <link>https://dev.to/henrique_yuri_f42f2fca47a/your-axe-run-is-green-and-your-dark-mode-has-1041-contrast-36i4</link>
      <guid>https://dev.to/henrique_yuri_f42f2fca47a/your-axe-run-is-green-and-your-dark-mode-has-1041-contrast-36i4</guid>
      <description>&lt;p&gt;I shipped a page that reported &lt;strong&gt;zero axe violations&lt;/strong&gt;. It had button text at a contrast ratio of &lt;strong&gt;1.04:1&lt;/strong&gt; — which is, for practical purposes, invisible text.&lt;/p&gt;

&lt;p&gt;The scan wasn't broken. It was answering a narrower question than I thought I was asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;I had a theme system built the ordinary way. Tokens on &lt;code&gt;:root&lt;/code&gt;, overridden in a &lt;code&gt;prefers-color-scheme&lt;/code&gt; media query, and overridden again by an explicit &lt;code&gt;[data-theme]&lt;/code&gt; attribute so a manual toggle wins in both directions.&lt;/p&gt;

&lt;p&gt;Buttons came in two flavours: a solid primary and a bordered secondary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.btn&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--panel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.btn.sec&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In dark mode the accent goes light green, so white-on-accent stops working. I patched it the way you patch things at 1am:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;dark&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.btn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#10241b&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;:root:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;light&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="nc"&gt;.btn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#10241b&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now count the specificity.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Selector&lt;/th&gt;
&lt;th&gt;Specificity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.btn.sec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,2,0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:root[data-theme=dark] .btn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,3,0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:root:not([data-theme=light]) .btn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0,3,0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;:not()&lt;/code&gt; doesn't add specificity of its own, but its &lt;em&gt;argument&lt;/em&gt; does. So &lt;code&gt;:root&lt;/code&gt; (0,1,0) + &lt;code&gt;[data-theme=light]&lt;/code&gt; (0,1,0) + &lt;code&gt;.btn&lt;/code&gt; (0,1,0) lands at 0,3,0.&lt;/p&gt;

&lt;p&gt;My theme patch outranks the component modifier. In dark mode, every &lt;strong&gt;secondary&lt;/strong&gt; button — transparent background, sitting on a &lt;code&gt;#1a1c1f&lt;/code&gt; panel — got painted &lt;code&gt;#10241b&lt;/code&gt;. Dark green on near-black. &lt;strong&gt;1.04:1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The nasty part is that this class of bug is invisible in review. The rule looks correct. It &lt;em&gt;is&lt;/em&gt; correct, for the buttons it was written for. It just also matched buttons it was never meant to touch, in one theme only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the scan didn't catch it
&lt;/h2&gt;

&lt;p&gt;axe-core evaluates the DOM &lt;strong&gt;as currently rendered&lt;/strong&gt;. It reads computed styles, and computed styles resolve exactly one colour scheme: whichever one the browser is in right now.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;npx axe https://example.com&lt;/code&gt; is not "does this page pass contrast." It's "does this page pass contrast &lt;em&gt;in the scheme this headless browser happened to boot in&lt;/em&gt;." If your CI runs light and your bug is dark, you get a green check that means nothing.&lt;/p&gt;

&lt;p&gt;I didn't want to take my own word for this, so I re-introduced the bug and ran axe against the same page twice, changing only the theme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runIn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;runOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same page. Same axe 4.12.1. Same second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;light → 0 violations
dark  → 2 violations
        color-contrast 1.13 (#10241b on #131416)
        color-contrast 1.04 (#10241b on #1a1c1f)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One run says ship it. The other says two elements are unreadable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the better fix
&lt;/h2&gt;

&lt;p&gt;The immediate fix is to stop overriding the colour and start &lt;strong&gt;swapping the token&lt;/strong&gt;. The override created a specificity contest; a token has none to win.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt;                             &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#1c5d3f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--on-accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt; &lt;span class="nd"&gt;:root:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;light&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                                    &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#6cc49a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--on-accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#10241b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;dark&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#6cc49a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--on-accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#10241b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.btn&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--on-accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.btn.sec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.btn.sec&lt;/code&gt; now wins cleanly, in every theme, because nothing is competing with it. The general rule: &lt;strong&gt;let themes change values, not selectors.&lt;/strong&gt; The moment a theme block starts naming components, you've entered a specificity war you will eventually lose in exactly one of your themes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing both schemes in CI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;page.emulateMedia()&lt;/code&gt; is the piece most setups are missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AxeBuilder&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@axe-core/playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROUTES&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/app/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SCHEMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colorScheme&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;SCHEMES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ROUTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`a11y &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emulateMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;colorScheme&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AxeBuilder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withTags&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats worth knowing, because they bit me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;emulateMedia&lt;/code&gt; only drives &lt;code&gt;prefers-color-scheme&lt;/code&gt;.&lt;/strong&gt; If you also ship a manual toggle that writes &lt;code&gt;data-theme&lt;/code&gt; or a class, that path is a &lt;em&gt;different&lt;/em&gt; code path and needs its own case. Set the attribute explicitly before analysing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analyse states, not just routes.&lt;/strong&gt; Contrast bugs love disabled buttons, validation errors, toasts, empty states and open modals — none of which exist in the DOM when the page first loads. Open the thing, then scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wider point
&lt;/h2&gt;

&lt;p&gt;Automated accessibility testing is worth doing and it is worth being clear-eyed about. The axe CLI prints its own disclaimer every run:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"only 20% to 50% of all accessibility issues can automatically be detected. Manual testing is always required."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'd add a corollary from this bug: within that 20–50%, a scan only covers the &lt;em&gt;rendered state you gave it&lt;/em&gt;. Every scheme, every breakpoint, every interaction state you don't render is a state you didn't test. A green check is evidence about one configuration, not a certificate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I found this while building &lt;a href="https://henriqueyuri00.github.io/acr-builder/" rel="noopener noreferrer"&gt;ACR Builder&lt;/a&gt; — a free, offline, client-side tool that walks the 50 WCAG 2.1 A/AA success criteria and exports a conformance report, for teams whose enterprise customers have started asking for accessibility documentation. Auditing it against itself turned up five real defects, this being the most embarrassing. MIT licensed, no signup, nothing leaves your browser. &lt;a href="https://github.com/henriqueyuri00/acr-builder" rel="noopener noreferrer"&gt;Source&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
