{"id":489470,"date":"2026-08-02T16:10:43","date_gmt":"2026-08-02T16:10:43","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=489470"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=489470","title":{"rendered":"Web Accessibility: 20 Exercises You Can Check Right in the Browser"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>On June 28, 2025, the European Union\u2019s transition period for the European Accessibility Act ended. For a broad range of commercial services\u2014online stores, banks, transport, and telecom\u2014web accessibility stopped being a matter of goodwill.<\/p>\n<p>Technically, the requirement comes down to EN 301 549 and, for the web, WCAG 2.1 Level AA. You cannot learn WCAG by reading it: half the criteria sound perfectly clear while leaving it completely unclear what exactly should be written in the markup.<\/p>\n<p>Some of our projects fall under the new requirements, and it is not only front-end developers who need to relearn their habits. Designers set contrast and focus order, analysts write error messages, and testers need to know what to check manually.<\/p>\n<p>That is how a project of 20 exercises emerged. In each one, the page looks fine at first glance. It is broken only from an accessibility perspective: headings skip levels, a div with a click handler acts as a button, a label sits next to an input but is not formally associated with it. You edit the markup and immediately see the result as the page rebuilds on the fly. Everything runs directly in the browser, and both your code and progress stay in localStorage:\u00a0<a href=\"https:\/\/a11y-exercises.github.io\/\" rel=\"noopener noreferrer nofollow\">a11y-exercises.github.io<\/a><\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/c91\/a12\/9c7\/c91a129c71d3b89f2a1239221de45fa6.png\" width=\"2782\" height=\"1957\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/c91\/a12\/9c7\/c91a129c71d3b89f2a1239221de45fa6.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/c91\/a12\/9c7\/c91a129c71d3b89f2a1239221de45fa6.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/figure>\n<h3>What exactly does the EAA require?<\/h3>\n<p>The details of the directive are often retold inaccurately, so here is the short version. It is <a href=\"https:\/\/eur-lex.europa.eu\/eli\/dir\/2019\/882\/oj\" rel=\"noopener noreferrer nofollow\">(EU) 2019\/882<\/a>: adopted in April 2019, to be transposed into national law by June 28, 2022, and applicable from June 28, 2025. In other words, 2025 is not the year the law was adopted; it is the year the transition period ended.<\/p>\n<ul>\n<li>\n<p>This is not \u201call websites,\u201d but a defined range of consumer products and services: e-commerce, banking, transport, telecom, e-books, and terminals.<\/p>\n<\/li>\n<li>\n<p>Before it, the private sector in the EU was not covered at all\u2014the earlier <a href=\"https:\/\/eur-lex.europa.eu\/eli\/dir\/2016\/2102\/oj\" rel=\"noopener noreferrer nofollow\">(EU) 2016\/2102<\/a> directive applied only to public-sector bodies. That is the major change.<\/p>\n<\/li>\n<li>\n<p>Microenterprises\u2014fewer than 10 people and annual turnover below \u20ac2 million\u2014are exempt from obligations concerning services.<\/p>\n<\/li>\n<li>\n<p>In Germany, the directive was transposed as the BFSG.<\/p>\n<\/li>\n<\/ul>\n<p>In short, WCAG 2.1 AA has turned from \u201cit would be nice\u201d into a checklist you may be asked to demonstrate compliance with.<\/p>\n<h3>An HTML page has a second version, and you cannot see it<\/h3>\n<p>From HTML markup, a browser builds not one tree but two. Everyone knows about the DOM. Alongside it, the browser builds the accessibility tree, and it is that tree\u2014not your HTML\u2014that reaches screen readers, braille displays, and voice control.<\/p>\n<p>It has noticeably fewer nodes and is organized differently. Every node has a role, a name, and a state: \u201cbutton,\u201d \u201cSave draft,\u201d \u201cpressed.\u201d An ordinary &lt;div&gt; containing text may only appear there as an unnamed piece of text, whereas &lt;button&gt;Save&lt;\/button&gt; becomes a proper node with the button role and the name \u201cSave.\u201d A blind user works with this second tree, so changing markup to make a page accessible matters only insofar as it changes that tree.<\/p>\n<p>The trickiest part is the name. It is not copied from an attribute; it is calculated by a separate algorithm with its own precedence order. Consider this page:<\/p>\n<pre><code class=\"xml\">&lt;h2 id=\"title\"&gt;Settings&lt;\/h2&gt;...&lt;button aria-label=\"Save draft\" aria-labelledby=\"title\"&gt;Save&lt;\/button&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Visually, you read \u201cSave.\u201d The code says \u201cSave draft.\u201d But a screen reader will announce \u201cSettings, button,\u201d because aria-labelledby outranks aria-label, which in turn outranks the text inside the button. There are three possible naming sources; one wins, and syntax highlighting will not show you which one.<\/p>\n<p>Everything else follows from that. A check that searches the code for aria-label=&#187;Save draft&#187; will happily accept this button, even though that is not what it is called aloud. That is why the project\u2019s checks do not read source code at all: they wait for the browser to render the page and build both trees, then query the result\u2014an element\u2019s role and name, its computed style, its size, and whether it appears in the second tree at all.<\/p>\n<p>At heart, this is the difference between \u201cthe correct method was called in the code\u201d and \u201cthe system reached the correct state.\u201d Here, the state is what a real person will hear.<\/p>\n<h3>How the project is built<\/h3>\n<p>Because there is no server, a reader\u2019s solution stays in their browser, on the same page where the rest of the application runs. This is normally the most uncomfortable part of this kind of training tool: you must run someone else\u2019s program while also allowing for an author freezing a tab with an infinite loop or accessing the training tool\u2019s own data. Hence web workers, separate sandboxes, and watchdog timers.<\/p>\n<p>I was lucky with the subject matter: the answer here is HTML and CSS. It does not need to be executed; it only needs to be rendered. The danger lies elsewhere. Nothing prevents someone from adding &lt;script&gt; to the markup, and if it is inserted into the page as-is, that script runs alongside the application code\u2014with access to saved progress, the other exercises, and everything else.<\/p>\n<p>But this is the important part: none of the twenty exercises need scripts. So there is no need to build a safe JavaScript execution environment. It is enough to make sure that no JavaScript from a solution runs at all. The task is much simpler and is solved with three layers of defense.<\/p>\n<p>First comes sanitization. The source has &lt;script&gt;, &lt;base&gt;, every &lt;meta http-equiv&gt;, and all inline on* handlers removed:<\/p>\n<pre><code class=\"javascript\">const safeSource = source  .replace(\/&lt;script\\b[^&gt;]*&gt;[\\s\\S]*?&lt;\\\/script&gt;\/gi, \"\")  .replace(\/&lt;base\\b[^&gt;]*&gt;\/gi, \"\")  .replace(\/&lt;meta\\b[^&gt;]*http-equiv[^&gt;]*&gt;\/gi, \"\")  .replace(\/\\son[a-z]+\\s*=\\s*(?:([\"'])[\\s\\S]*?\\1|[^\\s&gt;]+)\/gi, \"\");<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Second comes the sandbox. The markup is placed in an iframe through srcdoc with sandbox=&#187;allow-same-origin&#187;. Notice what is absent from that list: allow-scripts. Even if something slips through the regular expression, the browser will not execute it. The console honestly reports Blocked script execution for every such case.<\/p>\n<p>Third comes CSP inside the frame. The preview\u2019s &lt;head&gt; receives its own meta tag:<\/p>\n<pre><code class=\"xml\">&lt;meta http-equiv=\"Content-Security-Policy\"      content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:\"&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>default-src &#8216;none&#8217; means no outgoing requests: no images from other domains, no fonts, and no trackers. img-src data: is retained for exercises about alternative text.<\/p>\n<p>The checks themselves run not in the visible preview but in a separate off-screen frame (position: fixed; left: -10000px) with a five-second loading timeout.<\/p>\n<p>The checks then read the rendered document using ordinary browser APIs\u2014getComputedStyle and getBoundingClientRect\u2014and two libraries: isInaccessible from @testing-library\/dom (is an element excluded from the accessibility tree?) and computeAccessibleName from dom-accessibility-api (what name does it end up with?). When an exercise is about behavior rather than markup, @testing-library\/user-event does the work: it actually clicks and presses keys, and the check observes what happened.<\/p>\n<h3>One exercise in detail: four ways to hide something<\/h3>\n<p>Let us take exercise ten, which is perhaps the most illustrative. It starts with this scaffold:<\/p>\n<pre><code class=\"xml\">&lt;section class=\"demo\"&gt;  &lt;h1&gt;Four hiding techniques&lt;\/h1&gt;  &lt;div class=\"technique-grid\"&gt;    &lt;div id=\"visually-hidden-note\" class=\"hide-everything\"&gt;Extra context for screen readers&lt;\/div&gt;    &lt;div id=\"display-none-note\"&gt;Closed dialog content&lt;\/div&gt;    &lt;div id=\"aria-hidden-note\"&gt;Decorative confetti&lt;\/div&gt;    &lt;div id=\"hidden-note\"&gt;Inactive step&lt;\/div&gt;  &lt;\/div&gt;&lt;\/section&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The task is to hide each of the four blocks, but in different ways:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">Technique<\/p>\n<\/th>\n<th>\n<p align=\"left\">Visible<\/p>\n<\/th>\n<th>\n<p align=\"left\">In the accessibility tree<\/p>\n<\/th>\n<th>\n<p align=\"left\">Takes up space<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">display: none<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">hidden attribute<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">aria-hidden=&#187;true&#187;<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">.visually-hidden class<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes<\/p>\n<\/td>\n<td>\n<p align=\"left\">almost no (1px)<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The first two rows are equivalent in outcome\u2014hidden is essentially display: none from the UA stylesheet\u2014but they differ in the semantics of intent. The last two are mirror opposites, and they are the ones most often confused: aria-hidden hides from the screen reader what remains visible; .visually-hidden hides from sight what a screen reader should read.<\/p>\n<p>Solution:<\/p>\n<pre><code class=\"xml\">&lt;div id=\"visually-hidden-note\" class=\"visually-hidden\"&gt;Extra context for screen readers&lt;\/div&gt;&lt;div id=\"display-none-note\"    class=\"display-none\"&gt;Closed dialog content&lt;\/div&gt;&lt;div id=\"aria-hidden-note\"     aria-hidden=\"true\"&gt;Decorative confetti&lt;\/div&gt;&lt;div id=\"hidden-note\"          hidden&gt;Inactive step&lt;\/div&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Now for the interesting part: how is this checked? No strings, only the state of the rendered document. Here is the check for display: none:<\/p>\n<pre><code class=\"javascript\">const displayNonePasses =  displayNone !== null &amp;&amp;  displayNoneStyle?.display === \"none\" &amp;&amp;      \/\/ computed style, not an attribute  safelyIsInaccessible(displayNone) &amp;&amp;         \/\/ excluded from the accessibility tree  hasZeroBox(box(displayNone)) &amp;&amp;              \/\/ no geometry  !displayNone.hasAttribute(\"hidden\") &amp;&amp;       \/\/ by this technique specifically,  displayNone.getAttribute(\"aria-hidden\") !== \"true\"; \/\/ not another one<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The last two lines matter because without them the task could be solved by \u201cputting every possible attribute on everything.\u201d<\/p>\n<p>aria-hidden is the mirror case:<\/p>\n<pre><code class=\"javascript\">const ariaHiddenPasses =  ariaHidden.getAttribute(\"aria-hidden\") === \"true\" &amp;&amp;  safelyIsInaccessible(ariaHidden) &amp;&amp;          \/\/ gone from the tree  ariaHiddenStyle.display !== \"none\" &amp;&amp;        \/\/ but remains  ariaHiddenStyle.visibility !== \"hidden\" &amp;&amp;   \/\/ visible  hasPositiveBox(box(ariaHidden));             \/\/ with non-zero geometry<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>So it checks exactly this: \u201cgone from the accessibility tree, but still visible.\u201d<\/p>\n<p>The most tedious check is .visually-hidden, because the canonical recipe specifies not a single property but a combination: the element must remain in the accessibility tree, be position: absolute or fixed, have a non-zero box no larger than 2\u00d72 pixels, and have clipped overflow (clip-path or the legacy clip plus overflow: hidden). The check enumerates all of it literally.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/dfe\/cca\/0fe\/dfecca0fe64e34e8bddf4fbd1d7b33ab.png\" width=\"1317\" height=\"762\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/dfe\/cca\/0fe\/dfecca0fe64e34e8bddf4fbd1d7b33ab.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/dfe\/cca\/0fe\/dfecca0fe64e34e8bddf4fbd1d7b33ab.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/figure>\n<h3>Two tools alongside the checks<\/h3>\n<p>The checks answer the question \u201cis the exercise solved?\u201d There are also two additional buttons alongside them.<\/p>\n<h4>\u201cWhat will the screen reader say?\u201d<\/h4>\n<p>The first produces a transcript, line by line, of what a screen reader will announce as it moves through the markup from top to bottom.<\/p>\n<p>Under the hood is <a href=\"https:\/\/github.com\/guidepup\/virtual-screen-reader\" rel=\"noopener noreferrer nofollow\">Guidepup Virtual Screen Reader<\/a>, a virtual screen reader that runs in the browser on top of the accessibility tree. It attaches to the preview frame\u2019s body, walks the document, and accumulates phrases:<\/p>\n<pre><code class=\"javascript\">const { virtual } = await import(\"@guidepup\/virtual-screen-reader\/browser.js\");await virtual.start({ container: document.body, window: frameWindow });for (let step = 0; step &lt; 100; step += 1) {  await virtual.next();  if ((await virtual.lastSpokenPhrase()) === \"end of document\") break;}const log = (await virtual.spokenPhraseLog()).filter(Boolean);<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The 100-step limit protects against markup whose traversal never converges.<\/p>\n<p>You can not only read the transcript but listen to it: the adjacent button sends the log to SpeechSynthesisUtterance. The difference between a button and &lt;div onclick&gt; is invisible to the eye but audible as \u201cSave draft, button\u201d versus simply \u201cSave draft.\u201d<\/p>\n<p>This tool, rather than the checks, most often explains to users why the exercise requires what it requires.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/694\/fe2\/d5c\/694fe2d5c72b8d923294e59216a10e99.png\" width=\"1817\" height=\"803\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/694\/fe2\/d5c\/694fe2d5c72b8d923294e59216a10e99.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/694\/fe2\/d5c\/694fe2d5c72b8d923294e59216a10e99.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/figure>\n<h4>axe-core<\/h4>\n<p>The second button runs <a href=\"https:\/\/github.com\/dequelabs\/axe-core\" rel=\"noopener noreferrer nofollow\">axe-core<\/a>, the same engine used by Lighthouse and axe DevTools.<\/p>\n<p>It runs in the third frame: a clone of the preview is taken, scripts, nested frames, object, and embed are removed, on* handlers are stripped, CSP is tightened\u2014and only then is axe injected into this sterile clone. The visible preview is never touched; users should not see anything happen to it.<\/p>\n<p>It does not run every rule, only those relevant to the current exercise:<\/p>\n<pre><code class=\"javascript\">const result = await frameAxe.run(auditPreview.document.documentElement, {  runOnly: { type: \"rule\", values: exercise.axeRules },  resultTypes: [\"violations\", \"incomplete\"],  iframes: false,  preload: false,});<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>For icon-only buttons, that is button-name; for headings, heading-order; for hiding techniques, aria-hidden-focus\u2014did you hide from a screen reader something that can still receive focus? A full scan would produce a large number of irrelevant notes about contrast and lang.<\/p>\n<p>axe does not decide whether an exercise passes. By various estimates, automatic checks catch around a third of real accessibility problems: everything that can be formalized as a markup rule. The quality of alternative text, a logical focus order, and the clarity of an error message cannot be caught by anything except a person. A tool that says \u201c0 violations; your product is accessible\u201d does more harm than good, and I tried not to build one.<\/p>\n<h3>The cost<\/h3>\n<p>\u201cRuns entirely in the browser\u201d sounds attractive until you look at the payload. Here are actual gzipped build figures:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">What<\/p>\n<\/th>\n<th>\n<p align=\"left\">Size (gzip)<\/p>\n<\/th>\n<th>\n<p align=\"left\">When it loads<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Application with the editor (CodeMirror 6)<\/p>\n<\/td>\n<td>\n<p align=\"left\">204 KB<\/p>\n<\/td>\n<td>\n<p align=\"left\">immediately<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">React + runtime<\/p>\n<\/td>\n<td>\n<p align=\"left\">~80 KB<\/p>\n<\/td>\n<td>\n<p align=\"left\">immediately<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Total first visit<\/p>\n<\/td>\n<td>\n<p align=\"left\">~290 KB<\/p>\n<\/td>\n<td>\n<p align=\"left\">\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">axe-core<\/p>\n<\/td>\n<td>\n<p align=\"left\">150 KB<\/p>\n<\/td>\n<td>\n<p align=\"left\">on the \u201caudit\u201d button<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Screen-reader emulator (Guidepup)<\/p>\n<\/td>\n<td>\n<p align=\"left\">~100 KB<\/p>\n<\/td>\n<td>\n<p align=\"left\">on demand<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Exercise checks<\/p>\n<\/td>\n<td>\n<p align=\"left\">3\u201320 KB<\/p>\n<\/td>\n<td>\n<p align=\"left\">one chunk per exercise range<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The key decision is to load every heavy component on demand. axe-core weighs more than all the other checking tools combined, so there is no reason to load it for people who never press the audit button. Checks are also split into chunks by exercise range rather than bundled into one file: not everyone reaches exercise 20.<\/p>\n<h3>What did not work out<\/h3>\n<p>Some topics did not make it into the browser. I left out exercises whose correct answer depends on how NVDA specifically traverses a table or behaves in Forms Mode: it is not possible to test that honestly in a browser.<\/p>\n<p>Some exercises became syntax exercises. Where a WCAG criterion boils down to \u201cadd an attribute,\u201d the exercise inevitably checks knowledge of the attribute rather than understanding. I could not always redesign such cases as \u201cchoose the right one among several working approaches.\u201d<\/p>\n<h3>Source code<\/h3>\n<p>The code is open: <a href=\"https:\/\/github.com\/a11y-exercises\/a11y-exercises.github.io\" rel=\"noopener noreferrer nofollow\">https:\/\/github.com\/a11y-exercises\/a11y-exercises.github.io<\/a>, under the MIT license.<\/p>\n<p>The exercises live in app\/lib\/exercises.ts. Each one is a single structure containing a broken scaffold, a reference solution, task text with a hint, and links to primary sources\u2014WCAG and ARIA APG. The checks live alongside them in app\/lib\/validators*.ts. To add your own exercise, it is enough to write two functions.<\/p>\n<p>Twenty exercises do not cover the whole subject. Tables, complex widgets such as comboboxes and trees, and mobile gestures are untouched. If you have learned hard lessons with any of those, send them over\u2014I would be happy to add them.<\/p>\n<p>Where it all started: I borrowed the idea from <a href=\"https:\/\/typescript-exercises.github.io\/\" rel=\"noopener noreferrer nofollow\">TypeScript Exercises<\/a>. It uses the same pattern\u2014broken or incomplete code in the browser and a check that looks at the result\u2014applied to the type system. If you like this format, try the original too. <\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1065676\/\">https:\/\/habr.com\/ru\/articles\/1065676\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>On June 28, 2025, the European Union\u2019s transition period for the European Accessibility Act ended. For a broad range of commercial services\u2014online stores, banks, transport, and telecom\u2014web accessibility stopped being a matter of goodwill.Technically, the requirement comes down to EN 301 549 and, for the web, WCAG 2.1 Level AA. You cannot learn WCAG by reading it: half the criteria sound perfectly clear while leaving it completely unclear what exactly should be written in the markup.Some of our projects fall under the new requirements, and it is not only front-end developers who need to relearn their habits. Designers set contrast and focus order, analysts write error messages, and testers need to know what to check manually.That is how a project of 20 exercises emerged. In each one, the page looks fine at first glance. It is broken only from an accessibility perspective: headings skip levels, a div with a click handler acts as a button, a label sits next to an input but is not formally associated with it. You edit the markup and immediately see the result as the page rebuilds on the fly. Everything runs directly in the browser, and both your code and progress stay in localStorage:\u00a0a11y-exercises.github.ioWhat exactly does the EAA require?The details of the directive are often retold inaccurately, so here is the short version. It is (EU) 2019\/882: adopted in April 2019, to be transposed into national law by June 28, 2022, and applicable from June 28, 2025. In other words, 2025 is not the year the law was adopted; it is the year the transition period ended.This is not \u201call websites,\u201d but a defined range of consumer products and services: e-commerce, banking, transport, telecom, e-books, and terminals.Before it, the private sector in the EU was not covered at all\u2014the earlier (EU) 2016\/2102 directive applied only to public-sector bodies. That is the major change.Microenterprises\u2014fewer than 10 people and annual turnover below \u20ac2 million\u2014are exempt from obligations concerning services.In Germany, the directive was transposed as the BFSG.In short, WCAG 2.1 AA has turned from \u201cit would be nice\u201d into a checklist you may be asked to demonstrate compliance with.An HTML page has a second version, and you cannot see itFrom HTML markup, a browser builds not one tree but two. Everyone knows about the DOM. Alongside it, the browser builds the accessibility tree, and it is that tree\u2014not your HTML\u2014that reaches screen readers, braille displays, and voice control.It has noticeably fewer nodes and is organized differently. Every node has a role, a name, and a state: \u201cbutton,\u201d \u201cSave draft,\u201d \u201cpressed.\u201d An ordinary &lt;div&gt; containing text may only appear there as an unnamed piece of text, whereas &lt;button&gt;Save&lt;\/button&gt; becomes a proper node with the button role and the name \u201cSave.\u201d A blind user works with this second tree, so changing markup to make a page accessible matters only insofar as it changes that tree.The trickiest part is the name. It is not copied from an attribute; it is calculated by a separate algorithm with its own precedence order. Consider this page:&lt;h2 id=&#187;title&#187;&gt;Settings&lt;\/h2&gt;&#8230;&lt;button aria-label=&#187;Save draft&#187; aria-labelledby=&#187;title&#187;&gt;Save&lt;\/button&gt;Visually, you read \u201cSave.\u201d The code says \u201cSave draft.\u201d But a screen reader will announce \u201cSettings, button,\u201d because aria-labelledby outranks aria-label, which in turn outranks the text inside the button. There are three possible naming sources; one wins, and syntax highlighting will not show you which one.Everything else follows from that. A check that searches the code for aria-label=&#187;Save draft&#187; will happily accept this button, even though that is not what it is called aloud. That is why the project\u2019s checks do not read source code at all: they wait for the browser to render the page and build both trees, then query the result\u2014an element\u2019s role and name, its computed style, its size, and whether it appears in the second tree at all.At heart, this is the difference between \u201cthe correct method was called in the code\u201d and \u201cthe system reached the correct state.\u201d Here, the state is what a real person will hear.How the project is builtBecause there is no server, a reader\u2019s solution stays in their browser, on the same page where the rest of the application runs. This is normally the most uncomfortable part of this kind of training tool: you must run someone else\u2019s program while also allowing for an author freezing a tab with an infinite loop or accessing the training tool\u2019s own data. Hence web workers, separate sandboxes, and watchdog timers.I was lucky with the subject matter: the answer here is HTML and CSS. It does not need to be executed; it only needs to be rendered. The danger lies elsewhere. Nothing prevents someone from adding &lt;script&gt; to the markup, and if it is inserted into the page as-is, that script runs alongside the application code\u2014with access to saved progress, the other exercises, and everything else.But this is the important part: none of the twenty exercises need scripts. So there is no need to build a safe JavaScript execution environment. It is enough to make sure that no JavaScript from a solution runs at all. The task is much simpler and is solved with three layers of defense.First comes sanitization. The source has &lt;script&gt;, &lt;base&gt;, every &lt;meta http-equiv&gt;, and all inline on* handlers removed:const safeSource = source  .replace(\/&lt;script\\b[^&gt;]*&gt;[\\s\\S]*?&lt;\\\/script&gt;\/gi, &#171;&#187;)  .replace(\/&lt;base\\b[^&gt;]*&gt;\/gi, &#171;&#187;)  .replace(\/&lt;meta\\b[^&gt;]*http-equiv[^&gt;]*&gt;\/gi, &#171;&#187;)  .replace(\/\\son[a-z]+\\s*=\\s*(?:([&#171;&#8216;])[\\s\\S]*?\\1|[^\\s&gt;]+)\/gi, &#171;&#187;);Second comes the sandbox. The markup is placed in an iframe through srcdoc with sandbox=&#187;allow-same-origin&#187;. Notice what is absent from that list: allow-scripts. Even if something slips through the regular expression, the browser will not execute it. The console honestly reports Blocked script execution for every such case.Third comes CSP inside the frame. The preview\u2019s &lt;head&gt; receives its own meta tag:&lt;meta http-equiv=&#187;Content-Security-Policy&#187;      content=&#187;default-src &#8216;none&#8217;; style-src &#8216;unsafe-inline&#8217;; img-src data:; font-src data:&#187;&gt;default-src &#8216;none&#8217; means no outgoing requests: no images from other domains, no fonts, and no trackers. img-src data: is retained for exercises about alternative text.The checks themselves run not in the visible preview but in a separate off-screen frame (position: fixed; left: -10000px) with a five-second loading timeout.The checks then read the rendered document using ordinary browser APIs\u2014getComputedStyle and getBoundingClientRect\u2014and two libraries: isInaccessible from @testing-library\/dom (is an element excluded from the accessibility tree?) and computeAccessibleName from dom-accessibility-api (what name does it end up with?). When an exercise is about behavior rather than markup, @testing-library\/user-event does the work: it actually clicks and presses keys, and the check observes what happened.One exercise in detail: four ways to hide somethingLet us take exercise ten, which is perhaps the most illustrative. It starts with this scaffold:&lt;section class=&#187;demo&#187;&gt;  &lt;h1&gt;Four hiding techniques&lt;\/h1&gt;  &lt;div class=&#187;technique-grid&#187;&gt;    &lt;div id=&#187;visually-hidden-note&#187; class=&#187;hide-everything&#187;&gt;Extra context for screen readers&lt;\/div&gt;    &lt;div id=&#187;display-none-note&#187;&gt;Closed dialog content&lt;\/div&gt;    &lt;div id=&#187;aria-hidden-note&#187;&gt;Decorative confetti&lt;\/div&gt;    &lt;div id=&#187;hidden-note&#187;&gt;Inactive step&lt;\/div&gt;  &lt;\/div&gt;&lt;\/section&gt;The task is to hide each of the four blocks, but in different ways:TechniqueVisibleIn the accessibility treeTakes up spacedisplay: nonenononohidden attributenononoaria-hidden=&#187;true&#187;yesnoyes.visually-hidden classnoyesalmost no (1px)The first two rows are equivalent in outcome\u2014hidden is essentially display: none from the UA stylesheet\u2014but they differ in the semantics of intent. The last two are mirror opposites, and they are the ones most often confused: aria-hidden hides from the screen reader what remains visible; .visually-hidden hides from sight what a screen reader should read.Solution:&lt;div id=&#187;visually-hidden-note&#187; class=&#187;visually-hidden&#187;&gt;Extra context for screen readers&lt;\/div&gt;&lt;div id=&#187;display-none-note&#187;    class=&#187;display-none&#187;&gt;Closed dialog content&lt;\/div&gt;&lt;div id=&#187;aria-hidden-note&#187;     aria-hidden=&#187;true&#187;&gt;Decorative confetti&lt;\/div&gt;&lt;div id=&#187;hidden-note&#187;          hidden&gt;Inactive step&lt;\/div&gt;Now for the interesting part: how is this checked? No strings, only the state of the rendered document. Here is the check for display: none:const displayNonePasses =  displayNone !== null &amp;&amp;  displayNoneStyle?.display === &#171;none&#187; &amp;&amp;      \/\/ computed style, not an attribute  safelyIsInaccessible(displayNone) &amp;&amp;         \/\/ excluded from the accessibility tree  hasZeroBox(box(displayNone)) &amp;&amp;              \/\/ no geometry  !displayNone.hasAttribute(&#171;hidden&#187;) &amp;&amp;       \/\/ by this technique specifically,  displayNone.getAttribute(&#171;aria-hidden&#187;) !== &#171;true&#187;; \/\/ not another oneThe last two lines matter because without them the task could be solved by \u201cputting every possible attribute on everything.\u201daria-hidden is the mirror case:const ariaHiddenPasses =  ariaHidden.getAttribute(&#171;aria-hidden&#187;) === &#171;true&#187; &amp;&amp;  safelyIsInaccessible(ariaHidden) &amp;&amp;          \/\/ gone from the tree  ariaHiddenStyle.display !== &#171;none&#187; &amp;&amp;        \/\/ but remains  ariaHiddenStyle.visibility !== &#171;hidden&#187; &amp;&amp;   \/\/ visible  hasPositiveBox(box(ariaHidden));             \/\/ with non-zero geometrySo it checks exactly this: \u201cgone from the accessibility tree, but still visible.\u201dThe most tedious check is .visually-hidden, because the canonical recipe specifies not a single property but a combination: the element must remain in the accessibility tree, be position: absolute or&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-489470","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/489470","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=489470"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/489470\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=489470"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=489470"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=489470"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}