tailwind2-9 Exercise 2: Contrast Ratios and User Preferences ================================================================ Setup: a folder with npm install tailwindcss @tailwindcss/cli and in.css containing @import "tailwindcss"; Build with node node_modules/@tailwindcss/cli/dist/index.mjs -i in.css -o out.css The scripts below use my own small DevTools driver (cdp.mjs: real key presses and clicks, Emulation.setEmulatedMedia, raw send for Accessibility and Page.printToPDF). If you do not have it, the same checks can be made by hand in Chrome DevTools: Rendering panel (emulate prefers-reduced-motion, prefers-contrast, forced-colors, print), Elements > Accessibility tab (computed name), and File > Print. IMPORTANT: every class you measure must appear in a file the scanner reads, or the element is unstyled (my first run gave 21.00 for most pairs because the classes were not built). I added classes.html: --- r6.mjs : computed colours to sRGB through a canvas, then the WCAG formula --- import { withPage } from '../../c4/cdp.mjs'; const pairs = [['text-slate-400','bg-white'],['text-slate-500','bg-white'],['text-slate-600','bg-white'],['text-gray-400','bg-white'],['text-gray-500','bg-white'],['text-white','bg-blue-500'],['text-white','bg-blue-600'],['text-blue-500','bg-white'],['text-blue-600','bg-white'],['text-white','bg-red-500'],['text-white','bg-emerald-500'],['text-white','bg-emerald-700'],['text-slate-900','bg-yellow-300'],['text-slate-500','bg-slate-100']]; await withPage('file:///C:/lwdj/tw2/c9/a/page.html', async (p) => { const res = await p.ev(`(() => { const cv = document.createElement('canvas'); cv.width = cv.height = 1; const cx = cv.getContext('2d', { willReadFrequently: true }); const rgb = (c) => { cx.clearRect(0,0,1,1); cx.fillStyle = '#000'; cx.fillStyle = c; cx.fillRect(0,0,1,1); const d = cx.getImageData(0,0,1,1).data; return [d[0],d[1],d[2]]; }; const lum = ([r,g,b]) => { const f = v => { v/=255; return v<=0.03928? v/12.92 : Math.pow((v+0.055)/1.055,2.4); }; return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b); }; const out = []; for (const [t,b] of ${JSON.stringify(pairs)}) { const e = document.createElement('div'); e.className = t+' '+b; document.body.appendChild(e); const cs = getComputedStyle(e); const fg = rgb(cs.color), bg = rgb(cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? '#fff' : cs.backgroundColor); const L1 = lum(fg), L2 = lum(bg); const r = (Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05); out.push([t,b,'#'+fg.map(x=>x.toString(16).padStart(2,'0')).join(''),'#'+bg.map(x=>x.toString(16).padStart(2,'0')).join(''), r.toFixed(2)]); e.remove(); } return out; })()`); for (const r of res) console.log(r[0].padEnd(16), r[1].padEnd(16), r[2], r[3], 'ratio', r[4], Number(r[4])>=4.5?'AA text ok':(Number(r[4])>=3?'only large text/UI (3:1)':'FAIL')); }); Results text-slate-400 bg-white #90a1b9 #ffffff 2.63 FAIL text-slate-500 bg-white #62748e #ffffff 4.76 AA text ok text-slate-600 bg-white #45556c #ffffff 7.58 AA text ok text-gray-400 bg-white #99a1af #ffffff 2.60 FAIL text-gray-500 bg-white #6a7282 #ffffff 4.84 AA text ok text-white bg-blue-500 #ffffff #2b7fff 3.76 large text / UI only text-white bg-blue-600 #ffffff #155dfc 5.25 AA text ok text-blue-500 bg-white #2b7fff #ffffff 3.76 large text / UI only text-blue-600 bg-white #155dfc #ffffff 5.25 AA text ok text-white bg-red-500 #ffffff #fb2c36 3.81 large text / UI only text-white bg-emerald-500 #ffffff #00bc7d 2.47 FAIL text-white bg-emerald-700 #ffffff #007a55 5.36 AA text ok text-slate-900 bg-yellow-300 #0f172b #ffdf20 13.44 AA text ok text-slate-500 bg-slate-100 #62748e #f1f5f9 4.35 fails for normal text (the check I could predict: black on white gave exactly 21.00 when the classes were missing, which is how I noticed the problem) --- r4.mjs : the preferences (motion, contrast, forced colours) --- import { withPage } from '../../c4/cdp.mjs'; await withPage('file:///C:/lwdj/tw2/c9/a/page.html', async (p) => { for (let i=0;i<8;i++) await p.key('Tab'); console.log('skip link focused:', await p.ev('document.activeElement.id'), '| position', await p.css('#s4','position'), '| width', await p.css('#s4','width'), '| bg', await p.css('#s4','background-color'), '| left/top', await p.css('#s4','left'), await p.css('#s4','top')); const an = async (id) => `${await p.css('#'+id,'animation-name')} | transition-property ${(await p.css('#'+id,'transition-property')).slice(0,30)}`; console.log('MOTION no preference:'); for (const id of ['m1','m2','m3']) console.log(' ', id, await p.css('#'+id,'animation-name')); console.log(' m4 transition-duration', await p.css('#m4','transition-duration')); await p.media([{ name: 'prefers-reduced-motion', value: 'reduce' }]); console.log('MOTION prefers-reduced-motion: reduce:'); for (const id of ['m1','m2','m3']) console.log(' ', id, await p.css('#'+id,'animation-name')); console.log(' m4 transition-duration', await p.css('#m4','transition-duration'), '| transition-property', await p.css('#m4','transition-property')); await p.media([]); console.log('CONTRAST default: c1', await p.css('#c1','color'), '| c2 border', await p.css('#c2','border-top-color')); await p.media([{ name: 'prefers-contrast', value: 'more' }]); console.log('CONTRAST more: c1', await p.css('#c1','color'), '| c2 border', await p.css('#c2','border-top-color')); await p.media([{ name: 'prefers-contrast', value: 'less' }]); console.log('CONTRAST less: c1', await p.css('#c1','color'), '| c2 border', await p.css('#c2','border-top-color')); await p.media([]); const fc = async (id) => `${await p.css('#'+id,'background-color')} / ${await p.css('#'+id,'color')} / forced-color-adjust ${await p.css('#'+id,'forced-color-adjust')}`; console.log('FORCED default:'); for (const id of ['x1','x2','x3','x4']) console.log(' ', id, await fc(id)); await p.media([{ name: 'forced-colors', value: 'active' }]); console.log('FORCED active:'); for (const id of ['x1','x2','x3','x4']) console.log(' ', id, await fc(id)); console.log(' matchMedia forced-colors:', await p.ev(`matchMedia('(forced-colors: active)').matches`)); }); MOTION no preference: m1 spin | m2 spin | m3 spin | m4 transition-duration 0.7s MOTION reduce: m1 spin | m2 none | m3 none | m4 transition-property none CONTRAST default: text oklch(0.554 ...) border oklch(0.869 ...) more: text oklch(0.208 ...) border oklch(0.208 ...) less: text unchanged, border oklch(0.968 ...) FORCED default: x1/x2/x3 bg oklch(0.546 0.245 262.881) text white ; x4 bg blue, text black FORCED active: x1 bg white text black | x2 bg white text black | x3 unchanged (forced-color-adjust: none) | x4 transparent ; matchMedia forced-colors true WHY THIS WORKS AS AN ANSWER --------------------------- The ratios come from the computed colours, not from guesses: grey-400 and slate-400 on white fail (about 2.6), a light-grey panel pushes slate-500 below 4.5 (4.35), and white on emerald-500 is only 2.47, whereas the next shades down pass. Preferences are not applied for you: m1 kept spinning under prefers-reduced-motion because it has no variant; m2 (motion-reduce:animate-none) and m3 (motion-safe:animate-spin) stopped. contrast-more: changed the text and border colours, contrast-less: only the border, as written. In forced colours the browser replaced x1/x2's colours with system colours, x3 kept its own because of forced-color-adjust-none (so its contrast is now your responsibility), and x4's not-forced-colors: background disappeared. Chrome's emulation, not a real Windows contrast theme.