diff --git a/src/tests/xterm-theme.test.ts b/src/tests/xterm-theme.test.ts new file mode 100644 index 000000000..b3f419be3 --- /dev/null +++ b/src/tests/xterm-theme.test.ts @@ -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 =/); +}); diff --git a/web/components/gsd/main-session-terminal.tsx b/web/components/gsd/main-session-terminal.tsx index f48b43a31..95216badc 100644 --- a/web/components/gsd/main-session-terminal.tsx +++ b/web/components/gsd/main-session-terminal.tsx @@ -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 diff --git a/web/components/gsd/shell-terminal.tsx b/web/components/gsd/shell-terminal.tsx index b2a3b29fc..22050df45 100644 --- a/web/components/gsd/shell-terminal.tsx +++ b/web/components/gsd/shell-terminal.tsx @@ -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 diff --git a/web/lib/xterm-theme.ts b/web/lib/xterm-theme.ts new file mode 100644 index 000000000..afaa1ef39 --- /dev/null +++ b/web/lib/xterm-theme.ts @@ -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, + }; +}