Ctrl K
ComunidadGuías…

scripts/check-skin-contrast.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Measures every text/surface pair of the skins in src/styles.css, parsed from
// the CSS itself. Run it after touching a palette:
//
//   pnpm check:contrast
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const css = readFileSync(join(root, "src/styles.css"), "utf8");

function declarations(body) {
  const tokens = {};
  for (const [, name, value] of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
    tokens[name] = value.trim();
  }
  return tokens;
}

/** The `@theme` defaults plus the bare `:root` block: a skin that does not
 * redefine one of these still ships it, so it is measured. */
function parseBase(source) {
  const base = {};
  for (const [, body] of source.matchAll(/(?:@theme|:root)\s*\{([^}]*)\}/g)) {
    Object.assign(base, declarations(body));
  }
  return base;
}

/** Every `[data-skin="x"] { … }` block, as id → {token: value}, over the
 * inherited base so a skin is measured as it actually renders. */
function parseSkins(source) {
  const { desktop, phone } = splitPhone(source);
  const base = parseBase(desktop);
  const skins = new Map();
  for (const [, id, body] of desktop.matchAll(/\[data-skin="([a-z-]+)"\]\s*\{([^}]*)\}/g)) {
    skins.set(id, { ...base, ...declarations(body) });
  }
  // The phone block re-declares some tokens of a skin under a width query, so
  // that skin is measured twice: as it renders on desktop and as "<id> (phone)".
  for (const [, id, body] of phone.matchAll(/\[data-skin="([a-z-]+)"\]\)?\s*\{([^}]*)\}/g)) {
    skins.set(`${id} (phone)`, { ...skins.get(id), ...declarations(body) });
  }
  return skins;
}

/** The `@media (max-width: 767.98px) { … }` block that re-declares skin tokens,
 * cut out of the source so its skin selectors are not mistaken for a second
 * definition of the skin. Other blocks under the same query hold layout only. */
function splitPhone(source) {
  for (const { index: start } of source.matchAll(/@media \(max-width: 767\.98px\)\s*\{/g)) {
    let depth = 0;
    let end = source.indexOf("{", start);
    for (let i = end; i < source.length; i++) {
      if (source[i] === "{") depth++;
      else if (source[i] === "}" && --depth === 0) {
        end = i + 1;
        break;
      }
    }
    const phone = source.slice(start, end);
    if (phone.includes("[data-skin=")) return { desktop: source.slice(0, start) + source.slice(end), phone };
  }
  return { desktop: source, phone: "" };
}

function parseHex(value) {
  const match = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value.trim());
  if (!match) return null;
  const h = match[1];
  const full = h.length <= 4 ? Array.from(h, (c) => c + c).join("") : h;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
    a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1,
  };
}

const NAMED = { black: "#000000", white: "#ffffff" };

/** A token's value with every var() replaced by the token it names, as the skin renders it. */
function resolve(value, tokens, depth = 0) {
  if (depth > 8) return value;
  return value.replace(/var\((--[\w-]+)(?:,\s*([^()]+))?\)/g, (_, name, fallback) => resolve(tokens[name] ?? fallback ?? "", tokens, depth + 1));
}

const linear = (v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
const gamma = (v) => Math.max(0, Math.min(255, Math.round(255 * (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055))));
function toOklab({ r, g, b }) {
  const [lr, lg, lb] = [linear(r), linear(g), linear(b)];
  const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
  const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
  const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
  return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
}
function fromOklab([L, A, B]) {
  const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3, m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3, s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
  return { r: gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), g: gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
    b: gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), a: 1 };
}

/** An opaque or translucent hex, a named colour, or color-mix() of two of them in oklab, as CSS mixes it. */
function parseColor(value) {
  const text = value.trim();
  const named = NAMED[text.toLowerCase()];
  if (named) return parseHex(named);
  const mix = /^color-mix\(\s*in oklab\s*,\s*([^,]+?)(?:\s+([\d.]+)%)?\s*,\s*([^,]+?)(?:\s+([\d.]+)%)?\s*\)$/i.exec(text);
  if (!mix) return parseHex(text);
  const [first, second] = [parseColor(mix[1]), parseColor(mix[3])];
  if (!first || !second || first.a !== 1 || second.a !== 1) return null;
  const p2 = mix[4] !== undefined ? Number(mix[4]) / 100 : mix[2] !== undefined ? 1 - Number(mix[2]) / 100 : 0.5;
  const [a, b] = [toOklab(first), toOklab(second)];
  return fromOklab(a.map((value, i) => value * (1 - p2) + b[i] * p2));
}

/** Foreground alpha composited over an opaque background. */
function flatten(fg, bg) {
  if (fg.a === 1) return fg;
  return {
    r: fg.r * fg.a + bg.r * (1 - fg.a),
    g: fg.g * fg.a + bg.g * (1 - fg.a),
    b: fg.b * fg.a + bg.b * (1 - fg.a),
    a: 1,
  };
}

function luminance({ r, g, b }) {
  const channel = (v) => {
    const s = v / 255;
    return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function contrast(fgValue, bgValue) {
  const bg = parseColor(bgValue);
  const parsedFg = parseColor(fgValue);
  if (!parsedFg || !bg || bg.a !== 1) return null;
  const fg = flatten(parsedFg, bg);
  const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
  return (hi + 0.05) / (lo + 0.05);
}

// Pairs taken from what the components render.
// The frame carries the sidebar's names, previews and times in a framed window.
const SURFACES = ["--color-app", "--color-panel", "--color-frame", "--color-raised", "--color-raised-hover", "--color-card", "--color-inset", "--color-composer", "--color-menu"];
const PAIRS = [
  ...SURFACES.map((s) => ["--color-ink", s, 4.5]),
  ...SURFACES.map((s) => ["--color-ink-secondary", s, 4.5]),
  // Quiet text is body-size, so it is held to the same 4.5:1.
  ...SURFACES.map((s) => ["--color-ink-tertiary", s, 4.5]),
  ["--color-bubble-user-ink", "--color-bubble-user", 4.5],
  ["--color-accent-ink", "--color-accent", 4.5],
  ["--color-danger-ink", "--color-danger", 4.5],
  ["--color-success-ink", "--color-success", 4.5],
  ["--color-accent-text", "--color-app", 4.5],
  ["--color-accent-text", "--color-panel", 4.5],
  ["--color-accent-text", "--color-card", 4.5],
  ["--color-danger", "--color-card", 4.5],
  ["--color-success", "--color-card", 4.5],
  ["--color-warning", "--color-card", 4.5],
  // The usage card's and model menu's "At its limit", and the usage bars in the model button.
  ["--color-warning", "--color-menu", 4.5],
  ["--color-warning", "--color-composer", 3],
  // borders and dots are UI components, not text — AA asks 3:1 of them
  ["--color-hairline", "--color-app", 1.5],
  ["--color-accent", "--color-app", 3],
  ["--color-scrollbar", "--color-app", 1.5],
  // The focus ring sits outside the control (outline-offset: 2px), on whatever
  // surface is behind it. WCAG 1.4.11 asks 3:1 of a non-text indicator.
  ["--color-focus", "--color-app", 3],
  ["--color-focus", "--color-panel", 3],
  ["--color-focus", "--color-card", 3],
  // A focused text field paints its border in the ring colour, so the ring
  // must also clear the field fills.
  ["--color-focus", "--color-inset", 3],
  ["--color-focus", "--color-raised", 3],
  ["--color-focus", "--color-control", 3],
  // Surface against surface: two surfaces with one value hide everything painted
  // on one over the other. A surface only has to be seen, so the bar is a
  // just-perceptible step, not a WCAG ratio. `control` can land on any surface.
  ["--color-control", "--color-card", 1.06],
  ["--color-control", "--color-panel", 1.06],
  ["--color-control", "--color-app", 1.04],
  ["--color-control", "--color-inset", 1.04],
  ["--color-control", "--color-raised-hover", 1.04],
  ["--color-raised-hover", "--color-card", 1.04],
  ["--color-inset", "--color-card", 1.04],
  ["--color-card", "--color-app", 1.04],
  ["--color-panel", "--color-app", 1.03],
  // The app's card is told from the frame round it.
  ["--color-frame", "--color-app", 1.05],
];

const skins = parseSkins(css);
// Midnight faithfully keeps two upstream contrast gaps. They may improve, but
// must not get worse; every other below-target pair is a regression.
const BASELINE_FLOORS = new Map([
  ["midnight|--color-accent-ink|--color-accent", 3.65],
  ["midnight|--color-danger-ink|--color-danger", 3.10],
]);
const BASELINE_DRIFT = 0.01;

let failed = false;
for (const [id, tokens] of skins) {
  const problems = [];
  const missing = [];
  const unmeasurable = [];
  let measured = 0;
  for (const [fg, bg, min] of PAIRS) {
    // A pair we cannot measure is reported, never skipped.
    if (!tokens[fg] || !tokens[bg]) {
      missing.push(!tokens[fg] ? fg : bg);
      continue;
    }
    const ratio = contrast(resolve(tokens[fg], tokens), resolve(tokens[bg], tokens));
    if (ratio === null) {
      unmeasurable.push({ fg, bg, fgValue: tokens[fg], bgValue: tokens[bg] });
      continue;
    }
    measured++;
    if (ratio < min) problems.push({ fg, bg, ratio, min });
  }
  let skinFailed = missing.length > 0 || unmeasurable.length > 0;
  if (missing.length) {
    console.log(`✗ ${id} — undefined token(s): ${[...new Set(missing)].join(", ")}`);
  }
  for (const { fg, bg, fgValue, bgValue } of unmeasurable) {
    console.log(`✗ ${id} — cannot measure ${fg} on ${bg} (${fgValue} on ${bgValue})`);
  }
  if (problems.length === 0) {
    if (!skinFailed) console.log(`✓ ${id} — ${measured} pairs, none below target`);
    if (skinFailed) failed = true;
    continue;
  }
  for (const { fg, bg, ratio } of problems) {
    const floor = BASELINE_FLOORS.get(`${id.replace(/ \(phone\)$/, "")}|${fg}|${bg}`);
    if (floor === undefined || ratio < floor - BASELINE_DRIFT) skinFailed = true;
  }
  console.log(`${skinFailed ? "✗" : "~"} ${id}${skinFailed ? "" : " (known upstream gaps)"}`);
  for (const { fg, bg, ratio, min } of problems) {
    const floor = BASELINE_FLOORS.get(`${id.replace(/ \(phone\)$/, "")}|${fg}|${bg}`);
    const baseline = floor === undefined ? "" : `; baseline ${floor.toFixed(2)}:1`;
    console.log(`    ${fg} on ${bg}: ${ratio.toFixed(2)}:1 (needs ${min}:1${baseline})`);
  }
  if (skinFailed) failed = true;
}

// The inverted bubbles (Daylight's ink-black, Pop's ink) have
// their own inherited context: the editor, labels, quotes and file chips
// explicitly use these tokens, not parent color.
for (const skinId of ["daylight", "pop"]) {
  const scope = css.match(new RegExp(`@scope \\(\\[data-skin="${skinId}"\\]\\) to \\(\\[data-skin\\]\\)\\s*\\{\\s*\\.bg-bubble-user\\s*\\{([^}]*)\\}`))?.[1];
  if (!scope) {
    failed = true;
    console.log(`✗ ${skinId} bubble — no @scope block for the inverted bubble`);
    continue;
  }
  const bubble = { ...skins.get(skinId), ...declarations(scope) };
  let bubbleFailed = false;
  for (const [fg, bg] of [
    ["--color-ink", "--color-bubble-user"],
    ["--color-ink-secondary", "--color-bubble-user"],
    ["--color-ink", "--color-raised"],
    ["--color-ink-secondary", "--color-raised-hover"],
    ["--color-ink-secondary", "--color-inset"],
    ["--color-ink-tertiary", "--color-bubble-user"],
    ["--color-ink-tertiary", "--color-raised-hover"],
    ["--color-ink-tertiary", "--color-inset"],
    ["--color-accent", "--color-inset"],
    ["--color-accent-text", "--color-bubble-user"],
    ["--color-ink", "--color-control"],
    ["--color-accent-ink", "--color-accent"],
    ["--color-danger-ink", "--color-danger"],
    ["--color-success-ink", "--color-success"],
  ]) {
    const ratio = contrast(resolve(bubble[fg] ?? "", bubble), resolve(bubble[bg] ?? "", bubble));
    if (ratio === null || ratio < 4.5) {
      failed = true;
      bubbleFailed = true;
      console.log(`✗ ${skinId} bubble — ${fg} on ${bg}: ${ratio?.toFixed(2) ?? "unmeasurable"}:1 (needs 4.5:1)`);
    }
  }
  if (!bubbleFailed) console.log(`✓ ${skinId} bubble — editor, controls and paired fills above 4.5:1`);
}

// MausBot Cloud's night card paints its own sky in every skin: an indigo dusk in the light scheme, a deep night in the
// dark one. Its text sits between the sky's first two stops; the close button's glyph also lands on the far stop.
const night = [...css.matchAll(/:root\s*\{([^}]*)\}/g)].map(([, body]) => declarations(body)).find(tokens => tokens["--cloud-night-from"]);
if (!night) {
  failed = true;
  console.log("✗ night card — no :root block defines --cloud-night-from");
} else {
  for (const [scheme, pick] of [["dusk", 1], ["night", 2]]) {
    const value = (token) => /^light-dark\(\s*([^,]+?)\s*,\s*([^)]+?)\s*\)$/.exec(night[token] ?? "")?.[pick] ?? night[token] ?? "";
    let schemeFailed = false;
    for (const [fg, bg, min] of [
      ["--cloud-night-ink", "--cloud-night-from", 4.5],
      ["--cloud-night-ink", "--cloud-night-mid", 4.5],
      ["--cloud-night-ink-quiet", "--cloud-night-from", 4.5],
      ["--cloud-night-ink-quiet", "--cloud-night-mid", 4.5],
      ["--cloud-night-ink-quiet", "--cloud-night-to", 3],
    ]) {
      const ratio = contrast(value(fg), value(bg));
      if (ratio === null || ratio < min) {
        failed = true;
        schemeFailed = true;
        console.log(`✗ night card (${scheme}) — ${fg} on ${bg}: ${ratio?.toFixed(2) ?? "unmeasurable"}:1 (needs ${min}:1)`);
      }
    }
    if (!schemeFailed) console.log(`✓ night card (${scheme}) — its text and close button clear the sky behind them`);
  }
}

process.exit(failed ? 1 : 0);