fix(ui): make Control UI terminal readable in light mode (#123184)

The light terminal theme reused the dark-background ANSI palette (1.4-2.6:1
contrast on #f7f8fa; brightWhite was 1.06), and the intro banner hardcoded
256-color indices 223/216 that bypass the client theme entirely.

Light mode now gets its own darkened ANSI-16 palette (>=4.5:1, bright
variants darker for emphasis, brightWhite maps to strongest ink), and the
banner emits themable ANSI-16 yellow/bright-red so each mode owns its
rendering. Dark mode is visually unchanged.
This commit is contained in:
Peter Steinberger
2026-08-13 14:14:35 -07:00
committed by GitHub
parent f04fdd5594
commit dbea5a77f0
3 changed files with 37 additions and 12 deletions
+2 -2
View File
@@ -25,9 +25,9 @@ describe("composeTerminalIntroBanner", () => {
const banner = composeTerminalIntroBanner();
expect(banner).toBe(
`\r\n\x1b[38;5;223mWelcome to the Claw.\x1b[0m\r\n\r\n\x1b[38;5;216m${EXPECTED_ART.join("\r\n")}\r\n\r\n\x1b[0m`,
`\r\n\x1b[33mWelcome to the Claw.\x1b[0m\r\n\r\n\x1b[91m${EXPECTED_ART.join("\r\n")}\r\n\r\n\x1b[0m`,
);
expect(banner.startsWith("\r\n\x1b[38;5;223mWelcome to the Claw.\x1b[0m")).toBe(true);
expect(banner.startsWith("\r\n\x1b[33mWelcome to the Claw.\x1b[0m")).toBe(true);
expect(banner.endsWith("\r\n\r\n\x1b[0m")).toBe(true);
expect(banner.replaceAll("\r\n", "")).not.toContain("\n");
});
+5 -2
View File
@@ -22,8 +22,11 @@ const TERMINAL_INTRO_ART = [
// Always full art: open-time request.cols is the pre-fit boot grid (the client
// resizes immediately after open), so width gating keyed on it suppressed the
// art on real, wide terminals.
// ANSI-16 colors only: the server can't know the client's light/dark mode, and
// fixed 256-color indices (223/216) bypass the client theme and vanish on light
// backgrounds. Yellow/bright-red stay warm on dark and darken on light.
export function composeTerminalIntroBanner(): string {
const headline = `\x1b[38;5;223mWelcome to the Claw.${RESET}`;
const art = `\x1b[38;5;216m${TERMINAL_INTRO_ART.join("\r\n")}\r\n\r\n`;
const headline = `\x1b[33mWelcome to the Claw.${RESET}`;
const art = `\x1b[91m${TERMINAL_INTRO_ART.join("\r\n")}\r\n\r\n`;
return `\r\n${headline}\r\n\r\n${art}${RESET}`;
}
+30 -8
View File
@@ -8,9 +8,10 @@ type TerminalTheme = NonNullable<
NonNullable<CreateGhosttyTerminalOptions["terminalOptions"]>["theme"]
>;
// ANSI palette tuned to sit on the Control UI's near-black / near-white surfaces.
// Shared 8 + bright 8; foreground/background/cursor are overridden per mode below.
const ANSI = {
// ANSI palettes tuned per mode: colors that read on the near-black surface sit
// at 1.4-2.6:1 on the light surface, so light gets its own darkened set
// (>=4.5:1 on #f7f8fa) instead of sharing the dark palette.
const DARK_ANSI = {
black: "#1b1e26",
red: "#ff6b6b",
green: "#4ec9a8",
@@ -29,6 +30,29 @@ const ANSI = {
brightWhite: "#ffffff",
} as const;
// Same hue identities as DARK_ANSI, darkened for the light surface. Bright
// variants go darker than normal ones so bold/bright text stays emphatic
// instead of washing out; brightWhite maps to the strongest ink, matching
// the light-theme convention in Ghostty/iTerm paired themes.
const LIGHT_ANSI = {
black: "#3a3f4b",
red: "#c62f3d",
green: "#177a5e",
yellow: "#8f6400",
blue: "#1e66d0",
magenta: "#94439c",
cyan: "#0f7487",
white: "#1b1e26",
brightBlack: "#5c6370",
brightRed: "#a3242f",
brightGreen: "#0f664e",
brightYellow: "#755200",
brightBlue: "#1a55ab",
brightMagenta: "#7c3382",
brightCyan: "#0c6070",
brightWhite: "#0a0c10",
} as const;
// Dark mirrors the claw tokens in styles/base.css (`--bg` #0e1015,
// `--accent` #ff5c5c) — keep them in sync when the tokens change. Light is a
// deliberate neutral cool white: the light theme families diverge (ivory,
@@ -47,16 +71,14 @@ export function terminalTheme(mode: "dark" | "light"): TerminalTheme {
const colors = terminalDynamicColors(mode);
if (mode === "light") {
return {
...ANSI,
...LIGHT_ANSI,
...colors,
cursorAccent: "#f7f8fa",
selectionBackground: "rgba(90, 162, 255, 0.30)",
black: "#3a3f4b",
white: "#1b1e26",
selectionBackground: "rgba(30, 102, 208, 0.30)",
};
}
return {
...ANSI,
...DARK_ANSI,
...colors,
cursorAccent: "#0e1015",
selectionBackground: "rgba(90, 162, 255, 0.32)",