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.
170 lines
7.5 KiB
JavaScript
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();
|