fix(web): improve light theme terminal contrast (#2819)

Unify the Power Mode xterm light palette behind a shared helper and replace low-contrast ANSI white/yellow entries with contrast-safe values.

Add a regression test that guards both the readable light-theme palette and the shared helper wiring so the duplicated terminal palettes do not drift again.

Closes #2810
This commit is contained in:
mastertyko 2026-03-27 21:47:44 +01:00 committed by GitHub
parent a0b9a85a20
commit cedf6a558d
4 changed files with 129 additions and 141 deletions

View file

@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const { getXtermTheme } = await import("../../web/lib/xterm-theme.ts");
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.replace("#", "");
const value = normalized.length === 3
? normalized.split("").map((char) => char + char).join("")
: normalized;
const int = Number.parseInt(value, 16);
return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}
function srgbToLinear(channel: number): number {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
}
function contrastRatio(foreground: string, background: string): number {
const luminance = (hex: string) => {
const [r, g, b] = hexToRgb(hex).map(srgbToLinear);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
return (lighter + 0.05) / (darker + 0.05);
}
test("light xterm palette keeps warning and ANSI white entries readable", () => {
const theme = getXtermTheme(false);
assert.ok(contrastRatio(theme.foreground, theme.background) >= 14, "foreground should remain highly legible");
assert.ok(contrastRatio(theme.yellow, theme.background) >= 4.5, "yellow should meet readable contrast");
assert.ok(contrastRatio(theme.brightYellow, theme.background) >= 4.5, "bright yellow should meet readable contrast");
assert.ok(contrastRatio(theme.white, theme.background) >= 4.5, "white should stay readable on light background");
assert.ok(contrastRatio(theme.brightWhite, theme.background) >= 4.5, "bright white should stay readable on light background");
});
test("terminal components share the central xterm theme helper", () => {
const shellSource = readFileSync(
resolve(import.meta.dirname, "../../web/components/gsd/shell-terminal.tsx"),
"utf8",
);
const mainSource = readFileSync(
resolve(import.meta.dirname, "../../web/components/gsd/main-session-terminal.tsx"),
"utf8",
);
assert.match(shellSource, /from \"@\/lib\/xterm-theme\"/);
assert.match(mainSource, /from \"@\/lib\/xterm-theme\"/);
assert.doesNotMatch(shellSource, /const XTERM_LIGHT_THEME =/);
assert.doesNotMatch(mainSource, /const XTERM_LIGHT_THEME =/);
});

View file

@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
import "@xterm/xterm/css/xterm.css"
type XTerminal = import("@xterm/xterm").Terminal
@ -23,75 +24,6 @@ const MIN_INITIAL_ATTACH_HEIGHT = 120
const MIN_INITIAL_ATTACH_COLS = 20
const MIN_INITIAL_ATTACH_ROWS = 8
const XTERM_DARK_THEME = {
background: "#0a0a0a",
foreground: "#e4e4e7",
cursor: "#e4e4e7",
cursorAccent: "#0a0a0a",
selectionBackground: "#27272a",
selectionForeground: "#e4e4e7",
black: "#18181b",
red: "#ef4444",
green: "#22c55e",
yellow: "#eab308",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#52525b",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#facc15",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#fafafa",
} as const
const XTERM_LIGHT_THEME = {
background: "#f5f5f5",
foreground: "#1a1a1a",
cursor: "#1a1a1a",
cursorAccent: "#f5f5f5",
selectionBackground: "#d4d4d8",
selectionForeground: "#1a1a1a",
black: "#1a1a1a",
red: "#dc2626",
green: "#16a34a",
yellow: "#ca8a04",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#e4e4e7",
brightBlack: "#71717a",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#eab308",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
} as const
function getXtermTheme(isDark: boolean) {
return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME
}
function getXtermOptions(isDark: boolean, fontSize?: number) {
return {
cursorBlink: true,
cursorStyle: "bar" as const,
fontSize: fontSize ?? 13,
fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
lineHeight: 1.35,
letterSpacing: 0,
theme: getXtermTheme(isDark),
allowProposedApi: true,
scrollback: 10000,
convertEol: false,
}
}
function getAttachableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
if (!container || !terminal) return null

View file

@ -8,6 +8,7 @@ import { validateImageFile } from "@/lib/image-utils"
import { filterInitialGsdHeader } from "@/lib/initial-gsd-header-filter"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
import "@xterm/xterm/css/xterm.css"
type XTerminal = import("@xterm/xterm").Terminal
@ -37,78 +38,6 @@ interface ShellTerminalProps {
projectCwd?: string
}
// ─── xterm themes ─────────────────────────────────────────────────────────────
const XTERM_DARK_THEME = {
background: "#0a0a0a",
foreground: "#e4e4e7",
cursor: "#e4e4e7",
cursorAccent: "#0a0a0a",
selectionBackground: "#27272a",
selectionForeground: "#e4e4e7",
black: "#18181b",
red: "#ef4444",
green: "#22c55e",
yellow: "#eab308",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#52525b",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#facc15",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#fafafa",
} as const
const XTERM_LIGHT_THEME = {
background: "#f5f5f5",
foreground: "#1a1a1a",
cursor: "#1a1a1a",
cursorAccent: "#f5f5f5",
selectionBackground: "#d4d4d8",
selectionForeground: "#1a1a1a",
black: "#1a1a1a",
red: "#dc2626",
green: "#16a34a",
yellow: "#a16207",
blue: "#2563eb",
magenta: "#9333ea",
cyan: "#0891b2",
white: "#e4e4e7",
brightBlack: "#71717a",
brightRed: "#ef4444",
brightGreen: "#22c55e",
brightYellow: "#92400e",
brightBlue: "#3b82f6",
brightMagenta: "#a855f7",
brightCyan: "#06b6d4",
brightWhite: "#fafafa",
} as const
function getXtermTheme(isDark: boolean) {
return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME
}
function getXtermOptions(isDark: boolean, fontSize?: number) {
return {
cursorBlink: true,
cursorStyle: "bar" as const,
fontSize: fontSize ?? 13,
fontFamily:
"'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
lineHeight: 1.35,
letterSpacing: 0,
theme: getXtermTheme(isDark),
allowProposedApi: true,
scrollback: 10000,
convertEol: false,
}
}
function getRenderableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
if (!container || !terminal) return null

70
web/lib/xterm-theme.ts Normal file
View file

@ -0,0 +1,70 @@
const XTERM_DARK_THEME = {
background: "#0a0a0a",
foreground: "#e4e4e7",
cursor: "#e4e4e7",
cursorAccent: "#0a0a0a",
selectionBackground: "#27272a",
selectionForeground: "#e4e4e7",
black: "#18181b",
red: "#ef4444",
green: "#22c55e",
yellow: "#eab308",
blue: "#3b82f6",
magenta: "#a855f7",
cyan: "#06b6d4",
white: "#e4e4e7",
brightBlack: "#52525b",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#facc15",
brightBlue: "#60a5fa",
brightMagenta: "#c084fc",
brightCyan: "#22d3ee",
brightWhite: "#fafafa",
} as const;
const XTERM_LIGHT_THEME = {
background: "#f5f5f5",
foreground: "#18181b",
cursor: "#18181b",
cursorAccent: "#f5f5f5",
selectionBackground: "#d4d4d8",
selectionForeground: "#18181b",
black: "#18181b",
red: "#b91c1c",
green: "#166534",
yellow: "#854d0e",
blue: "#1d4ed8",
magenta: "#7e22ce",
cyan: "#0f766e",
// Keep ANSI white entries readable on a light terminal surface.
white: "#52525b",
brightBlack: "#71717a",
brightRed: "#dc2626",
brightGreen: "#15803d",
brightYellow: "#713f12",
brightBlue: "#2563eb",
brightMagenta: "#9333ea",
brightCyan: "#0f766e",
brightWhite: "#27272a",
} as const;
export function getXtermTheme(isDark: boolean) {
return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME;
}
export function getXtermOptions(isDark: boolean, fontSize?: number) {
return {
cursorBlink: true,
cursorStyle: "bar" as const,
fontSize: fontSize ?? 13,
fontFamily:
"'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
lineHeight: 1.35,
letterSpacing: 0,
theme: getXtermTheme(isDark),
allowProposedApi: true,
scrollback: 10000,
convertEol: false,
};
}