Mert Özen Further With Every Line
Frontend Accessibility (a11y) — #02

Reading WCAG 2.2 as a Developer: the Most Commonly Failed Criteria and a Bookstore Audit

Mert Özen Nov 15, 2026 17 min 4 views
Reading WCAG 2.2 as a Developer: the Most Commonly Failed Criteria and a Bookstore Audit

Lighthouse's accessibility score of 100 only says the automatically measurable part is clean; problems such as non-text contrast, autocomplete, placeholders counted as labels and focus management are only found by a manual audit. The post covers the most common failures from WebAIM Million 2026, the twelve problems found in a Bookstore that scores 100, the first fixes, measuring WCAG 2.2's new criteria and a draft accessibility statement page.

Version note: The examples in this post were run with WCAG 2.2, Angular 22.2 (TypeScript 6.0), Node.js 24, Playwright 1.65 (Chromium 158), axe-core 4.14 and Lighthouse 13.6.

Guess First

In the previous post we scanned three pages of Bookstore, the online bookstore used as the shared example across this site's series, with axe-core and found a single WCAG violation. Today we run the same pages through Lighthouse's accessibility category as well. Lighthouse's accessibility score is also built on axe rules; out of 100, what do the catalog and book details pages get?

Then we look at the same pages like a person would: we measure borders, fill in forms, narrow the page to 320 pixels. If Lighthouse gave 100, how many problems does a manual audit find: none, one or two, or around ten? The answer also shows what automated tools miss and why.

The Most Common Failures in the Real World

Every year WebAIM, the web accessibility center at Utah State University, automatically scans the home pages of the million most visited sites. According to data collected in February 2026, 95.9% of home pages have at least one automatically detectable WCAG failure, 56.1 errors per page on average. Six failure types stand out: low-contrast text (83.9% of pages), images without alt text (53.1%), form inputs without labels (51%), empty links (46.3%), empty buttons (30.6%) and a missing document language (13.5%). WebAIM itself adds a warning to the report: the absence of detected errors does not show that a page is accessible.

Let's see what these six mean for Bookstore. Text contrast is clean: axe's color-contrast rule passed; the gray secondary text #6b7280 on white is 4.83:1, above AA's 4.5:1 threshold. The details of contrast math and building a palette are in the "Color and Contrast" post of the UI/UX Design Principles series. Bookstore has no cover images, so there is no alt text problem either; the alt text rules for images are in the "Images and Media" post of the HTML5 and Semantic HTML series. We found the unlabeled input in the previous post. There are no empty links or buttons, and the html element has lang="en". One thing not on the list but common is heading structure. The catalog page has no h1 at all; screen reader users often scan a page by jumping between headings.

So Bookstore is on the "good" side of these statistics. But all six types are the kind an automated tool can detect; the real job of an audit is to find what the tool cannot see.

What Does Lighthouse's 100 Mean?

Running Lighthouse from the DevTools panel and reading the score was covered in the "Lighthouse, Rendering and Coverage" post of the Browser DevTools series. Here we use the CLI, run only the accessibility category and save the result as JSON. Lighthouse uses the Chrome installed on the machine:

npx lighthouse http://127.0.0.1:52411/ --only-categories=accessibility --output=json --output-path=reports/catalog-before.json --quiet --chrome-flags="--headless=new"
npx lighthouse http://127.0.0.1:52411/books/9780441172719 --only-categories=accessibility --output=json --output-path=reports/book-before.json --quiet --chrome-flags="--headless=new"
node lh-summary.mjs reports/catalog-before.json reports/book-before.json
// Prints the accessibility score and the failed audits of Lighthouse JSON reports.
// Usage: node lh-summary.mjs reports/*.json
import { readFileSync } from 'node:fs';

for (const file of process.argv.slice(2)) {
  const lhr = JSON.parse(readFileSync(file, 'utf8'));
  const category = lhr.categories.accessibility;
  const failed = category.auditRefs
    .map(ref => lhr.audits[ref.id])
    .filter(a => a.score === 0)
    .map(a => a.id);
  const manual = category.auditRefs.filter(ref => lhr.audits[ref.id].scoreDisplayMode === 'manual').length;
  console.log(`${file}: Lighthouse ${lhr.lighthouseVersion} accessibility ${Math.round(category.score * 100)}`);
  console.log(`  failed: ${failed.join(', ') || 'none'}; manual checks listed: ${manual}`);
}
reports/catalog-before.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10
reports/book-before.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10

Both pages got 100. Lighthouse's accessibility score is a weighted average of the axe-based audits inside it; on the catalog page 19 audits applied and 47 counted as "not applicable" because the page has nothing for them to check. The report also lists 10 manual checks that never enter the score: a logical Tab order, visual order matching DOM order, focus not getting trapped, focus being managed when new content arrives, custom controls having the right role and name, and so on. Lighthouse leaves these as "check it yourself". 100 means the measurable part is clean; nothing more.

Measuring What axe Does Not

Part of a manual audit can be sped up with a script. audit.mjs measures five things axe does not check on every page: the page title and the number of h1s, the border contrast of form fields, autocomplete on fields that ask for personal data, click targets smaller than 24 pixels, and horizontal overflow at 320 pixels wide:

// Manual-audit helpers: measures what axe does not check on Bookstore's pages.
// Usage: node audit.mjs
import { chromium } from 'playwright';
import { open, pages } from './lib.mjs';

const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();

for (const name of Object.keys(pages)) {
  await open(page, name);
  const result = await page.evaluate(() => {
    const luminance = rgb => {
      const [r, g, b] = rgb.match(/\d+(\.\d+)?/g).slice(0, 3).map(Number).map(v => {
        v /= 255;
        return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * r + 0.7152 * g + 0.0722 * b;
    };
    const ratio = (a, b) => {
      const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
      return Math.floor(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
    };
    // 1.4.11: the border is the only thing that shows where a text field is
    const fields = [...document.querySelectorAll('input:not([type=hidden]), select')].map(el => {
      const style = getComputedStyle(el);
      return `${el.name || el.type || el.tagName.toLowerCase()} ${ratio(style.borderTopColor, 'rgb(255, 255, 255)')}:1`;
    });
    // 1.3.5: fields that collect the user's own data need an autocomplete token
    const personal = [...document.querySelectorAll('input[name=name], input[type=email]')]
      .map(el => `${el.name}=${el.getAttribute('autocomplete') ?? '(none)'}`);
    // 2.5.8: targets under 24x24 CSS px, and whether the spacing exception saves them
    const targets = [...document.querySelectorAll('a[href], button, input, select')]
      .map(el => ({ el, r: el.getBoundingClientRect() })).filter(t => t.r.width > 0);
    const isSmall = t => t.r.width < 24 || t.r.height < 24;
    const center = t => [t.r.left + t.r.width / 2, t.r.top + t.r.height / 2];
    const small = targets.filter(isSmall).map(t => {
      const [cx, cy] = center(t);
      // a 24px circle on the target must not touch another target or another small target's circle
      const crowded = targets.some(o => {
        if (o === t) return false;
        const nx = Math.max(o.r.left, Math.min(cx, o.r.right)), ny = Math.max(o.r.top, Math.min(cy, o.r.bottom));
        const [ox, oy] = center(o);
        return Math.hypot(cx - nx, cy - ny) < 12 || (isSmall(o) && Math.hypot(cx - ox, cy - oy) < 24);
      });
      return { label: (t.el.textContent || t.el.type).trim().slice(0, 20), w: Math.round(t.r.width), h: Math.round(t.r.height), crowded };
    });
    return { title: document.title, h1: document.querySelectorAll('h1').length, fields, personal, small };
  });
  // 1.4.10: at 320 CSS px wide nothing should need horizontal scrolling (data tables are an exception)
  await page.setViewportSize({ width: 320, height: 640 });
  const overflow = await page.evaluate(() => {
    const doc = document.documentElement;
    const wide = [...document.querySelectorAll('main *')].filter(e => e.getBoundingClientRect().right > doc.clientWidth + 1);
    return { scroll: doc.scrollWidth - doc.clientWidth, culprits: [...new Set(wide.map(e => e.closest('table') ? 'table' : e.tagName.toLowerCase()))] };
  });
  await page.setViewportSize({ width: 1280, height: 800 });
  console.log(`--- ${name}`);
  console.log(`  title="${result.title}" h1=${result.h1}`);
  console.log(`  field borders: ${result.fields.join(', ') || '-'}`);
  console.log(`  autocomplete: ${result.personal.join(', ') || '-'}`);
  const smallest = result.small.toSorted((x, y) => x.w * x.h - y.w * y.h)[0];
  const crowded = result.small.filter(s => s.crowded).map(s => s.label);
  console.log(`  targets < 24px: ${result.small.length}` + (smallest ? ` (smallest "${smallest.label}" ${smallest.w}x${smallest.h}),` +
    ` without enough spacing: ${crowded.join(', ') || 'none'}` : ''));
  console.log(`  320px: horizontal overflow ${overflow.scroll}px ${overflow.culprits.join(',')}`);
}
await browser.close();
--- catalog
  title="Bookstore" h1=0
  field borders: search 1.47:1, select-one 1.47:1
  autocomplete: -
  targets < 24px: 23 (smallest "Catalog" 56x18), without enough spacing: none
  320px: horizontal overflow 0px 
--- book
  title="Book · Bookstore" h1=1
  field borders: number 1.47:1
  autocomplete: -
  targets < 24px: 4 (smallest "Catalog" 56x18), without enough spacing: none
  320px: horizontal overflow 0px 
--- cart
  title="Cart · Bookstore" h1=1
  field borders: number 1.47:1, name 1.47:1, email 1.47:1
  autocomplete: name=(none), email=(none)
  targets < 24px: 4 (smallest "1984" 37x18), without enough spacing: none
  320px: horizontal overflow 108px table

Border contrast is 1.47:1 on every page. Unlike text contrast, this is a matter for 1.4.11 (Non-text Contrast): if the border is the only visual cue that identifies a text field, it needs at least 3:1 contrast against the background. #d1d5db is a light gray; for a low-vision user the search box or the quantity field almost disappears on the white background. axe does not check this rule at all.

The name and e-mail fields of the order form have no autocomplete. 1.3.5 (Identify Input Purpose) requires that the purpose of fields asking for information about the user can be determined programmatically; in practice, the right autocomplete token. That lets the browser fill the field automatically, and some assistive technologies can put a familiar icon next to it. For a user with a motor disability or memory problems, every character they do not have to type is a gain. axe's autocomplete-valid rule only checks whether an existing value is valid; if the attribute is missing, it stays silent.

Click targets are fine: there are links 18 pixels tall, but all of them have enough space around them. At 320 pixels wide the cart table overflows by 108 pixels; 1.4.10 (Reflow) exempts data tables whose two-dimensional layout carries meaning, but wrapping the table in a keyboard-scrollable wrapper is still a good idea. How to do that is in the "Tables and Tabular Data" post of the HTML5 and Semantic HTML series.

Bookstore's Audit Table

I collected the script's output, the previous post's scan and a short keyboard pass into one table. The last column says how the problem was tested or in which post of the series it will be handled:

CriterionProblemAffected usersFixTest
4.1.2 Name, Role, ValueThe quantity input in the cart has no nameScreen reader, voice controlHidden label: "Quantity of 1984"axe label, accessibility tree
3.3.2 Labels or InstructionsThe order form has only placeholdersCognitive, low vision, screen readerVisible label with for/idManual; axe passes it
1.3.5 Identify Input PurposeNo autocomplete on name and e-mailMotor, cognitiveautocomplete="name", "email"audit.mjs
1.4.11 Non-text ContrastForm field border is 1.47:1Low visionBorder #6b7280 (4.83:1)audit.mjs
1.3.1 Info and RelationshipsNo h1 on the catalogScreen reader users navigating by headingVisible h1: "Catalog"axe best practice
1.3.1 Info and RelationshipsEmpty column header in the cart tableScreen reader users moving cell by cellHidden header: "Actions"axe best practice
3.2.6 Consistent HelpNo way to get help or contact usCognitive, everyoneIn the footer, same place on every pageManual
2.4.2 Page TitledEvery book's title is "Book · Bookstore"Screen reader, anyone with many tabsBook name in the titlePost 03
2.4.3 Focus OrderFocus stays where it was when the page changesKeyboard, screen readerFocus management on route changePost 03
4.1.3 Status Messages"Added." and the book count are not announcedScreen readerLive regionPost 05
3.3.1 Error IdentificationThe order error is not tied to a fieldScreen reader, cognitiveTie the error message to the fieldPosts 04 and 05
1.4.10 ReflowThe cart table overflows at 320 pixelsLow-vision zoom usersWithin the exception; scrollable wrapperaudit.mjs

Twelve rows in an app Lighthouse gave 100. They do not all weigh the same: without a name on the quantity field a screen reader user cannot edit the cart, while border contrast is at the "hard but possible" level. When you set priorities, ask two questions: can the user complete the task, and how many pages does it affect? The ordering process is at the top on both.

The First Fixes

The first seven rows are small HTML and CSS changes. In the cart table we added hidden text to the empty header and a hidden label carrying the book's name to the quantity field. In the order form, visible labels and autocomplete replaced the placeholders:

  <table class="cart">
    <thead>
      <tr><th>Book</th><th>Price</th><th>Quantity</th><th>Total</th><th><span class="visually-hidden">Actions</span></th></tr>
    </thead>
    <tbody>
      @for (line of store.cart()!.items; track line.isbn) {
        <tr>
          <td><a [routerLink]="['/books', line.isbn]">{{ line.title }}</a></td>
          <td>{{ line.unitPrice | currency: 'TRY' : 'symbol-narrow' }}</td>
          <td>
            <label class="visually-hidden" [for]="'qty-' + line.isbn">Quantity of {{ line.title }}</label>
            <input [id]="'qty-' + line.isbn" type="number" min="1" max="99" [value]="line.quantity"
                   (change)="changeQuantity(line.isbn, $any($event.target).valueAsNumber)" />
          </td>
          <td>{{ line.lineTotal | currency: 'TRY' : 'symbol-narrow' }}</td>
          <td><button type="button" (click)="store.remove(line.isbn)">Remove</button></td>
        </tr>
      }
    </tbody>
  </table>
  <p class="total" data-testid="cart-total">Total: {{ store.cart()!.total | currency: 'TRY' : 'symbol-narrow' }}</p>

  <form class="checkout" (submit)="checkout($event, nameBox.value, emailBox.value)">
    <div class="field">
      <label for="customer-name">Full name</label>
      <input #nameBox id="customer-name" name="name" autocomplete="name" required />
    </div>
    <div class="field">
      <label for="customer-email">E-mail</label>
      <input #emailBox id="customer-email" name="email" type="email" autocomplete="email" required />
    </div>
    <button type="submit">Place order</button>
  </form>

Why is the quantity field's label hidden? The "Quantity" column header already gives the same information visually, but when a screen reader reaches a form field with Tab it does not always read the table header; the field needs its own name. For the hidden label we wrote a utility class in CSS. display: none or visibility: hidden also removes content from the accessibility tree; this class only removes it from the screen. The form field border is in the same file:

input, select {
  padding: 0.4rem;
  /* #6b7280 on white is 4.83:1; form field borders need at least 3:1 (WCAG 1.4.11) */
  border: 1px solid #6b7280;
  border-radius: 6px;
}
.checkout { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; margin-top: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.25rem; }

/* Hidden on screen, still in the accessibility tree */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

The catalog page got an h1 at the top, and app.html got a footer that sits in the same place on every page:

<h1>Catalog</h1>
<header class="site-header">
  <a routerLink="/" class="brand">Bookstore</a>
  <nav>
    <a routerLink="/">Catalog</a>
    <a routerLink="/cart" data-testid="cart-link">Cart ({{ cartStore.itemCount() }})</a>
  </nav>
</header>
<main>
  <router-outlet />
</main>
<footer class="site-footer">
  <p>Need help? Write to <a href="mailto:support@example.com">support@example.com</a></p>
  <a routerLink="/accessibility">Accessibility statement</a>
</footer>

I rebuilt and ran both scripts again. The scan now covers four pages because I added the statement page we write below to lib.mjs:

--- catalog (/)
  passed rules=21
--- book (/books/9780441172719)
  passed rules=13
--- cart (/cart)
  passed rules=17
--- statement (/accessibility)
  passed rules=12
axe-core 4.14.0, WCAG violations: 0
--- cart
  title="Cart · Bookstore" h1=1
  field borders: number 4.83:1, name 4.83:1, email 4.83:1
  autocomplete: name=name, email=email
  targets < 24px: 6 (smallest "1984" 37x18), without enough spacing: none
  320px: horizontal overflow 108px table
- row "1984 ₺279.00 Quantity of 1984 ₺279.00 Remove":
  - cell "1984":
    - link "1984":
      - /url: /books/9780451524935
  - cell "₺279.00"
  - cell "Quantity of 1984":
    - text: Quantity of 1984
    - spinbutton "Quantity of 1984": "1"
  - cell "₺279.00"
  - cell "Remove":
    - button "Remove"
- text: Full name
- textbox "Full name"
- text: E-mail
- textbox "E-mail"
- button "Place order"

The quantity field is now spinbutton "Quantity of 1984"; the name comes from the hidden label, not from the visual header. The form fields kept the same names ("Full name", "E-mail"), but now they come from a visible label instead of the placeholder, and that label stays on screen when the user starts typing. The accessibility tree looks the same while the user experience is different: knowing what the tree does not tell you is part of the audit too.

WCAG 2.2's New Criteria in Bookstore

2.5.8 Target Size (Minimum): a click target must be at least 24x24 CSS pixels. If it is smaller, a circle 24 pixels in diameter centered on the target must not touch another target or another small target's circle; that is exactly what audit.mjs measures. Links inside a sentence, controls drawn by the browser itself (like the arrows of a number input) and targets whose size is essential are exempt. Bookstore's 18-pixel-tall links pass thanks to spacing. One caveat: axe's target-size rule is off in a default scan; it only runs when you ask for the wcag22aa tag. Our scan.mjs asks for that tag, so the rule ran and passed.

2.4.11 Focus Not Obscured (Minimum): an element that receives keyboard focus must not be completely hidden by author-created content such as a sticky header or a cookie banner. Bookstore's header is not sticky right now, so the criterion is met. In the next post we will make the header sticky, create the problem ourselves and fix it with scroll-padding.

2.5.7 Dragging Movements: everything done by dragging needs a way to do it with a single pointer without dragging. Bookstore has no drag and drop. If one day we want to reorder the books in the cart by dragging, adding "move up" and "move down" buttons to each row meets this criterion. Dragging for keyboard users is already a matter for 2.1.1; 2.5.7 is for users of a mouse or touch screen who cannot perform the dragging motion.

3.2.6 Consistent Help: this criterion does not require a help mechanism; if you provide one, it must sit in the same relative order across pages. The contact line in the footer is in the same place on every page, so it is met. 3.3.7 Redundant Entry: information already entered in the same process must not be asked for again; it should be filled in automatically or be selectable. Bookstore's checkout is a single step, so it has no counterpart yet. Once a billing address is added, a "same as delivery address" option will be needed. 3.3.8 Accessible Authentication (Minimum) requires an alternative or help when the sign-in step asks for a cognitive test, such as remembering a password or solving a puzzle: not blocking password paste and password managers is the practical meaning of this. These two form criteria are covered in detail in the "Form Design" post of the UI/UX Design Principles series.

The Accessibility Statement Page

An accessibility statement is the page that states plainly which standard the site aims for, where it stands now, the known problems and how users can report a problem. For public-sector sites the EU's Web Accessibility Directive 2016/2102 makes it mandatory; W3C's WAI also has guidance that suggests its sections, along with a generator. For Bookstore we wrote the statement as an Angular page; the known limitations live in an array so that each fix only removes one entry:

import { Component } from '@angular/core';

interface KnownLimitation {
  problem: string;
  criteria: string;
  plannedFix: string;
}

// Keep this page in step with the audit: update it whenever a limitation is fixed or found.
@Component({
  selector: 'app-accessibility-page',
  templateUrl: './accessibility-page.html',
})
export class AccessibilityPage {
  protected readonly reviewedOn = '15 November 2026';
  protected readonly contactEmail = 'accessibility@example.com';

  protected readonly limitations: KnownLimitation[] = [
    {
      problem: 'After moving to another page, keyboard focus stays where it was and the page title does not name the book.',
      criteria: '2.4.2, 2.4.3',
      plannedFix: 'December 2026',
    },
    {
      problem: 'Messages such as "Added" and the number of search results are not announced by screen readers.',
      criteria: '4.1.3',
      plannedFix: 'January 2027',
    },
    {
      problem: 'Order errors are shown below the form but are not linked to the fields they belong to.',
      criteria: '3.3.1, 3.3.3',
      plannedFix: 'January 2027',
    },
  ];
}
<article class="statement">
  <h1>Accessibility statement</h1>
  <p>
    We want everyone to be able to find, buy and follow up on books at Bookstore, including people who use
    a screen reader, only a keyboard, zoom or voice control.
  </p>

  <h2>Conformance status</h2>
  <p>
    Our target is WCAG 2.2 level AA. Bookstore is partially conformant: most pages meet the target,
    the parts listed below do not yet.
  </p>
  <p>
    Under Presidential Circular No. 2025/10 we also follow the Level A checklist of the Ministry of Family
    and Social Services.
  </p>

  <h2>Known limitations</h2>
  <ul>
    @for (item of limitations; track item.problem) {
      <li>{{ item.problem }} (WCAG {{ item.criteria }}; planned fix: {{ item.plannedFix }})</li>
    }
  </ul>

  <h2>How we checked</h2>
  <p>
    Self-evaluation: automated checks with axe-core 4.14 and Lighthouse 13.6, then manual checks with the keyboard,
    at 320 CSS pixels wide and against the checklist. Screen reader testing is planned.
  </p>

  <h2>Feedback</h2>
  <p>
    If something does not work for you, write to <a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>.
    We answer within five working days.
  </p>
  <p class="muted">Last reviewed on {{ reviewedOn }}.</p>
</article>

We added the route to app.routes.ts; the page title is "Accessibility statement · Bookstore". The two most important qualities of a statement are honesty and contact. Writing "fully conformant" is easy; with open rows in the audit table it is a claim, and users stop trusting the statement at the first problem. Writing "partially conformant" and listing the known problems, the affected criteria and an estimated fix date tells both users and a Review Commission what to expect. The feedback address being a mailbox someone actually reads, and keeping the promised reply time, are part of the statement too.

In Production

Plan the audit by user tasks, not by a list of pages. For Bookstore the tasks are "find a book", "add to cart", "edit the cart" and "place an order". WCAG's complete-process rule asks for the same: if one step of checkout is inaccessible, the rest of the process being conformant changes nothing.

Enter findings in an issue tracker with the criterion number and prioritize by user impact. "1.4.11, low" and "4.1.2, blocks ordering" should not compete in the same sprint. Map which finding closed in which post or release to the statement page.

Do not make the Lighthouse score a target metric. In this post we found twelve problems on a page that scored 100. The score is only useful as a warning when it drops; how to set up automated checks in CI is the subject of the series' last post.

Keep values like colors and borders in one place. In Bookstore the border color is a single line in styles.css, so the fix took one line; had the same color been copied into the CSS of twenty components, it would have been twenty changes and twenty separate risks.

Common Pitfalls

First, using display: none to hide a label. The element is removed from the accessibility tree too, and the input stays nameless.

Second, counting a placeholder as a label because axe passed it. Name computation uses the placeholder as a last resort, but the hint disappears when the user starts typing.

Third, measuring only text contrast. Form field borders, icons, the focus indicator and the lines in charts fall under 1.4.11, and most automated tools do not look at them.

Fourth, reading 3.2.6 as "put a help button on every page". The criterion asks for consistency, not for help to exist.

Fifth, writing "fully conformant" on the statement page and never touching it again. The statement is an output of the audit; it should change as the audit changes.

A Small Experiment

★☆☆ Set the border color in styles.css to #9ca3af and run audit.mjs. Does it pass 1.4.11's 3:1 threshold? Then calculate the same color's text contrast on white and compare it with 4.5:1.

★★☆ Put the cart table in a wrapper with tabindex="0", role="region" and an aria-label, and give it overflow-x: auto. At 320 pixels wide, does audit.mjs still see the overflow at page level, and does the wrapper receive focus with Tab?

★★★ Add a 16-pixel "add to favorites" icon button to the catalog's book cards and leave the gap at 0.25rem. Do audit.mjs and axe's target-size rule give the same result? Between enlarging the button to 24 pixels and increasing the spacing, which changes the design less?

Two of the open rows in the audit table are about focus: where focus is left when the page changes, and the page title. In the next post we use Bookstore with the keyboard only: Tab order, a visible focus style, skip links, managing focus in a modal dialog, inert, roving tabindex, and focus and title on route changes with the Angular router.