<?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: Artemy Andruschak</title>
    <description>The latest articles on DEV Community by Artemy Andruschak (@artemy_and).</description>
    <link>https://dev.to/artemy_and</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%2F4148730%2Fffaab19c-9321-4a49-8e6b-fb9e803e7f03.jpg</url>
      <title>DEV Community: Artemy Andruschak</title>
      <link>https://dev.to/artemy_and</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/artemy_and"/>
    <language>en</language>
    <item>
      <title>A self-hosted accessibility monitor that turns 66 errors into 13 things to fix</title>
      <dc:creator>Artemy Andruschak</dc:creator>
      <pubDate>Tue, 29 Sep 2026 12:14:01 +0000</pubDate>
      <link>https://dev.to/artemy_and/a-self-hosted-accessibility-monitor-that-turns-66-errors-into-13-things-to-fix-5fe2</link>
      <guid>https://dev.to/artemy_and/a-self-hosted-accessibility-monitor-that-turns-66-errors-into-13-things-to-fix-5fe2</guid>
      <description>&lt;p&gt;Since the European Accessibility Act started applying in June 2025, a lot of shops, agencies and small product teams have the same question: where do our sites actually stand, and is it getting better or worse?&lt;/p&gt;

&lt;p&gt;The tools for that tend to fall into two groups. Enterprise monitors do the whole job, at enterprise prices, with your data on their servers. Open-source scanners are free, but they either run once, or keep a history only for the URLs you add one by one.&lt;/p&gt;

&lt;p&gt;I wanted checking a site to be quick and easy, with a report that's clear and honest about what it covers. And I wanted it free, on my own server. So I built &lt;a href="https://github.com/Artemy-And/tabwalk" rel="noopener noreferrer"&gt;Tabwalk&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Tabwalk scans whole sites daily or weekly with Playwright and axe-core, turns one template bug into one row instead of hundreds, and runs on your own server with one &lt;code&gt;docker compose up -d&lt;/code&gt;. Free and AGPL-3.0.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0r56r0uw6wuh599kzqc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0r56r0uw6wuh599kzqc.png" alt="A Tabwalk report for a demo shop" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Finds pages.&lt;/strong&gt; Tabwalk reads &lt;code&gt;sitemap.xml&lt;/code&gt; (or &lt;code&gt;sitemap_index.xml&lt;/code&gt;). With no sitemap, it takes the links from the home page. Tracking parameters like &lt;code&gt;utm_*&lt;/code&gt; are stripped so the same page isn't checked twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checks each page.&lt;/strong&gt; A worker opens it in headless Chromium through Playwright and runs axe-core with the WCAG 2.2 A/AA rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it again.&lt;/strong&gt; Every site is rescanned daily or weekly, and each scan is compared with the previous one: what's new, what got fixed, and how the count trends.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  One mistake counts once
&lt;/h2&gt;

&lt;p&gt;A template bug shows up on every page that uses the template. If a scanner reports it 500 times, nobody reads the report.&lt;/p&gt;

&lt;p&gt;So Tabwalk fingerprints every finding. It takes the element's markup, blanks out everything that changes from page to page, and hashes the result together with the rule id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ruleId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&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;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;id|for|href|src|srcset|value|aria-labelledby|aria-describedby|aria-controls|data-&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;="&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*"/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1="*"&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]{1,400}&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&amp;lt;&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;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&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;ruleId&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;normalized&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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 product cards with an empty link, &lt;code&gt;&amp;lt;a href="/p/1042" class="card-link"&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;a href="/p/2077" class="card-link"&amp;gt;&lt;/code&gt;, both become &lt;code&gt;&amp;lt;a href="*" class="card-link"&amp;gt;&lt;/code&gt;. Same rule, same fingerprint, one row with a page count.&lt;/p&gt;

&lt;p&gt;In the demo shop that ships with the repo, that turns 66 failing elements into 13 rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  It says what it can't judge
&lt;/h2&gt;

&lt;p&gt;axe-core returns a third category next to passes and violations: &lt;code&gt;incomplete&lt;/code&gt;. These are checks it can't decide on its own, like contrast over a background image.&lt;/p&gt;

&lt;p&gt;Tabwalk keeps them and labels them "needs a human". Automated checks cover only part of WCAG, so the honest thing for a tool to do is say which parts it measured and which parts are yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  No Redis, no cron container
&lt;/h2&gt;

&lt;p&gt;Scans are background jobs, and the usual answer is Redis + BullMQ, plus cron for the schedule. Tabwalk uses &lt;a href="https://github.com/timgit/pg-boss" rel="noopener noreferrer"&gt;pg-boss&lt;/a&gt; for both: the queue and the schedule live in the same Postgres as the data. Every 15 minutes the worker queues a scan for each site that is due.&lt;/p&gt;

&lt;p&gt;The whole thing is four containers: Postgres, API (Hono), worker and web (nginx serving the React dashboard):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Artemy-And/tabwalk.git
&lt;span class="nb"&gt;cd &lt;/span&gt;tabwalk
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it runs on your network, it can check staging and internal sites a cloud service can't reach. There's no account and no telemetry; results stay in your Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  In CI
&lt;/h2&gt;

&lt;p&gt;The same checker runs as a GitHub Action, without the dashboard:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Artemy-And/tabwalk@v0.2.1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://staging.example.com&lt;/span&gt;
    &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serious&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job summary lists every problem. "Needs a human" results are listed too, but they never fail the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it without a real site
&lt;/h2&gt;

&lt;p&gt;The repo includes a small demo shop with problems built in: images without alt text, icon buttons without names, a heading jump, unlabeled form fields, a video without captions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.demo.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;http://acmestore.example/&lt;/code&gt; as a site and run a scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It doesn't make a site compliant, and it doesn't change anything on it: no overlay, no widget. There's no login yet, pages behind a login aren't scanned, and reports can't be exported yet.&lt;/p&gt;

&lt;p&gt;Next up: plain-language explanations, turning the "needs a human" results into a checklist, and suggested code fixes. Suggested, never applied automatically.&lt;/p&gt;

&lt;p&gt;Tabwalk is free and AGPL-3.0. If you try it, I'd love to hear what's missing. Issues, discussions and pull requests are open, and there are a few &lt;a href="https://github.com/Artemy-And/tabwalk/labels/good%20first%20issue" rel="noopener noreferrer"&gt;good first issues&lt;/a&gt; if you'd like to help. And if it's useful to you, a star on &lt;a href="https://github.com/Artemy-And/tabwalk" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; helps other people find it.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>opensource</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
