204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import themeCss from "./styles/theme-vars.css?raw";
|
|
|
|
interface ParsedColor {
|
|
rgb: [number, number, number];
|
|
alpha: number;
|
|
}
|
|
|
|
function parseThemeVars() {
|
|
const matches = [
|
|
...themeCss.matchAll(/:root([^{]*)\{([\s\S]*?)\n {2}\}/g),
|
|
];
|
|
|
|
const byTheme: Record<string, Record<string, string>> = {};
|
|
|
|
for (const match of matches) {
|
|
const selector = match[1];
|
|
const body = match[2];
|
|
const themes = [
|
|
...selector.matchAll(/data-theme="([^"]+)"/g),
|
|
].map((themeMatch) => themeMatch[1]);
|
|
|
|
if (themes.length === 0) continue;
|
|
|
|
const vars: Record<string, string> = {};
|
|
for (const varMatch of body.matchAll(/--([\w-]+):\s*([^;]+);/g)) {
|
|
vars[varMatch[1]] = varMatch[2].trim();
|
|
}
|
|
|
|
for (const theme of themes) {
|
|
byTheme[theme] = { ...(byTheme[theme] ?? {}), ...vars };
|
|
}
|
|
}
|
|
|
|
return byTheme;
|
|
}
|
|
|
|
function parseColor(value: string | undefined): ParsedColor {
|
|
if (!value) {
|
|
throw new Error("Missing color token.");
|
|
}
|
|
|
|
const hexMatch = value.match(/^#([\da-f]{3,8})$/i);
|
|
if (hexMatch) {
|
|
let hex = hexMatch[1];
|
|
if (hex.length === 3) {
|
|
hex = hex
|
|
.split("")
|
|
.map((char) => char + char)
|
|
.join("");
|
|
}
|
|
|
|
let alpha = 1;
|
|
if (hex.length === 8) {
|
|
alpha = parseInt(hex.slice(6), 16) / 255;
|
|
hex = hex.slice(0, 6);
|
|
}
|
|
|
|
return {
|
|
rgb: [
|
|
parseInt(hex.slice(0, 2), 16),
|
|
parseInt(hex.slice(2, 4), 16),
|
|
parseInt(hex.slice(4, 6), 16),
|
|
],
|
|
alpha,
|
|
};
|
|
}
|
|
|
|
const rgbMatch = value.match(/^rgba?\(([^)]+)\)$/i);
|
|
if (rgbMatch) {
|
|
const parts = rgbMatch[1].split(",").map((part) => part.trim());
|
|
return {
|
|
rgb: [
|
|
Number(parts[0]),
|
|
Number(parts[1]),
|
|
Number(parts[2]),
|
|
],
|
|
alpha: parts[3] === undefined ? 1 : Number(parts[3]),
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unsupported color format: ${value}`);
|
|
}
|
|
|
|
function blendColor(foreground: ParsedColor, background: ParsedColor): ParsedColor {
|
|
if (foreground.alpha >= 1) return foreground;
|
|
|
|
return {
|
|
rgb: foreground.rgb.map((channel, index) =>
|
|
channel * foreground.alpha + background.rgb[index] * (1 - foreground.alpha),
|
|
) as [number, number, number],
|
|
alpha: 1,
|
|
};
|
|
}
|
|
|
|
function relativeChannel(channel: number) {
|
|
const normalized = channel / 255;
|
|
return normalized <= 0.03928
|
|
? normalized / 12.92
|
|
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
}
|
|
|
|
function luminance(color: ParsedColor) {
|
|
const [r, g, b] = color.rgb;
|
|
return (
|
|
0.2126 * relativeChannel(r)
|
|
+ 0.7152 * relativeChannel(g)
|
|
+ 0.0722 * relativeChannel(b)
|
|
);
|
|
}
|
|
|
|
function contrastRatio(foreground: ParsedColor, background: ParsedColor) {
|
|
const a = luminance(foreground);
|
|
const b = luminance(background);
|
|
const [lighter, darker] = a >= b ? [a, b] : [b, a];
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
describe("theme contrast tokens", () => {
|
|
const themeVars = parseThemeVars();
|
|
const themeIds = Object.keys(themeVars);
|
|
|
|
it("keeps secondary text readable on the dashboard surfaces", () => {
|
|
for (const themeId of themeIds) {
|
|
const vars = themeVars[themeId];
|
|
const surface = parseColor(vars["app-surface"]);
|
|
const elevated = parseColor(vars["app-surface-elevated"]);
|
|
const muted = parseColor(vars["app-text-muted"]);
|
|
const soft = parseColor(vars["app-text-soft"]);
|
|
const faint = parseColor(vars["app-text-faint"]);
|
|
|
|
expect(
|
|
contrastRatio(muted, surface),
|
|
`${themeId} muted text should stay comfortably readable on the base surface`,
|
|
).toBeGreaterThanOrEqual(7);
|
|
|
|
expect(
|
|
contrastRatio(soft, surface),
|
|
`${themeId} soft text should stay readable on the base surface`,
|
|
).toBeGreaterThanOrEqual(5);
|
|
|
|
expect(
|
|
contrastRatio(faint, elevated),
|
|
`${themeId} faint text should still clear caption contrast on elevated surfaces`,
|
|
).toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
});
|
|
|
|
it("preserves a descending text hierarchy in every theme", () => {
|
|
for (const themeId of themeIds) {
|
|
const vars = themeVars[themeId];
|
|
const surface = parseColor(vars["app-surface"]);
|
|
const muted = parseColor(vars["app-text-muted"]);
|
|
const soft = parseColor(vars["app-text-soft"]);
|
|
const faint = parseColor(vars["app-text-faint"]);
|
|
|
|
const mutedContrast = contrastRatio(muted, surface);
|
|
const softContrast = contrastRatio(soft, surface);
|
|
const faintContrast = contrastRatio(faint, surface);
|
|
|
|
expect(
|
|
mutedContrast,
|
|
`${themeId} muted text should remain stronger than soft text`,
|
|
).toBeGreaterThan(softContrast);
|
|
expect(
|
|
softContrast,
|
|
`${themeId} soft text should remain stronger than faint text`,
|
|
).toBeGreaterThan(faintContrast);
|
|
}
|
|
});
|
|
|
|
it("keeps semantic chips readable when translucent fills sit on app surfaces", () => {
|
|
const chipPairs = [
|
|
["app-attention-text", "app-attention-background"],
|
|
["app-success-text", "app-success-background"],
|
|
["app-danger-text", "app-danger-background"],
|
|
["app-info-text", "app-info-background"],
|
|
["role-leader-badge-text", "role-leader-badge-bg"],
|
|
["role-worker-badge-text", "role-worker-badge-bg"],
|
|
["stage-plan-text", "stage-plan-bg"],
|
|
["stage-review-text", "stage-review-bg"],
|
|
["stage-freeze-text", "stage-freeze-bg"],
|
|
["stage-execution-text", "stage-execution-bg"],
|
|
["stage-verification-text", "stage-verification-bg"],
|
|
] as const;
|
|
|
|
for (const themeId of themeIds) {
|
|
const vars = themeVars[themeId];
|
|
const surface = parseColor(vars["app-surface"]);
|
|
|
|
for (const [textToken, backgroundToken] of chipPairs) {
|
|
const text = parseColor(vars[textToken]);
|
|
const translucentBackground = parseColor(vars[backgroundToken]);
|
|
const background = blendColor(translucentBackground, surface);
|
|
|
|
expect(
|
|
contrastRatio(text, background),
|
|
`${themeId} ${textToken} on ${backgroundToken} should remain readable`,
|
|
).toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
}
|
|
});
|
|
});
|