diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index a4d9c350c47..0b7eaa9149d 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -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', diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme-injected.test.ts new file mode 100644 index 00000000000..92e4127b2fc --- /dev/null +++ b/mobile/src/terminal/terminal-webview-theme-injected.test.ts @@ -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 = {}): Record { + const context: Record = { + 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 & { 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) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts index 1f798fe337e..c2d9beb4786 100644 --- a/mobile/src/terminal/terminal-webview-theme-injected.ts +++ b/mobile/src/terminal/terminal-webview-theme-injected.ts @@ -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; + } } ` diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index b6b82f0d133..4d01ce480c8 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -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(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 diff --git a/src/renderer/src/lib/terminal-contrast-correction.test.ts b/src/renderer/src/lib/terminal-contrast-correction.test.ts index 18197b2afe1..7f5cd76cbfe 100644 --- a/src/renderer/src/lib/terminal-contrast-correction.test.ts +++ b/src/renderer/src/lib/terminal-contrast-correction.test.ts @@ -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) + }) +})