Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
138 lines
5.8 KiB
TypeScript
138 lines
5.8 KiB
TypeScript
import { colorToOklch, colorToRgb, parseColor, type RgbColor, rgbColor } from "@earendil-works/pi-tui";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
generateSystemThemeColors,
|
|
type SystemThemeInput,
|
|
wcagContrast,
|
|
} from "../src/modes/interactive/theme/system-theme.ts";
|
|
import {
|
|
getAvailableThemes,
|
|
getThemeByName,
|
|
getThemeExportColors,
|
|
setTerminalColors,
|
|
type ThemeToken,
|
|
} from "../src/modes/interactive/theme/theme.ts";
|
|
|
|
const rgb = (hex: string): RgbColor => colorToRgb(parseColor(hex));
|
|
const lightness = ({ r, g, b }: RgbColor) => colorToOklch(rgbColor(r, g, b)).l;
|
|
|
|
const DRACULA: SystemThemeInput = {
|
|
background: rgb("#282a36"),
|
|
foreground: rgb("#f8f8f2"),
|
|
palette: ["#21222c", "#ff5555", "#50fa7b", "#f1fa8c", "#bd93f9", "#ff79c6", "#8be9fd", "#f8f8f2"]
|
|
.concat(["#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff"])
|
|
.map(rgb),
|
|
};
|
|
|
|
/** Dark with a palette, light with an unreadable foreground, background only, and mid-gray. */
|
|
const TERMINALS: Record<string, SystemThemeInput> = {
|
|
dracula: DRACULA,
|
|
solarizedLight: { background: rgb("#fdf6e3"), foreground: rgb("#657b83") },
|
|
backgroundOnly: { background: rgb("#1e1e1e") },
|
|
midGray: { background: rgb("#808080"), foreground: rgb("#ffffff") },
|
|
};
|
|
|
|
const PANELS: ThemeToken[] = ["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "selectedBg"];
|
|
|
|
function resolved(input: SystemThemeInput, token: ThemeToken): RgbColor {
|
|
const value = generateSystemThemeColors(input).colors[token];
|
|
if (value === "") return PANELS.includes(token) ? input.background! : input.foreground!;
|
|
return rgb(value as string);
|
|
}
|
|
|
|
afterEach(() => {
|
|
setTerminalColors({});
|
|
});
|
|
|
|
describe("generateSystemThemeColors", () => {
|
|
it("keeps body text readable (WCAG 4.5:1) on the background and its panels", () => {
|
|
for (const [name, input] of Object.entries(TERMINALS)) {
|
|
const text = resolved(input, "text");
|
|
for (const surface of [input.background!, resolved(input, "selectedBg")]) {
|
|
expect(wcagContrast(text, surface), name).toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
expect(
|
|
wcagContrast(resolved(input, "toolTitle"), resolved(input, "toolErrorBg")),
|
|
name,
|
|
).toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
});
|
|
|
|
it("orders foreground roles by contrast and keeps panels close to the background", () => {
|
|
for (const [name, input] of Object.entries(TERMINALS)) {
|
|
const background = lightness(input.background!);
|
|
const offset = (token: ThemeToken) => lightness(resolved(input, token)) - background;
|
|
// On mid-gray the levels collapse to the strongest reachable color.
|
|
if (name !== "midGray") {
|
|
expect(Math.abs(offset("text")), name).toBeGreaterThan(Math.abs(offset("muted")));
|
|
expect(Math.abs(offset("muted")), name).toBeGreaterThan(Math.abs(offset("dim")));
|
|
}
|
|
const lighter = generateSystemThemeColors(input).appearance === "dark";
|
|
for (const panel of PANELS) {
|
|
expect(wcagContrast(resolved(input, panel), input.background!), `${name} ${panel}`).toBeLessThan(2);
|
|
expect(offset(panel) > 0, `${name} ${panel}`).toBe(lighter);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("uses the terminal foreground and palette hues", () => {
|
|
expect(generateSystemThemeColors(DRACULA).colors.text).toBe("");
|
|
// Solarized's foreground is below 4.5:1 on its own background, so text is darkened.
|
|
expect(generateSystemThemeColors(TERMINALS.solarizedLight).colors.text).not.toBe("");
|
|
const hue = ({ r, g, b }: RgbColor) => colorToOklch(rgbColor(r, g, b)).h;
|
|
expect(Math.abs(hue(resolved(DRACULA, "error")) - hue(DRACULA.palette![1]))).toBeLessThan(8);
|
|
});
|
|
|
|
// https://github.com/earendil-works/pi/issues/10255
|
|
it("keeps pastel palette colors pastel at other lightnesses", () => {
|
|
const frappe: SystemThemeInput = {
|
|
background: rgb("#303446"),
|
|
foreground: rgb("#c6d0f5"),
|
|
palette: ["#51576d", "#e78284", "#a6d189", "#e5c890", "#8caaee", "#f4b8e4", "#81c8be", "#b5bfe2"]
|
|
.concat(["#626880", "#e67172", "#8ec772", "#d9ba73", "#7b9ef0", "#f2a4db", "#5abfb5", "#a5adce"])
|
|
.map(rgb),
|
|
};
|
|
const chroma = ({ r, g, b }: RgbColor) => colorToOklch(rgbColor(r, g, b)).c;
|
|
const pink = frappe.palette![5];
|
|
const accent = resolved(frappe, "accent");
|
|
// The accent is darker than the pink, but must not gain chroma (it was 2x before the cap).
|
|
expect(lightness(accent)).toBeLessThan(lightness(pink) - 0.05);
|
|
expect(chroma(accent)).toBeLessThanOrEqual(chroma(pink) * 1.03);
|
|
for (const panel of ["userMessageBg", "customMessageBg"] as const) {
|
|
expect(chroma(resolved(frappe, panel)), panel).toBeLessThanOrEqual(0.1);
|
|
}
|
|
});
|
|
|
|
it("renders grayscale at zero saturation", () => {
|
|
const { colors } = generateSystemThemeColors({ ...DRACULA, saturation: 0 });
|
|
expect(colorToOklch(parseColor(colors.error as string)).c).toBeLessThan(0.005);
|
|
});
|
|
|
|
it("falls back to palette indices and faint text without a background", () => {
|
|
const { colors, dim, appearance } = generateSystemThemeColors({ appearanceHint: "light" });
|
|
expect(appearance).toBe("light");
|
|
expect([colors.error, colors.text, colors.userMessageBg]).toEqual([1, "", ""]);
|
|
expect(dim).toContain("muted");
|
|
expect(generateSystemThemeColors({ saturation: 0 }).colors.error).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("system theme", () => {
|
|
it("is listed first, has no export colors, and is generated from the terminal colors", () => {
|
|
expect(getAvailableThemes()[0]).toBe("system");
|
|
expect(getThemeExportColors("system")).toEqual({});
|
|
|
|
setTerminalColors(DRACULA);
|
|
const theme = getThemeByName("system")!;
|
|
expect(theme.appearance).toBe("dark");
|
|
expect(theme.getFgAnsi("text")).toBe("\x1b[39m");
|
|
expect(theme.getFgAnsi("error")).toMatch(/^\x1b\[38;/);
|
|
});
|
|
|
|
it("renders faint tokens with SGR 2 and closes it", () => {
|
|
const theme = getThemeByName("system")!;
|
|
expect(theme.fg("muted", "x")).toBe("\x1b[39m\x1b[2mx\x1b[22;39m");
|
|
expect(theme.style("x", { fg: "muted" })).toBe("\x1b[39m\x1b[2mx\x1b[22m\x1b[39m");
|
|
});
|
|
});
|