chore(vendor): import pi 1.3.0
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
This commit is contained in:
137
third_party/pi/packages/coding-agent/test/system-theme.test.ts
vendored
Normal file
137
third_party/pi/packages/coding-agent/test/system-theme.test.ts
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user