<?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: Artur Pokładek</title>
    <description>The latest articles on DEV Community by Artur Pokładek (@friga).</description>
    <link>https://dev.to/friga</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%2F4190817%2F31613b77-5898-4733-a95e-b10dbc49559e.png</url>
      <title>DEV Community: Artur Pokładek</title>
      <link>https://dev.to/friga</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/friga"/>
    <language>en</language>
    <item>
      <title>Five Times the Monster on the Client's Site Was Me</title>
      <dc:creator>Artur Pokładek</dc:creator>
      <pubDate>Sat, 31 Oct 2026 20:24:15 +0000</pubDate>
      <link>https://dev.to/friga/five-times-the-monster-on-the-clients-site-was-me-3b9</link>
      <guid>https://dev.to/friga/five-times-the-monster-on-the-clients-site-was-me-3b9</guid>
      <description>&lt;p&gt;Everyone posts haunted house stories today. Mine are less atmospheric. In every one of them, the thing that broke the client's site was me.&lt;/p&gt;

&lt;p&gt;Here are five, in no particular order of shame.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "It's live, check it out"
&lt;/h2&gt;

&lt;p&gt;I sent the client a message saying the changes were done. The client opened the site and saw the old version.&lt;/p&gt;

&lt;p&gt;The cache was holding on to the old site, and I was holding on to the belief that I had deployed. Only one of us was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Lorem ipsum, live and in production
&lt;/h2&gt;

&lt;p&gt;Placeholder text is supposed to be replaced before anyone sees it. Mine made it onto the live site.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. It ran fine on my phone
&lt;/h2&gt;

&lt;p&gt;I checked the mobile version on my own phone, saw it working, and moved on. The client opened it on an older phone and it fell apart. Turns out "works on my phone" is just "works on my machine" with a smaller screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The animation that ate the battery
&lt;/h2&gt;

&lt;p&gt;It looked great on my laptop, smooth and exactly what I wanted.&lt;/p&gt;

&lt;p&gt;On a phone it heated the device and drained the battery.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The environment variables that weren't there anymore
&lt;/h2&gt;

&lt;p&gt;I overwrote the environment variables on production. Yes, the real ones.&lt;/p&gt;

&lt;p&gt;None of these needed a full moon. Just me, convinced it was fine.&lt;/p&gt;

&lt;p&gt;What's the scariest thing you've shipped yourself?&lt;/p&gt;

</description>
      <category>halloween</category>
      <category>webdev</category>
      <category>discuss</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Things That Pass Every Check on a Client Site and Still Don't Work</title>
      <dc:creator>Artur Pokładek</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/friga/things-that-pass-every-check-on-a-client-site-and-still-dont-work-5am4</link>
      <guid>https://dev.to/friga/things-that-pass-every-check-on-a-client-site-and-still-dont-work-5am4</guid>
      <description>&lt;p&gt;A client never opens Lighthouse. They open their inbox, and there are no enquiries in it.&lt;/p&gt;

&lt;p&gt;I keep running into this on client sites and on sites I take over from someone else, one of them built with v0. The last one on the list I built myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The form says thank you
&lt;/h2&gt;

&lt;p&gt;On a shop I took over, the contact form showed a green "Thank you". The email behind it never arrived.&lt;/p&gt;

&lt;p&gt;Autofill can break a form too. One site had a honeypot field that the browser filled in on its own, and the real enquiries were thrown away as bot submissions. Elsewhere the enquiries did arrive, in the spam folder, because the domain had no SPF or DKIM records.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "What matters is that it works"
&lt;/h2&gt;

&lt;p&gt;That's what the owner of a shop built with v0 told me. Row Level Security in Supabase was switched off, so the shop worked just as well for anyone who opened the browser console.&lt;/p&gt;

&lt;p&gt;On another site the Supabase service role key sat in the client JS bundle. That key skips RLS entirely. Turning RLS on wouldn't have helped there anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The noindex that shipped with the site
&lt;/h2&gt;

&lt;p&gt;The site went live months ago with the &lt;code&gt;noindex&lt;/code&gt; tag from staging still in the head. Google is very polite about this. It was asked not to index the pages, and for months it didn't.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;robots.txt&lt;/code&gt; that blocked &lt;code&gt;/_next/&lt;/code&gt; did the same job from a different angle. That folder is where Next.js keeps its scripts and styles. Googlebot couldn't fetch them, and on that site it rendered an empty page.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Lighthouse 100 on placeholders
&lt;/h2&gt;

&lt;p&gt;The score was measured on placeholder images. Then the client started uploading photos straight from their phone, several megabytes each, and LCP went downhill from there.&lt;/p&gt;

&lt;p&gt;The same perfect score sat on a site whose hero took seconds to show up on a real phone on 4G.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The cookie banner with two Accept buttons
&lt;/h2&gt;

&lt;p&gt;The banner has a Reject button, and clicking it sets exactly the same cookies as Accept.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The pages that send Google somewhere else
&lt;/h2&gt;

&lt;p&gt;Many subpages share the same title and the same canonical URL, and each one tells Google that the real page lives somewhere else. Google takes them at their word. One page stays in the index and the rest drop out.&lt;/p&gt;

&lt;p&gt;Sometimes the sitemap joins in, full of &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;vercel.app&lt;/code&gt; preview links.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. 100vh on an iPhone
&lt;/h2&gt;

&lt;p&gt;The hero is exactly one screen tall in Chrome's device emulator. On the phone itself, Safari's address bar covers the call to action.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;svh&lt;/code&gt; or &lt;code&gt;dvh&lt;/code&gt; fixes it, once somebody picks up an iPhone and checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The menu that needs a mouse
&lt;/h2&gt;

&lt;p&gt;Subpages open on hover. Phones don't hover, and on a phone those pages can't be reached from the menu at all.&lt;/p&gt;

&lt;p&gt;The phone number in the footer has no &lt;code&gt;tel:&lt;/code&gt; link either. On the device that could actually call it, you can read the number and that's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. alt="image"
&lt;/h2&gt;

&lt;p&gt;Every image on the site has &lt;code&gt;alt="image"&lt;/code&gt;, and the accessibility audit is green. That audit checked that the attribute exists and stopped there.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. The domain nobody owns
&lt;/h2&gt;

&lt;p&gt;The domain expires, or the SSL certificate does, and only then does anyone ask whose account it's on.&lt;/p&gt;

&lt;p&gt;One client's domain sat on my own account for a while. Handing it over took far more effort than it should have.&lt;/p&gt;

&lt;p&gt;The same silence covers smaller things. The map on one site died when its API key expired, and nobody noticed. Somewhere there is a backup, too, and a restore has never been tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Me
&lt;/h2&gt;

&lt;p&gt;I've sat looking at a green CI run while something on production was down.&lt;/p&gt;

&lt;p&gt;I've also tested a contact form by sending it to my own inbox, watched the message arrive and called it done. At the client's end, the enquiries landed in spam. The only inbox that form ever worked for was mine.&lt;/p&gt;

&lt;p&gt;That's eleven. Put your twelfth in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>discuss</category>
      <category>frontend</category>
      <category>seo</category>
    </item>
    <item>
      <title>The Only Comment on My Post About Reviewing AI Was a Bot That Shipped Its Prompt</title>
      <dc:creator>Artur Pokładek</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/friga/the-only-comment-on-my-post-about-reviewing-ai-was-a-bot-that-shipped-its-prompt-43kc</link>
      <guid>https://dev.to/friga/the-only-comment-on-my-post-about-reviewing-ai-was-a-bot-that-shipped-its-prompt-43kc</guid>
      <description>&lt;p&gt;Last week I published a post about AI-built client sites and the one step everybody skips: someone looking at the result before it goes live.&lt;/p&gt;

&lt;p&gt;The next morning it had 4 readers, an average read time of 34 seconds and exactly one comment. The comment was a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the account actually posted
&lt;/h2&gt;

&lt;p&gt;An account pasted what is clearly a set of instructions for an LLM, in place of the comment those instructions were meant to produce. In short: write one or two casual sentences, start with a lowercase letter, no marketing, react to the video, ask a specific question, no hashtags, no links.&lt;/p&gt;

&lt;p&gt;It even came with a sample comment, a question about whether I had noticed generic copy in the product descriptions. My post had no video and no product descriptions.&lt;/p&gt;

&lt;p&gt;It also got one like, which is one more than the post itself had.&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%2F0imszrh9uy5kpn3ztkhd.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%2F0imszrh9uy5kpn3ztkhd.png" alt="A DEV comment that reads as instructions for writing a casual comment, with the author's name blurred" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that made me laugh
&lt;/h2&gt;

&lt;p&gt;Near the end the prompt says: ensure no em dash.&lt;/p&gt;

&lt;p&gt;I follow the same rule. I take dashes out of my client emails, because people now read them as a sign that a machine wrote the text.&lt;/p&gt;

&lt;p&gt;Whoever set this up knew the tells well enough to ban them. Nobody checked whether the output was a comment at all.&lt;/p&gt;

&lt;p&gt;That is my previous post in one screenshot. The cosmetics were handled and the only check that mattered never happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  I replied anyway
&lt;/h2&gt;

&lt;p&gt;I thanked them for the support and pointed out that they had shipped the prompt instead of the output.&lt;/p&gt;

&lt;p&gt;Full disclosure, since this whole thing is about review: Claude helped me draft that reply. I read it before it went out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it bugs me more than it should
&lt;/h2&gt;

&lt;p&gt;One bot on a post with 4 readers changes nothing. What bothers me is the version that works.&lt;/p&gt;

&lt;p&gt;When the pipeline does its job, you get one casual lowercase sentence with a question at the end, and it looks like someone read your post. I would have answered it like I answer a person.&lt;/p&gt;

&lt;p&gt;This one only got caught because it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I stop
&lt;/h2&gt;

&lt;p&gt;I have no idea how common this is on DEV. I have one example, and it is the one that failed.&lt;/p&gt;

&lt;p&gt;I also have no reliable way to tell a well-made bot comment from a lazy human one. And I write my own posts with AI help, so I am in no position to demand purity from anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Have you caught one?
&lt;/h2&gt;

&lt;p&gt;Have you had a comment on your posts that you later realised came from a bot? How did you notice, and did you reply, report it or just leave it there?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>meta</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Vibe Coding Is Not the Problem. Nobody Reviewing It Is.</title>
      <dc:creator>Artur Pokładek</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/friga/vibe-coding-is-not-the-problem-nobody-reviewing-it-is-2ohi</link>
      <guid>https://dev.to/friga/vibe-coding-is-not-the-problem-nobody-reviewing-it-is-2ohi</guid>
      <description>&lt;p&gt;The owner of an online shop sent me a site to look at. He didn't know it was built with AI. I told him, and he didn't care.&lt;/p&gt;

&lt;p&gt;If you think AI-built client sites are a mess, that shop proves you right. Someone had built it for him in v0. The Supabase tables were open, and the contact form quietly lost every message.&lt;/p&gt;

&lt;p&gt;I still think vibe coding a small business website is fine, as long as someone looks at it before launch. Almost nobody does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Console first
&lt;/h2&gt;

&lt;p&gt;On someone else's site I always start in the same place: the browser console and the network tab.&lt;/p&gt;

&lt;p&gt;The shop's database was Supabase, with Row Level Security switched off. It took me somewhere under an hour to spot it.&lt;/p&gt;

&lt;p&gt;Supabase's own &lt;a href="https://supabase.com/docs/guides/getting-started/api-keys" rel="noopener noreferrer"&gt;API keys guide&lt;/a&gt; says the key in the browser is public by design, and RLS is the thing that decides what that key can reach. Their &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Row Level Security page&lt;/a&gt; is blunter: leave RLS off on a table in an exposed schema, and any role holding a grant on that table can read it and write to it.&lt;/p&gt;

&lt;p&gt;In this case the tables only held page content, with no customer data or enquiries in them. That was luck. If the next prompt adds a newsletter table the same way, real email addresses end up behind the same public key.&lt;/p&gt;

&lt;p&gt;The contact form sent, and the messages never arrived anywhere. For a shop, customers writing in and hearing nothing back is the expensive bug on the list.&lt;/p&gt;

&lt;p&gt;Then the boring part. There was no sitemap and no robots.txt, and the page titles were either missing or the same on every page, so Google had very little to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shrug
&lt;/h2&gt;

&lt;p&gt;When I walked him through all of that, he shrugged and said that what matters is that it works.&lt;/p&gt;

&lt;p&gt;So "it works" was the entire security review. To be fair, that's also how most of us test regex.&lt;/p&gt;

&lt;p&gt;I get why he said it. From the outside, the form looked like it sent. I fixed the form, the database rules and the SEO anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who was supposed to check this?
&lt;/h2&gt;

&lt;p&gt;The person who built it for him, before handing it over. Each item on my list is a check for someone who knows to look, and the owner of a shop has no reason to know what Row Level Security is.&lt;/p&gt;

&lt;p&gt;Open the network tab and see what the public key returns, then send yourself a test message through the form. The SEO part is one look at the page source plus two URLs: /robots.txt and /sitemap.xml.&lt;/p&gt;

&lt;p&gt;None of that happened before the handover. Whether the form delivers mail, or what the database hands to anyone who asks, never got tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  I build with the same tools
&lt;/h2&gt;

&lt;p&gt;I use Claude Code and Cursor on client sites. They cut my build time massively. The concept and the technical review are still mine, and I don't see that changing.&lt;/p&gt;

&lt;p&gt;When a developer complains that AI builds broken sites but has never shipped a broken site themselves, I don't believe them.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm not here to roast v0
&lt;/h2&gt;

&lt;p&gt;I have no verdict on v0 itself. A tired developer can ship an open database and a dead contact form just as easily.&lt;/p&gt;

&lt;p&gt;I also don't tell clients to stop using AI.&lt;/p&gt;

&lt;p&gt;If you've inherited an AI-built client site, what did you find first? And if you think a review wouldn't have saved it, tell me what would have.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>webdev</category>
      <category>ai</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>AI Agents Write Code for My Client Sites. My Shaders and Canonical Tags Stay Off Limits.</title>
      <dc:creator>Artur Pokładek</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/friga/ai-agents-write-code-for-my-client-sites-my-shaders-and-canonical-tags-stay-off-limits-146c</link>
      <guid>https://dev.to/friga/ai-agents-write-code-for-my-client-sites-my-shaders-and-canonical-tags-stay-off-limits-146c</guid>
      <description>&lt;p&gt;My AI agent rewrote one of my GLSL shaders. On a Samsung A-series phone the scene went from 60 FPS to about 20, and I went back to my own version.&lt;/p&gt;

&lt;p&gt;Claude Code and Codex have lived in my terminal since early 2025 (Copilot in VS Code before that), and they write a lot of the code for my studio's client sites. Most days that's a good thing.&lt;/p&gt;

&lt;p&gt;The agents handle the Next.js skeleton, types, routing, forms, tests and refactors, plus sitemaps, robots.txt, schema markup and translations. My starter repo runs on 10 to 20 skills pulled from other people's work and checked before they went in, and a second agent reviews my plans before anything gets built.&lt;/p&gt;

&lt;p&gt;Design direction stays with me, and so does a short do-not-touch list. You could keep a list like that for the risky stuff only and let the agent run free on admin panels, internal tools and short landing pages. I keep the same list everywhere, and for most of it I can point to the moment an agent got it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anything that has to run on a real phone
&lt;/h2&gt;

&lt;p&gt;GSAP timelines and shaders are the first thing on the list. One section had a ScrollTrigger pin that kept jumping, and I handed it to the agent.&lt;/p&gt;

&lt;p&gt;The agent fixed the jumping pin so well that on the phone there was nothing left to pin. I saw it during my own phone test, in the evening, and spent the next two to three hours putting the section back together. The shader from the top failed the same phone test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Several pages, one canonical
&lt;/h2&gt;

&lt;p&gt;On one project the agent gave several Next.js pages the same title and the same canonical. I only found it when I ran an SEO cannibalization skill on the project. Titles and canonicals I set myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing loads before consent
&lt;/h2&gt;

&lt;p&gt;The project used Consent Mode v2. The agent put GA4 and the Meta Pixel straight into the layout anyway, and they loaded before the user gave consent.&lt;/p&gt;

&lt;p&gt;I spotted it myself, and it's one of the reasons I built my own analytics setup skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The package I didn't check, and the copy
&lt;/h2&gt;

&lt;p&gt;The agent suggested an npm package and I accepted it without checking, so that one is on me. It pulled in dependencies I didn't want.&lt;/p&gt;

&lt;p&gt;Copy is the one job I still hand over and then take back. The agent writes the draft, I correct it, and on Polish sites I sometimes run my own skill that checks the text for AI tells. A client doesn't pay for a site that sounds like "Discover the future."&lt;/p&gt;

&lt;h2&gt;
  
  
  Same rules for the admin panel
&lt;/h2&gt;

&lt;p&gt;A site looks AI-made when nobody had the nerve to tell the agent no. That includes the screens only your client's staff will ever open, so the admin panel gets the same list as the homepage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I might be wrong
&lt;/h2&gt;

&lt;p&gt;Shaders and GSAP may stop being a problem within a year, and then the list gets shorter. For now I keep it in my head and nowhere else.&lt;/p&gt;

&lt;p&gt;So that's my list. What's one thing your agent doesn't get to touch anymore, and what happened the day it went on the list? If you think the admin panel rule is overkill, tell me what you let slide there and how it went.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>discuss</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Stop asking AI if your site is accessible. Ask it what to test by hand.</title>
      <dc:creator>Artur Pokładek</dc:creator>
      <pubDate>Sat, 17 Oct 2026 18:41:53 +0000</pubDate>
      <link>https://dev.to/friga/stop-asking-ai-if-your-site-is-accessible-ask-it-what-to-test-by-hand-1l2p</link>
      <guid>https://dev.to/friga/stop-asking-ai-if-your-site-is-accessible-ask-it-what-to-test-by-hand-1l2p</guid>
      <description>&lt;p&gt;Give a current model a browser, a keyboard and axe, and it finds more accessibility bugs than axe does. It also flags plenty the human auditors never wrote down. In a &lt;a href="https://arxiv.org/abs/2609.09379" rel="noopener noreferrer"&gt;September 2026 preprint&lt;/a&gt; that scored five models against human audit reports, the best agent setups caught 77% to 86% of known WCAG violations, where axe-core caught 36%. But 44% to 45% of what the agents flagged wasn't in the human reports, compared with 10% for axe. Some may be real bugs the auditors missed, but nearly half the flags need a person before anyone can act on them, while axe, wrong about one in ten, can fail a build on its own. So axe stays the gate, and a model sorts its output and tells me what to test by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much a scanner finds
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://webaim.org/projects/million/" rel="noopener noreferrer"&gt;WebAIM Million 2026&lt;/a&gt; found detectable WCAG 2 failures on 95.9% of the top million home pages, 56.1 errors per page on average. Low contrast text was on 83.9% of them and missing alt text on 53.1%. You don't need a language model to tell you an image has no alt text.&lt;/p&gt;

&lt;p&gt;Deque, which makes axe, &lt;a href="https://www.deque.com/blog/automated-testing-study-identifies-57-percent-of-digital-accessibility-issues" rel="noopener noreferrer"&gt;reported in 2021&lt;/a&gt; that its automated tests covered 57% of issues by volume, from &lt;a href="https://www.deque.com/automated-accessibility-testing-coverage/" rel="noopener noreferrer"&gt;its own audits&lt;/a&gt;. In a 2017 &lt;a href="https://accessibility.blog.gov.uk/2017/02/24/what-we-found-when-we-tested-tools-on-the-worlds-least-accessible-webpage/" rel="noopener noreferrer"&gt;GDS test&lt;/a&gt; on a page with 143 deliberate failures, 10 tools combined caught 71%, the best single tool about 40%.&lt;/p&gt;

&lt;p&gt;The W3C's own guidance says &lt;a href="https://www.w3.org/WAI/test-evaluate/tools/selecting/" rel="noopener noreferrer"&gt;"Web accessibility evaluation tools can not determine accessibility, they can only assist in doing so."&lt;/a&gt; A Lighthouse 100 only means its &lt;a href="https://developer.chrome.com/docs/lighthouse/accessibility/scoring" rel="noopener noreferrer"&gt;weighted automated audits&lt;/a&gt; passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking a model for an audit
&lt;/h2&gt;

&lt;p&gt;The preprint, by &lt;a href="https://arxiv.org/abs/2609.09379" rel="noopener noreferrer"&gt;Mishra and colleagues&lt;/a&gt;, isn't peer-reviewed. They took 24 pages from 11 scholarly platforms with existing Library Accessibility Alliance audit reports and checked each model's findings against them. The strongest setups were browser agents (GPT-5.5, Claude Sonnet 5, Gemini 3.1 Pro) with the DOM, screenshots, a keyboard and axe. Claude Opus 5 ran inside Claude Code, and Gemini 3.5 Flash and others got a single batch pass.&lt;/p&gt;

&lt;p&gt;In the paper's terms, axe-core had precision 0.90 and recall 0.36. The reference browser-agent run (GPT-5.5) reached recall 0.86 at precision 0.56, a repeat run 0.88, and Claude Code 0.77 at 0.55 with the authors' parity prompt. The authors had seen 19 of the 24 pages while building the setup, so this isn't a clean held-out test, and a "correct" pass was inferred from what the human reports didn't mention.&lt;/p&gt;

&lt;p&gt;A browser agent driving Playwright MCP can press Tab and tell you where focus landed, and in this preprint the browser agents caught 6 of 7 focus order failures from the human audits. The paper gives no false alarm rate per criterion and didn't test focus visibility at all. On navigation failures (focus, state, feedback), only 57.2% of the failures a DeepSeek V4 Flash browser agent reported in the &lt;a href="https://arxiv.org/abs/2609.17959" rel="noopener noreferrer"&gt;A11yLTLNav&lt;/a&gt; preprint were confirmed, compared with 88.7% for the authors' rule-based checker, which uses no LLM, both on 31 generated clones of real sites. Nobody I found has measured a model judging what a screen reader announces. So focus order, focus visibility and screen reader output stay on the manual list. An agent may add to that list, never close it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A six-step flow
&lt;/h2&gt;

&lt;p&gt;Steps 1 and 6 run in CI. A model drafts the output of steps 2 and 5, and you check it. Steps 3 and 4 are done by hand. After a fix, go back to step 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Automated scan
&lt;/h3&gt;

&lt;p&gt;Playwright with &lt;code&gt;@axe-core/playwright&lt;/code&gt;, following the &lt;a href="https://playwright.dev/docs/accessibility-testing" rel="noopener noreferrer"&gt;Playwright accessibility testing docs&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @playwright/test @axe-core/playwright @types/node
npx playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium   &lt;span class="c"&gt;# a fresh project has no browsers yet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;page.goto('/')&lt;/code&gt; needs a &lt;code&gt;baseURL&lt;/code&gt;, and a second project covers the mobile layout:&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="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;devices&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;webServer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm run start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reuseExistingServer&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="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;projects&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desktop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;use&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="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Desktop Chrome&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mobile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;use&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="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pixel 7&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="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;One shared helper holds the scan configuration:&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="c1"&gt;// tests/axe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&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="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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scan&lt;/span&gt; &lt;span class="o"&gt;=&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="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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag22aa&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 chat widget is the vendor's code, so it's excluded to keep this build&lt;/span&gt;
    &lt;span class="c1"&gt;// independent of their releases. It still ships on our page: it stays on the&lt;/span&gt;
    &lt;span class="c1"&gt;// manual checklist and in a ticket with the vendor.&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#third-party-chat-widget&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/a11y.spec.ts&lt;/span&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;fs&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;node:fs&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scan&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;./axe&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home: initial state&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;testInfo&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;goto&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="k"&gt;await&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;main&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// let the UI settle first&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="nf"&gt;scan&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="nf"&gt;analyze&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;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;testInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputPath&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-results.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// input for step 2&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;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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;testInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&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-results&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="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&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;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="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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home: menu open&lt;/span&gt;&lt;span class="dl"&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;goto&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="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;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Open menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&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="nf"&gt;scan&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="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#menu&lt;/span&gt;&lt;span class="dl"&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;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="nf"&gt;toEqual&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;Keep &lt;code&gt;wcag21a&lt;/code&gt;. In axe-core 4.14 it holds one rule, &lt;code&gt;label-content-name-mismatch&lt;/code&gt; (2.5.3 Label in Name), and it's easy to drop. The &lt;a href="https://github.com/dequelabs/axe-core/blob/develop/doc/API.md" rel="noopener noreferrer"&gt;axe-core API docs&lt;/a&gt; list every tag, including &lt;code&gt;EN-301-549&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;analyze()&lt;/code&gt; scans the DOM as it is at that moment, so add a test per interactive state: an open menu, a modal, a form after a failed submit. Cover one URL per template, with the same tests in a &lt;code&gt;for&lt;/code&gt; loop over &lt;code&gt;['/', '/products/example', '/cart', '/contact']&lt;/code&gt;. axe also returns an &lt;code&gt;incomplete&lt;/code&gt; array for checks where &lt;a href="https://github.com/dequelabs/axe-core" rel="noopener noreferrer"&gt;it could not be certain&lt;/a&gt;. Those go on the manual list.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Starting from hundreds of violations?
  &lt;p&gt;On an existing site, &lt;code&gt;toEqual([])&lt;/code&gt; is a red build on day one, and red builds nobody can fix tend to get skipped. The Playwright docs suggest snapshotting violation fingerprints instead, so the test fails when the set changes. It replaces the two &lt;code&gt;toEqual([])&lt;/code&gt; tests above until the backlog is gone.&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="c1"&gt;// tests/a11y-baseline.spec.ts&lt;/span&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="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AxeResults&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;axe-core&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scan&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;./axe&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;fingerprints&lt;/span&gt; &lt;span class="o"&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;AxeResults&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&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="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&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;targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&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;n&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="p"&gt;})),&lt;/span&gt;
    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;2&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home: no new violations&lt;/span&gt;&lt;span class="dl"&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;goto&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="k"&gt;await&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;main&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&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="nf"&gt;scan&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="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="nf"&gt;fingerprints&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="nf"&gt;toMatchSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home-a11y.txt&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;The first run fails and writes the snapshot. Commit it, and re-record with &lt;code&gt;--update-snapshots&lt;/code&gt; after each fix. Snapshots are named per project and OS (&lt;code&gt;home-a11y-desktop-linux.txt&lt;/code&gt;), so record them on the OS your CI runs.&lt;/p&gt;

&lt;p&gt;Once the snapshot is down to minor and moderate issues, add a hard gate: filter &lt;code&gt;results.violations&lt;/code&gt; to &lt;code&gt;impact&lt;/code&gt; critical and serious and expect an empty list. If one rule floods the report, &lt;code&gt;disableRules(['rule-id'])&lt;/code&gt; turns it off, with a ticket number in a comment next to it.&lt;/p&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Triage the JSON with a model
&lt;/h3&gt;

&lt;p&gt;One bug in a shared &lt;code&gt;&amp;lt;Button&amp;gt;&lt;/code&gt; can show up as dozens of violations. The test writes its JSON to &lt;code&gt;test-results/&amp;lt;test&amp;gt;-&amp;lt;project&amp;gt;/axe-results.json&lt;/code&gt;, and Playwright clears that folder on every run, so copy the file out. Most of it is passing checks, so trim it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="s1"&gt;'{violations: [.violations[] | {id, impact, help,
      nodes: [.nodes[] | {target, html: .html[0:200]}]}],
    incomplete: [.incomplete[] | {id, help, nodes: [.nodes[] | {target}]}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  axe-results.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; axe-slim.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give the model the trimmed JSON and the source of the affected components, and constrain it hard. Check first that you're allowed to send that code to the model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input: axe-slim.json below holds the `violations` and `incomplete` output of
axe-core (tags wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa) for &amp;lt;URL&amp;gt;,
plus the source of the affected components.

Rules:
- Use only the axe output and the code I gave you. Do not add issues.
- Quote the exact `target` selectors from the input for every group.
- Mark anything you infer (a root cause, which component renders a node) GUESS.
- Do not say whether the page is accessible or compliant, and do not write fixes yet.

Tasks:
1. Group violations by likely root cause and component.
2. Order the groups by axe impact, then by node count. Put anything in
   checkout, sign-in or contact forms first.
3. For each group, explain the WCAG success criterion in two sentences.
4. Turn the `incomplete` items into a manual checklist for this page.
5. From the element types in this code (forms, dialogs, carousels, media),
   list what I need to test by keyboard and screen reader.

Output: a markdown table with columns
group | rule ids | target selectors | node count | impact | component | GUESS?
then the lists from tasks 4 and 5.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quoted selectors let you confirm in seconds that a group is real.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keyboard pass
&lt;/h3&gt;

&lt;p&gt;Unplug the mouse, or at least stop touching it. Safari on a Mac needs Safari &amp;gt; Settings &amp;gt; Advanced &amp;gt; "Press Tab to highlight each item on a webpage" turned on first (&lt;a href="https://support.apple.com/guide/safari/ibrw1075/mac" rel="noopener noreferrer"&gt;Apple&lt;/a&gt;). From &lt;a href="https://webaim.org/techniques/keyboard/" rel="noopener noreferrer"&gt;WebAIM's keyboard guide&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Focus is visible on every interactive element. &lt;code&gt;outline: none&lt;/code&gt; with no replacement fails.&lt;/li&gt;
&lt;li&gt;Tab and Shift+Tab follow the visual layout, with no traps.&lt;/li&gt;
&lt;li&gt;Modals keep focus inside, close on Esc and return focus to the button that opened them.&lt;/li&gt;
&lt;li&gt;Enter works on links, Enter and Space on buttons (including anything with &lt;code&gt;role="button"&lt;/code&gt;), Space on checkboxes, arrow keys inside radio groups and tabs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first Tab stop skips to the main content.&lt;/li&gt;
&lt;li&gt;No sticky header, cookie banner or chat bubble covers the focused element completely (&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html" rel="noopener noreferrer"&gt;2.4.11&lt;/a&gt;, new at AA in WCAG 2.2).&lt;/li&gt;
&lt;li&gt;After a client-side route change, focus lands somewhere sensible.&lt;/li&gt;
&lt;li&gt;Tooltips and mega menus can be dismissed without moving focus and stay put while the pointer moves onto them (&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html" rel="noopener noreferrer"&gt;1.4.13&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Text over photos and gradients passes contrast. axe can't measure that, so use a colour picker.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Screen reader pass
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.nvaccess.org/download/" rel="noopener noreferrer"&gt;NVDA&lt;/a&gt; is free and runs on Windows 10 and later. From &lt;a href="https://webaim.org/resources/shortcuts/nvda" rel="noopener noreferrer"&gt;WebAIM's NVDA shortcuts&lt;/a&gt;: H, D and F jump between headings, landmarks and form fields, NVDA + F7 opens the Elements List and NVDA + Space switches between browse and focus mode. If pressing H types an "h" into a field, you're in focus mode.&lt;/p&gt;

&lt;p&gt;On a Mac, VoiceOver toggles with Command-F5 (&lt;a href="https://support.apple.com/guide/voiceover/turn-voiceover-on-or-off-vo2682/mac" rel="noopener noreferrer"&gt;Apple's guide&lt;/a&gt;), VO + U opens the Rotor, and per &lt;a href="https://webaim.org/articles/voiceover/" rel="noopener noreferrer"&gt;WebAIM&lt;/a&gt; it works best in Safari.&lt;/p&gt;

&lt;p&gt;Listen for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Headings and landmarks that form a sensible outline&lt;/li&gt;
&lt;li&gt;Link text that makes sense out of context&lt;/li&gt;
&lt;li&gt;Each field's label, required state and error, plus focus on the first error or an error summary after a failed submit&lt;/li&gt;
&lt;li&gt;States: expanded or collapsed on disclosure buttons, selected on the active tab&lt;/li&gt;
&lt;li&gt;A name on every icon-only button (cart, close, search)&lt;/li&gt;
&lt;li&gt;Dynamic messages like "added to cart" announced at all&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Fixes drafted by a model, reviewed by you
&lt;/h3&gt;

&lt;p&gt;Give the model one group from step 2 with its component code, and ask for a fix plus a Playwright test that fails if the bug comes back. In Microsoft's &lt;a href="https://microsoft.github.io/a11y-llm-eval-report/" rel="noopener noreferrer"&gt;A11y LLM Eval&lt;/a&gt;, models writing UI code passed its automated checks 12% of the time with no accessibility instructions and 86% with a skill that makes the model review its own output. Those are automated checks, so read the diff yourself, the way you'd read a junior's pull request, ARIA first. The ARIA Authoring Practices have a section titled &lt;a href="https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/" rel="noopener noreferrer"&gt;"No ARIA is better than Bad ARIA"&lt;/a&gt;. Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Would a native &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; do the job?&lt;/li&gt;
&lt;li&gt;Does every &lt;code&gt;aria-label&lt;/code&gt; contain the visible text? &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html" rel="noopener noreferrer"&gt;2.5.3 Label in Name&lt;/a&gt; exists because "people who operate with voice interaction use the visible labels in their commands". A button that shows "Buy" with &lt;code&gt;aria-label="Add product to cart"&lt;/code&gt; fails it.&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;aria-hidden="true"&lt;/code&gt; on something focusable? Then it's &lt;a href="https://dequeuniversity.com/rules/axe/4.14/aria-hidden-focus" rel="noopener noreferrer"&gt;skipped by screen readers but still in the focus order&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the fix, rerun axe and repeat the keyboard and screen reader checks for that component.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Regression test back in CI
&lt;/h3&gt;

&lt;p&gt;Every bug you found by hand gets its own test, because axe won't notice it coming back. For the menu, in &lt;code&gt;tests/menu.spec.ts&lt;/code&gt;:&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;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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;menu closes on Escape and returns focus&lt;/span&gt;&lt;span class="dl"&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;goto&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="c1"&gt;// If the label changes to "Close menu" when open, locate it by test id instead&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="o"&gt;=&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;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Open menu&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;menu&lt;/span&gt; &lt;span class="o"&gt;=&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;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#menu&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;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// open it the way a keyboard user would&lt;/span&gt;
  &lt;span class="k"&gt;await&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;menu&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// move focus off the trigger first&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;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Item A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;focus&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="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escape&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeHidden&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&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;trigger&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&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;Run every model-drafted test against the broken version and watch it fail first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gaps in this flow
&lt;/h2&gt;

&lt;p&gt;It doesn't test zoom and reflow, motion or cognitive load. Ten minutes with NVDA also won't tell you how someone who uses a screen reader every day experiences the site. Before a launch, pay disabled people to test it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The EAA question from your PM
&lt;/h2&gt;

&lt;p&gt;This isn't legal advice. Each point below links to its source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The European Accessibility Act &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32019L0882" rel="noopener noreferrer"&gt;applies from 28 June 2025&lt;/a&gt;. E-commerce services are in scope, defined as services provided "at the individual request of a consumer with a view to concluding a consumer contract" (Article 3(30)).&lt;/li&gt;
&lt;li&gt;Microenterprises providing services are exempt (Article 4(5)).&lt;/li&gt;
&lt;li&gt;Products and services that meet harmonised standards cited in the Official Journal are presumed to conform (Article 15). EN 301 549 V4.1.1, published by ETSI on 2 September 2026, is based on WCAG 2.2 A and AA, and as of mid-September 2026 it had not been cited there. If you're building or rebuilding now, aim for WCAG 2.2 AA.&lt;/li&gt;
&lt;li&gt;For evidence, keep scan results, manual test notes and known issues with a fix plan.&lt;/li&gt;
&lt;li&gt;On effort: steps 1 and 2 scale with a script. Steps 3 and 4 are manual work per template and per state, so list both (home, product, cart, checkout; menu open, modal, form with errors) before anyone estimates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where I'd stop
&lt;/h2&gt;

&lt;p&gt;A verdict on whether a site is accessible, WCAG conformant or EAA compliant stays with a person. So does the call on focus order and on what a screen reader announces. Two smaller things are easy to let slide: closing a violation as a false positive before I've looked at the element, and alt text for an image the model hasn't seen on the page.&lt;/p&gt;

&lt;p&gt;If you've got a triage prompt that holds up better than this one, post it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI-assisted. Reviewed and edited by me.&lt;/em&gt;&lt;/p&gt;

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