freezmo/tests/contrast.mjs
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
2026-10-12 21:15:56 +00:00

169 lines
7.4 KiB
JavaScript

/**
* 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],
["settings", "/", "settings"],
];
for (const [label, path, mode] of screens) {
if (filter && !label.includes(filter)) continue;
for (const theme of ["dark", "light"]) {
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK], theme });
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();