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:
parent
a0b9a85a20
commit
cedf6a558d
4 changed files with 129 additions and 141 deletions
57
src/tests/xterm-theme.test.ts
Normal file
57
src/tests/xterm-theme.test.ts
Normal 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 =/);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
70
web/lib/xterm-theme.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue