def-peter_obsidian-aera-theme/tests/foundations.test.mjs
2026-07-21 16:57:28 +08:00

364 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CALLOUT_CONTRAST_PAIRS,
CORE_CONTRAST_PAIRS,
contrastRatio,
relativeLuminance,
runContrastCli,
} from "../scripts/contrast.mjs";
import { declarationsFor } from "./helpers/css.mjs";
const css = await readFile(new URL("../theme.css", import.meta.url), "utf8");
const normalizeWhitespace = (value) => value.replace(/\s+/g, " ");
test("body defines the Aera reading typography without overriding user sizing", () => {
const body = declarationsFor(css, "body");
assert.equal(
normalizeWhitespace(body.get("--font-text-theme")),
'"Avenir Next", Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif',
);
assert.equal(
normalizeWhitespace(body.get("--font-monospace-theme")),
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
);
assert.equal(body.get("--line-height-normal"), "1.8");
assert.equal(body.get("--p-spacing"), "var(--size-4-3)");
assert.equal(body.get("--heading-spacing"), "var(--size-4-8)");
assert.equal(body.get("--bold-modifier"), "200");
for (const property of [
"--font-interface-theme",
"--font-text-size",
"--file-line-width",
]) {
assert.equal(body.has(property), false, `${property} must remain user-controlled`);
}
});
const expectedColors = {
".theme-light": {
"--color-base-00": "#f8fafc",
"--color-base-05": "#f5f7fa",
"--color-base-10": "#f1f4f8",
"--color-base-20": "#e9eef5",
"--color-base-25": "#dde3eb",
"--color-base-30": "#cdd5df",
"--color-base-35": "#bec8d4",
"--color-base-40": "#a9b4c2",
"--color-base-50": "#8793a3",
"--color-base-60": "#647184",
"--color-base-70": "#475467",
"--color-base-100": "#202936",
"--background-primary": "var(--color-base-00)",
"--background-secondary": "var(--color-base-10)",
"--text-normal": "var(--color-base-100)",
"--text-muted": "var(--color-base-60)",
"--text-faint": "var(--color-base-50)",
"--text-highlight-bg": "rgba(245, 198, 50, 0.22)",
"--accent-h": "215",
"--accent-s": "100%",
"--accent-l": "54.3%",
"--interactive-accent": "hsl(var(--accent-h), var(--accent-s), var(--accent-l))",
"--text-accent":
"hsl(var(--accent-h), var(--accent-s), calc(var(--accent-l) - 10%))",
"--text-accent-hover":
"hsl(var(--accent-h), var(--accent-s), calc(var(--accent-l) - 14%))",
"--aera-inline-code-background": "#e5e9ee",
"--aera-inline-code-color": "#5d697b",
"--aera-callout-symbol-opacity": "0.28",
"--aera-callout-background-opacity": "0.08",
"--aera-callout-title-semantic-weight": "54%",
"--aera-callout-body-semantic-weight": "54%",
"--aera-quote-background": "#f1f3f6",
"--aera-quote-fold": "#d9dee5",
},
".theme-dark": {
"--color-base-00": "#17191c",
"--color-base-05": "#1b1e22",
"--color-base-10": "#20242a",
"--color-base-20": "#282d34",
"--color-base-25": "#30363e",
"--color-base-30": "#39414b",
"--color-base-35": "#46505c",
"--color-base-40": "#596575",
"--color-base-50": "#748092",
"--color-base-60": "#9aa5b4",
"--color-base-70": "#c1c7d0",
"--color-base-100": "#e7ebf0",
"--background-primary": "var(--color-base-00)",
"--background-secondary": "var(--color-base-10)",
"--text-normal": "var(--color-base-100)",
"--text-muted": "var(--color-base-60)",
"--text-faint": "var(--color-base-50)",
"--text-highlight-bg": "rgba(245, 198, 50, 0.26)",
"--accent-h": "213",
"--accent-s": "100%",
"--accent-l": "62.5%",
"--interactive-accent": "hsl(var(--accent-h), var(--accent-s), var(--accent-l))",
"--text-accent": "hsl(var(--accent-h), var(--accent-s), var(--accent-l))",
"--text-accent-hover":
"hsl(var(--accent-h), var(--accent-s), calc(var(--accent-l) + 5%))",
"--aera-inline-code-background": "#30363e",
"--aera-inline-code-color": "#c1c7d0",
"--aera-callout-symbol-opacity": "0.32",
"--aera-callout-background-opacity": "0.12",
"--aera-callout-title-semantic-weight": "76%",
"--aera-callout-body-semantic-weight": "66%",
"--aera-quote-background": "#22262c",
"--aera-quote-fold": "#3a414a",
},
};
const calloutTypeColors = {
".theme-light": {
note: [8, 109, 221],
warning: [236, 117, 0],
error: [233, 49, 71],
example: [120, 82, 238],
quote: [158, 158, 158],
tip: [0, 191, 188],
success: [8, 185, 78],
},
".theme-dark": {
note: [2, 122, 255],
warning: [233, 151, 63],
error: [251, 70, 76],
example: [168, 130, 255],
quote: [158, 158, 158],
tip: [83, 223, 221],
success: [68, 207, 110],
},
};
function hexChannels(hex) {
return [1, 3, 5].map((offset) =>
Number.parseInt(hex.slice(offset, offset + 2), 16),
);
}
function mixChannels(foreground, background, foregroundWeight) {
return foreground.map(
(channel, index) =>
channel * foregroundWeight + background[index] * (1 - foregroundWeight),
);
}
function channelLuminance(channel) {
const srgb = channel / 255;
return srgb <= 0.04045
? srgb / 12.92
: ((srgb + 0.055) / 1.055) ** 2.4;
}
function channelContrastRatio(foreground, background) {
const luminance = (channels) => {
const [red, green, blue] = channels.map(channelLuminance);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
const foregroundLuminance = luminance(foreground);
const backgroundLuminance = luminance(background);
return (
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
);
}
function calloutSemanticWeight(colorDeclaration, themeDeclarations) {
const literal = colorDeclaration.match(
/rgb\(var\(--callout-color\)\) ([0-9.]+)%/,
);
if (literal) return Number(literal[1]) / 100;
const variable = colorDeclaration.match(
/rgb\(var\(--callout-color\)\) var\((--[^)]+)\)/,
);
assert.ok(variable, colorDeclaration);
return Number.parseFloat(themeDeclarations.get(variable[1])) / 100;
}
const lightCalloutSemanticWeights = {
note: 0.64,
warning: 0.6,
error: 0.64,
example: 0.64,
quote: 0.58,
tip: 0.54,
success: 0.58,
};
for (const [selector, expected] of Object.entries(expectedColors)) {
test(`${selector} defines the complete Aera color foundation`, () => {
const declarations = declarationsFor(css, selector);
for (const [property, value] of Object.entries(expected)) {
assert.equal(declarations.get(property), value, `${selector} ${property}`);
}
});
}
test("semantic callout titles and content meet WCAG AA in both themes", () => {
const roles = [
["title", declarationsFor(css, ".callout-title").get("color")],
["content", declarationsFor(css, ".callout").get("color")],
];
for (const [themeSelector, types] of Object.entries(calloutTypeColors)) {
const theme = declarationsFor(css, themeSelector);
const base = hexChannels(theme.get("--color-base-00"));
const normal = hexChannels(theme.get("--color-base-100"));
const backgroundOpacity = Number.parseFloat(
theme.get("--aera-callout-background-opacity"),
);
for (const [type, semantic] of Object.entries(types)) {
const background = mixChannels(semantic, base, backgroundOpacity);
for (const [role, colorDeclaration] of roles) {
const weight =
themeSelector === ".theme-light"
? lightCalloutSemanticWeights[type]
: calloutSemanticWeight(colorDeclaration, theme);
const foreground = mixChannels(semantic, normal, weight);
const ratio = channelContrastRatio(foreground, background);
assert.ok(
ratio >= 4.5,
`${themeSelector} ${type} ${role} is ${ratio.toFixed(2)}:1`,
);
}
}
}
});
test("eleven core foreground/background pairs meet WCAG AA contrast", () => {
assert.deepEqual(CORE_CONTRAST_PAIRS, [
["light text", "#202936", "#f8fafc"],
["light muted", "#647184", "#f8fafc"],
["light text accent", "#005ee2", "#f8fafc"],
["dark text", "#e7ebf0", "#17191c"],
["dark muted", "#9aa5b4", "#17191c"],
["dark text accent", "#4096ff", "#17191c"],
["light inline code", "#5d697b", "#e5e9ee"],
["dark inline code", "#c1c7d0", "#30363e"],
["monokai normal", "#f8f8f2", "#272822"],
["monokai comment", "#929388", "#272822"],
["monokai keyword", "#ff4b8b", "#272822"],
]);
for (const [name, foreground, background] of CORE_CONTRAST_PAIRS) {
assert.ok(
contrastRatio(foreground, background) >= 4.5,
`${name} must meet WCAG AA`,
);
}
});
test("core contrast ratios match the WCAG calculations", () => {
const expectedRatios = [
"14.02",
"4.74",
"5.43",
"14.71",
"7.06",
"5.89",
"4.57",
"7.17",
"13.94",
"4.78",
"4.69",
];
for (const [index, [, foreground, background]] of CORE_CONTRAST_PAIRS.entries()) {
assert.equal(contrastRatio(foreground, background).toFixed(2), expectedRatios[index]);
}
});
test("contrast calculation matches WCAG reference endpoints", () => {
assert.equal(contrastRatio("#000000", "#ffffff"), 21);
assert.equal(contrastRatio("#647184", "#647184"), 1);
});
test("contrast CLI prints the core and semantic callout pairs", () => {
assert.equal(CALLOUT_CONTRAST_PAIRS.length, 28);
const allPairs = [
...CORE_CONTRAST_PAIRS,
...CALLOUT_CONTRAST_PAIRS,
];
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../scripts/contrast.mjs", import.meta.url))],
{ encoding: "utf8" },
);
const lines = result.stdout.trim().split("\n");
assert.equal(result.status, 0, result.stderr);
assert.equal(lines.length, 39);
for (const [index, [name, foreground, background]] of allPairs.entries()) {
assert.ok(contrastRatio(foreground, background) >= 4.5, name);
assert.match(lines[index], new RegExp(`^PASS ${name}:`));
}
});
test("contrast CLI runs when invoked through a symbolic link", (t) => {
const directory = mkdtempSync(join(tmpdir(), "aera-contrast-cli-"));
const symlinkPath = join(directory, "contrast.mjs");
const cliPath = fileURLToPath(new URL("../scripts/contrast.mjs", import.meta.url));
t.after(() => rmSync(directory, { recursive: true, force: true }));
try {
symlinkSync(cliPath, symlinkPath);
} catch (error) {
if (["EACCES", "EPERM"].includes(error.code)) {
t.skip("creating symbolic links is not permitted in this environment");
return;
}
throw error;
}
const result = spawnSync(process.execPath, [symlinkPath], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /PASS light text:/);
});
test("contrast CLI marks a failing pair and requests exit code 1", () => {
const originalExitCode = process.exitCode;
const output = [];
try {
process.exitCode = undefined;
runContrastCli(
[["failing pair", "#777777", "#777777"]],
(line) => output.push(line),
);
assert.equal(process.exitCode, 1);
assert.match(output[0], /^FAIL failing pair: 1\.00:1/);
} finally {
process.exitCode = originalExitCode;
}
});
test("contrast helpers reject invalid hex colors with a clear error", () => {
for (const value of ["#fff", "202936", "#gggggg", "#1234567", 42]) {
assert.throws(
() => relativeLuminance(value),
(error) =>
error instanceof TypeError &&
error.message === `Invalid hex color: ${String(value)}`,
);
}
assert.throws(
() => contrastRatio("#202936", "not-a-color"),
/Invalid hex color: not-a-color/,
);
});