test/contrast.test.js
Rama main · réplica de lectura del repositorio original.
import { test } from "node:test";
import assert from "node:assert/strict";
import { listThemes, loadTheme } from "../src/theme.js";
import { contrast, ensureContrast } from "../src/chartpalette.js";
/**
* The two surfaces a theme paints under its own text.
*
* A title and a divider are the only slides where the theme chooses both the
* ground and the ink, and the pairing is never checked by rendering: the .pptx
* writes, the colours are valid, and a headline at 2.9:1 looks fine to whoever
* picked it. `claymorphism` shipped a divider headline at 2.95:1 and its
* standfirst at 2.35:1; `chalkboard` set its section number at 1.33:1, which
* is not secondary text, it is absent.
*
* Headline ink is display type — large, so 3:1 is its floor. Everything muted
* is a section number at eyebrow size or a standfirst at subhead size, neither
* of which is large text, so 4.5:1 is the floor there.
*
* The debt list is the surfaces that clear 3:1 but not 4.5:1. It reads as a
* to-do list and mostly is not one: `tools/contrast-debt.mjs` splits it three
* ways, and only five of the original twenty-four were payable.
*
* `muted` is a dimmed `ink`. It may move toward ink; it may not cross the
* ground and come out dark on a saturated divider under white headline type,
* because that is a different design rather than a repaired one. Under that
* one constraint, twelve of these surfaces cannot be fixed by any token edit —
* their `ink` is already pure white and pure white does not clear 4.5:1 against
* the ground, so nothing dimmer can. Another seven are reachable only by moving
* muted so far into ink that the two are the same colour, which pays the debt
* by deleting the distinction it exists to express.
*
* So the nineteen that remain are a question about those backgrounds, not about
* these tokens, and changing a divider ground is the theme's identity. Run the
* tool before assuming an entry here is simply outstanding work.
*
* The list is still a to-do
* list for docs/ROADMAP.md 10 "Every theme against every slide type", not a
* configuration: the test fails if an entry is left in after it is paid, so
* the list cannot rot.
*/
const INK_FLOOR = 3.0; // display type on its own surface
const MUTED_FLOOR = 4.5; // eyebrow and subhead are not large text
const OWED = new Set([
"blueprint section", // 3.91:1
"brutalist-paper section", // 3.70:1
"claymorphism section", // 3.52:1
"corporate-alegria section", // 3.41:1
"glassmorphism section", // 3.49:1
"gradient-mesh-dark section", // 3.03:1
"material-you section", // 3.69:1
"memphis-postmodern section", // 3.21:1
"minimal-muji section", // 3.05:1
"mono-terminal-light section", // 3.81:1
"nature-organic section", // 3.52:1
"neubrutalism section", // 3.06:1
"neumorphism section", // 3.55:1
"paper-pastel section", // 4.21:1
"risograph section", // 3.07:1
"soft-glass-light section", // 3.55:1
"sunset section", // 3.90:1
"swiss-international section", // 3.86:1
"warm-humanist section", // 3.25:1
].map((s) => s.trim()));
async function surfaces() {
const out = [];
for (const name of await listThemes()) {
const theme = await loadTheme(name);
for (const surface of ["title", "section"]) {
out.push({ key: `${name} ${surface}`, ...theme.surfaces[surface] });
}
}
return out;
}
test("every title and divider surface carries its own headline", async () => {
const bad = (await surfaces())
.map((s) => ({ key: s.key, ratio: contrast(s.ink, s.bg) }))
.filter((s) => s.ratio < INK_FLOOR)
.map((s) => `${s.key}: headline ${s.ratio.toFixed(2)}:1`);
assert.deepEqual(bad, [], `divider or title headline below ${INK_FLOOR}:1`);
});
test("every title and divider surface carries its secondary text", async () => {
const unexpected = [], paid = [];
for (const s of await surfaces()) {
const ratio = contrast(s.muted, s.bg);
// Nothing may fall below the headline floor, recorded as debt or not:
// below that the text is not muted, it is gone.
assert.ok(ratio >= INK_FLOOR, `${s.key}: secondary text at ${ratio.toFixed(2)}:1 is not muted, it is absent`);
if (ratio < MUTED_FLOOR && !OWED.has(s.key)) unexpected.push(`${s.key}: ${ratio.toFixed(2)}:1`);
if (ratio >= MUTED_FLOOR && OWED.has(s.key)) paid.push(s.key);
}
assert.deepEqual(unexpected, [], `secondary text below ${MUTED_FLOOR}:1 and not recorded as debt`);
assert.deepEqual(paid, [], "the debt list names surfaces that now pass — delete those lines");
});
/**
* The THIRD surface a theme paints under its own text, and the one that was
* missing: an ink-filled panel in light mode.
*
* `callout`, `takeaway` and `compare`'s verdict bar invert the ground locally
* — a near-black panel on a light page — and then drew the label in
* `accent_alt`, which is the theme's secondary accent FOR THE PAGE, not for
* the panel. Every theme redefines that token under `dark:` precisely because
* the ground changed there; nothing did the same for a panel that inverts on
* its own. Seventeen of the thirty-four put the label under 3:1 against the
* panel it sits on, and `swiss-international` drew #1D3557 navy on #141414.
*
* No sweep could see it. The .pptx writes, the fit is clean, the colours are
* valid, and the geometry watcher only asks whether the box is on the slide.
*
* `onInk()` in src/layouts.js is what these assert; the floors are its two
* arguments. There is no debt list because after the nudge there is no debt.
*/
test("an ink-filled panel carries the accent drawn on it", async () => {
const bad = [];
for (const name of await listThemes()) {
const theme = await loadTheme(name);
const raw = theme.palette.accent_alt ?? theme.palette.accent;
const label = contrast(ensureContrast(raw, theme.palette.ink, MUTED_FLOOR), theme.palette.ink);
// A bullet marker is a graphical element, so WCAG's non-text floor applies.
const marker = contrast(ensureContrast(raw, theme.palette.ink, INK_FLOOR), theme.palette.ink);
if (label < MUTED_FLOOR) bad.push(`${name}: panel label ${label.toFixed(2)}:1`);
if (marker < INK_FLOOR) bad.push(`${name}: panel marker ${marker.toFixed(2)}:1`);
}
assert.deepEqual(bad, [], "callout/takeaway/verdict ink panel cannot carry its own accent");
});
/**
* The body text on those same panels is the theme's `surface` token, which is
* chosen to sit UNDER ink rather than on it. It happens to be the far side of
* the same contrast, so it passes everywhere — but nothing said so, and a
* theme darkening its surface would break the panel silently.
*/
test("an ink-filled panel carries its body text", async () => {
const bad = [];
for (const name of await listThemes()) {
const theme = await loadTheme(name);
const ratio = contrast(theme.palette.surface, theme.palette.ink);
if (ratio < MUTED_FLOOR) bad.push(`${name}: panel body ${ratio.toFixed(2)}:1`);
}
assert.deepEqual(bad, [], "ink panel body text below the muted floor");
});