I do marketing for HelloAI (https://tryhello.app), the app in this test. Insider test, not a neutral review. My earlier posts here were documents it wrote; this time I asked for a runnable thing: a WCAG color contrast checker as one self-contained HTML file. It produced the whole file in a single response. Below is that exact file, unchanged, plus everything I did to check it before publishing. Repairs from me: zero.
I picked a contrast checker deliberately: the correct answer is exact math, so a generated one can be verified instead of trusted.
The prompt
Build me a single-file HTML page (inline CSS and JS, no external libraries) that checks WCAG color contrast. Two hex color inputs (foreground and background), a live text preview in those colors, the contrast ratio rounded to 2 decimals, and pass/fail badges for AA normal text, AA large text, AAA normal, and AAA large. Add four preset color pairs I can click to load. Keep it compact, under 200 lines. Give me the complete file as code I can save and open.
The file
Save as contrast.html and open it in a browser:
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>WCAG Contrast Checker</title> <style> * { box-sizing: border-box; } body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; color: #17202a; background: #f2f4f7; } main { max-width: 540px; margin: auto; padding: 22px; background: white; border: 1px solid #d9dee5; border-radius: 12px; } h1 { margin: 0 0 4px; font-size: 1.35rem; } .hint { margin: 0 0 18px; color: #586575; font-size: .9rem; } .inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } label { display: block; font-weight: 650; } input[type="text"] { display: block; width: 100%; margin-top: 5px; padding: 9px; border: 1px solid #b8c1cc; border-radius: 6px; font: inherit; } .preview { margin: 16px 0; min-height: 92px; padding: 18px; border: 1px solid #d9dee5; border-radius: 8px; } .preview strong { display: block; font-size: 1.2rem; } .ratio { margin: 12px 0; font-size: 1.08rem; font-weight: 700; } .badges { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .badge { display: flex; justify-content: space-between; gap: 8px; padding: 9px 10px; border-radius: 6px; background: #f2f4f7; } .pass { color: #146c3a; font-weight: 700; } .fail { color: #a12622; font-weight: 700; } h2 { margin: 18px 0 8px; font-size: 1rem; } .presets { display: flex; flex-wrap: wrap; gap: 8px; } button { padding: 7px 10px; border: 1px solid #b8c1cc; border-radius: 6px; background: white; font: inherit; cursor: pointer; } button:hover { background: #edf1f5; } @media (max-width: 420px) { body { padding: 12px; } main { padding: 16px; } .inputs { grid-template-columns: 1fr; } } </style> </head> <body> <main> <h1>WCAG Contrast Checker</h1> <p class="hint">Enter 3- or 6-digit hex colors, with or without #.</p> <div class="inputs"> <label for="fg">Foreground <input id="fg" type="text" value="#1A1A1A" autocomplete="off" spellcheck="false"> </label> <label for="bg">Background <input id="bg" type="text" value="#FFFFFF" autocomplete="off" spellcheck="false"> </label> </div> <div id="preview" class="preview"> <strong>Live text preview</strong> Normal text sample · Large text sample </div> <div id="ratio" class="ratio" aria-live="polite"></div> <div id="results" class="badges"></div> <h2>Presets</h2> <div id="presets" class="presets"></div> </main> <script> const fg = document.querySelector("#fg"); const bg = document.querySelector("#bg"); const preview = document.querySelector("#preview"); const ratio = document.querySelector("#ratio"); const results = document.querySelector("#results"); const pairs = [ ["Black on white", "#000000", "#FFFFFF"], ["White on navy", "#FFFFFF", "#1D3557"], ["Yellow on black", "#FFFF00", "#000000"], ["Gray on white", "#777777", "#FFFFFF"] ]; function parseHex(value) { let hex = value.trim().replace(/^#/, ""); if (/^[0-9a-f]{3}$/i.test(hex)) hex = [...hex].map(x => x + x).join(""); if (!/^[0-9a-f]{6}$/i.test(hex)) return null; return [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16)); } function luminance(rgb) { const channels = rgb.map(v => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; } function update() { const f = parseHex(fg.value), b = parseHex(bg.value); fg.setAttribute("aria-invalid", String(!f)); bg.setAttribute("aria-invalid", String(!b)); if (!f || !b) { preview.textContent = "Enter valid hex colors to see the preview."; preview.style.color = preview.style.backgroundColor = ""; ratio.textContent = "Please enter valid 3- or 6-digit hex colors."; results.replaceChildren(); return; } const foreground = "#" + f.map(v => v.toString(16).padStart(2, "0")).join(""); const background = "#" + b.map(v => v.toString(16).padStart(2, "0")).join(""); preview.innerHTML = "<strong>Live text preview</strong>Normal text sample · Large text sample"; preview.style.color = foreground; preview.style.backgroundColor = background; const l1 = luminance(f), l2 = luminance(b); const value = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); ratio.textContent = `Contrast ratio: ${value.toFixed(2)}:1`; const checks = [ ["AA normal text", 4.5], ["AA large text", 3], ["AAA normal text", 7], ["AAA large text", 4.5] ]; results.innerHTML = checks.map(([name, threshold]) => { const passes = value >= threshold; return `<div class="badge"><span>${name}</span><span class="${passes ? "pass" : "fail"}">${passes ? "PASS" : "FAIL"}</span></div>`; }).join(""); } [fg, bg].forEach(input => input.addEventListener("input", update)); pairs.forEach(([name, foreground, background]) => { const button = document.createElement("button"); button.type = "button"; button.textContent = name; button.addEventListener("click", () => { fg.value = foreground; bg.value = background; update(); }); document.querySelector("#presets").append(button); }); update(); </script> </body> </html> How I checked it
I did not want to publish math I had not run:
- I pulled its
parseHexandluminancefunctions out verbatim and ran 12 color pairs through them, and through a second implementation I wrote myself from the WCAG 2.x formula. All 12 matched, including the boundary pair that matters most:#777777on white (4.48, fails AA normal) versus#767676on white (4.54, passes AA normal). A checker that gets the 4.5 line wrong is worse than no checker. - Three-digit expansion and missing-
#inputs behaved (#777equals#777777). - Six invalid inputs (a named color, a 4-digit value, junk, an empty field) were all rejected with a clear message. No crash, no stale badges.
- I loaded the actual file in a browser and clicked through it: presets load, the badges show the exact expected FAIL/PASS pattern on the boundary gray, and invalid input clears the results.
- Cross-check against an outside oracle: WebAIM's contrast checker API on 5 of the pairs. The pass/fail calls agreed on all 5. The displayed ratio can differ in the last digit (this shows 4.48 where WebAIM shows 4.47 on
#777777) because this tool rounds and WebAIM truncates. The decisions are unaffected: both compare the unrounded value against the threshold.
Honest limits
- Hex input only. No named colors,
rgb(),hsl(), or transparency. Named colors are rejected, not resolved. - The preview text says "Normal text sample · Large text sample" but renders both at the same size. Cosmetic, but split them if you ship this.
- It implements the WCAG 2.x luminance math, which is what current conformance targets use (2.1 and 2.2 carry the same 4.5 / 3 / 7 / 4.5 thresholds; I checked both specs). APCA, the candidate approach for WCAG 3, is different math and this is not it.
- A passing ratio is a floor, not a readability guarantee. Font, weight, size and context still matter.
- One prompt, one run. The file above is exactly what it produced this time; the next prompt could produce something worse. Verify before you ship, whoever wrote the code.
The app: https://tryhello.app (free plan, no card)