{"id":"l-a11y-4-1","courseId":"web-accessibility","lang":"en","kind":"lesson","summary":"Measure and fix text and interface contrast against WCAG 2.2 ratios, stop relying on colour alone, and understand why APCA isn’t part of the standard yet.","html":"<p>Low-contrast text is the most common accessibility failure on the web: WebAIM found it on 83.9% of the top million home pages in 2026. It is also the easiest to measure, because contrast is a number. Ticketly’s designers love pale grey on white and white on pastel orange. Maha, at 400% zoom with low vision, reads neither, and neither does anyone checking a ticket on a phone in the sun.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">The contrast ratio</h2>\n<p>WCAG compares the <strong>relative luminance</strong> of two colours, a measure of perceived brightness from 0 (black) to 1 (white). The ratio runs from 1:1 (identical colours) to 21:1 (black on white). Here is the calculation, the same one every contrast checker runs:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-run=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"js\" data-index=\"0\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> luminance</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">hex</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">r</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">g</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">b</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">3</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">].</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">map</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">i</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> c</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> parseInt</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(hex.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">slice</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(i, i </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">16</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">/</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 255</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> c </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">&#x3C;=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.04045</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ?</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> c </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">/</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 12.92</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> :</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ((c </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.055</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">/</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1.055</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">**</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 2.4</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  });</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.2126</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> r </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.7152</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> g </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.0722</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> b;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> contrast</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">a</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">b</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">hi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">lo</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">luminance</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(a), </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">luminance</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(b)].</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">sort</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">x</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">y</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> y </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> x);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ((hi </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.05</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">/</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (lo </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.05</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toFixed</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'price #9a9a9a on white:'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">contrast</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#9a9a9a'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#ffffff'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'price #767676 on white:'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">contrast</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#767676'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#ffffff'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'white on orange button:'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">contrast</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#ffffff'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#f4a261'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'ink on orange button:  '</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">contrast</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#1b1830'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#f4a261'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p>The green channel dominates luminance, which is why white text on orange or yellow fails so badly even when it looks bright.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">The thresholds you need</h2>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>What</th>\n<th>Level AA</th>\n<th>Criterion</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Normal text</td>\n<td><strong>4.5:1</strong></td>\n<td>1.4.3 Contrast (Minimum)</td>\n</tr>\n<tr>\n<td>Large text: at least 24px, or 18.66px bold</td>\n<td><strong>3:1</strong></td>\n<td>1.4.3</td>\n</tr>\n<tr>\n<td>Input borders, focus indicators, icons, chart lines needed to understand content</td>\n<td><strong>3:1</strong></td>\n<td>1.4.11 Non-text Contrast</td>\n</tr>\n<tr>\n<td>Text, enhanced</td>\n<td>7:1</td>\n<td>1.4.6 (AAA)</td>\n</tr>\n</tbody>\n</table></div><p>Exemptions are narrow: logos, purely decorative elements and disabled controls. Hint text inside inputs, “secondary” grey captions, footer links and text over photos all count. For text over an image, add a solid or semi-opaque overlay behind the text and measure against the lightest part of the image it can sit on.</p>\n<p>Ticketly’s fixes:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">ticketly/styles.css</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.price</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">color</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">#595959</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }                 </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">/* was #9a9a9a: 2.81:1 → 7.00:1 */</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.btn</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">background</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">#f4a261</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">color</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">#1b1830</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; } </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">/* was white text: 2.06:1 → 8.35:1 */</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">border</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">px</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> solid</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> #77708f</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }        </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">/* was #ddd: 1.36:1 → 4.67:1 */</span></span></code></pre></div>\n<p>The orange button keeps its brand colour; only the text colour changed. That is usually the cheapest fix.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Checking contrast in one state only</span></p>\n<p>A button that passes in its default state can fail on hover (pale tint), in dark mode, or when it is disabled-looking but still active. Check every state and every theme. In DevTools, the colour picker for any text shows its contrast ratio and whether it passes AA and AAA.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Don’t rely on colour alone</h2>\n<p>WCAG 1.4.1 Use of Color (A) asks that colour is never the <strong>only</strong> way to convey information. About 1 in 12 men and 1 in 200 women have some form of colour vision deficiency, most often red-green.</p>\n<p>On Ticketly that rules out:</p>\n<ul>\n<li><strong>Links in body text</strong> that differ from surrounding text only by colour. Keep the underline (or ensure 3:1 against the body text plus another cue on hover and focus; the underline is far simpler).</li>\n<li><strong>Errors shown only as a red border.</strong> Add a text message and an icon, connected as you did in <a href=\"/lesson/web-accessibility/l-a11y-2-3\">Accessible Forms</a>.</li>\n<li><strong>Seat availability as green and red dots.</strong> Add a pattern or a label: “available”, “taken”.</li>\n</ul>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 200\" role=\"img\" aria-labelledby=\"l-a11y-4-1-t1\"><defs><marker id=\"arrow-l-a11y-4-1-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-a11y-4-1-t1\">Left: seat dots that differ only by colour look identical in greyscale. Right: available seats are outlined circles and taken seats are filled circles with a cross, still distinct without colour, with a legend in words.</title>\n  <text class=\"d-label-strong\" x=\"150\" y=\"30\" text-anchor=\"middle\">Colour only</text>\n  <circle class=\"d-dot\" cx=\"80\" cy=\"80\" r=\"16\"/>\n  <circle class=\"d-dot\" cx=\"130\" cy=\"80\" r=\"16\"/>\n  <circle class=\"d-dot\" cx=\"180\" cy=\"80\" r=\"16\"/>\n  <circle class=\"d-dot\" cx=\"230\" cy=\"80\" r=\"16\"/>\n  <text class=\"d-label-muted\" x=\"155\" y=\"140\" text-anchor=\"middle\">Which seats are free?</text>\n  <path class=\"d-line\" d=\"M340 30 L340 170\"/>\n  <text class=\"d-label-strong\" x=\"520\" y=\"30\" text-anchor=\"middle\">Shape + label</text>\n  <circle class=\"d-box\" cx=\"450\" cy=\"80\" r=\"16\"/>\n  <circle class=\"d-dot\" cx=\"500\" cy=\"80\" r=\"16\"/>\n  <path class=\"d-line\" d=\"M490 70 L510 90 M510 70 L490 90\"/>\n  <circle class=\"d-box\" cx=\"550\" cy=\"80\" r=\"16\"/>\n  <circle class=\"d-dot\" cx=\"600\" cy=\"80\" r=\"16\"/>\n  <path class=\"d-line\" d=\"M590 70 L610 90 M610 70 L590 90\"/>\n  <circle class=\"d-box\" cx=\"440\" cy=\"135\" r=\"9\"/>\n  <text class=\"d-label\" x=\"456\" y=\"140\">available</text>\n  <circle class=\"d-dot\" cx=\"550\" cy=\"135\" r=\"9\"/>\n  <text class=\"d-label\" x=\"566\" y=\"140\">taken</text>\n</svg><figcaption>Same information, with and without colour</figcaption></figure>\n<h2 id=\"sec-4\" tabindex=\"-1\">What about APCA?</h2>\n<p>You will meet the <strong>Accessible Perceptual Contrast Algorithm (APCA)</strong> in design tools. It models perception better than the WCAG 2 ratio: it accounts for font size and weight, and treats light-on-dark differently from dark-on-light, which matters for dark mode. It has been explored for <strong>WCAG 3</strong>, which is still a W3C working draft.</p>\n<p>APCA is not part of WCAG 2.2, and no law references it. An auditor will test your pages with the 4.5:1 and 3:1 ratios. Use APCA as an extra aid if your team likes it, especially for thin fonts and dark themes, but every colour pair must still pass the WCAG 2 ratio.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Fix contrast in the design tokens</span></p>\n<p>Contrast bugs come back when every component picks its own grey. Define a small set of text colour tokens that are verified against every background token, in light and dark themes, and use only those. One fixed token fixes a hundred screens.</p>\n</aside>\n<p>Strong contrast helps people see Ticketly at its default size. The next lesson asks what happens when they change that size, and what happens when the page moves.</p>\n","toc":[{"id":"sec-1","text":"The contrast ratio","level":2},{"id":"sec-2","text":"The thresholds you need","level":2},{"id":"sec-3","text":"Don’t rely on colour alone","level":2},{"id":"sec-4","text":"What about APCA?","level":2}],"takeaways":["Body text needs a contrast ratio of at least 4.5:1 with its background; large text (24px, or 18.66px bold) needs 3:1.","Borders of inputs, focus indicators, icons and chart lines that people need to see require 3:1 against their surroundings (1.4.11 Non-text Contrast).","Colour can’t be the only way information is shown; pair it with text, an icon, an underline or a pattern.","APCA is a promising perceptual contrast model explored for WCAG 3, but WCAG 2.2 and the laws that cite it use the 4.5:1 and 3:1 ratios."],"further":[{"title":"W3C: Understanding 1.4.3 Contrast (Minimum)","url":"https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html"},{"title":"W3C: Understanding 1.4.11 Non-text Contrast","url":"https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html"},{"title":"W3C: Understanding 1.4.1 Use of Color","url":"https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html"},{"title":"WebAIM Contrast Checker","url":"https://webaim.org/resources/contrastchecker/"}],"quiz":[{"q":"<p>Ticketly’s prices are <code dir=\"ltr\">#767676</code> on white, a ratio of 4.54:1, set at 16px regular. Does that pass 1.4.3 Contrast (Minimum)?</p>","options":[{"text":"Yes, because 4.54 is at least 4.5","why":"Correct. The threshold is a minimum, not a rounded target, so 4.54:1 passes for normal-size text. It is the lightest grey that does on white."},{"text":"No, because 16px text needs 7:1","why":"7:1 is the AAA level (1.4.6 Contrast Enhanced). Level AA requires 4.5:1 for normal text."},{"text":"No, because grey text never passes","why":"Contrast depends on the ratio of luminance, not on hue or saturation. Dark enough greys pass easily."},{"text":"Only if the text is bold","why":"Bold text only lowers the requirement to 3:1 when it is at least 18.66px (14pt). At 16px, bold or not, the threshold is 4.5:1."}],"answer":0},{"q":"<p>A form field’s border is <code dir=\"ltr\">#dddddd</code> on a white page (1.36:1). The label text has excellent contrast. What fails?</p>","options":[{"text":"Nothing, because borders are decorative","why":"The border is the only thing that shows where the field is. Visual information needed to identify a component must meet 3:1."},{"text":"1.4.3 Contrast (Minimum), because all colours on the page must reach 4.5:1","why":"1.4.3 applies to text. Non-text elements such as borders are covered by 1.4.11 at 3:1."},{"text":"1.4.11 Non-text Contrast, because the border identifying the input needs at least 3:1","why":"Correct. A border such as <code dir=\"ltr\">#77708f</code> (4.67:1) makes the field’s boundary visible to people with low vision."}],"answer":2},{"q":"<p>Ticketly’s checkout marks invalid fields with a red border and nothing else. Who is most clearly failed by this design?</p>","options":[{"text":"Nobody, if the red has 3:1 contrast with the background","why":"Contrast helps people see the border, but not tell red from the normal border colour. Colour alone still carries the meaning."},{"text":"Users with red-green colour blindness, and screen reader users, who get no text at all","why":"Correct. About 1 in 12 men has some colour vision deficiency. Add an error message, an icon and aria-invalid, as in the forms lesson."},{"text":"Only keyboard users, who can’t see the border while typing","why":"Keyboard users see the border fine. The barrier is that colour is the only signal."}],"answer":1},{"q":"<p>A designer proposes checking every colour pair with APCA instead of the WCAG 2 contrast ratio. What should you tell them?</p>","options":[{"text":"Fine; APCA replaced the contrast ratio in WCAG 2.2","why":"WCAG 2.2 still uses the 4.5:1 and 3:1 ratios. APCA isn’t part of any published WCAG version."},{"text":"APCA is banned by the W3C, so it must not be used","why":"Nothing bans it. It is a research model you can use as an extra design aid."},{"text":"APCA only works for dark mode, so use it there","why":"APCA works for any polarity; that is one of its strengths. The issue is that conformance is still measured with the WCAG 2 ratio."},{"text":"Use APCA as an extra aid if you like, but colours must still meet the WCAG 2 ratios, because those are what WCAG 2.2 and the law use","why":"Correct. APCA has been explored for WCAG 3, which is still a draft. Audits today test the WCAG 2 contrast ratio."}],"answer":3}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<article class=\"pass\">\n  <h2>Weekend pass</h2>\n  <p class=\"price\">SAR 320 for both nights</p>\n  <p class=\"note\">Includes entry to the <a href=\"#lounge\">harbour lounge</a> and a free drink token.</p>\n  <label for=\"qty\">Quantity</label>\n  <input id=\"qty\" type=\"number\" min=\"1\" max=\"6\" value=\"1\">\n  <button type=\"button\" class=\"btn\">Add to basket</button>\n</article>","css":"body { background: #ffffff; }\n.pass { display: grid; gap: 8px; justify-items: start; max-width: 340px; }\n.pass h2 { margin: 0; }\n.pass p { margin: 0; }\n.price { color: #9a9a9a; }\n.note a { color: #1b1830; text-decoration: none; font-weight: 600; }\ninput { font: inherit; padding: 6px 8px; border: 1px solid #dddddd; border-radius: 6px; background: #ffffff; }\n.btn { font: inherit; padding: 8px 16px; border: 0; border-radius: 8px; background: #f4a261; color: #ffffff; }\n","js":""},"solution":{"html":"<article class=\"pass\">\n  <h2>Weekend pass</h2>\n  <p class=\"price\">SAR 320 for both nights</p>\n  <p class=\"note\">Includes entry to the <a href=\"#lounge\">harbour lounge</a> and a free drink token.</p>\n  <label for=\"qty\">Quantity</label>\n  <input id=\"qty\" type=\"number\" min=\"1\" max=\"6\" value=\"1\">\n  <button type=\"button\" class=\"btn\">Add to basket</button>\n</article>","css":"body { background: #ffffff; }\n.pass { display: grid; gap: 8px; justify-items: start; max-width: 340px; }\n.pass h2 { margin: 0; }\n.pass p { margin: 0; }\n.price { color: #595959; }\n.note a { color: #1b1830; text-decoration: underline; font-weight: 600; }\ninput { font: inherit; padding: 6px 8px; border: 1px solid #77708f; border-radius: 6px; background: #ffffff; }\n.btn { font: inherit; padding: 8px 16px; border: 0; border-radius: 8px; background: #f4a261; color: #1b1830; }\n","js":""},"checks":[{"id":"price","test":"const rgb = (c) => { const cv = document.createElement('canvas'); cv.width = cv.height = 1; const x = cv.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, 1, 1); x.fillStyle = c; x.fillRect(0, 0, 1, 1); return [...x.getImageData(0, 0, 1, 1).data].slice(0, 3); }; const lum = (c) => { const [r, g, b] = rgb(c).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((m, n) => n - m); return (hi + 0.05) / (lo + 0.05); }; const bg = (el) => { while (el) { const c = getComputedStyle(el).backgroundColor; if (c && c !== 'transparent' && !/rgba\\(.*,\\s*0\\)$/.test(c)) return c; el = el.parentElement; } return '#ffffff'; }; const el = $('.price'); return ratio(getComputedStyle(el).color, bg(el)) >= 4.5;"},{"id":"button","test":"const rgb = (c) => { const cv = document.createElement('canvas'); cv.width = cv.height = 1; const x = cv.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, 1, 1); x.fillStyle = c; x.fillRect(0, 0, 1, 1); return [...x.getImageData(0, 0, 1, 1).data].slice(0, 3); }; const lum = (c) => { const [r, g, b] = rgb(c).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((m, n) => n - m); return (hi + 0.05) / (lo + 0.05); }; const bg = (el) => { while (el) { const c = getComputedStyle(el).backgroundColor; if (c && c !== 'transparent' && !/rgba\\(.*,\\s*0\\)$/.test(c)) return c; el = el.parentElement; } return '#ffffff'; }; const el = $('.btn'); return ratio(getComputedStyle(el).color, bg(el)) >= 4.5;"},{"id":"input-border","test":"const rgb = (c) => { const cv = document.createElement('canvas'); cv.width = cv.height = 1; const x = cv.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, 1, 1); x.fillStyle = c; x.fillRect(0, 0, 1, 1); return [...x.getImageData(0, 0, 1, 1).data].slice(0, 3); }; const lum = (c) => { const [r, g, b] = rgb(c).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((m, n) => n - m); return (hi + 0.05) / (lo + 0.05); }; const bg = (el) => { while (el) { const c = getComputedStyle(el).backgroundColor; if (c && c !== 'transparent' && !/rgba\\(.*,\\s*0\\)$/.test(c)) return c; el = el.parentElement; } return '#ffffff'; }; const el = $('input'); const cs = getComputedStyle(el); return parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== 'none' && ratio(cs.borderTopColor, bg(el.parentElement)) >= 3;"},{"id":"link-cue","test":"const rgb = (c) => { const cv = document.createElement('canvas'); cv.width = cv.height = 1; const x = cv.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, 1, 1); x.fillStyle = c; x.fillRect(0, 0, 1, 1); return [...x.getImageData(0, 0, 1, 1).data].slice(0, 3); }; const lum = (c) => { const [r, g, b] = rgb(c).map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((m, n) => n - m); return (hi + 0.05) / (lo + 0.05); }; const bg = (el) => { while (el) { const c = getComputedStyle(el).backgroundColor; if (c && c !== 'transparent' && !/rgba\\(.*,\\s*0\\)$/.test(c)) return c; el = el.parentElement; } return '#ffffff'; }; const a = $('.note a'); const cs = getComputedStyle(a); const p = getComputedStyle(a.parentElement); const underlined = cs.textDecorationLine.includes('underline') || (parseFloat(cs.borderBottomWidth) > 0 && cs.borderBottomStyle !== 'none'); return underlined && ratio(cs.color, bg(a)) >= 4.5;"}],"title":"Fix the Weekend pass colours","prompt":"<p>Edit only the CSS so the Weekend pass card meets WCAG 2.2 AA:</p>\n<ul>\n<li><code dir=\"ltr\">.price</code> text needs at least <strong>4.5:1</strong> against the white background.</li>\n<li>The <code dir=\"ltr\">.btn</code> text needs at least <strong>4.5:1</strong> against the button’s background (you may keep the orange).</li>\n<li>The quantity <code dir=\"ltr\">input</code> border needs at least <strong>3:1</strong> against the card background.</li>\n<li>The link inside <code dir=\"ltr\">.note</code> differs from the paragraph only by weight; give it a non-colour cue such as an underline, and keep its text at 4.5:1.</li>\n</ul>\n<p>The checks compute real contrast ratios from your computed styles, so any passing colours are accepted.</p>\n","hints":["On white, any grey at #767676 or darker passes 4.5:1.","White on #f4a261 is only 2.06:1. Dark text on the same orange is well above 8:1.","A border of #77708f gives about 4.7:1 on white. For the link, text-decoration: underline is the simplest cue."],"explanation":"<p>Darkening the price to #595959 takes it from 2.81:1 to 7:1. The button keeps its brand orange and switches to dark text (8.35:1), which is usually cheaper than changing a brand colour. The input border moves from 1.36:1 to 4.67:1, so people with low vision can find the field (1.4.11). The link gets its underline back, so it is identifiable without relying on colour or weight differences (1.4.1). You can paste any of these pairs into the contrast function from the lesson to see the numbers.</p>\n","checkLabels":{"price":"Price text reaches 4.5:1 against its background","button":"Button text reaches 4.5:1 against the button colour","input-border":"The input border reaches 3:1 against the card background","link-cue":"The link has an underline (or bottom border) and 4.5:1 text contrast"}},"words":625,"readMinutes":3}