fix(terminal): apply contrast floor to mobile and preview terminals

Mobile WebView and AgentTerminalPreview now apply xterm's minimumContrastRatio
gate (3 for dark backgrounds, 4.5 for light) to ensure readable output on all
backgrounds. Gate resolves composited background luminance. Matches desktop
implementation with comprehensive tests.
This commit is contained in:
Jinjing
2026-07-23 00:11:59 -07:00
parent 5cde924e4c
commit 7a09cfe5e9
5 changed files with 208 additions and 5 deletions
@@ -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',
@@ -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)
})
})
@@ -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;
}
}
`
@@ -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
@@ -4,6 +4,7 @@ import {
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 {
@@ -60,3 +61,36 @@ describe('DARK_BG_MIN_CONTRAST rescue window', () => {
}
})
})
// #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)
})
})