refactor(terminal): extract contrast correction gating into shared helpe

Centralizes the minimumContrastRatio gating logic (#7934) into a
dedicated module so both the settings preview and live terminal
appearance apply the same contrast-by-luminance rule, preventing drift.
Adds value-gating to skip redundant writes that would clear xterm's
contrast cache on re-applies.
This commit is contained in:
Jinjing
2026-07-20 18:27:13 -07:00
parent 216803a4b8
commit 9c29ecea0f
6 changed files with 59 additions and 23 deletions
@@ -8,7 +8,7 @@ import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-op
import { buildFontFamily } from '@/components/terminal-pane/layout-serialization'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
import { clampNumber, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast'
import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures'
import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings'
@@ -196,12 +196,11 @@ export function TerminalSettingsPreview({
return
}
terminal.options.theme = composedTheme
// Why: mirror applyTerminalAppearance's background-luminance gating (#7934) so the preview matches live panes.
terminal.options.minimumContrastRatio = isTerminalBackgroundLight(composedTheme.background, {
appSurface: effectiveMode
})
? 4.5
: 1
// Why: share applyTerminalAppearance's gating helper (#7934) so the preview can't drift from live panes.
terminal.options.minimumContrastRatio = resolveTerminalMinimumContrastRatio(
composedTheme.background,
effectiveMode
)
// Why: xterm renders an alpha-channel background opaque unless allowTransparency is set (matches applyTerminalAppearance).
terminal.options.allowTransparency =
settings.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1
@@ -455,6 +455,30 @@ describe('applyTerminalAppearance theme assignment', () => {
expect(pane.terminal.options.minimumContrastRatio).toBe(4.5)
})
it('skips the minimumContrastRatio write on a no-op re-apply (preserves xterm contrast cache)', () => {
const pane = makePane(1)
let writes = 0
let stored: number | undefined
Object.defineProperty(pane.terminal.options, 'minimumContrastRatio', {
configurable: true,
enumerable: true,
get: () => stored,
set: (value: number) => {
stored = value
writes += 1
}
})
const settings = getDefaultSettings('/tmp')
apply(pane, { ...settings, theme: 'dark' })
const writesAfterFirst = writes
apply(pane, { ...settings, theme: 'dark' })
// The value-gate must not rewrite an unchanged ratio — each write clears xterm's contrast cache.
expect(writes).toBe(writesAfterFirst)
})
})
describe('publishTerminalViewAttributesAtAppStart', () => {
@@ -24,7 +24,7 @@ import type { TerminalViewAttributes } from '../../../../shared/terminal-view-at
import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher'
import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings'
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast'
import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
// Why Pick over a hand-rolled type: stays tied to xterm's canonical signature so upstream tightening surfaces here.
type Mode2031Parser = Pick<IParser, 'registerCsiHandler'>
@@ -208,15 +208,13 @@ export function applyTerminalAppearance(
if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) {
pane.terminal.options.theme = theme
}
// Why gate by background luminance, not appearance.mode (#7934): xterm's contrast correction rescues
// invisible white/bright-white ANSI text on light backgrounds but over-corrects vibrant ANSI colors on
// dark ones — and either theme slot can hold either kind (match-dark-mode, light theme in the dark slot).
// Gate off the configured theme background; the live OSC-11 background is deliberately preserved by the
// theme write above, so a TUI that repaints its background at runtime won't re-gate (known limitation).
// Why value-gated: writing minimumContrastRatio clears xterm's contrast cache, so skip on no-op re-applies.
const minimumContrastRatio = isTerminalBackgroundLight(theme?.background, {
appSurface: appearance.mode
})
? 4.5
: 1
const minimumContrastRatio = resolveTerminalMinimumContrastRatio(
theme?.background,
appearance.mode
)
if (pane.terminal.options.minimumContrastRatio !== minimumContrastRatio) {
pane.terminal.options.minimumContrastRatio = minimumContrastRatio
}
@@ -114,8 +114,7 @@ describe('buildDefaultTerminalOptions', () => {
expect(normalizeTerminalFastScrollSensitivity(25)).toBe(20)
})
it('starts with light-theme contrast correction so first paint never flashes low-contrast text', () => {
// Initial value is the light-background setting (4.5); applyTerminalAppearance re-gates it by background luminance (dark bg => 1; #7934).
it('defaults minimumContrastRatio to the light-background value (applyTerminalAppearance re-gates it)', () => {
expect(buildDefaultTerminalOptions().minimumContrastRatio).toBe(4.5)
})
@@ -1,5 +1,6 @@
import type { ITerminalOptions } from '@xterm/xterm'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy'
import { LIGHT_BG_MIN_CONTRAST } from '@/lib/terminal-contrast-correction'
type TerminalCursorStyle = NonNullable<ITerminalOptions['cursorStyle']>
type TerminalCursorInactiveStyle = NonNullable<ITerminalOptions['cursorInactiveStyle']>
@@ -47,11 +48,8 @@ export function buildDefaultTerminalOptions(): ITerminalOptions {
scrollSensitivity: DEFAULT_TERMINAL_SCROLL_SENSITIVITY,
fastScrollSensitivity: DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY,
allowTransparency: false,
// Why 4.5 as the initial value: agent CLIs render body text with ANSI white/bright-white that
// vanishes on light (white) backgrounds. applyTerminalAppearance re-gates this by the theme's
// background luminance (light bg => 4.5, dark bg => 1; #7934) — start high so the first paint on
// a light theme never flashes low-contrast text before appearance-apply runs.
minimumContrastRatio: 4.5,
// Initial value only; applyTerminalAppearance re-gates by background luminance (#7934) before any content paints.
minimumContrastRatio: LIGHT_BG_MIN_CONTRAST,
// Why: on macOS, non-US layouts rely on Option to compose characters like @ and €.
macOptionIsMeta: false,
macOptionClickForcesSelection: true,
@@ -0,0 +1,18 @@
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.
export const LIGHT_BG_MIN_CONTRAST = 4.5
export const DARK_BG_MIN_CONTRAST = 1
// 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.
export function resolveTerminalMinimumContrastRatio(
background: string | undefined,
appSurface: 'dark' | 'light'
): number {
return isTerminalBackgroundLight(background, { appSurface })
? LIGHT_BG_MIN_CONTRAST
: DARK_BG_MIN_CONTRAST
}