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.
This commit is contained in:
Jinjing
2026-07-23 00:06:42 -07:00
parent c445f26541
commit 5cde924e4c
3 changed files with 78 additions and 9 deletions
@@ -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', () => {
@@ -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)
}
})
})
@@ -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.