Frutiger Aero (Settings -> Theme): a third theme, not a dark/light variant. A sky gradient with a strip of grass, the glacier blobs become clouds, the orbs soap bubbles, glass gets the classic horizon gloss and the buttons a glossy aqua. The light/dark toggles (header, rail, chat header) hide while it is on and the picker says why; the pre-paint script applies the class before first paint and the PWA status bar follows (#3fa3e0). Its muted/frost text is darker than Light's because those labels sit directly on the sky. Contrast, measured on real pixels (tests/contrast.mjs, now covering aero): - The light settings sheet was translucent over a dark scrim, so its muted text measured ~2.8:1. Sheets and dialogs now use --glass-sheet-bg (a more opaque surface), and Light's --text-muted is a touch darker. - Result: 0 below-minimum findings and 0 axe violations across every screen, all three themes, phone and desktop. Also: the suites now seed the cookie notice as already dismissed (they run as a returning user); the regression suite opts out to test the notice itself — the previous deploy failed its gate because the notice blocked a chat chip click. The notice also hides during immersive sessions (overlay-open), so it can never cover a study feed. Version 0.1.8, SW -> freezmo-v54.
169 lines
7.4 KiB
JavaScript
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", "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();
|