Examples · Web Development
An accessibility review of a page
An HTML page with typical accessibility problems is checked twice. Rules in Python find what is certain, the contrast ratio computed by code included; a model finds what needs judgement and explains each problem for a student. The program locates every quote of the model in the page and compares the two lists.
Intermediate · 35 min
The idea
Some accessibility problems are facts: an <img> has an alt attribute or it does not; grey #b0b0b0 on white has a contrast of 2.17:1, and the minimum for normal text (WCAG level AA) is 4.5:1. A rule finds these the same way every time.
Other problems need judgement: is alt="IMG_2041.jpg" a description? Does “Click here” say where the link goes? A rule can find that an alt exists, not whether it is useful. A model reads the page more as a person does.
So the program does both, and compares:
- rules in Python, with the parser of the standard library, for what is certain;
- a model, with a JSON schema, for what needs judgement, with an explanation a first-year student can follow;
- a comparison: what both found, what only the rules found, what only the model found.
What you need
A key and the openai library, as in Your first API call in Python. The rules use html.parser, which comes with Python.
The page
Save it as page.html. It is the booking page of a bike workshop in Legnano, and it has about ten problems. Find them before you run anything.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ciclofficina Olona - Book a repair</title>
</head>
<body>
<img src="logo.png">
<h1>Ciclofficina Olona</h1>
<p style="color: #b0b0b0; background-color: #ffffff">Open Tuesday to Saturday, 9:00-12:30 and 15:00-19:00</p>
<p style="color: #767676; background-color: #ffffff">Via Novara 12, Legnano</p>
<h3>Book a repair</h3>
<img src="workshop.jpg" alt="IMG_2041.jpg">
<form action="/book" method="post">
<input type="text" name="name" placeholder="Your name">
<label for="phone">Phone</label>
<input type="tel" id="phone" name="phone">
<label>Bike <select name="bike"><option>City bike</option><option>E-bike</option></select></label>
<p>The fields in red are required.</p>
<div class="button" onclick="document.forms[0].submit()">Book now</div>
</form>
<h2>Prices</h2>
<p>Puncture repair 12 euro, full service 45 euro. <a href="/prices.pdf">Click here</a> for the full list.</p>
<a href="https://www.instagram.com/ciclofficina.olona"><img src="instagram.png"></a>
</body>
</html>
The program
Save it as a11y_review.py, next to the page. (a11y is short for “accessibility”: an a, 11 letters, a y.)
import json
import os
import re
import sys
from html.parser import HTMLParser
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
KINDS = ["missing_lang", "missing_alt", "useless_alt", "missing_label", "low_contrast",
"not_a_button", "skipped_heading", "vague_link", "color_only", "other"]
def luminance(color):
"""Relative luminance of a #rrggbb color, as WCAG 2 defines it."""
r, g, b = (int(color[i:i + 2], 16) / 255 for i in (1, 3, 5))
r, g, b = (c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in (r, g, b))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(fg, bg):
light, dark = sorted([luminance(fg), luminance(bg)], reverse=True)
return (light + 0.05) / (dark + 0.05)
class Rules(HTMLParser):
"""What is certain: each rule looks at the tags and gives the same answer every time."""
def __init__(self):
super().__init__()
self.found = [] # (line, kind, detail)
self.fields = [] # (line, tag, id) of the fields with no label around them
self.label_for = set()
self.in_label = 0
self.heading = 0
def handle_starttag(self, tag, attrs):
a = dict(attrs)
line = self.getpos()[0]
if tag == "html" and not a.get("lang"):
self.found.append((line, "missing_lang", "<html> has no lang"))
if tag == "img" and "alt" not in a:
self.found.append((line, "missing_alt", f"<img src=\"{a.get('src')}\"> has no alt"))
if re.fullmatch(r"h[1-6]", tag):
level = int(tag[1])
if level > self.heading + 1:
self.found.append((line, "skipped_heading", f"<{tag}> after <h{self.heading}>"))
self.heading = level
if tag == "label":
self.in_label += 1
if a.get("for"):
self.label_for.add(a["for"])
if tag in ("input", "select", "textarea") and a.get("type") not in ("hidden", "submit", "button"):
if not (self.in_label or "aria-label" in a or "aria-labelledby" in a):
self.fields.append((line, tag, a.get("id")))
if "onclick" in a and tag not in ("button", "a", "input"):
self.found.append((line, "not_a_button", f"<{tag}> with onclick: no keyboard, no role"))
colors = dict(re.findall(r"(?:^|;)\s*(color|background-color)\s*:\s*(#[0-9a-fA-F]{6})", a.get("style") or ""))
if len(colors) == 2:
ratio = contrast(colors["color"], colors["background-color"])
if ratio < 4.5:
self.found.append((line, "low_contrast", f"contrast {ratio:.2f}:1, at least 4.5:1 needed"))
def handle_endtag(self, tag):
if tag == "label":
self.in_label -= 1
def results(self):
# A <label for="..."> can come after its field, so the fields are checked at the end.
for line, tag, field_id in self.fields:
if field_id not in self.label_for:
self.found.append((line, "missing_label", f"<{tag}> has no label"))
return sorted(self.found)
path = sys.argv[1]
model = sys.argv[2] if len(sys.argv) > 2 else "qwen3.8-27b"
page = open(path, encoding="utf-8").read()
rules = Rules()
rules.feed(page)
certain = rules.results()
schema = {
"type": "object",
"properties": {
"problems": {
"type": "array",
"items": {
"type": "object",
"properties": {
"quote": {"type": "string"}, # copied from the page: the program finds its line
"kind": {"type": "string", "enum": KINDS},
"explanation": {"type": "string"},
"fix": {"type": "string"},
},
"required": ["quote", "kind", "explanation", "fix"],
},
},
},
"required": ["problems"],
}
answer = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": "You review web pages for accessibility (WCAG 2.2, level AA). List each "
"problem once. In quote, copy exactly the tag that has the problem, as "
"it is in the page. In explanation, write one or two plain sentences "
"for a first-year web development student: who has trouble, and why. "
"In fix, give the corrected HTML."},
{"role": "user", "content": page},
],
response_format={"type": "json_schema", "json_schema": {"name": "a11y", "schema": schema}},
)
if answer.choices[0].finish_reason != "stop":
sys.exit(f"The answer was cut off ({answer.choices[0].finish_reason}): run it again.")
problems = json.loads(answer.choices[0].message.content)["problems"]
print("The rules:")
for line, kind, detail in certain:
print(f" line {line:2} {kind}: {detail}")
print(f"The model ({model}):")
judged = []
for p in problems:
where = page.find(p["quote"].strip())
if where < 0 or not p["quote"].strip():
print(f" NOT IN THE PAGE: «{p['quote']}» {p['kind']}")
continue
line = page[:where].count("\n") + 1
judged.append((line, p["kind"]))
print(f" line {line:2} {p['kind']}: {p['explanation']}")
print(f" fix: {p['fix']}")
ours = {(line, kind) for line, kind, _ in certain}
theirs = set(judged)
print("Both: ", ", ".join(f"{k} {l}" for l, k in sorted(ours & theirs)) or "-")
print("Rules only: ", ", ".join(f"{k} {l}" for l, k in sorted(ours - theirs)) or "-")
print("Model only: ", ", ".join(f"{k} {l}" for l, k in sorted(theirs - ours)) or "-")
Run it with python3 a11y_review.py page.html, or name another model: python3 a11y_review.py page.html gemma-4-26b. Qwen3.8 thinks before it answers, and took about 35 seconds in our runs; Gemma took 7 to 9.
What you should see
A run of qwen3.8-27b, with one long fix shortened with …:
The rules:
line 2 missing_lang: <html> has no lang
line 8 missing_alt: <img src="logo.png"> has no alt
line 10 low_contrast: contrast 2.17:1, at least 4.5:1 needed
line 12 skipped_heading: <h3> after <h1>
line 15 missing_label: <input> has no label
line 20 not_a_button: <div> with onclick: no keyboard, no role
line 24 missing_alt: <img src="instagram.png"> has no alt
The model (qwen3.8-27b):
line 2 missing_lang: Screen readers and other assistive technologies need to know the page language to pronounce words correctly. Without a lang attribute, a screen reader may mispronounce Italian words or fail to switch its speech synthesis voice.
fix: <html lang="it">
line 8 missing_alt: A blind user using a screen reader gets no information about this image at all. The logo should be described, or marked as decorative if it adds no meaning.
fix: <img src="logo.png" alt="Ciclofficina Olona logo">
line 10 low_contrast: The text color #b0b0b0 on a white background gives a contrast ratio of about 2.2 to 1, well below the 4.5 to 1 minimum for normal text. Users with low vision or in bright environments will struggle to read the opening hours.
fix: <p style="color: #595959; background-color: #ffffff">…</p>
line 13 useless_alt: The alt text is just a camera filename, so a screen reader user hears "IMG 2041 dot JPG" instead of learning what the picture shows. The alt text should describe the image's purpose or content.
fix: <img src="workshop.jpg" alt="Inside the Ciclofficina Olona workshop with workbenches and tools">
line 15 missing_label: The name field has no <label> element, only a placeholder. Placeholders disappear when the user starts typing, and some screen readers do not announce them reliably, so the user has no persistent way to know what the field is for.
fix: <label for="name">Name</label>
<input type="text" id="name" name="name">
line 20 not_a_button: A <div> is not a button: it cannot be focused with the Tab key and screen readers will not announce it as a button. A keyboard-only user cannot activate the form submission at all.
fix: <button type="submit">Book now</button>
line 24 missing_alt: The image inside the link has no alt text, so a screen reader user hears nothing when navigating to this link. They cannot tell what the link leads to.
fix: <a href="https://www.instagram.com/ciclofficina.olona"><img src="instagram.png" alt="Ciclofficina Olona on Instagram"></a>
line 23 vague_link: The link text "Click here" does not tell the user where the link goes. A screen reader user who jumps to a list of links will hear "Click here" with no context about the destination.
fix: <a href="/prices.pdf">Full price list (PDF)</a>
Both: missing_lang 2, missing_alt 8, low_contrast 10, missing_label 15, not_a_button 20, missing_alt 24
Rules only: skipped_heading 12
Model only: useless_alt 13, vague_link 23
The rules print the same seven lines every time. The model's list changes from run to run.
Who found what
Four runs per model, on the same page:
| Problem | Rules | qwen3.8-27b |
gemma-4-26b |
|---|---|---|---|
No lang (line 2) |
always | 2 of 4 | 0 of 4 |
Logo with no alt (8) |
always | 4 of 4 | 4 of 4 |
| Grey on white, 2.17:1 (10) | always | 4 of 4 | 4 of 4 |
<h3> after <h1> (12) |
always | 1 of 4 | 0 of 4 |
| Name field with only a placeholder (15) | always | 4 of 4 | 4 of 4 |
<div> used as a button (20) |
always | 4 of 4 | 4 of 4 |
Instagram image link with no alt (24) |
always | 4 of 4 | 4 of 4 |
alt="IMG_2041.jpg" (13) |
never | 4 of 4 | 4 of 4 |
| “Click here” (23) | never | 4 of 4 | 3 of 4 |
| “The fields in red are required” (19) | never | 2 of 4 | 0 of 4 |
Line 11 is a trap: #767676 on white is 4.54:1, just above the minimum. The rules let it pass, and so did both models, in all eight runs.
What to notice
- The models skip what is missing. A missing
langis an attribute that is not there, a skipped heading is a tag that is not there; in eight runs, the models named the missinglangtwice and the skipped heading once. The rules never miss them, because missing is exactly what a rule checks. - The rules cannot judge.
alt="IMG_2041.jpg"passes the rule: the attribute is there. It takes a reader to see that a file name is not a description. Both models saw it in every run. - The kind is a judgement too. Qwen3.8 called the file-name
altamissing_altin two runs, and once filed “the fields in red” asuseless_alt. The explanation was right each time, the label was not. That is why the comparison showsmissing_alt 13as “model only” in those runs: the program compares kinds, and a rule knows thealtis there. - A number from a model is an estimate. Qwen3.8 wrote “about 2.2 to 1” and, in another run, “about 2.1 : 1”. Gemma gave no number. The code computes 2.17 with the WCAG formula, every time. When a number decides pass or fail, as at 4.54, compute it.
- A fix is a proposal. In one run Qwen3.8 proposed
<html lang="en">, in another<html lang="it">. The page is in English, soenis right: the model probably guessed from the Italian names. Read every fix before you paste it. - What nobody saw. “The fields in red are required” relies on colour alone, and Qwen3.8 flagged it in two runs. But nothing on the page is red: the sentence points at a colour that is not there. No model noticed that, and no rule can.
Why the program looks for the quote
The model does not give line numbers: it copies the tag, and the program looks for it with page.find. If the quote is not in the page, the finding is printed as NOT IN THE PAGE and left out of the comparison. In our eight runs every quote was found. The check costs one line, and it is the reason the comparison can trust the line numbers: they come from the page, not from the model.
Good to know
- Rules are not a full audit. These see only inline styles with
#rrggbbcolours, not your CSS files; they do not know thatalt=""is right for a decorative image and wrong for a photo. Tools such as axe or Lighthouse have many more rules; none of them replaces a person with a screen reader. - The logo. Both models wanted
alt="Ciclofficina Olona logo", in all eight runs. Right next to it, the<h1>says “Ciclofficina Olona”, so a screen reader would say the name twice.alt=""(decorative) is also defensible. That is a judgement, and now it is yours. - Keep the rules first in a pipeline. They are fast, free and the same every time: run them on every commit, and ask the model only when the page changes.
Exercise
- Fix the page until the rules print nothing, then run the model again. What does it still find?
- Change
#767676on line 11 to#777777. What do the rules say now? Do the models notice the difference? - Write a rule for the file-name
alt: analtthat ends in.jpg,.pngor.webp. Which other uselessalttexts (“image”, “photo”, “logo”) would you add, and where does a rule stop being enough? - The parser gives every tag in order. Write a rule for links whose text is “click here”, “here” or “read more”. Is it a rule or a judgement?
