Work sample · Accessibility remediation

WCAG 2.2 remediation: a booking form, before and after

This is a demo we built for this sample, not client work. Tidewell Bikes is a fictional business. We wrote the “before” page with defects that are common on real booking and contact forms, fixed them on the “after” page, and checked each fix. Neither form sends anything.

Standard
WCAG 2.2, Levels A and AA (W3C Recommendation, 12 December 2024)
Pages
/work/wcag-remediation/before and /work/wcag-remediation/after
Checked on
29 September 2026
Environment
macOS on an Intel Mac. Chromium 152 (the browser built into the Claude desktop app) for the keyboard walk-through and its accessibility tree. Node.js 24 for the automated check.

Summary

All 16 are fixed on the after page. The automated check went from 23 failures to 0, and the keyboard walk-through that failed on the before page (the booking button could not be reached, and focus was invisible) passed on the after page.

Severity is our own scale: high stops some people from completing the booking, medium makes it harder or fails a Level AA criterion, low is a smaller or advisory issue.

Findings

Each issue on the before page, the WCAG 2.2 success criterion it fails, the fix on the after page and how the fix was checked.
#Issue on the before pageWCAG 2.2SeverityWhat we changedHow we checked
1 “Full name”, “Service” and “Tell us about your bike” are plain text next to the fields, not labels. The browser gave the name field and the notes field no name at all and named the service menu after its first option, “Choose...”. 1.3.1 (A)Info and Relationships4.1.2 (A)Name, Role, Value High A <label for> for every field. Optional fields say “(optional)”. Automated check; accessibility tree now shows “Full name”, “Email address”, “Service”, “Preferred date”.
2 Email and preferred date use a placeholder as their only label. It disappears once you type, and nothing says which date format is expected. 3.3.2 (A)Labels or Instructions High Visible labels, plus hints that stay on screen: “We send the booking confirmation here.” and “Day, month and year, for example 14/05/2027.”, tied to the field with aria-describedby. Automated check; accessibility tree.
3 The workshop drawing has no alt attribute. 1.1.1 (A)Non-text Content Medium alt="Drawing of a bicycle on a repair stand in the Tidewell workshop" Automated check; accessibility tree.
4 The logo is the only content of the home link and has no alt, so the link has no name (W3C failure F89). 2.4.4 (A)Link Purpose (In Context)4.1.2 (A)Name, Role, Value High alt="Tidewell Bikes home" on the logo image. Automated check; accessibility tree.
5 “For our opening hours and service prices, click here or here.” Two links, two destinations, and nothing in the sentence says which is which. 2.4.4 (A)Link Purpose (In Context) Medium Link text says where it goes: “opening hours”, “services we offer”, “Service week details”. Automated check; accessibility tree.
6 “Book now” is a div with a click handler. It has no button role and never receives keyboard focus, so the form cannot be sent without a mouse (W3C failures F54 and F59). 2.1.1 (A)Keyboard4.1.2 (A)Name, Role, Value High A native <button type="submit">. Validation runs on the form’s submit event, so Enter in a field also sends it. Keyboard: before, Tab passed 10 stops and skipped the button; after, the button is reached and Enter submits. Accessibility tree: button “Book now”.
7 .shop *:focus { outline: none; } removes the focus indicator from every link and field in the demo (W3C failure F78). 2.4.7 (AA)Focus Visible High :focus-visible { outline: 3px solid #1a56db; outline-offset: 2px; } Keyboard: computed outline was none on all 10 stops before and a 3 px solid ring on every stop after; screenshots. Ring contrast in the table below.
8 After a failed submit the only sign of an error is a red border on the field. No text says what is wrong. 3.3.1 (A)Error Identification1.4.1 (A)Use of Color High A text message above each field in error, with a hidden “Error:” prefix for screen readers, aria-invalid="true" and aria-describedby. The thicker border and left bar stay as a second cue. An error summary lists every problem with a link to the field. Browser: an empty submit gave 4 red borders and no text before; 4 messages, 4 aria-invalid fields and a summary after.
9 When the email or date is in the wrong format, nothing tells the user how to correct it. 3.3.3 (AA)Error Suggestion Medium Messages that say how to fix it: “Enter an email address in the correct format, like [email protected]”, “Enter the date as day, month and year, for example 14/05/2027”. Browser: typed sam@ and 31/02/2027; both messages appeared.
10 The result of pressing “Book now” (errors or the thank-you text) appears silently. Screen reader users are not told anything happened. 4.1.3 (AA)Status Messages Medium On errors, focus moves to the error summary, so it is read at once. The thank-you message is written into a role="status" region, which is announced without moving focus (W3C technique ARIA22). Browser: focus landed on “There are 4 problems”; the confirmation appeared in the status region shown in the accessibility tree. Not yet heard on a screen reader (see limits).
11 Text below 4.5:1: help text 2.52:1, links 2.64:1 (2.43:1 on the offer panel), button text 2.02:1, placeholders 1.78:1. 1.4.3 (AA)Contrast (Minimum) Medium New colours, every pair 7:1 or higher. No placeholders. Computed with the WCAG formula (table below).
12 Field borders are #e3e3e3 on white, 1.28:1, so the fields are hard to find. 1.4.11 (AA)Non-text Contrast Medium #767676 borders, 4.54:1 on white. Computed with the WCAG formula.
13 The offer’s close button is 16 × 16 CSS px and sits 1 px from the “See details” link. A 24 px circle centred on it overlaps the link by 3 px, so the spacing exception does not apply. Its only name is “×”, and closing the offer deletes the focused button without moving focus anywhere. 2.5.8 (AA)Target Size (Minimum) Medium A 32 × 32 px button at the end of the panel, named “Dismiss the service week offer”. After closing, focus moves to the page heading. Measured in the browser with getBoundingClientRect: 16 × 16 with a 1 px gap before; 32 × 32 after. Keyboard: Enter on the button hid the offer and focus moved to “Tidewell Bikes”.
14 The page has no lang attribute, so screen readers guess the language. 3.1.1 (A)Language of Page Medium <html lang="en"> Automated check.
15 Name and email fields have no autocomplete values, so browsers cannot fill them in reliably. 1.3.5 (AA)Identify Input Purpose Low autocomplete="name" and autocomplete="email". Automated check.
16 Headings jump from h1 to h4. This is not a failure by itself; W3C technique G141 advises nesting headings in order so the outline makes sense. AdvisoryG141, related to 1.3.1 Low h2 for the page sections, h3 inside the error summary. Automated check; accessibility tree.

Contrast, with the numbers

Ratios computed with the WCAG 2.2 relative luminance formula. Text needs 4.5:1; field borders and focus indicators need 3:1.
ElementBeforeAfterNeeds
Help and hint text#a3a3a3 on #ffffff: 2.52:1#595959 on #ffffff: 7.00:14.5:1
Links#58a6e0 on #ffffff: 2.64:1
#58a6e0 on #fff4e0: 2.43:1
#1d5c4b on #ffffff: 7.82:14.5:1
Button text#ffffff on #f6a623: 2.02:1#ffffff on #1d5c4b: 7.82:14.5:1
Placeholder text#c2c2c2 on #ffffff: 1.78:1No placeholders; labels #1f2328: 15.80:14.5:1
Error textNone (red border only)#b3261e on #ffffff: 6.54:14.5:1
Field border#e3e3e3 on #ffffff: 1.28:1#767676 on #ffffff: 4.54:13:1
Focus indicatorNone#1a56db on #ffffff: 6.18:1
#1a56db on #fff4e0: 5.67:1
3:1

Worked example: help text #a3a3a3 on white

Each channel is 163, so the sRGB value is 163 / 255 = 0.6392. It is above 0.04045, so it is linearised as ((0.6392 + 0.055) / 1.055)2.4 = 0.3663. For a grey all three channels match, so the relative luminance is L = 0.2126 × 0.3663 + 0.7152 × 0.3663 + 0.0722 × 0.3663 = 0.3663. White has L = 1. The contrast ratio is (1 + 0.05) / (0.3663 + 0.05) = 2.52:1, below the 4.5:1 minimum. The replacement #595959 gives L = 0.0999 and (1.05) / (0.1499) = 7.00:1.

Keyboard walk-through

Automated check

A small static check we wrote for this sample in Node.js, using only built-in modules. It reads each page and its stylesheet and reports what a static check can see: language, title, text alternatives, field labels, autocomplete, link text, heading order, non-native buttons, live regions, contrast from the CSS, focus styles and target sizes. Read the script.

$ node check.mjs before.html after.html
== before.html: 23 fail, 7 warn, 10 pass, 0 info
== after.html: 0 fail, 0 warn, 37 pass, 0 info
Full output for both pages
$ node check.mjs before.html after.html
== before.html: 23 fail, 7 warn, 10 pass, 0 info
FAIL  3.1.1                   <html> has no lang attribute
PASS  2.4.2                   <title> is "Book a service - Tidewell Bikes (intentionally inaccessible demo)"
FAIL  1.1.1                   <img src="demo-logo.svg"> has no alt attribute
FAIL  1.1.1                   <img src="demo-workshop.svg"> has no alt attribute
FAIL  1.3.1/4.1.2             <input#name> has no programmatic label
FAIL  1.3.5                   <input#name> collects "name" without an autocomplete value
FAIL  3.3.2                   <input#email> has only a placeholder ("Email *") and no label
FAIL  1.3.5                   <input#email> collects "email" without an autocomplete value
FAIL  1.3.1/4.1.2             <select#service> has no programmatic label
FAIL  3.3.2                   <input#date> has only a placeholder ("Preferred date *") and no label
FAIL  1.3.1/4.1.2             <textarea#notes> has no programmatic label
FAIL  2.4.4/4.1.2             link to /work/wcag-remediation/before has no accessible name
FAIL  2.4.4                   "click here" (to #hours) shares its paragraph with another vague link, so context cannot tell them apart
FAIL  2.4.4                   "here" (to #prices) shares its paragraph with another vague link, so context cannot tell them apart
WARN  4.1.2                   <button.close.js-close> is named only "×"
WARN  1.3.1 (advisory, G141)  <h4> "Book a service" follows <h1>, skipping a level
FAIL  2.1.1/4.1.2             <div.btn.js-book> "Book now" looks like a button but is not one: no button role and not in the tab order
WARN  4.1.3                   form has no live region (role=status/alert or aria-live); check how results are announced
WARN  3.3.1                   <input#name> has no error text tied to it by aria-describedby
WARN  3.3.1                   <input#email> has no error text tied to it by aria-describedby
WARN  3.3.1                   <select#service> has no error text tied to it by aria-describedby
WARN  3.3.1                   <input#date> has no error text tied to it by aria-describedby
PASS  1.4.3                   body: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
PASS  1.4.3                   .demo-banner: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS  1.4.3                   .demo-banner a: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS  1.4.3                   .promo: text #333333 on #fff4e0 = 11.59:1 (needs 4.5:1)
FAIL  1.4.3                   .promo a: text #58a6e0 on #fff4e0 = 2.43:1 (needs 4.5:1)
PASS  1.4.3                   .close: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
FAIL  1.4.3                   a: text #58a6e0 on #ffffff = 2.64:1 (needs 4.5:1)
PASS  1.4.3                   input: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
PASS  1.4.3                   select: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
PASS  1.4.3                   textarea: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
FAIL  1.4.3                   input::placeholder: text #c2c2c2 on #ffffff = 1.78:1 (needs 4.5:1)
FAIL  1.4.3                   textarea::placeholder: text #c2c2c2 on #ffffff = 1.78:1 (needs 4.5:1)
FAIL  1.4.3                   .help: text #a3a3a3 on #ffffff = 2.52:1 (needs 4.5:1)
FAIL  1.4.3                   .btn: text #ffffff on #f6a623 = 2.02:1 (needs 4.5:1)
PASS  1.4.3                   .msg.ok: text #2e7d32 on #ffffff = 5.13:1 (needs 4.5:1)
FAIL  1.4.11                  form field border #e3e3e3 on #ffffff = 1.28:1 (needs 3:1)
FAIL  2.4.7                   .shop *:focus sets outline: none and no general :focus-visible rule replaces it
FAIL  2.5.8                   .close is 16 x 16 CSS px (under 24 x 24; spacing exception checked in the browser)

== after.html: 0 fail, 0 warn, 37 pass, 0 info
PASS  3.1.1                   <html lang="en">
PASS  2.4.2                   <title> is "Book a service - Tidewell Bikes (fixed demo)"
PASS  1.1.1                   <img src="demo-logo.svg"> alt="Tidewell Bikes home"
PASS  1.1.1                   <img src="demo-workshop.svg"> alt="Drawing of a bicycle on a repair stand in the Tidewell workshop"
PASS  1.3.1/4.1.2             <input#name> is labelled by label[for]: "Full name"
PASS  1.3.5                   <input#name> autocomplete="name"
PASS  1.3.1/4.1.2             <input#email> is labelled by label[for]: "Email address"
PASS  1.3.5                   <input#email> autocomplete="email"
PASS  1.3.1/4.1.2             <select#service> is labelled by label[for]: "Service"
PASS  1.3.1/4.1.2             <input#date> is labelled by label[for]: "Preferred date"
PASS  1.3.1/4.1.2             <textarea#notes> is labelled by label[for]: "Tell us about your bike (optional)"
PASS  4.1.2                   <button.close.js-close> is named "Dismiss the service week offer"
PASS  4.1.2                   <button.btn> is named "Book now"
PASS  4.1.3                   live region present: <div#status.status>
PASS  3.3.1                   <input#name> is described by an error message container <p#name-error.error>
PASS  3.3.1                   <input#email> is described by an error message container <p#email-error.error>
PASS  3.3.1                   <select#service> is described by an error message container <p#service-error.error>
PASS  3.3.1                   <input#date> is described by an error message container <p#date-error.error>
PASS  1.4.3                   body: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS  1.4.3                   .skip-link: text #1d5c4b on #ffffff = 7.82:1 (needs 4.5:1)
PASS  1.4.3                   .demo-banner: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS  1.4.3                   .demo-banner a: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS  1.4.3                   a: text #1d5c4b on #ffffff = 7.82:1 (needs 4.5:1)
PASS  1.4.3                   .promo: text #1f2328 on #fff4e0 = 14.50:1 (needs 4.5:1)
PASS  1.4.3                   .close: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS  1.4.3                   .hint: text #595959 on #ffffff = 7.00:1 (needs 4.5:1)
PASS  1.4.3                   .help: text #595959 on #ffffff = 7.00:1 (needs 4.5:1)
PASS  1.4.3                   input: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS  1.4.3                   select: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS  1.4.3                   textarea: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS  1.4.3                   .error: text #b3261e on #ffffff = 6.54:1 (needs 4.5:1)
PASS  1.4.3                   .error-summary a: text #b3261e on #ffffff = 6.54:1 (needs 4.5:1)
PASS  1.4.3                   .btn: text #ffffff on #1d5c4b = 7.82:1 (needs 4.5:1)
PASS  1.4.3                   .status.ok: text #17683c on #ffffff = 6.81:1 (needs 4.5:1)
PASS  1.4.11                  form field border #767676 on #ffffff = 4.54:1 (needs 3:1)
PASS  2.4.7                   :focus-visible { outline: 3px solid #1a56db }; ring #1a56db on #ffffff = 6.18:1
PASS  2.5.8                   .close is 32 x 32 CSS px

Limits of this sample

Sources

Success criterion names, numbers and levels were checked against the W3C pages below on 29 September 2026.

All work samples