/** * Contrast diagnostic (NOT part of `npm run test:e2e`): axe cannot judge text * on a blurred glass surface — it reports those nodes as "incomplete" — so * this samples the **rendered pixels** instead. * * For every text element it takes a screenshot of that element's box, finds * the dominant colour in it (the background: glyphs are the minority of the * pixels) and compares it with the text colour from the computed style using * the WCAG relative-luminance formula. Large text (>= 24px, or >= 18.66px * bold) is held to 3:1, everything else to 4.5:1. * * node tests/contrast.mjs # every screen, both themes * node tests/contrast.mjs study # filter by screen name * * It always exits 0 — the numbers are for tuning the palette. */ import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs"; const filter = process.argv[2] ?? ""; const browser = await launchBrowser(); const failures = []; /** WCAG relative luminance of an [r, g, b] triple (0-255). */ const LUMINANCE = ` const luminance = ([r, g, b]) => { const channel = (value) => { const v = value / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); }; const contrast = (a, b) => { const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (l1 + 0.05) / (l2 + 0.05); }; `; const screens = [ ["library", "/", null], ["study", "/deck/seed_deck", null], ["create", "/create", null], ["homework", "/homework", null], ["chat", "/chat", null], ["stats", "/stats", null], ["exam", "/exam", null], ["podcast", "/podcast/pod_contrast", null], ["settings", "/", "settings"], ["notfound", "/no-such-page", null], ]; for (const [label, path, mode] of screens) { if (filter && !label.includes(filter)) continue; for (const theme of ["dark", "light", "aero"]) { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 }); await seed(page, { decks: [DECK], theme, extra: { "freezmo.podcasts.v1": JSON.stringify([ { id: "pod_contrast", title: "Why the sky is blue", summary: "Rayleigh scattering, explained out loud.", language: "English", sourceText: "Rayleigh scattering makes the sky blue.", createdAt: 1, updatedAt: 1, sections: [ { heading: "Daylight", text: "Look up on a clear day. The sky is blue — a blue so obvious that it is easy to stop asking why." }, { heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones." }, ], }, ]), }, }); await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); if (mode === "settings") { await page.locator("nav.tabbar").getByRole("button").click(); await page.getByRole("dialog").waitFor({ timeout: 6000 }); await page.waitForTimeout(700); } // Every text-bearing element with a visible box, small enough to sample. // Content behind an open sheet is dimmed on purpose and not readable, and // disabled controls are exempt from 1.4.3 — both are skipped. const targets = await page.evaluate(() => { const out = []; const dialog = document.querySelector('[role="dialog"]'); const root = dialog ?? document; for (const element of root.querySelectorAll("p, span, li, h1, h2, h3, button, a, label, dt, dd")) { if (element.closest("[disabled]") || element.closest('[aria-disabled="true"]')) continue; const text = (element.textContent ?? "").trim(); if (text.length < 2) continue; if (element.children.length > 0 && [...element.children].some((child) => (child.textContent ?? "").trim().length > 0)) continue; const style = getComputedStyle(element); if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) < 0.5) continue; const box = element.getBoundingClientRect(); if (box.width < 8 || box.height < 8 || box.bottom < 0 || box.top > window.innerHeight) continue; out.push({ id: out.length, text: text.slice(0, 40), color: style.color, fontSize: Number.parseFloat(style.fontSize), fontWeight: Number(style.fontWeight), box: { x: box.x, y: box.y, width: box.width, height: box.height }, }); } return out; }); const screenshot = await page.screenshot({ type: "png", scale: "css" }); const dataUrl = `data:image/png;base64,${screenshot.toString("base64")}`; const report = await page.evaluate( async ({ targets, dataUrl, luminanceSource }) => { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = dataUrl; }); const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height; const ctx = canvas.getContext("2d"); ctx.drawImage(image, 0, 0); const full = ctx.getImageData(0, 0, canvas.width, canvas.height).data; const { contrast } = new Function(`${luminanceSource} return { contrast };`)(); const parseColor = (value) => { const match = /rgba?\(([^)]+)\)/.exec(value); if (!match) return null; const parts = match[1].split(",").map((part) => Number.parseFloat(part)); return [parts[0], parts[1], parts[2], parts[3] ?? 1]; }; const results = []; for (const target of targets) { const text = parseColor(target.color); if (!text) continue; // Sample a slightly larger box and ignore pixels close to the text // colour: those are the glyphs themselves, not the background. const x0 = Math.max(0, Math.floor(target.box.x) - 2); const y0 = Math.max(0, Math.floor(target.box.y) - 2); const x1 = Math.min(canvas.width, Math.ceil(target.box.x + target.box.width) + 2); const y1 = Math.min(canvas.height, Math.ceil(target.box.y + target.box.height) + 2); if (x1 - x0 < 4 || y1 - y0 < 4) continue; const counts = new Map(); let sampled = 0; for (let y = y0; y < y1; y += 1) { for (let x = x0; x < x1; x += 1) { const index = (y * canvas.width + x) * 4; const pixel = [full[index], full[index + 1], full[index + 2]]; const distance = Math.max( Math.abs(pixel[0] - text[0]), Math.abs(pixel[1] - text[1]), Math.abs(pixel[2] - text[2]), ); if (distance <= 24) continue; // glyph pixel (or its antialiasing) const key = `${pixel[0]},${pixel[1]},${pixel[2]}`; counts.set(key, (counts.get(key) ?? 0) + 1); sampled += 1; } } if (sampled < 12) continue; // all glyphs: nothing to measure against let best = null; for (const [key, count] of counts) if (!best || count > best.count) best = { key, count }; const background = best.key.split(",").map(Number); const ratio = contrast(text, background); const large = target.fontSize >= 24 || (target.fontSize >= 18.66 && Number(target.fontWeight) >= 700); const minimum = large ? 3 : 4.5; if (ratio < minimum) { results.push({ text: target.text, ratio: Number(ratio.toFixed(2)), minimum, color: target.color, background: best.key }); } } return results; }, { targets, dataUrl, luminanceSource: LUMINANCE }, ); if (report.length > 0) { console.log(`\n[${theme}] ${label} — ${report.length} below minimum`); for (const row of report) { console.log(` ${row.ratio}:1 (needs ${row.minimum}) "${row.text}" — ${row.color} on rgb(${row.background})`); failures.push({ theme, label, ...row }); } } await context.close(); } } console.log(`\ncontrast scan done — ${failures.length} below-minimum findings`); await browser.close();