freezmo/tests/contrast.mjs
Martin Dvorak e56653dae1 Frutiger Aero theme, pixel-measured contrast fixes, sheet surfaces
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.
2026-10-12 22:10:16 +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", "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();