{"id":"l-a11y-5-1","courseId":"web-accessibility","lang":"en","kind":"lesson","summary":"Run axe in the browser and in Playwright tests to catch the common machine-detectable failures early, and know exactly which problems automation will never find.","html":"<p>Samir has a saying: “The robot finds the typos, a person finds the plot holes.” Automated accessibility tools are fast, cheap and tireless, and they find a large share of the problems on a typical page in seconds. They are also completely unable to tell whether your alt text is true, your focus order makes sense, or your dialog is usable. This lesson shows how to get the most from them, and where they stop.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">axe and its family</h2>\n<p><strong>axe-core</strong>, maintained by Deque, is an open-source JavaScript library of accessibility rules. It runs inside the page, inspects the DOM and computed styles, and reports violations with the WCAG criterion, the failing element and a fix suggestion. It is unusually careful about false positives: when it isn’t sure, it reports an item as “needs review” rather than a violation.</p>\n<p>You will meet it in several places:</p>\n<ul>\n<li><strong>axe DevTools</strong>, a browser extension, for on-demand scans while you develop.</li>\n<li><strong>Lighthouse’s</strong> accessibility category in Chrome DevTools, which runs a subset of axe rules and produces a score.</li>\n<li><strong>Test integrations</strong>: <code dir=\"ltr\">@axe-core/playwright</code> for end-to-end tests, and wrappers such as <code dir=\"ltr\">vitest-axe</code> for component tests.</li>\n</ul>\n<p>WebAIM’s <strong>WAVE</strong> extension and Microsoft’s <strong>Accessibility Insights</strong> are good alternatives or companions; Accessibility Insights also adds guided manual checks.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">What automation catches, and what it can’t</h2>\n<p>Automated rules are good at objective, structural facts:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>Automation reliably catches</th>\n<th>Needs a human</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Missing alt attribute</td>\n<td>Whether alt text is accurate and useful</td>\n</tr>\n<tr>\n<td>Inputs, buttons and links with no name</td>\n<td>Whether the name makes sense, or matches the visible label</td>\n</tr>\n<tr>\n<td>Text contrast below 4.5:1 or 3:1</td>\n<td>Contrast over images and gradients</td>\n</tr>\n<tr>\n<td>Missing <code dir=\"ltr\">lang</code>, duplicate ids, invalid ARIA values</td>\n<td>Whether the language is right for each part</td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">aria-hidden</code> on focusable elements</td>\n<td>Focus order, keyboard traps, focus after a dialog closes</td>\n</tr>\n<tr>\n<td>Some heading and landmark problems</td>\n<td>Whether headings describe the content</td>\n</tr>\n<tr>\n<td></td>\n<td>Whether live updates are announced, and whether instructions are clear</td>\n</tr>\n</tbody>\n</table></div><p>How much is that overall? Studies disagree because they count differently. When the UK Government Digital Service planted 142 known barriers in a test page in 2017, the best tool found 40% of them, and some found far fewer. Deque, counting issues found in thousands of real audits, reports that automated tests caught about 57% of issues by volume, mostly because a few automatable problems, especially contrast, occur so often. A fair summary: automation finds the most frequent problems, but only covers a minority of what WCAG asks.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Chasing the score</span></p>\n<p>A team once spent a sprint pushing Ticketly’s Lighthouse score from 86 to 100, mostly by adding <code dir=\"ltr\">aria-label</code> everywhere a rule complained. The score went up; the experience got worse, because several labels no longer matched the visible text. Fix the underlying problem the rule points to, and never add ARIA only to silence a tool.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">axe in your Playwright tests</h2>\n<p>Scanning in CI turns accessibility into a regression test: once a page is clean, a new missing label fails the build. With Playwright and <code dir=\"ltr\">@axe-core/playwright</code>:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">tests/a11y.spec.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { test, expect } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@playwright/test'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> AxeBuilder </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@axe-core/playwright'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> WCAG_AA</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'wcag2a'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'wcag2aa'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'wcag21a'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'wcag21aa'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'wcag22aa'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">];</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'event page has no detectable WCAG A/AA violations'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">page</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">goto</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'/events/friday'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> results</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> await</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> AxeBuilder</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ page }).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">withTags</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">WCAG_AA</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">analyze</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(results.violations).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([]);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'booking dialog has no detectable violations when open'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">page</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">goto</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'/events/friday'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Book now Friday pass'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">click</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'dialog'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Book Friday pass'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> })).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeVisible</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> results</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> await</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> AxeBuilder</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ page })</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    .</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">include</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#booking'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    .</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">withTags</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">WCAG_AA</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    .</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">analyze</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(results.violations).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([]);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>The second test matters as much as the first. axe only sees what is rendered at the moment of the scan, so closed dialogs, collapsed menus, error states and the dark theme are untested unless your test puts the page into those states first.</p>\n<p>Notice that the test also finds elements with <code dir=\"ltr\">getByRole</code> and an accessible name. Locating by role and name is Playwright’s recommended approach anyway, and it gives you a small accessibility check for free: if the dialog loses its name, the locator stops matching.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Ratchet, don’t block</span></p>\n<p>On an existing codebase, the first scan may report hundreds of issues. Rather than switching CI off, record the current violations as a baseline and fail the build only on new ones. Fix the baseline over time, starting with the pages most people use, such as checkout.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">Linting catches problems even earlier</h2>\n<p>In React projects, <code dir=\"ltr\">eslint-plugin-jsx-a11y</code> flags problems as you type: an <code dir=\"ltr\">img</code> without <code dir=\"ltr\">alt</code>, a <code dir=\"ltr\">div</code> with <code dir=\"ltr\">onClick</code> but no role or key handler, an <code dir=\"ltr\">aria-*</code> attribute that doesn’t exist. It catches less than axe because it only sees source code, not the rendered page, but it catches it minutes after the mistake instead of days.</p>\n<p>A layered setup looks like this: the linter in the editor, axe in component and end-to-end tests, and a person with a keyboard and a screen reader before every release. The next lesson is that person.</p>\n","toc":[{"id":"sec-1","text":"axe and its family","level":2},{"id":"sec-2","text":"What automation catches, and what it can’t","level":2},{"id":"sec-3","text":"axe in your Playwright tests","level":2},{"id":"sec-4","text":"Linting catches problems even earlier","level":2}],"takeaways":["axe-core is the open-source rules engine behind axe DevTools, Lighthouse’s accessibility audit and many test integrations.","Automated tools reliably catch missing alt, labels and names, contrast failures, invalid ARIA and missing page language.","They can’t judge meaning, so alt quality, focus order, keyboard behaviour, live announcements and label-in-name mismatches need a human.","Run axe in CI against key pages and states, such as an open dialog, so regressions fail the build, and treat a clean scan as the floor, not the finish."],"further":[{"title":"Playwright: Accessibility testing","url":"https://playwright.dev/docs/accessibility-testing"},{"title":"W3C WAI: Selecting Web Accessibility Evaluation Tools","url":"https://www.w3.org/WAI/test-evaluate/tools/selecting/"},{"title":"web.dev Learn Accessibility: Automated testing","url":"https://web.dev/learn/accessibility/test-automated"}],"quiz":[{"q":"<p>Ticketly’s home page scores 100 in Lighthouse’s accessibility audit. What can you conclude?</p>","options":[{"text":"The page meets WCAG 2.2 AA","why":"Lighthouse runs a subset of axe rules. A perfect score means none of those automated checks failed, not that the page conforms."},{"text":"None of the automated checks Lighthouse runs found a problem; keyboard, screen reader and content issues are still untested","why":"Correct. It is a good sign and a useful regression guard, but most of WCAG needs human judgment."},{"text":"Screen reader users will have no trouble with it","why":"Automated tools can’t tell whether names are meaningful, focus moves sensibly, or updates are announced. Only testing with a screen reader shows that."}],"answer":1},{"q":"<p>Which of these problems would axe flag by default?</p>","options":[{"text":"An image with <code dir=\"ltr\">alt=&quot;image&quot;</code>","why":"The alt attribute is present, so there is nothing for a rule to detect. Whether the text is meaningful needs a human."},{"text":"A modal that leaves focus behind it when it opens","why":"Focus management depends on interaction and intent; a static scan can’t know where focus should go."},{"text":"A status message in a plain paragraph that screen readers never announce","why":"axe can’t know which text changes dynamically or should be announced. This is found by testing with a screen reader."},{"text":"A text input with no label and no accessible name","why":"Correct. Missing form labels are exactly the kind of objective, structural failure automated rules detect reliably."}],"answer":3},{"q":"<p>Why might this Playwright test pass even though Ticketly’s booking dialog has contrast failures?</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">goto</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'/events/friday'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> results</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> await</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> AxeBuilder</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ page }).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">analyze</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(results.violations).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([]);</span></span></code></pre></div>\n","options":[{"text":"AxeBuilder ignores color contrast by default","why":"color-contrast is one of axe’s default rules. The problem is what is on the page at scan time."},{"text":"The dialog is closed when the scan runs, so its content isn’t rendered and isn’t tested","why":"Correct. Open the dialog in the test (click Book now) and scan again, ideally with .include() on the dialog, to test that state."},{"text":"Playwright runs headless, and contrast can’t be measured without a visible window","why":"Headless browsers render pages fully, and axe computes contrast from styles, so headless mode isn’t the issue."}],"answer":1}],"exercise":{"kind":"spot-bug","language":"html","code":"<main>\n  <h1>Harbour Lights 2026</h1>\n  <img src=\"crowd.jpg\" alt=\"image\">\n  <p class=\"section-title\">Tickets</p>\n  <label for=\"qty\">Quantity</label>\n  <input id=\"qty\" type=\"number\" min=\"1\" max=\"6\" value=\"1\">\n  <button type=\"button\" onclick=\"addToBasket()\">Add to basket</button>\n  <p id=\"basket-status\"></p>\n  <button type=\"button\" aria-label=\"Book\">Reserve seats</button>\n  <a href=\"/faq\">Festival FAQ</a>\n</main>","bugLines":[3,4,8,9],"title":"Find what axe can't","prompt":"<p>This part of Ticketly passes an axe scan with <strong>zero violations</strong>. A screen reader user still runs into four real WCAG failures. Select the lines a human reviewer would flag. (<code dir=\"ltr\">.section-title</code> is styled to look exactly like the other section headings, and <code dir=\"ltr\">addToBasket()</code> writes “Ticket added” into <code dir=\"ltr\">#basket-status</code>.)</p>\n","hints":["axe checks that things exist. Look for things that exist but mean the wrong thing.","Compare what a sighted user sees with what the accessibility tree says, line by line.","One problem is about text, one about structure, one about dynamic updates, and one about voice control."],"explanation":"<p><strong>Line 3:</strong> <code dir=\"ltr\">alt=&quot;image&quot;</code> is present, so axe is satisfied, but it tells a screen reader user nothing (“image, image”). Describe the photo or, if it is decorative, use <code dir=\"ltr\">alt=&quot;&quot;</code>.</p>\n<p><strong>Line 4:</strong> the paragraph looks like a section heading but isn’t one, so it is missing from heading navigation (1.3.1 Info and Relationships). Use <code dir=\"ltr\">&lt;h2&gt;Tickets&lt;/h2&gt;</code>.</p>\n<p><strong>Line 8:</strong> <code dir=\"ltr\">#basket-status</code> changes after each click, but it isn’t a live region, so screen readers announce nothing (4.1.3 Status Messages). Add <code dir=\"ltr\">role=&quot;status&quot;</code>.</p>\n<p><strong>Line 9:</strong> the visible text is “Reserve seats” but the accessible name is “Book”, so a voice control user saying “click Reserve seats” gets nothing (2.5.3 Label in Name). Remove the <code dir=\"ltr\">aria-label</code>. axe has a rule for this, but it is experimental and off by default.</p>\n<p>The other lines are fine: the input is labelled, the buttons and link have clear names.</p>\n","checkLabels":{}},"words":728,"readMinutes":3}