fix(terminal): restore a dark-background contrast floor (#10108)

* fix(terminal): restore a dark-background contrast floor

Fully disabling xterm minimumContrastRatio on dark backgrounds (#9599)
left near-background body text unreadable — Antigravity paints #262b30
on #1e242a (~1.1:1). Keep light backgrounds at WCAG-AA 4.5 and use a
milder dark floor (3) so dark-on-dark body text is lifted without the
full light-bg correction strength.

Fixes #10104

* fix(terminal): extend dark-bg contrast floor to preview + mobile terminals

The dark-background minimumContrastRatio floor (#10104) is applied per
`new Terminal()` construction site. Beyond the live pane, agent output also
renders in the dashboard popout preview and the mobile WebView, which were
still at the floor-1 default, so Antigravity output stayed unreadable there.

- AgentTerminalPreview: gate via resolveTerminalMinimumContrastRatio
- mobile WebView: port the gate as resolveTerminalContrastFloor (Chrome-74 JS)
- tests: builtin-catalog guard + mobile vm-harness coverage

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Wooseong Kim 2026-07-23 17:04:45 +09:00 committed by GitHub
parent e8d5d50c35
commit 1648251fb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 286 additions and 14 deletions

View File

@ -290,6 +290,7 @@ window.onerror = function(msg) {
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
var terminalMinimumContrastRatio = 3;
var webglAddon = null;
var webglRecoveryTimer = null;
var activeAltScreenSnapshot = false;
@ -714,6 +715,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
cols: cols || 80,
rows: rows || 24,
theme: terminalTheme,
minimumContrastRatio: terminalMinimumContrastRatio,
fontFamily: terminalFontFamily,
fontSize: fontPxForScale(currentTextScale),
fontWeight: '300',

View File

@ -0,0 +1,79 @@
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it } from 'vitest'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
const DARK_FLOOR = 3
const LIGHT_FLOOR = 4.5
// Eval the injected theme JS in a bare context so the declared helpers become
// callable properties on it (mirrors terminal-webview-engine.test.ts).
function loadThemeInjected(extra: Record<string, unknown> = {}): Record<string, unknown> {
const context: Record<string, unknown> = {
defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' },
...extra
}
new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context)
return context
}
describe('mobile terminal-webview contrast floor gate', () => {
it('parses at the Chrome 74 syntax floor', () => {
expect(() => parse(TERMINAL_WEBVIEW_THEME_JS, { ecmaVersion: 2019 })).not.toThrow()
})
it('picks the dark floor for dark composed backgrounds', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
for (const bg of ['#1a1b26', '#1e242a', '#282828', '#000000', 'black']) {
expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
}
})
it('picks the light floor for light composed backgrounds', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
for (const bg of ['#ffffff', '#fbf1c7', 'white', 'rgb(240 240 240)']) {
expect(resolveTerminalContrastFloor(bg)).toBe(LIGHT_FLOOR)
}
})
it('composites transparency over the dark app surface before deciding', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
// Fully transparent → app surface (dark) → dark floor.
expect(resolveTerminalContrastFloor('transparent')).toBe(DARK_FLOOR)
// Faint white over the dark surface stays dark; opaque-enough white flips light.
expect(resolveTerminalContrastFloor('rgba(255,255,255,0.15)')).toBe(DARK_FLOOR)
expect(resolveTerminalContrastFloor('rgba(255,255,255,0.9)')).toBe(LIGHT_FLOOR)
})
it('defaults unparseable backgrounds to the dark floor so output never stays invisible', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
for (const bg of [undefined, null, '', 'not-a-color', '#12', 42]) {
expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
}
})
it('writes the resolved floor onto a live terminal when the theme changes', () => {
const term = { options: { theme: undefined as unknown, minimumContrastRatio: 1 } }
const context = loadThemeInjected({
term,
document: {
documentElement: { style: { background: '' } },
body: { style: { background: '' } }
}
}) as Record<string, unknown> & { applyTerminalTheme: (input: unknown) => void }
context.applyTerminalTheme({ theme: { background: '#ffffff' } })
expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
context.applyTerminalTheme({ theme: { background: '#1e242a' } })
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
})
})

View File

@ -1,7 +1,85 @@
import { colors } from '../theme/mobile-theme'
// Theme normalization and page-surface painting injected into the WebView IIFE.
// Mirrors the desktop minimumContrastRatio gate (src/renderer/src/lib/terminal-contrast-correction.ts,
// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text
// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light
// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode,
// because either theme slot can hold either kind of theme.
export const TERMINAL_WEBVIEW_THEME_JS = `
var DARK_BG_MIN_CONTRAST = 3;
var LIGHT_BG_MIN_CONTRAST = 4.5;
// Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark).
var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 };
function parseTerminalBackgroundRgba(value) {
if (typeof value !== 'string') return null;
var v = value.trim().toLowerCase();
if (!v) return null;
if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 };
if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 };
if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
if (hex) {
var h = hex[1];
var ch;
if (h.length === 3 || h.length === 4) {
ch = h.split('').map(function (p) { return parseInt(p + p, 16); });
} else {
ch = [];
for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16));
}
return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 };
}
var rgb = v.match(/^rgba?\\(([^)]+)\\)$/);
if (!rgb) return null;
var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\\s/]+/);
parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; });
if (parts.length < 3) return null;
var channel = function (p) {
var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p);
return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null;
};
var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]);
if (r === null || g === null || b === null) return null;
var a = 1;
if (parts[3] !== undefined) {
var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
}
return { r: r, g: g, b: b, a: a };
}
function terminalRelativeLuminance(rgb) {
var lin = function (c) {
var n = c / 255;
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
};
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
}
function terminalContrastRatio(a, b) {
var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// Pick the xterm minimumContrastRatio floor from the composed terminal background.
// Unparseable input defaults to the dark floor so agent output never stays invisible.
function resolveTerminalContrastFloor(background) {
var color = parseTerminalBackgroundRgba(background);
if (!color) return DARK_BG_MIN_CONTRAST;
var composited = color.a < 1
? {
r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)),
g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)),
b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a))
}
: color;
var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >=
terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited);
return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST;
}
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
@ -22,6 +100,10 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
if (term) term.options.theme = terminalTheme;
terminalMinimumContrastRatio = resolveTerminalContrastFloor(background);
if (term) {
term.options.theme = terminalTheme;
term.options.minimumContrastRatio = terminalMinimumContrastRatio;
}
}
`

View File

@ -20,6 +20,7 @@ import {
} from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { translate } from '@/i18n/i18n'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
@ -53,15 +54,16 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
const containerRef = useRef<HTMLDivElement>(null)
const settings = useAppStore((state) => state.settings)
const systemPrefersDark = useSystemPrefersDark()
const terminalTheme = useMemo(() => {
const { terminalTheme, terminalMode } = useMemo(() => {
if (!settings) {
return null
return { terminalTheme: null, terminalMode: 'dark' as const }
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
return composeActiveTerminalTheme(
const theme = composeActiveTerminalTheme(
appearance.theme ?? getBuiltinTheme(appearance.themeName),
settings
)
return { terminalTheme: theme, terminalMode: appearance.mode }
}, [settings, systemPrefersDark])
// A null snapshot means no serializer knows this pty (it died or was never
// spawned this session) — say so instead of painting a silent blank terminal.
@ -271,6 +273,10 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500),
rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200),
theme: terminalTheme ?? undefined,
minimumContrastRatio: resolveTerminalMinimumContrastRatio(
terminalTheme?.background,
terminalMode
),
scrollback: 1000
})
try {
@ -399,7 +405,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
void window.api.terminalPreview.unsubscribe(ptyId)
terminal?.dispose()
}
}, [ptyId, terminalTheme])
}, [ptyId, terminalTheme, terminalMode])
return (
// Why: a size FIXED by the viewport (not shrink-to-fit) + overflow-hidden

View File

@ -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', () => {

View File

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import {
DARK_BG_MIN_CONTRAST,
LIGHT_BG_MIN_CONTRAST,
resolveTerminalMinimumContrastRatio
} from './terminal-contrast-correction'
import { TERMINAL_THEME_CATALOG } from './terminal-themes'
// 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)
}
})
})
// #10104: pin which real builtin dark themes have normal ANSI colors below the floor, so a new theme
// or floor tweak forces an explicit decision instead of a silent #7934-style regression.
describe('DARK_BG_MIN_CONTRAST vs the builtin theme catalog', () => {
// Normal (non-bright) chromatic ANSI channels — the vibrant body-text colors #7934 protects.
// Bright variants are excluded: several themes (e.g. Solarized) repurpose them as achromatic grays.
const CHROMATIC_ANSI = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'] as const
// Accepted below-floor cases: near-illegible primaries on very dark backgrounds where the mild
// lift helps rather than harms. Keep in sync with the comment in terminal-contrast-correction.ts.
const ACCEPTED_BELOW_FLOOR = ['Gruvbox Dark:red', 'Homebrew:blue', 'Homebrew:red']
it('leaves every dark-theme chromatic ANSI color at/above the floor, except the pinned exceptions', () => {
const belowFloor: string[] = []
for (const [name, theme] of Object.entries(TERMINAL_THEME_CATALOG)) {
const background = theme.background
// Only dark-slot themes get the dark floor; the resolver picks it exactly for those.
if (
!background ||
resolveTerminalMinimumContrastRatio(background, 'dark') !== DARK_BG_MIN_CONTRAST
) {
continue
}
for (const channel of CHROMATIC_ANSI) {
const color = theme[channel]
if (color && contrastRatio(background, color) < DARK_BG_MIN_CONTRAST) {
belowFloor.push(`${name}:${channel}`)
}
}
}
expect(belowFloor.sort()).toEqual(ACCEPTED_BELOW_FLOOR)
})
})

View File

@ -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, #9599, #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.