freezmo/tests/contrast.mjs
Martin Dvorak 7e42b03f7e Custom Frutiger Aero 404
app/not-found.tsx renders a standalone screen: the root layout's ice
scene is the backdrop, the glass card/rim/bubbles come from the shared
recipes (full Aero with that theme on, frosted glass in light/dark),
and the mascot is the scene's glossy ice orb at full strength with a
snowflake and a "404" chip. Two ways out - back to the library and
into the chat - both 44px targets, en + cs.

The regression suite pins the 404 status, both links and the Czech
copy; the pixel-contrast scan covers the screen in all three themes.
The service worker no longer caches non-OK navigations as the offline
shell (a typo'd URL used to write the error page into the shell
cache). Version 0.5.6, SW -> freezmo-v78.
2026-10-14 15:27:47 +00:00

170 lines
7.5 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"],
["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 });
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();