src/lib/appearance.test.ts
Linked repository file from this build.
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
animationDurations,
animationDurationMs,
appearanceBootScript,
defaultAppearance,
layouts,
normalizeAppearance,
palettes,
} from "./appearance";
// Palette colors come before the palette font blocks in globals.css, as in the real cascade.
const css = [
"styles/palettes.css",
"globals.css",
"styles/tooltips.css",
"styles/shell.css",
"styles/feedback-and-motion.css",
]
.map((file) => readFileSync(join(import.meta.dir, "../app", file), "utf8"))
.join("\n");
const requiredTokens = [
"background",
"foreground",
"card",
"card-foreground",
"popover",
"popover-foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"destructive",
"destructive-foreground",
"success",
"success-foreground",
"warning",
"warning-foreground",
"info",
"info-foreground",
"border",
"input",
"field",
"ring",
"sidebar",
"sidebar-foreground",
"sidebar-primary",
"sidebar-primary-foreground",
"sidebar-accent",
"sidebar-accent-foreground",
"sidebar-border",
"sidebar-ring",
"chart-1",
"chart-2",
"chart-3",
"chart-4",
"chart-5",
] as const;
function block(selector: string): Record<string, string> {
const start = css.indexOf(`${selector} {`);
if (start < 0) throw new Error(`Missing CSS block for ${selector}`);
const body = css.slice(start, css.indexOf("}", start));
const tokens: Record<string, string> = {};
for (const match of body.matchAll(/--([a-z0-9-]+):\s*(#[0-9a-f]{6})\s*;/gi)) {
tokens[match[1]] = match[2].toLowerCase();
}
return tokens;
}
function luminance(hex: string): number {
const channel = (index: number) => {
const value = parseInt(hex.slice(index, index + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
}
function hue(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
if (d === 0) return 0;
const h =
max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return h * 60;
}
export function contrast(a: string, b: string): number {
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (high + 0.05) / (low + 0.05);
}
const AA_TEXT = 4.5;
const AA_UI = 3;
describe("palette token sets", () => {
for (const palette of palettes) {
for (const mode of ["light", "dark"] as const) {
const selector =
mode === "light"
? `:root[data-palette="${palette}"]`
: `:root.dark[data-palette="${palette}"]`;
describe(`${palette} ${mode}`, () => {
const t = block(selector);
test("defines every semantic token as a hex color", () => {
for (const token of requiredTokens) expect(t[token], token).toMatch(/^#[0-9a-f]{6}$/);
});
test("body text meets AA on page, card and popover surfaces", () => {
expect(contrast(t.foreground, t.background)).toBeGreaterThanOrEqual(AA_TEXT);
expect(contrast(t["card-foreground"], t.card)).toBeGreaterThanOrEqual(AA_TEXT);
expect(contrast(t["popover-foreground"], t.popover)).toBeGreaterThanOrEqual(AA_TEXT);
expect(contrast(t["secondary-foreground"], t.secondary)).toBeGreaterThanOrEqual(AA_TEXT);
expect(contrast(t["accent-foreground"], t.accent)).toBeGreaterThanOrEqual(AA_TEXT);
});
test("muted text meets AA on page, card and muted surfaces", () => {
for (const surface of [t.background, t.card, t.muted]) {
expect(contrast(t["muted-foreground"], surface)).toBeGreaterThanOrEqual(AA_TEXT);
}
});
test("status colors work as text on surfaces and as filled buttons", () => {
for (const name of ["primary", "destructive", "success", "warning", "info"] as const) {
for (const surface of [t.background, t.card]) {
expect(contrast(t[name], surface), `${name} on surface`).toBeGreaterThanOrEqual(
AA_TEXT,
);
}
expect(
contrast(t[`${name}-foreground`], t[name]),
`${name}-foreground on ${name}`,
).toBeGreaterThanOrEqual(AA_TEXT);
}
});
test("navigation chrome is readable", () => {
expect(contrast(t["sidebar-foreground"], t.sidebar)).toBeGreaterThanOrEqual(AA_TEXT);
expect(
contrast(t["sidebar-accent-foreground"], t["sidebar-accent"]),
).toBeGreaterThanOrEqual(AA_TEXT);
expect(
contrast(t["sidebar-primary-foreground"], t["sidebar-primary"]),
).toBeGreaterThanOrEqual(AA_TEXT);
});
test("form field borders meet the non-text contrast minimum", () => {
expect(contrast(t.input, t.background)).toBeGreaterThanOrEqual(AA_UI);
expect(contrast(t.input, t.card)).toBeGreaterThanOrEqual(AA_UI);
expect(contrast(t.input, t.field)).toBeGreaterThanOrEqual(AA_UI);
expect(contrast(t.ring, t.background)).toBeGreaterThanOrEqual(AA_UI);
});
test("fields and cards separate from the surface beneath them", () => {
expect(contrast(t.foreground, t.field)).toBeGreaterThanOrEqual(AA_TEXT);
// Cards must not dissolve into the canvas; light schemes need a visibly darker page.
expect(contrast(t.card, t.background)).toBeGreaterThanOrEqual(
mode === "light" ? 1.08 : 1.03,
);
});
test("red-green safe variant keeps status text and fills readable", () => {
const safe = block(selector.replace(":root", ':root[data-vision="rg-safe"]'));
for (const name of ["success", "info", "warning", "destructive"] as const) {
expect(safe[name], name).toMatch(/^#[0-9a-f]{6}$/);
for (const surface of [t.background, t.card]) {
expect(contrast(safe[name], surface), `${name} on surface`).toBeGreaterThanOrEqual(
AA_TEXT,
);
}
expect(contrast(safe[`${name}-foreground`], safe[name])).toBeGreaterThanOrEqual(
AA_TEXT,
);
}
// Good and bad must differ in hue family, not just shade: success moves to blue.
expect(hue(safe.success)).toBeGreaterThan(150);
expect(hue(safe.success)).toBeLessThan(260);
});
test(`is a ${mode} scheme`, () => {
const dark = luminance(t.background) < luminance(t.foreground);
expect(dark).toBe(mode === "dark");
});
});
}
}
test("the default palette also styles the bare root", () => {
expect(block(':root,\n:root[data-palette="one"]')).toEqual(block(':root[data-palette="one"]'));
expect(css).toContain(':root.dark,\n:root.dark[data-palette="one"]');
});
test("every layout has a root font size or inherits the default", () => {
expect(css).toMatch(/html\s*\{\s*font-size:\s*16px/);
for (const layout of layouts) {
expect(css).toContain(`[data-layout="${layout}"]`);
}
});
});
describe("normalizeAppearance", () => {
test("returns defaults for missing or invalid values", () => {
expect(normalizeAppearance(null)).toEqual(defaultAppearance);
expect(normalizeAppearance({ theme: "sepia", palette: 3, layout: "grid" })).toEqual(
defaultAppearance,
);
});
test("keeps valid values and fills missing keys", () => {
expect(normalizeAppearance({ theme: "dark", palette: "gruvbox" })).toEqual({
...defaultAppearance,
theme: "dark",
vision: "standard",
palette: "gruvbox",
layout: "classic",
});
expect(normalizeAppearance({ vision: "rg-safe" }).vision).toBe("rg-safe");
expect(normalizeAppearance({ vision: "nonsense" }).vision).toBe("standard");
expect(normalizeAppearance({ layout: "console" }).layout).toBe("workbench");
});
test("maps palette and layout ids from previous pickers", () => {
expect(normalizeAppearance({ palette: "slate" }).palette).toBe("nord");
expect(normalizeAppearance({ palette: "paper" }).palette).toBe("flexoki");
expect(normalizeAppearance({ palette: "high-contrast" }).palette).toBe("plain");
expect(normalizeAppearance({ palette: "current" }).palette).toBe("one");
expect(normalizeAppearance({ layout: "sidebar" }).layout).toBe("workbench");
expect(normalizeAppearance({ layout: "rail" }).layout).toBe("workbench");
});
test("maps the old three font sizes onto pixel values", () => {
expect(normalizeAppearance({ fontSize: "small" }).fontSize).toBe(14);
expect(normalizeAppearance({ fontSize: "medium" }).fontSize).toBe(16);
expect(normalizeAppearance({ fontSize: "large" }).fontSize).toBe(22);
});
test("maps the five-step names once that scale is current", () => {
expect(normalizeAppearance({ fontSize: "medium", fontSizeScale: 2 }).fontSize).toBe(22);
expect(normalizeAppearance({ fontSize: "extra-large", fontSizeScale: 2 }).fontSize).toBe(32);
expect(normalizeAppearance({ fontSize: "nonsense", fontSizeScale: 2 }).fontSize).toBe(16);
});
test("snaps pixel font sizes onto the even 12–32 scale", () => {
expect(normalizeAppearance({ fontSize: 22 }).fontSize).toBe(22);
expect(normalizeAppearance({ fontSize: "24" }).fontSize).toBe(24);
expect(normalizeAppearance({ fontSize: 15 }).fontSize).toBe(16);
expect(normalizeAppearance({ fontSize: 4 }).fontSize).toBe(12);
expect(normalizeAppearance({ fontSize: 45 }).fontSize).toBe(32);
});
test("boot script knows every palette and layout", () => {
for (const palette of palettes) expect(appearanceBootScript).toContain(palette);
for (const layout of layouts) expect(appearanceBootScript).toContain(layout);
expect(appearanceBootScript).toContain("qccrm.appearance");
expect(appearanceBootScript).toContain("fontSize");
});
});
describe("animation duration preferences", () => {
test("supports each timing and defaults older records to moderate", () => {
expect(animationDurationMs).toEqual({ off: 0, short: 100, moderate: 150, long: 250 });
for (const animationDuration of animationDurations) {
expect(normalizeAppearance({ animationDuration }).animationDuration).toBe(animationDuration);
}
expect(normalizeAppearance({}).animationDuration).toBe("moderate");
expect(normalizeAppearance({ animationDuration: "invalid" }).animationDuration).toBe(
"moderate",
);
});
test("first-paint duration matches normalized settings before hydration", () => {
for (const animationDuration of [...animationDurations, "invalid", undefined]) {
const root = { dataset: {} as Record<string, string>, style: {}, classList: { toggle() {} } };
new Function("document", "localStorage", "matchMedia", appearanceBootScript)(
{ documentElement: root },
{ getItem: () => JSON.stringify({ animationDuration }) },
() => ({ matches: false }),
);
expect(root.dataset.animationDuration).toBe(
normalizeAppearance({ animationDuration }).animationDuration,
);
}
});
});