From 409e808f5834dc4f2f37b54abf24d3a2f6ecad75 Mon Sep 17 00:00:00 2001 From: Maximilian Blazek <68336485+dgtlntv@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:40:36 +0200 Subject: [PATCH] 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 --- .../modes/interactive/theme/system-theme.ts | 29 +++++++++++++++---- .../coding-agent/test/system-theme.test.ts | 20 +++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/theme/system-theme.ts b/packages/coding-agent/src/modes/interactive/theme/system-theme.ts index 88cc802a6..a215797e3 100644 --- a/packages/coding-agent/src/modes/interactive/theme/system-theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/system-theme.ts @@ -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. */ diff --git a/packages/coding-agent/test/system-theme.test.ts b/packages/coding-agent/test/system-theme.test.ts index 63b66a073..83f626c09 100644 --- a/packages/coding-agent/test/system-theme.test.ts +++ b/packages/coding-agent/test/system-theme.test.ts @@ -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);