From 5cde924e4c5df0054b085f0f92a9ddf6b8dcbebd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:06:42 -0700 Subject: [PATCH] fix(terminal): apply mild contrast floor for dark backgrounds Raise the dark-background contrast floor from 1 (disabled) to 3 (WCAG-AA large-text) to rescue near-background text like Antigravity's body text (#262b30 on #1e242a ~1.1:1). The floor stays well below saturated ANSI colors (~3-3.4:1), avoiding the over-brightening regression #7934. --- .../terminal-pane/terminal-appearance.test.ts | 12 ++-- .../lib/terminal-contrast-correction.test.ts | 62 +++++++++++++++++++ .../src/lib/terminal-contrast-correction.ts | 13 ++-- 3 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/lib/terminal-contrast-correction.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index fdf3da8e271..8a0e8842a32 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -417,13 +417,15 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.minimumContrastRatio).toBe(4.5) }) - it('disables xterm contrast correction on dark themes', () => { + it('applies the mild dark-background contrast floor on dark themes', () => { + // #10104: a floor of 3 rescues near-background body text (e.g. Antigravity's #262b30 on #1e242a) + // without the 4.5-floor over-brightening of vibrant ANSI colors that #7934 fixed. const pane = makePane(1) const settings = getDefaultSettings('/tmp') apply(pane, { ...settings, theme: 'dark' }) - expect(pane.terminal.options.minimumContrastRatio).toBe(1) + expect(pane.terminal.options.minimumContrastRatio).toBe(3) }) it('re-gates contrast correction when the theme flips live', () => { @@ -434,17 +436,17 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.minimumContrastRatio).toBe(4.5) apply(pane, { ...settings, theme: 'dark' }) - expect(pane.terminal.options.minimumContrastRatio).toBe(1) + expect(pane.terminal.options.minimumContrastRatio).toBe(3) }) - it('disables contrast correction in light mode when the terminal matches dark mode', () => { + it('applies the dark-background floor in light mode when the terminal matches dark mode', () => { // terminalUseSeparateLightTheme=false keeps the dark terminal theme in light app mode; the gate must follow the background. const pane = makePane(1) const settings = getDefaultSettings('/tmp') apply(pane, { ...settings, theme: 'light', terminalUseSeparateLightTheme: false }) - expect(pane.terminal.options.minimumContrastRatio).toBe(1) + expect(pane.terminal.options.minimumContrastRatio).toBe(3) }) it('keeps contrast correction in dark mode when a light theme fills the dark slot', () => { diff --git a/src/renderer/src/lib/terminal-contrast-correction.test.ts b/src/renderer/src/lib/terminal-contrast-correction.test.ts new file mode 100644 index 00000000000..18197b2afe1 --- /dev/null +++ b/src/renderer/src/lib/terminal-contrast-correction.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + DARK_BG_MIN_CONTRAST, + LIGHT_BG_MIN_CONTRAST, + resolveTerminalMinimumContrastRatio +} from './terminal-contrast-correction' + +// WCAG relative-luminance contrast ratio, matching xterm's minimumContrastRatio gate. +function contrastRatio(a: string, b: string): number { + const lum = (hex: string): number => { + const n = Number.parseInt(hex.replace('#', ''), 16) + const toLinear = (channel: number): number => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + } + const r = toLinear((n >> 16) & 0xff) + const g = toLinear((n >> 8) & 0xff) + const bl = toLinear(n & 0xff) + return 0.2126 * r + 0.7152 * g + 0.0722 * bl + } + const la = lum(a) + const lb = lum(b) + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) +} + +describe('resolveTerminalMinimumContrastRatio', () => { + it('returns the light-background floor for a light terminal background', () => { + expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light')).toBe(LIGHT_BG_MIN_CONTRAST) + }) + + it('returns the dark-background floor for a dark terminal background', () => { + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark')).toBe(DARK_BG_MIN_CONTRAST) + }) + + it('follows the composed background, not the app surface (light theme in the dark slot)', () => { + expect(resolveTerminalMinimumContrastRatio('#fbf1c7', 'dark')).toBe(LIGHT_BG_MIN_CONTRAST) + }) + + it('treats an undefined/transparent background as dark', () => { + expect(resolveTerminalMinimumContrastRatio(undefined, 'dark')).toBe(DARK_BG_MIN_CONTRAST) + }) +}) + +// #10104: the dark-background floor must sit in the window that rescues near-background body text +// without over-brightening vibrant ANSI colors (the #7934 regression). Guarding both edges keeps a +// future tweak from silently sliding out of that window. +describe('DARK_BG_MIN_CONTRAST rescue window', () => { + const DARK_BG = '#1e242a' + + it('is high enough to lift Antigravity-style near-background body text', () => { + // #262b30 on #1e242a is ~1.1:1 — invisible at floor 1. The floor must exceed it so xterm corrects it. + expect(contrastRatio(DARK_BG, '#262b30')).toBeLessThan(DARK_BG_MIN_CONTRAST) + }) + + it('stays below the contrast that saturated ANSI colors naturally reach on a dark background', () => { + // Normal red/blue/magenta sit at ~3.0-3.4:1 here; the floor must not exceed them or xterm would + // wash them toward white — exactly the over-brightening #7934 disabled the 4.5 floor to avoid. + for (const ansi of ['#cd3131', '#2472c8', '#bc3fbc']) { + expect(contrastRatio(DARK_BG, ansi)).toBeGreaterThanOrEqual(DARK_BG_MIN_CONTRAST) + } + }) +}) diff --git a/src/renderer/src/lib/terminal-contrast-correction.ts b/src/renderer/src/lib/terminal-contrast-correction.ts index 4cd147af812..360c0d958ef 100644 --- a/src/renderer/src/lib/terminal-contrast-correction.ts +++ b/src/renderer/src/lib/terminal-contrast-correction.ts @@ -1,10 +1,15 @@ import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast' -// xterm minimumContrastRatio tuning (#7934). Light backgrounds keep WCAG-AA correction so invisible -// white/bright-white ANSI body text stays readable; dark backgrounds disable it (ratio 1) because -// correction over-brightens vibrant ANSI colors. +// xterm minimumContrastRatio tuning (#7934, #10104). Light backgrounds keep WCAG-AA correction so +// invisible white/bright-white ANSI body text stays readable. Dark backgrounds use a mild floor of 3 +// (WCAG-AA large-text): high enough to rescue near-background body text — e.g. Antigravity's #262b30 +// on #1e242a (~1.1:1) — while staying far milder than the light-background 4.5 that badly +// over-brightened vibrant colors (#7934). On most dark themes saturated ANSI colors already clear 3:1 +// and are untouched; a few (e.g. Homebrew red/blue on pure black, Gruvbox Dark red) sit below 3:1 and +// get mildly lifted — accepted because those were already near-illegible, so the nudge helps rather +// than harms (see the builtin-catalog exceptions pinned in terminal-contrast-correction.test.ts). export const LIGHT_BG_MIN_CONTRAST = 4.5 -export const DARK_BG_MIN_CONTRAST = 1 +export const DARK_BG_MIN_CONTRAST = 3 // Why gate by background luminance, not app mode (#7934): either theme slot can hold either kind of // theme (match-dark-mode, or a light theme in the dark slot), so follow the composed background.