fix(coding-agent): keep pastel palettes pastel in the system theme (#10293)

Palette colors now keep the bell-curve falloff and are
also capped at the palette color's OKLCH chroma times that falloff.
Lightness and hue are unchanged, so contrast is unchanged.

closes #10255
This commit is contained in:
Maximilian Blazek 2026-10-01 13:40:36 +02:00 • committed by GitHub
parent c662ec7e37
commit 409e808f58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 5 deletions

View file

@ -5,7 +5,8 @@
* on the background and on the panels it is drawn on. Hue and saturation come from the terminal's palette
* color for the family's ANSI slot, or from the family's own hue when the terminal reports no palette.
* Lightness comes from the rules alone. Colors are built in OKHSL, whose saturation is relative to the
* sRGB gamut, and fade toward gray near black and white.
* sRGB gamut, and fade toward gray near black and white. A palette color never gains OKLCH chroma when it
* moves to another lightness, so pastel palettes stay pastel.
*
* A contrast level is a target-lightness curve: the OKLab lightness a token needs, given the lightness of
* the surface below it. The curves were fitted to the reference theme design from the "Pi themes: system
@ -21,9 +22,11 @@
import {
colorToOkhsl,
colorToOklch,
colorToRgb,
type OkhslChannels,
okhslColor,
oklabToOkhslLightness,
oklchColor,
type RgbColor,
rgbColor,
} from "@earendil-works/pi-tui";
@ -463,7 +466,7 @@ export function generateSystemThemeColors(input: SystemThemeInput): SystemThemeC
const saturation = clamp(input.saturation ?? 1, 0, 1);
const { background, foreground } = input;
if (!background) return indexedColors(saturation, input.appearanceHint);
const palette = input.palette?.length === 16 ? input.palette.map(okhslOf) : undefined;
const palette = input.palette?.length === 16 ? input.palette.map(sourceOf) : undefined;
const appearance = terminalAppearance(background, foreground);
const lighter = appearance === "dark";
@ -574,7 +577,7 @@ export function generateSystemThemeColors(input: SystemThemeInput): SystemThemeC
result[token] = "";
continue;
}
text = anchored(okhslOf(foreground), FAMILIES.neutral, oklabToOkhslLightness(needed), saturation);
text = anchored(sourceOf(foreground), FAMILIES.neutral, oklabToOkhslLightness(needed), saturation);
}
}
// Body text keeps at least 4.5:1 on the surfaces it is drawn on, even on relaxed mid-gray backgrounds.
@ -587,14 +590,30 @@ function okhslOf({ r, g, b }: RgbColor): OkhslChannels {
return colorToOkhsl(rgbColor(r, g, b));
}
/** A terminal color's OKHSL channels and its OKLCH chroma. */
interface SourceColor extends OkhslChannels {
chroma: number;
}
function sourceOf(color: RgbColor): SourceColor {
return { ...okhslOf(color), chroma: colorToOklch(rgbColor(color.r, color.g, color.b)).c };
}
/**
* A source color's hue at another OKHSL lightness. Its saturation applies at its own lightness and falls off
* toward black and white along the family's saturation curve, never rising above it.
*
* OKHSL saturation is relative to the most chroma sRGB allows at a lightness, so the same saturation can mean
* more chroma elsewhere: Catppuccin Frappe's pink #f4b8e4 (chroma 0.089) would become #eb76d1 (0.180) at the
* lightness the accent needs. Chroma is therefore also capped at the source's, with the same falloff.
*/
function anchored(source: OkhslChannels, family: Family, lightness: number, saturation: number): RgbColor {
function anchored(source: SourceColor, family: Family, lightness: number, saturation: number): RgbColor {
const anchor = saturationCurve(family, source.l);
const falloff = anchor > 0 ? Math.min(1, saturationCurve(family, lightness) / anchor) : 1;
return okhslColor(source.h, source.s * falloff * saturation, lightness);
const color = okhslColor(source.h, source.s * falloff * saturation, lightness);
const cap = source.chroma * falloff * saturation;
const { l, c } = colorToOklch(color);
return c <= cap ? color : colorToRgb(oklchColor(l, cap, source.h));
}
/** Move a text color toward white or black until it reaches the WCAG minimum on every surface. */

View file

@ -83,6 +83,26 @@ describe("generateSystemThemeColors", () => {
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);