Ctrl K
ComunidadGuías…

test/contrast.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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");
});