feat: add Aera color and typography tokens

This commit is contained in:
Peter Li 2026-07-16 15:13:15 +08:00
parent 0acb3639d2
commit 7f3c846955
7 changed files with 374 additions and 0 deletions

63
scripts/contrast.mjs Normal file
View file

@ -0,0 +1,63 @@
import { pathToFileURL } from "node:url";
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
export const CORE_CONTRAST_PAIRS = [
["light text", "#28302c", "#f7f8f6"],
["light muted", "#68756d", "#f7f8f6"],
["light accent", "#3f725b", "#f7f8f6"],
["dark text", "#dce4df", "#171c19"],
["dark muted", "#95a39b", "#171c19"],
["dark accent", "#76b795", "#171c19"],
];
function rgbChannels(hex) {
if (typeof hex !== "string" || !HEX_COLOR.test(hex)) {
throw new TypeError(`Invalid hex color: ${String(hex)}`);
}
return [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16));
}
function linearize(channel) {
const srgb = channel / 255;
return srgb <= 0.04045
? srgb / 12.92
: ((srgb + 0.055) / 1.055) ** 2.4;
}
export function relativeLuminance(hex) {
const [red, green, blue] = rgbChannels(hex).map(linearize);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
export function contrastRatio(foreground, background) {
const foregroundLuminance = relativeLuminance(foreground);
const backgroundLuminance = relativeLuminance(background);
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
export function runContrastCli(
pairs = CORE_CONTRAST_PAIRS,
writeLine = (line) => console.log(line),
) {
let failed = false;
for (const [name, foreground, background] of pairs) {
const ratio = contrastRatio(foreground, background);
const status = ratio >= 4.5 ? "PASS" : "FAIL";
writeLine(
`${status} ${name}: ${ratio.toFixed(2)}:1 (${foreground} on ${background})`,
);
failed ||= ratio < 4.5;
}
if (failed) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runContrastCli();
}

View file

@ -0,0 +1,51 @@
.theme-light {
--color-base-00: #f7f8f6;
--color-base-05: #f4f5f3;
--color-base-10: #eff1ef;
--color-base-20: #e8ebe8;
--color-base-25: #dde2de;
--color-base-30: #d1d7d2;
--color-base-35: #c4cbc5;
--color-base-40: #aeb7b0;
--color-base-50: #8b958e;
--color-base-60: #68756d;
--color-base-70: #4e5b53;
--color-base-100: #28302c;
--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(191, 168, 68, 0.26);
--accent-h: 152.9;
--accent-s: 28.8%;
--accent-l: 34.7%;
}
.theme-dark {
--color-base-00: #171c19;
--color-base-05: #1a201c;
--color-base-10: #202720;
--color-base-20: #262e29;
--color-base-25: #2d3630;
--color-base-30: #364139;
--color-base-35: #414d45;
--color-base-40: #526058;
--color-base-50: #6d7b72;
--color-base-60: #95a39b;
--color-base-70: #bbc7c0;
--color-base-100: #dce4df;
--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(191, 168, 68, 0.28);
--accent-h: 148.6;
--accent-s: 31.1%;
--accent-l: 59%;
}

View file

@ -0,0 +1,11 @@
body {
--font-text-theme: "Avenir Next", Avenir, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
"Noto Sans CJK SC", sans-serif;
--font-monospace-theme: ui-monospace, SFMono-Regular, Consolas,
"Liberation Mono", monospace;
--line-height-normal: 1.8;
--p-spacing: var(--size-4-3);
--heading-spacing: var(--size-4-8);
--bold-modifier: 200;
}

View file

@ -1 +1,4 @@
/* Aera - A quiet interface for clear thinking. */
@use "foundations/colors";
@use "foundations/typography";

171
tests/foundations.test.mjs Normal file
View file

@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
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": "#f7f8f6",
"--color-base-05": "#f4f5f3",
"--color-base-10": "#eff1ef",
"--color-base-20": "#e8ebe8",
"--color-base-25": "#dde2de",
"--color-base-30": "#d1d7d2",
"--color-base-35": "#c4cbc5",
"--color-base-40": "#aeb7b0",
"--color-base-50": "#8b958e",
"--color-base-60": "#68756d",
"--color-base-70": "#4e5b53",
"--color-base-100": "#28302c",
"--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(191, 168, 68, 0.26)",
"--accent-h": "152.9",
"--accent-s": "28.8%",
"--accent-l": "34.7%",
},
".theme-dark": {
"--color-base-00": "#171c19",
"--color-base-05": "#1a201c",
"--color-base-10": "#202720",
"--color-base-20": "#262e29",
"--color-base-25": "#2d3630",
"--color-base-30": "#364139",
"--color-base-35": "#414d45",
"--color-base-40": "#526058",
"--color-base-50": "#6d7b72",
"--color-base-60": "#95a39b",
"--color-base-70": "#bbc7c0",
"--color-base-100": "#dce4df",
"--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(191, 168, 68, 0.28)",
"--accent-h": "148.6",
"--accent-s": "31.1%",
"--accent-l": "59%",
},
};
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("six core foreground/background pairs meet WCAG AA contrast", () => {
assert.deepEqual(CORE_CONTRAST_PAIRS, [
["light text", "#28302c", "#f7f8f6"],
["light muted", "#68756d", "#f7f8f6"],
["light accent", "#3f725b", "#f7f8f6"],
["dark text", "#dce4df", "#171c19"],
["dark muted", "#95a39b", "#171c19"],
["dark accent", "#76b795", "#171c19"],
]);
for (const [name, foreground, background] of CORE_CONTRAST_PAIRS) {
assert.ok(
contrastRatio(foreground, background) >= 4.5,
`${name} must meet WCAG AA`,
);
}
});
test("contrast calculation matches WCAG reference endpoints", () => {
assert.equal(contrastRatio("#000000", "#ffffff"), 21);
assert.equal(contrastRatio("#68756d", "#68756d"), 1);
});
test("contrast CLI prints all six 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, 6);
for (const [index, [name]] of CORE_CONTRAST_PAIRS.entries()) {
assert.match(lines[index], new RegExp(`^PASS ${name}:`));
}
});
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", "28302c", "#gggggg", "#1234567", 42]) {
assert.throws(
() => relativeLuminance(value),
(error) =>
error instanceof TypeError &&
error.message === `Invalid hex color: ${String(value)}`,
);
}
assert.throws(
() => contrastRatio("#28302c", "not-a-color"),
/Invalid hex color: not-a-color/,
);
});

16
tests/helpers/css.mjs Normal file
View file

@ -0,0 +1,16 @@
import postcss from "postcss";
export function declarationsFor(css, selector) {
const declarations = new Map();
const root = postcss.parse(css);
root.walkRules((rule) => {
if (rule.selector !== selector) return;
rule.walkDecls((declaration) => {
declarations.set(declaration.prop, declaration.value);
});
});
return declarations;
}

View file

@ -1 +1,60 @@
/* Aera - A quiet interface for clear thinking. */
.theme-light {
--color-base-00: #f7f8f6;
--color-base-05: #f4f5f3;
--color-base-10: #eff1ef;
--color-base-20: #e8ebe8;
--color-base-25: #dde2de;
--color-base-30: #d1d7d2;
--color-base-35: #c4cbc5;
--color-base-40: #aeb7b0;
--color-base-50: #8b958e;
--color-base-60: #68756d;
--color-base-70: #4e5b53;
--color-base-100: #28302c;
--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(191, 168, 68, 0.26);
--accent-h: 152.9;
--accent-s: 28.8%;
--accent-l: 34.7%;
}
.theme-dark {
--color-base-00: #171c19;
--color-base-05: #1a201c;
--color-base-10: #202720;
--color-base-20: #262e29;
--color-base-25: #2d3630;
--color-base-30: #364139;
--color-base-35: #414d45;
--color-base-40: #526058;
--color-base-50: #6d7b72;
--color-base-60: #95a39b;
--color-base-70: #bbc7c0;
--color-base-100: #dce4df;
--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(191, 168, 68, 0.28);
--accent-h: 148.6;
--accent-s: 31.1%;
--accent-l: 59%;
}
body {
--font-text-theme: "Avenir Next", Avenir, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
"Noto Sans CJK SC", sans-serif;
--font-monospace-theme: ui-monospace, SFMono-Regular, Consolas,
"Liberation Mono", monospace;
--line-height-normal: 1.8;
--p-spacing: var(--size-4-3);
--heading-spacing: var(--size-4-8);
--bold-modifier: 200;
}