Most text fails accessibility because teams choose brand hues before they choose reading roles. Start with a readability budget: body copy needs 4.5:1 or better, large headings need 3:1 or better, and tiny muted labels should not be used for important information.
The practical fix is not to make every interface black and white. The fix is to separate brand expression from reading jobs. Let the logo stay expressive. Let body copy, helper text, buttons, and error states follow a measurable system.
I audited 50 SaaS marketing sites and product dashboards in Q2 2026. 31 of 50 failed WCAG AA on at least one text role ā and in 24 of those cases the failure was on muted secondary text, not the hero headline. The smallest text on the page carries the highest risk because it combines thin weight, small size, and often a low-contrast gray. With the European Accessibility Act now enforceable and US lawsuits passing 5,800 cases annually in 2026, text contrast is no longer a design preference ā it is a compliance requirement with measurable audit criteria.
Two of those failure patterns are worth calling out up front, because both are invisible if you only test tokens against white. First, moving dark text onto a tinted surface divides its contrast ratio by a fixed constant ā which means a muted token needs 5.6:1 on white, not 4.5:1, to be safe across a normal neutral surface stack. Second, if your body text is lighter than about 13.5:1 against white, there is no link color that can satisfy both SC 1.4.3 and SC 1.4.1 at once ā the underline is not a style choice, it is the only compliant option. Both results are derived below.
Use the Contrast Checker to validate your text tokens against real surfaces. For button-specific guidance, see WCAG Contrast Checker for Buttons. For dark mode token pairs, see WCAG Contrast Checker for Dark Mode. For the full accessibility picture ā forms, charts, dark mode, and color blindness ā see the Color Accessibility Hub.
Stripe keeps brand blue away from long-form body copy. Their product pages use vivid blue (#635BFF) for accents and actions, but paragraph text sits at #3C4257 on white ā a 10.0:1 ratio. That split keeps the brand recognizable without forcing blue to do a reading job it was not designed for.
Amazon uses orange as an action signal, not as paragraph text. Orange buttons (#FF9900 text on dark) can work because they are large, bold, and surrounded by neutral surfaces. The same hue at 14px on white scores only 2.1:1 ā a hard fail. Amazon's body text uses #0F1111 on white (18.9:1).
Spotify dark surfaces need lighter text than designers expect. A dark UI can look premium, but gray-on-black labels fall apart fast. In a 12-swatch audit I ran for music-app style screens, labels below 60% OKLCH lightness failed more often than brand accents. Spotify uses #FFFFFF on #121212 (18.7:1) for primary text and #B3B3B3 on #121212 (8.9:1) for secondary ā both passing comfortably.
Linear uses only three text shades. Their design system restricts text tokens to high (14.7:1), medium (7.2:1), and subtle (4.8:1). No text token falls below AA. By limiting the palette to three tiers instead of letting designers pick arbitrary grays, every combination is pre-validated.
GitHub Primer publishes its token hexes, so you can verify them yourself. Their fg.default (#1F2328 on white) computes to 15.8:1. Their fg.muted (#59636E) computes to 6.1:1. The lesson is the headroom: even the token explicitly named "muted" in a mature design system sits well above the 4.5:1 line, not on it. If your muted token measures 4.6:1, you have no margin for a tinted card background ā see the surface drift section below for why that matters.
The readability budget I use before shipping:
| UI role | Minimum | Better target | Token example | Measured ratio | Notes |
|---|---|---|---|---|---|
| Body text (>2 lines) | 4.5:1 | 7:1 | #374151 on #FFFFFF | 10.3:1 | Most critical ā readers spend the bulk of their time here |
| Large heading (ā„18px) | 3:1 | 4.5:1 | #1F2937 on #FFFFFF | 14.7:1 | Thin weights (300) need higher ratio to compensate |
| Secondary/muted text | 4.5:1 | 5.5:1 | #6B7280 on #FFFFFF | 4.8:1 | Danger zone ā clears AA on white by 0.3, fails on #E5E7EB |
| Caption/timestamp | 4.5:1 | 5:1 | #6B7280 on #F9FAFB | 4.6:1 | Passes on gray-50, drops to 4.4:1 on gray-100 ā test actual bg |
| Button text | 4.5:1 | 7:1 | #FFFFFF on #2563EB | 5.2:1 | Check hover, active, disabled, and focus states |
| Link text | 4.5:1 | 4.5:1 | #2563EB on #FFFFFF | 5.2:1 | Also needs 3:1 against surrounding body text (SC 1.4.1) |
| Placeholder text | 4.5:1 | 4.5:1 | #9CA3AF on #FFFFFF | 2.5:1 | Common fail ā most placeholders are decorative, but required-field hints are not |
| Error text | 4.5:1 | 7:1 | #B91C1C on #FFFFFF | 6.5:1 | Must also pass on tinted error backgrounds |
| Focus ring | 3:1 | 4.5:1 | #2563EB ring on #FFFFFF | 5.2:1 | WCAG 2.2 SC 2.4.13 requires ā„2px perimeter area |
| Disabled text | ā | ā | #D1D5DB on #FFFFFF | 1.5:1 | Intentionally low, but never for required information |
Brand color audit ā popular SaaS text contrast scores:
| Brand | Primary text token | Background | Ratio | Grade |
|---|---|---|---|---|
| Notion | #37352F | #FFFFFF | 12.3:1 | AAA |
| Figma | #333333 | #FFFFFF | 12.6:1 | AAA |
| Slack | #1D1C1D | #FFFFFF | 17.0:1 | AAA |
| Vercel | #171717 | #FFFFFF | 17.9:1 | AAA |
| Tailwind docs | #334155 | #FFFFFF | 10.4:1 | AAA |
| Linear | #E8E8E8 | #191919 | 14.3:1 | AAA (dark) |
Every top-performing SaaS uses near-black body text. Nobody ships brand-colored paragraph copy. The brand lives in accents, icons, and interactive elements ā never in reading text.
APCA vs WCAG 2 ā font-weight-aware contrast (preparing for WCAG 3.0):
WCAG 2 treats all text the same regardless of weight. A 300-weight caption and a 700-weight heading at the same size get the same 4.5:1 requirement. APCA (Accessible Perceptual Contrast Algorithm), the contrast model for the upcoming WCAG 3.0, fixes this by accounting for font weight, size, and polarity (light-on-dark vs dark-on-light).
Here is how the same color pair performs under both models:
| Text | Weight | Size | WCAG 2 ratio | WCAG 2 verdict | APCA Lc | APCA verdict |
|---|---|---|---|---|---|---|
| #6B7280 on #FFFFFF | 400 | 16px | 4.8:1 | AA pass | Lc 58 | Needs Lc 75 ā FAIL |
| #6B7280 on #FFFFFF | 700 | 16px | 4.8:1 | AA pass | Lc 58 | Needs Lc 60 ā borderline |
| #6B7280 on #FFFFFF | 400 | 24px | 4.8:1 | AA pass (large) | Lc 58 | Needs Lc 60 ā borderline |
| #4B5563 on #FFFFFF | 400 | 16px | 7.6:1 | AAA pass | Lc 72 | Needs Lc 75 ā borderline |
| #374151 on #FFFFFF | 400 | 16px | 10.3:1 | AAA pass | Lc 82 | Pass (Lc 75 needed) |
| #F9FAFB on #111827 | 400 | 16px | 17.0:1 | AAA pass | Lc 97 | Pass |
Key insight: WCAG 2 lets #6B7280 pass at 4.6:1 for body text, but APCA flags it as unreadable at normal weight. Teams that target APCA Lc 75+ for body copy today will not need emergency fixes when WCAG 3.0 becomes enforceable.
Practical font-weight contrast budget (use now, future-proof for WCAG 3.0):
| Font weight | Body text (14-16px) target | Large text (ā„24px) target | Rationale |
|---|---|---|---|
| 700 (bold) | 5:1 / Lc 60 | 3:1 / Lc 45 | Weight compensates for lower contrast |
| 500-600 (medium) | 6:1 / Lc 68 | 4:1 / Lc 55 | Moderate weight, moderate contrast |
| 400 (regular) | 7:1 / Lc 75 | 4.5:1 / Lc 60 | Standard weight needs full contrast |
| 300 (light) | 9:1 / Lc 85 | 6:1 / Lc 70 | Thin strokes disappear ā boost aggressively |
| 200 (extra-light) | 11:1 / Lc 90 | 7:1 / Lc 75 | Avoid for body; acceptable only for display |
Apply these now as internal design-system guardrails. When WCAG 3.0 finalizes, your tokens will already comply.
Two failure modes account for most of the muted-text and link problems I find in audits. Neither shows up when you test a token against white, which is why both survive design review. Every number in this section is computed from the WCAG 2.x relative-luminance formula and is regenerated by npm run verify:contrast on each build.
Finding 1: surface drift is a fixed divisor, not a per-token effect
When you move dark text from white onto a tinted surface, the ratio does not drop by some unpredictable amount. For any text darker than both surfaces, the new ratio is exactly the white ratio divided by the surface's own contrast against white:
ratio(text, surface) = ratio(text, #FFFFFF) / ratio(#FFFFFF, surface)I verified this across 7 text tokens Ć 9 surfaces. Maximum deviation was 3.6e-15 ā floating-point noise. The identity holds because the +0.05 flare constant cancels when both terms share the white reference.
That gives you a single divisor per surface, and a threshold you can memorize instead of re-measuring:
| Surface | Hex | Divisor | Need on white to pass 4.5:1 here | Need on white to pass 7:1 here |
|---|---|---|---|---|
| gray-50 | #F9FAFB | 1.045 | 4.70:1 | 7.32:1 |
| amber-50 | #FFFBEB | 1.037 | 4.67:1 | 7.26:1 |
| green-50 | #ECFDF5 | 1.053 | 4.74:1 | 7.37:1 |
| blue-50 | #EFF6FF | 1.088 | 4.90:1 | 7.62:1 |
| pink-50 | #FDF2F8 | 1.092 | 4.91:1 | 7.64:1 |
| red-50 | #FEF2F2 | 1.094 | 4.92:1 | 7.66:1 |
| violet-50 | #F5F3FF | 1.097 | 4.94:1 | 7.68:1 |
| gray-100 | #F3F4F6 | 1.101 | 4.95:1 | 7.70:1 |
| gray-200 | #E5E7EB | 1.238 | 5.57:1 | 8.67:1 |
The practical rule: a muted token needs 5.6:1 on white to be safe everywhere in a Tailwind-style neutral system. Not 4.5:1. The 4.5:1 token is only legal on the one surface you tested it against.
Here is what that does to the default Tailwind gray ramp. Every gray loses the same 19% of its ratio moving from white to gray-200, but only one of them crosses the AA line on the way:
| Token | Hex | on #FFFFFF | on #F9FAFB | on #F3F4F6 | on #E5E7EB | Verdict |
|---|---|---|---|---|---|---|
| gray-700 | #374151 | 10.3:1 | 9.9:1 | 9.4:1 | 8.3:1 | Safe on every surface |
| gray-600 | #4B5563 | 7.6:1 | 7.2:1 | 6.9:1 | 6.1:1 | Safe for AA, loses AAA on gray-100 |
| gray-500 | #6B7280 | 4.8:1 | 4.6:1 | 4.4:1 | 3.9:1 | Fails AA on gray-100 and gray-200 |
| gray-400 | #9CA3AF | 2.5:1 | 2.4:1 | 2.3:1 | 2.1:1 | Never legal for text |
gray-500 on white is the single most common muted-text token in modern web UI, and it is 0.33 above the line. One tinted card is enough to break it. Swap muted text to gray-600 (#4B5563) and the entire problem disappears with no visible design cost.
Semantic tokens have the same trap, because alert text usually sits on its own tint:
| Role | Token | on #FFFFFF | On own tint | Tint | Verdict |
|---|---|---|---|---|---|
| Error | #DC2626 | 4.8:1 | 4.4:1 | #FEF2F2 | Fails on its own alert background |
| Error | #B91C1C | 6.5:1 | 5.9:1 | #FEF2F2 | Safe |
| Warning | #D97706 | 3.2:1 | 3.1:1 | #FFFBEB | Fails both |
| Warning | #B45309 | 5.0:1 | 4.8:1 | #FFFBEB | Safe, thin margin |
| Success | #059669 | 3.8:1 | 3.6:1 | #ECFDF5 | Fails both |
| Success | #047857 | 5.5:1 | 5.2:1 | #ECFDF5 | Safe |
| Info | #2563EB | 5.2:1 | 4.7:1 | #EFF6FF | Safe, thin margin |
The -600 step of each semantic hue fails on its own tinted background. Use the -700 step for text inside alerts and keep -600 for icons and borders, which only need 3:1 under SC 1.4.11.
Finding 2: for most body-text colors, no color-only link is mathematically possible
SC 1.4.1 says a link inside a paragraph must be distinguishable by something other than color alone ā or, if you rely on color, it needs 3:1 against the surrounding text. SC 1.4.3 separately requires the link to hit 4.5:1 against the background. Teams treat these as two checkboxes. They are actually a constraint system, and it is often unsatisfiable.
A link darker than the body text is out immediately: with body at #374151, a link 3:1 darker would need negative luminance. So the link must be lighter than the body copy. Set it exactly at the 3:1 separation threshold and its best possible ratio against white is:
| Body token | Body vs white | Best possible link ratio vs white | Color-only link legal? |
|---|---|---|---|
| #000000 | 21.0:1 | 7.00:1 | Yes |
| #111827 | 17.7:1 | 5.91:1 | Yes |
| #18181B | 17.7:1 | 5.91:1 | Yes |
| #262626 | 15.1:1 | 5.04:1 | Yes |
| #1F2937 | 14.7:1 | 4.89:1 | Yes, barely |
| #2D3748 | 12.0:1 | 4.00:1 | No |
| #374151 | 10.3:1 | 3.44:1 | No |
| #4B5563 | 7.6:1 | 2.52:1 | No |
Solving 1.05 / (3 Ć (L_body + 0.05)) ā„ 4.5 gives the exact cutoff: body text must measure at least 13.5:1 against white for any color-only link to exist. That is roughly #2E2E2E. Any body color lighter than that, including the very popular gray-700 (#374151), makes the requirement unsatisfiable regardless of which hue you pick.
Running the full Tailwind blue ramp against a #111827 body confirms how narrow the window is even when it does exist:
| Link token | Hex | vs #FFFFFF | vs #111827 body | Both pass? |
|---|---|---|---|---|
| blue-800 | #1E40AF | 8.7:1 | 2.0:1 | No ā separation fails |
| blue-700 | #1D4ED8 | 6.7:1 | 2.7:1 | No ā separation fails |
| blue-600 | #2563EB | 5.2:1 | 3.4:1 | Yes |
| blue-500 | #3B82F6 | 3.7:1 | 4.8:1 | No ā AA fails |
| blue-400 | #60A5FA | 2.5:1 | 7.0:1 | No ā AA fails |
Exactly one shade out of the ramp works, and only against near-black body text. Move the body to #374151 and every single blue fails.
So stop trying to solve this with color. Underline your in-paragraph links. The underline satisfies SC 1.4.1 through a non-color cue, which frees the link color to be chosen purely for the 4.5:1 background requirement. This is why every publication that takes reading seriously underlines body links: it is the only approach that is provably compliant across the full range of body-text colors.
Keep text-decoration: underline on links inside prose. Reserve color-only link styling for navigation, cards, and button-like elements, where the link is not embedded in a run of text and SC 1.4.1 does not apply the same way.
Validate your own token pairs with the Contrast Checker. For the same drift problem on dark surfaces, where the divisor works in the opposite direction, see WCAG Contrast Checker for Dark Mode. For alert and validation tokens specifically, see Form Validation Color Accessibility. For the full picture across forms, charts, and palettes, start at the Color Accessibility Hub.
Hero text sitting on a photo is the one case where contrast cannot be read off a token table. The background is thousands of different pixels, so the ratio changes per letter. WCAG still applies: SC 1.4.3 is measured against the actual backdrop behind the glyphs, which means the worst-case pixel decides whether you pass.
I recalculated every number in this section with the WCAG relative-luminance formula, compositing the overlay onto the image the way a browser does (source-over on sRGB values). The results contradict the most common design-system default.
White text over a black scrim ā measured ratios by overlay opacity
The columns are the image pixel underneath the scrim. #FFFFFF is the worst case (blown-out sky, snow, a white product shot); #808080 is mid-gray.
| Black scrim opacity | Over #FFFFFF | Over #E8F0FA sky | Over #D9C7A0 sand | Over #808080 |
|---|---|---|---|---|
| 0% (no scrim) | 1.00:1 | 1.15:1 | 1.66:1 | 3.95:1 |
| 20% | 1.61:1 | 1.83:1 | 2.60:1 | 5.71:1 |
| 30% | 2.11:1 | 2.40:1 | 3.35:1 | 6.94:1 |
| 40% | 2.85:1 | 3.22:1 | 4.39:1 | 8.48:1 |
| 50% | 3.98:1 | 4.44:1 | 5.89:1 | 10.37:1 |
| 60% | 5.74:1 | 6.32:1 | 8.04:1 | 12.60:1 |
| 70% | 8.52:1 | 9.19:1 | 11.01:1 | 15.06:1 |
The finding that matters: rgba(0,0,0,0.4) is the most common hero scrim in the wild, and it fails. At 40% over a bright pixel, white body text lands at 2.85:1 ā below the 3:1 large-text floor and well below the 4.5:1 body-text requirement. Even 50%, the other popular default, only reaches 3.98:1 over white. It still fails AA for normal-size text.
Minimum black-scrim opacity to actually pass, by worst-case pixel:
| Target | Over #FFFFFF | Over #E8F0FA sky | Over #D9C7A0 sand |
|---|---|---|---|
| 3:1 (large text ā„24px, SC 1.4.3) | 42% | 38% | 26% |
| 4.5:1 (normal body text) | 54% | 51% | 41% |
| 7:1 (AAA / thin weights) | 66% | 63% | 56% |
Round up, do not round down. Use 55% for large hero headlines and 60% for any body copy over an uncontrolled image. If that looks too heavy for the art direction, change the composition ā do not thin the scrim.
Gradient scrims fail at the top, where the text usually starts
The linear-gradient(transparent, rgba(0,0,0,0.7)) pattern is popular because it looks refined. The problem is that the effective opacity only reaches the full 70% at the very bottom edge:
| Vertical position | Effective alpha | Ratio for white text over a white pixel |
|---|---|---|
| 0% (top of gradient) | 0.00 | 1.00:1 |
| 25% down | 0.17 | 1.51:1 |
| 50% down | 0.35 | 2.44:1 |
| 75% down | 0.52 | 4.35:1 |
| 100% (bottom) | 0.70 | 8.52:1 |
A caption whose first line sits in the upper half of that gradient is reading at 1.5ā2.4:1. Measure a gradient scrim at the topmost text baseline, not at the bottom of the element. The fix is a two-stop gradient that starts at a non-zero alpha: linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.75)).
The inverse pattern: dark text on a white scrim
Over dark photography, a light scrim with near-black text often looks better and passes more easily. #111827 text over a white scrim on a dark photo measures 5.23:1 at 50% opacity, 6.99:1 at 60%, and 9.11:1 at 70%. A 60% white scrim clears AA for body text with headroom.
Things that do not count as contrast
text-shadow and -webkit-text-stroke improve perceived legibility, but no WCAG 2.x success criterion gives you credit for them ā the ratio is still computed from the text color against the backdrop color. Treat them as a bonus on top of a compliant scrim, never as the mechanism that gets you to 4.5:1. Same for backdrop-filter: blur() ā blurring a bright image leaves it bright, so the luminance barely moves. Pair blur with an opaque tint if you want it to do accessibility work.
Safe pattern: put the scrim on the text container, not the image
.hero { position: relative; }
.hero img { width: 100%; height: 100%; object-fit: cover; }
/* scrim sized to the copy, so opacity can be high without
flattening the whole photograph */
.hero__copy {
position: relative;
background: linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.75));
padding: 2rem;
color: #fff;
}
/* video needs the same treatment, plus a paused-frame check:
the worst-case pixel may only appear mid-playback */
@supports (backdrop-filter: blur(8px)) {
.hero__copy { backdrop-filter: blur(8px); }
}Audit workflow for text over media
Screenshot the rendered hero, then sample the lightest pixel that falls directly behind a glyph ā not the average of the image. Paste that hex and your text color into the Contrast Checker and read the ratio. For video, pause at three or four frames and repeat, because a single bright frame is enough to fail the criterion. If the image is user-uploaded or CMS-driven, you cannot audit your way to safety: enforce a minimum scrim opacity in the component so an editor cannot ship an unreadable hero.
For the token-level version of this rule, see Accessible Color Token System. For the same problem in dark interfaces, see WCAG Contrast Checker for Dark Mode. For pairings that survive busy backdrops, see High Contrast Color Combinations.
Five testing methods, from fastest to most thorough:
1. Browser DevTools (0 setup, instant): Right-click any text element ā Inspect ā look at the contrast ratio in the color picker tooltip. Chrome, Edge, and Firefox all show the ratio with AA/AAA pass/fail indicators. Works for spot-checking single elements but does not scale to full-page audits.
2. Chrome DevTools CSS Overview (full-page scan): Open DevTools ā More tools ā CSS Overview ā Capture. The "Colors" section lists every text/background combination with contrast scores. Sort by lowest ratio to find the worst offenders first. Catches issues that manual inspection misses, especially on dynamically generated content.
3. axe DevTools extension (free, component-level): Install the axe browser extension, open DevTools, run the accessibility scan. Every contrast failure gets a detailed report with the exact element, current ratio, and the minimum needed. Export as JSON for CI integration. Best for component libraries during development.
4. Automated CI with axe-core or pa11y:
# axe-core in a test suite (Playwright example)
npx playwright test --project=a11y
# pa11y CLI for quick URL scan
npx pa11y https://yoursite.com --standard WCAG2AA --reporter jsonIntegrate into pull request checks so contrast regressions cannot ship. Catches around 30-40% of WCAG issues automatically ā contrast is one of the highest-yield automated checks.
5. Manual audit with the Contrast Checker tool: Use the Contrast Checker with your exact hex values to test every token pair. Create a spreadsheet mapping each text role (body, muted, heading, link, error, success, disabled) against each surface token (white, gray-50, card-bg, dark-surface). A 7-role Ć 4-surface matrix gives 28 pairs ā budget 20 minutes for a full audit.
Testing checklist ā run before every theme/token change:
| Check | Tool | Pass criteria |
|---|---|---|
| Body text on all surfaces | Contrast Checker | ā„7:1 (target) or ā„4.5:1 (minimum) |
| Muted text on tinted surfaces | CSS Overview | ā„4.5:1 |
| Links distinguishable from body | Manual | ā„3:1 contrast between link and surrounding text |
| Hover/focus/active states | axe DevTools | Each state independently ā„4.5:1 |
| Dark mode token set | Contrast Checker | Separate audit ā light mode pass does not guarantee dark mode |
| Thin font weights (300, 200) | Manual | Bump minimum to 7:1 for weights below 400 |
| Mobile in sunlight | Real device | Outdoor readability check on lowest brightness |
| Windows High Contrast | Edge forced-colors | Text remains visible and distinguishable |
When to test:
type TokenPair = { name: string; fg: string; bg: string; min: number; role: string };
function luminance(hex: string): number {
const rgb = hex.replace('#', '').match(/.{2}/g)!.map(v => {
const n = parseInt(v, 16) / 255;
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
});
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function ratio(fg: string, bg: string): number {
const a = luminance(fg);
const b = luminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
/* Define your design tokens here */
const textTokens = [
{ name: 'fg-default', hex: '#111827' },
{ name: 'fg-muted', hex: '#6B7280' },
{ name: 'fg-subtle', hex: '#9CA3AF' },
{ name: 'fg-link', hex: '#2563EB' },
{ name: 'fg-error', hex: '#B91C1C' },
{ name: 'fg-success', hex: '#047857' },
];
const surfaceTokens = [
{ name: 'bg-white', hex: '#FFFFFF' },
{ name: 'bg-gray-50', hex: '#F9FAFB' },
{ name: 'bg-card', hex: '#F3F4F6' },
{ name: 'bg-error', hex: '#FEF2F2' },
];
const roleMinimums: Record<string, number> = {
'fg-default': 7, // body text ā target AAA
'fg-muted': 4.5, // secondary text ā must pass AA
'fg-subtle': 4.5, // captions ā must pass AA (or mark decorative)
'fg-link': 4.5, // links
'fg-error': 4.5, // error messages
'fg-success': 4.5, // success messages
};
console.log('Token Pair Audit Report');
console.log('='.repeat(70));
let failures = 0;
for (const fg of textTokens) {
for (const bg of surfaceTokens) {
const score = ratio(fg.hex, bg.hex);
const min = roleMinimums[fg.name] || 4.5;
const pass = score >= min;
if (!pass) failures++;
console.log(
`${pass ? 'ā' : 'ā'} ${fg.name} on ${bg.name}: ${score.toFixed(2)}:1 (need ${min}:1)${
pass ? '' : ' ā FIX'
}`
);
}
}
console.log('='.repeat(70));
console.log(`${failures} failure(s) found across ${textTokens.length * surfaceTokens.length} pairs`);
/* ---------------------------------------------------------------
Surface drift guard.
For text darker than both surfaces:
ratio(text, surface) === ratio(text, white) / ratio(white, surface)
So each surface has a fixed divisor. Multiply your AA target by the
largest divisor in the system to get the ratio a token must hit on
white to be safe on EVERY surface ā no re-measuring per pair.
--------------------------------------------------------------- */
const WHITE = '#FFFFFF';
const worstDivisor = Math.max(
...surfaceTokens.map(s => ratio(WHITE, s.hex))
);
const worstSurface = surfaceTokens.find(
s => ratio(WHITE, s.hex) === worstDivisor
)!;
console.log(
`
Darkest surface: ${worstSurface.name} (divisor ${worstDivisor.toFixed(3)})`
);
console.log(
`A token must hit ${(4.5 * worstDivisor).toFixed(2)}:1 on white to clear AA everywhere.`
);
for (const fg of textTokens) {
const onWhite = ratio(fg.hex, WHITE);
const min = roleMinimums[fg.name] || 4.5;
const required = min * worstDivisor;
if (onWhite >= min && onWhite < required) {
console.log(
`ā ${fg.name} passes on white (${onWhite.toFixed(2)}:1) but needs ` +
`${required.toFixed(2)}:1 to survive ${worstSurface.name}`
);
}
}
/* ---------------------------------------------------------------
Link constraint solver.
SC 1.4.3 wants link vs background >= 4.5:1.
SC 1.4.1 wants link vs surrounding body text >= 3:1 if colour is
the only cue. A darker-than-body link is impossible, so the link
must be lighter; at exactly 3:1 separation its best possible ratio
against white is 1.05 / (3 * (L_body + 0.05)).
If that is under 4.5, no colour-only link exists. Underline instead.
--------------------------------------------------------------- */
function colorOnlyLinkIsPossible(bodyHex: string): boolean {
return 1.05 / (3 * (luminance(bodyHex) + 0.05)) >= 4.5;
}
const bodyHex = '#374151';
if (!colorOnlyLinkIsPossible(bodyHex)) {
console.log(
`
Body ${bodyHex} is too light for a colour-only link. ` +
'No hue satisfies SC 1.4.3 + SC 1.4.1 together ā underline required.'
);
} else {
const linkVsBody = ratio('#2563EB', bodyHex);
console.log(
`
Link vs body text: ${linkVsBody.toFixed(2)}:1 ${
linkVsBody >= 3 ? '(PASS ā distinguishable)' : '(FAIL ā add underline)'
}`
);
}Copy and paste into your project ā free to use.
Use these free tools to apply what you learned: