Skip to content
Fondazione ITS INCOM

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:

  1. rules in Python, with the parser of the standard library, for what is certain;
  2. a model, with a JSON schema, for what needs judgement, with an explanation a first-year student can follow;
  3. 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

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

Exercise

All examples