●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Automating Accessibility Audits with the Gemini API — A Design That Survives False Positives
Build a WCAG 2.2 audit pipeline with the Gemini API. Separate what can be measured from what needs judgment, then use structured output, baseline diffing, and tiered CI gates to keep false positives from killing the practice.
I still remember the night I first pointed a Gemini-powered accessibility audit at a site I run as an indie developer.
The JSON came back with thirty-seven violations. My pulse picked up. Then I started checking them one by one in the browser. A contrast ratio the model reported as 3.1:1 measured 7.2:1 in reality — perfectly compliant. Meanwhile, a focus ring that genuinely dissolved into the background appeared nowhere in the report.
The model seemed to be looking at the screen. It was not measuring pixels.
What follows is the pipeline I rebuilt from that failure. The core idea is a single sentence: never ask a language model to compute something a formula already answers. Hold that line, and your audit output stops being a curiosity and starts being something you can gate a build on.
What the Model Must Never Measure, and What It Should Judge
WCAG success criteria fall into two very different families. Some have a formula that produces exactly one answer. Others require reading context.
Hand the first family to an LLM and you will get a plausible-looking number. It is not a measurement — it is a generation. The second family, on the other hand, is precisely where rule-based tools fall down, and precisely where Gemini earns its keep.
Layer
Owner
Example criteria
Basis for the verdict
Deterministic
Playwright + arithmetic
1.4.3 contrast / 2.5.8 target size / 1.1.1 presence of alt / 3.1.1 lang
Computed style and bounding boxes, resolved by formula
Judgment
Gemini API
1.1.1 usefulness of alt text / 2.4.4 link purpose / 1.3.1 semantic heading structure / 1.4.1 color as sole cue
Requires meaning and context; no formula exists
Advisory
Gemini (screenshots)
Visual oddities, suspected layout breakage
Treated only as a flag that queues a human review
Take 1.1.1 Non-text Content. Whether an alt attribute exists is a one-line DOM query. Whether alt="image1" actually serves as a text alternative is unanswerable without surrounding context. Sending the first question to Gemini and the second to a regular expression is the most common way this architecture goes wrong.
It is exactly the hole I fell into first.
WCAG 2.2 and the Honest Limits of Automation
WCAG 2.2 defines more than eighty success criteria, and machines cannot settle all of them. Each of the four principles has a different affinity for automation.
Principle
Representative criteria
Deterministic coverage
Where AI helps
Perceivable
Contrast, text alternatives
High
Judging the quality of alt text
Operable
Keyboard access, target size
Moderate
Assessing focus order sanity
Understandable
Readability, input assistance
Low
Assessing how specific error messages are
Robust
Markup conformance
High
Rarely needed
Rule-based tooling reliably resolves roughly 30-40% of the criteria. The rest is human territory.
The point of adding Gemini is to convert part of that remainder into machine-readable findings — not to replace the checks that axe already performs correctly. Blur that boundary and you turn deterministic results into probabilistic ones.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How to split the pipeline into a deterministic layer and a judgment layer so contrast ratios are never guessed by a language model
✦A response_schema and temperature 0.0 setup that stops audit JSON from drifting between runs, making diffs meaningful
✦Baseline fingerprinting and tiered CI exit codes that surface new violations without letting false positives block every build
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Read the key from the environment in real deployments. If the audited page sits behind a login, persist an authenticated session with Playwright's storage_state so CI reproduces the same screen your reviewers see.
The Deterministic Layer — Compute the Contrast Ratio
WCAG defines the formula. Compute relative luminance, then take (L1 + 0.05) / (L2 + 0.05) with the lighter value on top. There is nothing to guess.
The real difficulty is obtaining the background color. getComputedStyle usually returns rgba(0, 0, 0, 0) — fully transparent. You have to walk up the ancestor chain until you hit the first opaque background.
CONTRAST_PROBE = """() => { const srgb = (c) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; const luminance = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b); const parse = (s) => (s.match(/[\\d.]+/g) || []).map(Number); // Resolve transparent backgrounds by walking ancestors. // Skip this and every element looks like it sits on white. const effectiveBg = (el) => { let node = el; while (node && node !== document.documentElement) { const c = parse(getComputedStyle(node).backgroundColor); if (c.length >= 3 && (c[3] === undefined || c[3] > 0)) return c.slice(0, 3); node = node.parentElement; } return [255, 255, 255]; }; const ratio = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); }; const out = []; for (const el of document.querySelectorAll('p, a, span, li, button, h1, h2, h3, h4, label')) { const text = (el.innerText || '').trim(); if (!text) continue; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || cs.display === 'none') continue; const size = parseFloat(cs.fontSize); const bold = parseInt(cs.fontWeight, 10) >= 700; // "Large text" is >= 24px, or >= 18.66px when bold const isLarge = size >= 24 || (size >= 18.66 && bold); const required = isLarge ? 3.0 : 4.5; const r = ratio(parse(cs.color).slice(0, 3), effectiveBg(el)); if (r < required) { out.push({ selector: el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''), text: text.slice(0, 60), ratio: Math.round(r * 100) / 100, required, font_px: size, }); } } return out;}"""def measure_contrast(url: str) -> list[dict]: """Measure contrast ratios. No AI involved, at any point.""" with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page(viewport={"width": 1280, "height": 720}) page.goto(url, wait_until="networkidle") violations = page.evaluate(CONTRAST_PROBE) browser.close() return violations
Every ratio this returns is a measurement. Run it a hundred times and the numbers do not move. The phantom "3.1:1" that misled me on night one simply cannot appear.
Target size falls out the same way. WCAG 2.2's criterion 2.5.8 asks for a minimum of 24×24 CSS pixels, so getBoundingClientRect() settles it.
TARGET_SIZE_PROBE = """() => { const MIN = 24; const out = []; for (const el of document.querySelectorAll('a, button, input, select, [role="button"]')) { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; // skip hidden elements if (r.width < MIN || r.height < MIN) { out.push({ selector: el.tagName.toLowerCase(), label: (el.innerText || el.getAttribute('aria-label') || '').slice(0, 40), width: Math.round(r.width), height: Math.round(r.height), }); } } return out;}"""
Output from this layer is CI-gate material. Either the number cleared the threshold or it did not.
I recommend running these two probes first, before anything else in the pipeline. Once they are settled, a questionable finding downstream never undermines the ground you are standing on.
The Judgment Layer — Ask Gemini About Meaning
Send Gemini only the questions arithmetic cannot answer. Python already confirmed that alt exists, so the question becomes whether that alt functions as an alternative.
And constrain the shape of the answer with response_schema, not with a sentence in the prompt. "Return JSON" produces JSON whose keys and nesting drift from run to run. Drifting output destroys the baseline diff described later.
class Violation(BaseModel): criterion: str = Field(description="WCAG success criterion number, e.g. 1.1.1") severity: Literal["critical", "major", "minor"] selector: str = Field(description="A CSS selector that locates the problem") excerpt: str = Field(description="Markup excerpt, 120 characters or fewer") reason: str = Field(description="Why this fails the criterion") fix: str = Field(description="A concrete fix, as a code fragment where possible")class SemanticAudit(BaseModel): violations: list[Violation] uncertain: list[str] = Field(description="Places a human should verify")SEMANTIC_PROMPT = """You are a WCAG 2.2 auditor.Inspect the HTML below for **only those criteria that require reading context**.Check:- 2.4.4 Link Purpose: link text such as "here" or "details" that hides its destination- 1.1.1 Alt text quality: non-empty alt that fails to describe the image (for example alt="image1", alt="photo")- 1.3.1 Info and Relationships: elements that look like headings but are not h1-h6, or look like tables but are not table- 3.3.2 Labels: form labels that do not explain what the control does- 1.4.1 Use of Color: meaning conveyed by color alone, e.g. "red fields are required"Do NOT check (already measured elsewhere):- contrast ratio, element dimensions, presence of alt, presence of langIf you are not confident, put the item in `uncertain` rather than `violations`.Never invent a violation to fill the list.HTML:{html}"""def audit_semantics(clean_html: str) -> SemanticAudit: response = client.models.generate_content( model="gemini-2.5-flash", contents=SEMANTIC_PROMPT.format(html=clean_html), config=types.GenerateContentConfig( temperature=0.0, # auditing needs no creativity seed=42, # further suppress run-to-run drift response_mime_type="application/json", response_schema=SemanticAudit, ), ) return SemanticAudit.model_validate_json(response.text)
The uncertain field turned out to be the single highest-leverage decision in this design.
Without it, the model pushes low-confidence guesses into violations because that is the only container available. Give it an exit and false positives drop noticeably. Telling a model that "I don't know" is an acceptable answer works about as well as it does with people.
You will often see the HTML truncated at the first 30,000 characters. That silently drops the footer and any form near the bottom of the page — and nothing tells you it happened.
Split by landmark instead, and audit each region independently.
LANDMARKS = ["header", "nav", "main", "aside", "footer", "form"]MAX_CHARS = 12000def split_by_landmark(page) -> list[tuple[str, str]]: """Chunk HTML per landmark; split an oversized main further by section.""" chunks: list[tuple[str, str]] = [] for tag in LANDMARKS: for i, handle in enumerate(page.query_selector_all(tag)): html = handle.evaluate("el => el.outerHTML") if len(html) <= MAX_CHARS: chunks.append((f"{tag}[{i}]", html)) continue for j, sub in enumerate(handle.query_selector_all("section, article")): sub_html = sub.evaluate("el => el.outerHTML") chunks.append((f"{tag}[{i}]>section[{j}]", sub_html[:MAX_CHARS])) return chunks
Chunking multiplies your API calls. But smaller chunks sharpen the findings, and you get the landmark of origin recorded for free. At roughly five chunks per page, gemini-2.5-flash keeps the cost unremarkable.
Screenshot Auditing — Useful, If You Stop Trusting It for Numbers
Visual auditing is worth keeping. What is not worth keeping is asking the model to read numbers off an image.
Where screenshots shine is capturing states the deterministic layer struggles to express — the focus ring immediately after a Tab press, for instance.
def _focus_clip(page, pad: int = 24) -> dict: """Return a rectangle around the currently focused element.""" box = page.evaluate( "() => { const r = document.activeElement.getBoundingClientRect();" " return {x: r.x, y: r.y, width: r.width, height: r.height}; }" ) return { "x": max(box["x"] - pad, 0), "y": max(box["y"] - pad, 0), "width": box["width"] + pad * 2, "height": box["height"] + pad * 2, }def capture_focus_states(url: str, steps: int = 8) -> list[bytes]: """Tab through the page, photographing each focus state.""" shots: list[bytes] = [] with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page(viewport={"width": 1280, "height": 720}) page.goto(url, wait_until="networkidle") for _ in range(steps): page.keyboard.press("Tab") page.wait_for_timeout(120) # let transition animations settle focused = page.evaluate("() => document.activeElement?.tagName") if not focused or focused == "BODY": break shots.append(page.screenshot(clip=_focus_clip(page))) browser.close() return shotsdef review_focus_visibility(shots: list[bytes]) -> str: parts = [types.Part.from_bytes(data=s, mime_type="image/png") for s in shots] parts.append( "Each image is a close-up of a focus position reached by pressing Tab. " "For each image, answer whether the focused element's outline is visually " "distinguishable from the background: 'clear', 'faint', or 'invisible'. " "Add one sentence of reasoning. Do not estimate contrast ratios." ) response = client.models.generate_content( model="gemini-2.5-pro", contents=parts, config=types.GenerateContentConfig(temperature=0.0), ) return response.text
That last prompt sentence carries the weight. Forbid numeric estimation and the model settles into the three-way judgment you actually asked for.
Crucially, this output is never recorded as a violation. It is a flag that queues human review. Keeping those two categories apart is what preserves the report's credibility.
Suppressing False Positives — Baselines and Expiring Waivers
The moment you automate auditing on an existing site, dozens of findings arrive at once. Keeping CI red until every one is fixed is not realistic. Ignoring them all means you never notice the newly introduced ones either.
So fingerprint each violation stably, and look only at the delta against a known set.
def fingerprint(v: dict) -> str: """A fingerprint immune to line-number and wording jitter.""" key = f"{v['criterion']}|{v['selector']}|{v.get('reason', '')[:40]}" return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]def diff_against_baseline(current: list[dict], baseline_path: str) -> dict: try: with open(baseline_path, encoding="utf-8") as f: known = set(json.load(f)["fingerprints"]) except FileNotFoundError: known = set() seen = {fingerprint(v): v for v in current} new_ids = set(seen) - known fixed_ids = known - set(seen) return { "new": [seen[i] for i in new_ids], "fixed_count": len(fixed_ids), "total": len(seen), }
Only the first 40 characters of reason feed the hash. Use the full text and a single reworded phrase from the model spawns a phantom "new violation" on the next run.
You also need a waiver list. A decorative image with alt="" is a deliberate choice, and being told about it every build teaches people to stop reading the report.
# a11y-suppress.yml- criterion: "1.1.1" selector: "img.decorative" reason: "Decorative image; role=presentation already applied" expires: "2026-12-31" # waivers expire, so they never become permanent excuses
Making expires mandatory keeps the suppression list from growing without bound. Expired entries resurface as warnings instead of quietly disappearing from everyone's memory.
A waiver list is the fastest-rotting artifact in any production setup. One expiry field is enough to work around that decay.
Wiring It into CI — Which Failures Deserve a Red Build
Get the gate wrong and the team disables the audit. My first version failed the build on every finding. Within three days I was the one adding a skip flag.
Weight the failures differently.
Finding type
CI behavior
Why
New critical from the deterministic layer
Fail the build
It is a measurement; there is nothing to argue about
New finding from the judgment layer
Comment on the PR, do not fail
Probabilistic output should never halt development
The two-tier exit code is the whole trick. sys.exit(2) fires only when a contrast ratio or a target size crosses a threshold. Gemini's findings exit with 1, which the workflow absorbs via continue-on-error.
Since that change, nobody has opened a PR to disable the audit. When the build goes red, there is always a measured reason.
Finally, fold the measured values and the semantic findings into one Markdown report. This is where Gemini works with the grain.
REPORT_PROMPT = """Below are accessibility audit results for a single page.Write a Markdown report.Structure:1. Summary (three lines max: count of new violations, heaviest item only)2. New criticals (with measured values, as a table)3. New major / minor findings4. Items requiring human review (uncertain plus visual flags)5. Remaining known violations and the change since last runRules:- Copy measured values (contrast ratio, target size) verbatim. Never round them.- Never supply a number by inference.- Express fixes as CSS or HTML fragments for the affected element.Target: {url}Measurements: {measured}Semantic audit: {semantic}Delta: {diff}"""def build_report(url: str, measured: dict, semantic: dict, diff: dict) -> str: response = client.models.generate_content( model="gemini-2.5-flash", contents=REPORT_PROMPT.format( url=url, measured=json.dumps(measured, ensure_ascii=False)[:6000], semantic=json.dumps(semantic, ensure_ascii=False)[:6000], diff=json.dumps(diff, ensure_ascii=False)[:3000], ), config=types.GenerateContentConfig(temperature=0.2), ) return response.text
"Never round them." "Never supply a number by inference." Before those two lines existed, 7.21 became 7.2 in the report, then "about 7," and eventually a figure with no source at all.
The generation step is exactly where provenance goes to die. Defend it there.
The paradox that emerged is this: the narrower the slice you hand to the model, the more you can trust what it returns.
The day I moved contrast back into arithmetic, the quality of Gemini's findings visibly improved. Freed from being asked to measure things it cannot measure, the model concentrated on what it is genuinely good at — reading meaning.
And more than anything: the first time a report told me a focus ring was invisible, I realized I had never once navigated my own site using only a keyboard. Automation was not a way to do less work. It was a way to notice what I had been failing to see.
The next step I would suggest is small. Run only the two deterministic probes against a page you own. No API key required. You will likely find more than you expected.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.