# Display State Audit Checklist Every user-facing display switch on your site multiplies the number of pages you actually ship. Two switches is four pages. This is the walk-through for finding the state nobody tested, plus a paste-into-DevTools contrast pass you can run yourself with no install and no account. Version 1.0, 2026-11-10. Source article: https://jwatte.com/blog/accessibility-toggles-combine-audit-every-state/ --- ## 0. The one thing to internalise **Count states before you count checks.** A contrast audit loads your page cold, in its default state, measures once, and reports. Every switch your visitors can flip is a page that tool never saw. The switches are not decoration: dark mode, high contrast, reduced motion, font-size preference, language. Each one is a real rendering of your site that a real person is looking at right now. The state that breaks is almost never the one you designed. It is the combination. --- ## 1. Build your state matrix List every input that changes how the page paints. Include the ones you did not build. | Source | Typical values | Do you control it | |---|---|---| | Your dark mode toggle | light, dark | Yes | | Your high contrast toggle | off, on | Yes | | OS colour scheme, via `prefers-color-scheme` | light, dark, no preference | No | | OS contrast setting, via `prefers-contrast` | no-preference, more, less | No | | Forced Colors Mode (Windows high contrast) | active, none | No | | Browser font size and page zoom | 100% to 200% | No | | Reduced motion | no-preference, reduce | No | | Language switch, if you ship one | per locale | Yes | Multiply the ones that change **colour**. Those are the states you must measure. ``` states = product of every colour-affecting switch 2 toggles = 4 states 2 toggles + OS dark = 8 states, if your CSS reads prefers-color-scheme independently ``` If a colour is defined **only** inside a media query or only inside an attribute selector, there is a state where it is undefined and something else wins. Find those first: ```bash grep -n "prefers-color-scheme\|data-theme\|high-contrast\|prefers-contrast" your.css ``` **Checklist** - [ ] Every colour-affecting switch is listed, including the OS-level ones you do not control - [ ] The total state count is written down - [ ] Every custom property has a definition on bare `:root`, not only inside a conditional block - [ ] You have opened the page yourself with *all* switches on at once --- ## 2. The trap that produces the four-state bug The high contrast palette is almost always written for white paper. It sets background to white, text to black, borders to solid black. That is correct on its own. The dark theme sets its own surface variables: card background, table header, panel, elevated surface. Those are usually different variable names. With both on, high contrast repaints the **text** and dark mode still paints the **surfaces**. Black on dark navy measures 1.01 to 1. That is the same colour twice. **The fix is not a specificity war.** Redefine the surface tokens inside the combined-state selector, so everything downstream inherits correct values without touching a single rule: ```css /* Light + high contrast: already correct in most stylesheets */ /* Dark + high contrast: the state nobody tests */ :root[data-theme="dark"] body.high-contrast { --color-bg: #000; --color-bg-alt: #000; --color-surface: #000; --color-dm-surface: #000; /* the dark theme's own surface names */ --color-dm-bg: #000; --color-text: #fff; --color-text-muted: #fff; --color-border: #fff; } ``` **Do not redefine your brand or accent tokens here without checking what they paint.** On the fleet this checklist came from, `--color-primary` was a *background* in twenty six places and a text colour in three. Setting it to white for contrast produced a white hero with white text. ```bash grep -c "background[^;]*var(--color-primary)" your.css # count backgrounds grep -c "color: *var(--color-primary)" your.css # count text uses ``` **Checklist** - [ ] Every surface token used by the dark theme is redefined in the combined state - [ ] Every token you redefined was checked for background-vs-text usage first - [ ] Buttons were treated as a background and label **pair**, never as a lone `color` fix - [ ] Containers that stay light regardless of theme (legal-page nav bars, print headers, anything with a hard-coded light background) got their own token block --- ## 3. Run the contrast pass yourself Open your page, set the state you want to test, open DevTools, paste this into the Console, press Enter. No install, nothing leaves your browser. ```js (() => { const P = c => { const m = (c || '').match(/[\d.]+/g) || [0,0,0]; return [+m[0]||0, +m[1]||0, +m[2]||0, m[3] === undefined ? 1 : +m[3]]; }; const lum = rgb => { const f = rgb.slice(0,3).map(v => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126*f[0] + 0.7152*f[1] + 0.0722*f[2]; }; const ratio = (a,b) => { const x = lum(a), y = lum(b); return (Math.max(x,y) + 0.05) / (Math.min(x,y) + 0.05); }; // Composite fg over bg. NOTE the alpha: dropping it turns a 5% white veil into solid white, // which is how a perfectly legible chip gets reported as white-on-white. const over = (fg, bg) => [0,1,2].map(i => Math.round(fg[i]*fg[3] + bg[i]*(1 - fg[3]))); const bgOf = el => { const layers = []; for (let n = el; n; n = n.parentElement) { const c = P(getComputedStyle(n).backgroundColor); if (c[3] > 0) layers.push(c); if (c[3] === 1) break; } let acc = [255,255,255]; // canvas assumed white; change if your body is not for (let i = layers.length - 1; i >= 0; i--) acc = over(layers[i], acc); return acc; }; const opacityOf = el => { let o = 1; for (let n = el; n && n !== document.documentElement; n = n.parentElement) { const v = parseFloat(getComputedStyle(n).opacity); if (!isNaN(v)) o *= v; } return o; }; const path = el => { const p = []; for (let n = el; n && n.nodeType === 1 && p.length < 4; n = n.parentElement) { p.unshift(n.tagName.toLowerCase() + (n.id ? '#' + n.id : n.classList.length ? '.' + [...n.classList].slice(0,2).join('.') : '')); } return p.join(' > '); }; const SKIP = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','BR','HEAD','META','LINK','TITLE']); const rows = []; document.querySelectorAll('*').forEach(el => { if (SKIP.has(el.tagName)) return; const own = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 1); if (!own) return; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return; const op = opacityOf(el); if (op < 0.02) return; const bg = bgOf(el); const fgRaw = P(cs.color); const fg = over([fgRaw[0], fgRaw[1], fgRaw[2], fgRaw[3] * op], bg); const px = parseFloat(cs.fontSize) || 16; const bold = (parseInt(cs.fontWeight, 10) || 400) >= 700; const large = px >= 24 || (px >= 18.66 && bold); const need = large ? 3 : 4.5; const cr = ratio(fg, bg); if (cr + 0.005 < need) rows.push({ ratio: +cr.toFixed(2), need, size: px + (bold ? ' bold' : ''), text: el.textContent.trim().replace(/\s+/g,' ').slice(0, 42), color: cs.color, background: 'rgb(' + bg.join(',') + ')', opacity: +op.toFixed(2), where: path(el), }); }); const state = { theme: document.documentElement.getAttribute('data-theme') || '(none)', bodyClass: document.body.className || '(none)', osDark: matchMedia('(prefers-color-scheme: dark)').matches, osContrast: matchMedia('(prefers-contrast: more)').matches ? 'more' : 'no-preference', forcedColors: matchMedia('(forced-colors: active)').matches, }; console.log('%cState under test', 'font-weight:bold', state); console.log(rows.length + ' element(s) below the WCAG AA minimum'); if (rows.length) console.table(rows.sort((a,b) => a.ratio - b.ratio).slice(0, 100)); window.__contrast = { state, rows }; return rows.length; })(); ``` Run it once per state. Flip a switch, **reload the page**, run it again. Reload rather than toggling and re-running: reading a computed style straight after flipping a class returns the previous state's colours, especially where custom properties are involved. Two things the snippet cannot see, so check them by eye: * Text drawn on a **background image or gradient**. The snippet reads `backgroundColor`, which is transparent behind an image. Sample the actual pixels under the text. * Text inside a **canvas, an iframe, or an SVG text node**. --- ## 4. Set the target above the floor 4.5 to 1 is the WCAG AA minimum for normal text. It is a floor, not a goal. A colour derived to land exactly on the floor has no margin for the conditions people actually read in: a phone at 40 percent brightness, a laptop screen tilted back, a bright room. | Role | Derive to | Why | |---|---|---| | Body text and headings | **7.0** | The AAA figure, used as a target for everything | | Links in body copy | **5.5** | Needs hue separation from the ink, not just legibility | | Muted or secondary text | **4.5** minimum, 5.5 preferred | This is where "muted" becomes "invisible" | | Disabled controls | no WCAG minimum | Still check it reads as *disabled*, not as *missing* | | Focus rings | **3.0** against both the control and the page | SC 1.4.11, and the one people miss | | Icon-only buttons | **3.0** | Non-text contrast, same criterion | **Lighten in HSL, not toward white.** Mixing a colour with white raises luminance and destroys saturation at the same rate. One brand navy pushed to 7 to 1 by mixing landed on a flat grey with no hue left. The same navy pushed by raising HSL lightness, holding hue and saturation, hit the same ratio and still read as navy. ```js // Raise L until the target is met, holding H and S. Null means this hue cannot get there. function lightenUntil(h, s, l, bgRgb, target) { for (let L = l; L <= 100; L += 0.5) { const rgb = hslToRgb(h, s, L); if (contrastRatio(rgb, bgRgb) >= target) return [h, s, L]; } return null; } ``` When `lightenUntil` returns null, the answer is a different surface, not a different text colour. Do not settle for the floor because the loop ran out. **Checklist** - [ ] Ink and headings derived to 7, not 4.5 - [ ] Links derived to 5.5 and still distinguishable in hue from the surrounding ink - [ ] Derivation walks HSL lightness, holding hue and saturation - [ ] Anything that could not reach target got a surface change, not a lowered target - [ ] Focus rings and icon-only controls checked at 3.0 against **both** neighbours --- ## 5. Spacing, the bug that reads as "the page looks cheap" A CSS reset zeroes every margin. The next rule usually sets the font stack, weight, line height and colour for `h1` through `h6` and forgets to give the margin back. Paragraphs get theirs back on the following line. Headings never do, so every heading in flowing content sits flush against the block underneath it. Nobody files this as a bug. They say the site looks unprofessional. Paste this to find it: ```js (() => { const tight = []; document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => { const n = h.nextElementSibling; if (!n) return; const cs = getComputedStyle(n); if (cs.display === 'none' || cs.position === 'absolute' || cs.position === 'fixed') return; const gap = Math.round(n.getBoundingClientRect().top - h.getBoundingClientRect().bottom); if (gap < 8) tight.push({ tag: h.tagName, gapPx: gap, heading: h.textContent.trim().slice(0, 40), next: n.tagName.toLowerCase() }); }); console.log(tight.length + ' heading(s) with less than 8px of air below'); if (tight.length) console.table(tight); return tight.length; })(); ``` The rhythm rules worth having, applied at the document level so you are not chasing components: ```css /* Air below every heading in flowing content */ main h1, main h2, main h3, main h4, main h5, main h6 { margin-bottom: 0.75rem; } /* Air above a heading that follows something, but never on a first child */ main * + h2 { margin-top: 3rem; } main * + h3 { margin-top: 2.25rem; } main > :first-child, main section > :first-child { margin-top: 0; } /* The specific complaints: a title flush against the card or table it introduces */ main h2 + .card, main h2 + table, main h3 + ul, main h3 + ol { margin-top: 1rem; } ``` **Checklist** - [ ] Zero headings report under 8px of gap on any page - [ ] Rhythm rules use adjacent-sibling selectors so first children are not pushed off their box - [ ] Checked at 360px wide as well as desktop; stacked cards collide differently --- ## 6. Why a correct fix can still not reach anyone Three ways a good change lands in the repo and does nothing on the page. **A broken comment eats the next rule.** CSS error recovery skips to the next closing brace and keeps parsing. A stray word after a comment close, or an unclosed comment, silently deletes the rule that follows. The stylesheet still parses, still validates in most editors, still deploys. Gate on structure before you ship: ```bash node -e " const s = require('fs').readFileSync(process.argv[1], 'utf8'); const open = (s.match(/\/\*/g) || []).length, close = (s.match(/\*\//g) || []).length; const strip = s.replace(/\/\*[\s\S]*?\*\//g, ''); const ob = (strip.match(/{/g) || []).length, cb = (strip.match(/}/g) || []).length; if (open !== close) { console.error('unbalanced comments: ' + open + ' vs ' + close); process.exit(1); } if (ob !== cb) { console.error('unbalanced braces: ' + ob + ' vs ' + cb); process.exit(1); } console.log('css structure OK'); " your.css ``` **An immutable cache header on a filename that never changes.** If `/css/site.css` is served with `Cache-Control: public, max-age=31536000, immutable`, every returning visitor keeps the old stylesheet for a year. You see the fix because your browser is fresh. The owner does not, because theirs is not. Either fingerprint the filename (`site.a1b2c3.css`) or drop `immutable`. ```bash curl -sI https://yoursite.com/css/site.css | grep -i cache-control ``` The same applies to images. Renaming the file is the only reliable cache bust when the URL is immutable and unversioned. **Your local copy is behind production.** If more than one person, machine, or process can deploy, diff live against local *before* you push, not after. ```bash for p in / /about/ /contact/; do curl -s "https://yoursite.com$p" -o /tmp/live.html if diff -q <(tr -d " \t" < /tmp/live.html) <(tr -d " \t" < "_site${p}index.html") > /dev/null; then echo "same $p" else echo "DIFF $p" fi done ``` **Checklist** - [ ] The generated or edited CSS passes a structure check before deploy - [ ] No unversioned asset is served `immutable` - [ ] Live was diffed against local before the deploy, not after - [ ] After deploy, the page was reloaded with cache disabled and re-measured --- ## 7. Sign-off Fill this in per site. One pass per state, and it is the whole difference between "we tested it" and "we measured it". | State | Contrast failures | Tight headings | Checked at 360px | Date | |---|---|---|---|---| | light, contrast off | | | | | | light, contrast on | | | | | | dark, contrast off | | | | | | **dark, contrast on** | | | | | | Forced Colors Mode | spot check by eye | | | | | 200% zoom, light | | | | | Zero is the only passing number in the first two columns. If a state cannot reach zero, write down which element and why, so the next person does not re-derive it. --- Companion file at https://jwatte.com/downloads/ * `audit-harness-traps.md` : if you automate any of this with an automated browser, the seven ways the harness will lie to you, and the code that stops each one Free checkers at https://jwatte.com/tools/ : text contrast, WCAG accessibility audit, form accessibility, and about seventy others. Browser-side, no signup, nothing uploaded. Written by J.A. Watte. https://jwatte.com