mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* feat(terminal): make the contrast floor user-configurable (#10754) The xterm minimumContrastRatio floor was hardcoded (3 on dark backgrounds, 4.5 on light) and applied to every pane with no way out, so TUIs that use deliberately low contrast were rewritten: Powerline separators drawn in the neighbouring segment's background became visible seams, and dimmed secondary text lost its hierarchy. Adds an optional `terminalMinimumContrastRatio` setting under Settings -> Terminal -> Rendering. Blank keeps today's automatic, background-luminance gated floor; 1 disables correction entirely (matching VS Code's documented `terminal.integrated.minimumContrastRatio` and iTerm2's off-by-default Minimum Contrast); values are clamped to xterm's 1-21 range. The floor is resolved in one place, so live panes, the Appearance preview and the dashboard terminal preview all follow it, and the existing value-gated write still avoids clearing xterm's contrast cache on no-op re-applies. The clamp also lives at the persistence boundary that every writer crosses, so a hand-edited profile or CLI write can never hand xterm a non-finite option. Mobile mirrors the desktop gate, so the resolved floor travels with the terminal theme payload as a new optional field; hosts that omit it leave older and newer clients on the luminance gate. Fixes #10754. Co-authored-by: Nyanako <44753291+Nanako0129@users.noreply.github.com> * fix(terminal): refresh mobile payload fixture and clarify contrast target * feat(terminal): make contrast controls intent-based with custom tuning --------- Co-authored-by: Nyanako <44753291+Nanako0129@users.noreply.github.com> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
|
||||
// uncovered region ships silently. A diff here means the emitted WebView source changed —
|
||||
// update these values only when that change is deliberate, and only after checking the
|
||||
// document still runs. Refactors that merely move slice boundaries must leave them alone.
|
||||
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
|
||||
const EXPECTED_LENGTH = 729776
|
||||
const EXPECTED_SHA256 = '5c69dce3236662c381abbfb5d2d6b7163e0f4dd6841d72753733f9470326fee3'
|
||||
const EXPECTED_LENGTH = 730428
|
||||
|
||||
describe('terminal WebView payload', () => {
|
||||
it('composes the expected document', () => {
|
||||
|
||||
@@ -76,4 +76,43 @@ describe('mobile terminal-webview contrast floor gate', () => {
|
||||
context.applyTerminalTheme({ theme: { background: '#1e242a' } })
|
||||
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
|
||||
})
|
||||
|
||||
// #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the
|
||||
// published value has to win here or the same session renders differently on the phone.
|
||||
describe('published desktop override', () => {
|
||||
function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void {
|
||||
const context = loadThemeInjected({
|
||||
term,
|
||||
document: {
|
||||
documentElement: { style: { background: '' } },
|
||||
body: { style: { background: '' } }
|
||||
}
|
||||
}) as Record<string, unknown> & { applyTerminalTheme: (input: unknown) => void }
|
||||
context.applyTerminalTheme(input)
|
||||
}
|
||||
|
||||
it('uses the published floor instead of the luminance gate', () => {
|
||||
const term = { options: { minimumContrastRatio: 0 } }
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 1 })
|
||||
expect(term.options.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it("clamps a published floor to xterm's 1-21 window", () => {
|
||||
const term = { options: { minimumContrastRatio: 0 } }
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 99 })
|
||||
expect(term.options.minimumContrastRatio).toBe(21)
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 0 })
|
||||
expect(term.options.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to the luminance gate for an older host that omits the field', () => {
|
||||
const term = { options: { minimumContrastRatio: 0 } }
|
||||
for (const published of [undefined, null, 'off', Number.NaN]) {
|
||||
applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: published })
|
||||
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
|
||||
applyOn(term, { theme: { background: '#ffffff' }, minimumContrastRatio: published })
|
||||
expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@ import { colors } from '../theme/mobile-theme'
|
||||
// #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.
|
||||
// because either theme slot can hold either kind of theme. An explicit desktop override published on
|
||||
// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it.
|
||||
export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
var DARK_BG_MIN_CONTRAST = 3;
|
||||
var LIGHT_BG_MIN_CONTRAST = 4.5;
|
||||
@@ -63,6 +64,12 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
// Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override".
|
||||
function normalizeTerminalContrastOverride(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return null;
|
||||
return Math.min(21, Math.max(1, value));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -100,7 +107,13 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
var background = terminalTheme.background || '${colors.terminalBg}';
|
||||
document.documentElement.style.background = background;
|
||||
document.body.style.background = background;
|
||||
terminalMinimumContrastRatio = resolveTerminalContrastFloor(background);
|
||||
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
|
||||
// an older host omits the field and the luminance gate stays authoritative.
|
||||
var publishedFloor = normalizeTerminalContrastOverride(
|
||||
input && typeof input === 'object' ? input.minimumContrastRatio : undefined
|
||||
);
|
||||
terminalMinimumContrastRatio =
|
||||
publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor;
|
||||
if (term) {
|
||||
term.options.theme = terminalTheme;
|
||||
term.options.minimumContrastRatio = terminalMinimumContrastRatio;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { updateSettings, type SettingsMutationOperations } from './settings-update'
|
||||
|
||||
function makeOperations(): SettingsMutationOperations {
|
||||
return {
|
||||
// Only the fields updateSettings reads; the rest of GlobalSettings is irrelevant to the clamp.
|
||||
state: { settings: { terminalFontSize: 14 }, repos: [] } as unknown as PersistedState,
|
||||
bumpLocalWorktreeScanGeneration: vi.fn(),
|
||||
removeRetainedBlob: vi.fn(),
|
||||
scheduleSave: vi.fn(),
|
||||
notifySettingsChanged: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// #10754: desktop IPC, the web RPC and the CLI all reach the store through this boundary, and xterm
|
||||
// throws on a non-finite minimumContrastRatio, so the clamp cannot live in the settings UI alone.
|
||||
describe('updateSettings terminalMinimumContrastRatio', () => {
|
||||
it('persists an in-range floor unchanged', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 1 }).terminalMinimumContrastRatio
|
||||
).toBe(1)
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 4.5 }).terminalMinimumContrastRatio
|
||||
).toBe(4.5)
|
||||
})
|
||||
|
||||
it('clamps a hand-edited value into xterm range', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 0 }).terminalMinimumContrastRatio
|
||||
).toBe(1)
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 500 }).terminalMinimumContrastRatio
|
||||
).toBe(21)
|
||||
})
|
||||
|
||||
it('drops an unusable value back to automatic rather than storing it', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
expect(
|
||||
updateSettings(operations, {
|
||||
terminalMinimumContrastRatio: Number.NaN
|
||||
}).terminalMinimumContrastRatio
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
updateSettings(operations, {
|
||||
terminalMinimumContrastRatio: 'off' as unknown as number
|
||||
}).terminalMinimumContrastRatio
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears the override so the automatic floor comes back', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 1 })
|
||||
expect(
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: undefined })
|
||||
.terminalMinimumContrastRatio
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a stored floor alone when an unrelated setting is written', () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
updateSettings(operations, { terminalMinimumContrastRatio: 1 })
|
||||
expect(updateSettings(operations, { terminalFontSize: 15 }).terminalMinimumContrastRatio).toBe(
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-c
|
||||
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
|
||||
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
|
||||
import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy'
|
||||
import { normalizeTerminalMinimumContrastRatio } from '../../../shared/terminal-minimum-contrast-settings'
|
||||
import { normalizeTaskProviderSettings } from '../../../shared/task-providers'
|
||||
import { normalizeOpenInApplications } from '../../../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings'
|
||||
@@ -123,6 +124,13 @@ export function updateSettings(
|
||||
updates.terminalScrollbackRows
|
||||
)
|
||||
}
|
||||
// Why here: every writer (desktop IPC, web RPC, CLI) crosses this boundary, so xterm can never be
|
||||
// handed an out-of-range floor, and undefined stays undefined to mean "automatic" (#10754).
|
||||
if ('terminalMinimumContrastRatio' in updates) {
|
||||
sanitizedUpdates.terminalMinimumContrastRatio = normalizeTerminalMinimumContrastRatio(
|
||||
updates.terminalMinimumContrastRatio
|
||||
)
|
||||
}
|
||||
if (
|
||||
'terminalTuiScrollSensitivity' in updates ||
|
||||
'terminalTuiScrollSensitivityDefaultedToOne' in updates
|
||||
|
||||
@@ -58,6 +58,43 @@ describe('buildPreviewTerminalOptions', () => {
|
||||
scrollback: 1000
|
||||
}
|
||||
|
||||
// #10754: the dashboard preview renders the agent's live buffer, so it has to reproduce the same
|
||||
// contrast floor the pane used or a Powerline statusline looks different in the popout.
|
||||
it('mirrors the automatic contrast floor when no override is set', () => {
|
||||
expect(
|
||||
buildPreviewTerminalOptions({
|
||||
...base,
|
||||
terminalInput: null,
|
||||
theme: { background: '#1e242a' }
|
||||
}).minimumContrastRatio
|
||||
).toBe(3)
|
||||
expect(
|
||||
buildPreviewTerminalOptions({
|
||||
...base,
|
||||
terminalInput: null,
|
||||
theme: { background: '#ffffff' },
|
||||
themeMode: 'light'
|
||||
}).minimumContrastRatio
|
||||
).toBe(4.5)
|
||||
})
|
||||
|
||||
it('honors the user contrast override, clamped to xterm range', () => {
|
||||
expect(
|
||||
buildPreviewTerminalOptions({
|
||||
...base,
|
||||
terminalInput: null,
|
||||
settings: { ...SETTINGS, terminalMinimumContrastRatio: 1 }
|
||||
}).minimumContrastRatio
|
||||
).toBe(1)
|
||||
expect(
|
||||
buildPreviewTerminalOptions({
|
||||
...base,
|
||||
terminalInput: null,
|
||||
settings: { ...SETTINGS, terminalMinimumContrastRatio: 0 }
|
||||
}).minimumContrastRatio
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the kitty advertisement and skips ConPTY options off Windows', () => {
|
||||
const options = buildPreviewTerminalOptions({
|
||||
...base,
|
||||
|
||||
@@ -80,7 +80,8 @@ export function buildPreviewTerminalOptions(args: {
|
||||
theme: args.theme ?? undefined,
|
||||
minimumContrastRatio: resolveTerminalMinimumContrastRatio(
|
||||
args.theme?.background,
|
||||
args.themeMode
|
||||
args.themeMode,
|
||||
args.settings?.terminalMinimumContrastRatio
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NumberField } from './SettingsFormControls'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// #10754: an optional setting needs a way back to "unset". Without a clear path the field can pin a
|
||||
// value but never restore Orca's automatic behavior, which is the state most users should be in.
|
||||
describe('NumberField clearable fields', () => {
|
||||
it('renders the placeholder and commits nothing while the value is unset', () => {
|
||||
render(
|
||||
<NumberField
|
||||
label="Minimum Contrast Ratio"
|
||||
description=""
|
||||
value={undefined}
|
||||
min={1}
|
||||
max={21}
|
||||
placeholder="Auto"
|
||||
onChange={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Minimum Contrast Ratio') as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
expect(input.getAttribute('placeholder')).toBe('Auto')
|
||||
})
|
||||
|
||||
it('clears the setting when the field is emptied', () => {
|
||||
const onChange = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<NumberField
|
||||
label="Minimum Contrast Ratio"
|
||||
description=""
|
||||
value={1}
|
||||
min={1}
|
||||
max={21}
|
||||
placeholder="Auto"
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Minimum Contrast Ratio')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still snaps back to the current value when the field is not clearable', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<NumberField label="Font Size" description="" value={14} min={6} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByLabelText('Font Size') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(input.value).toBe('14')
|
||||
})
|
||||
|
||||
it('clamps a committed value into the min/max window', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<NumberField
|
||||
label="Minimum Contrast Ratio"
|
||||
description=""
|
||||
value={undefined}
|
||||
min={1}
|
||||
max={21}
|
||||
onChange={onChange}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByLabelText('Minimum Contrast Ratio')
|
||||
fireEvent.change(input, { target: { value: '99' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(21)
|
||||
})
|
||||
})
|
||||
@@ -262,13 +262,17 @@ type ColorFieldProps = {
|
||||
type NumberFieldProps = {
|
||||
label: string
|
||||
description: string
|
||||
value: number
|
||||
/** undefined renders the field empty — pair it with `placeholder` and `onClear` for an unset state. */
|
||||
value: number | undefined
|
||||
defaultValue?: number
|
||||
min: number
|
||||
max?: number
|
||||
step?: number
|
||||
integer?: boolean
|
||||
onChange: (value: number) => void
|
||||
/** When set, emptying the field clears the setting instead of snapping back to the current value. */
|
||||
onClear?: () => void
|
||||
placeholder?: string
|
||||
suffix?: string
|
||||
className?: string
|
||||
}
|
||||
@@ -316,6 +320,8 @@ export function NumberField({
|
||||
step = 1,
|
||||
integer = false,
|
||||
onChange,
|
||||
onClear,
|
||||
placeholder,
|
||||
suffix,
|
||||
className
|
||||
}: NumberFieldProps): React.JSX.Element {
|
||||
@@ -331,6 +337,11 @@ export function NumberField({
|
||||
const commit = (): void => {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed === '') {
|
||||
if (onClear) {
|
||||
// Clearable fields treat empty as "unset" so the caller can fall back to its automatic value.
|
||||
onClear()
|
||||
return
|
||||
}
|
||||
// Empty input — reset to current value rather than committing 0
|
||||
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||
return
|
||||
@@ -369,6 +380,7 @@ export function NumberField({
|
||||
max={max}
|
||||
step={step}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { useState } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { TerminalContrastSetting } from './TerminalContrastSetting'
|
||||
|
||||
vi.mock('./SearchableSetting', () => ({ SearchableSetting: ({ children }) => children }))
|
||||
afterEach(cleanup)
|
||||
|
||||
function mount(initial: number | undefined = undefined): ReturnType<typeof vi.fn> {
|
||||
const persist = vi.fn()
|
||||
function Harness(): React.JSX.Element {
|
||||
const [settings, setSettings] = useState({
|
||||
terminalMinimumContrastRatio: initial
|
||||
} as GlobalSettings)
|
||||
return (
|
||||
<TerminalContrastSetting
|
||||
settings={settings}
|
||||
updateSettings={(patch) => {
|
||||
persist(patch)
|
||||
setSettings((previous) => ({ ...previous, ...patch }))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<Harness />)
|
||||
return persist
|
||||
}
|
||||
|
||||
describe('terminal contrast modes', () => {
|
||||
it('lets users turn correction off and restore automatic without editing a number', () => {
|
||||
const persist = mount()
|
||||
expect(screen.getByRole('radio', { name: 'Automatic' }).getAttribute('aria-checked')).toBe(
|
||||
'true'
|
||||
)
|
||||
expect(screen.queryByRole('spinbutton')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Off' }))
|
||||
expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 1 })
|
||||
expect(screen.queryByRole('spinbutton')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Automatic' }))
|
||||
expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: undefined })
|
||||
})
|
||||
|
||||
it('restores the custom target when toggling through off and automatic', () => {
|
||||
const persist = mount(7)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Off' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Automatic' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Custom' }))
|
||||
expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 7 })
|
||||
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('7')
|
||||
})
|
||||
|
||||
it('starts custom at a usable target and bounds precise input', () => {
|
||||
const persist = mount()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Custom' }))
|
||||
expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 4.5 })
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '99' } })
|
||||
fireEvent.blur(input)
|
||||
expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 21 })
|
||||
fireEvent.change(input, { target: { value: '1' } })
|
||||
fireEvent.blur(input)
|
||||
expect(screen.getByRole('radio', { name: 'Off' }).getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { Slider } from '../ui/slider'
|
||||
import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import {
|
||||
LIGHT_BG_MIN_CONTRAST,
|
||||
MIN_TERMINAL_CONTRAST_RATIO,
|
||||
MAX_TERMINAL_CONTRAST_RATIO,
|
||||
normalizeTerminalMinimumContrastRatio
|
||||
} from '@/lib/terminal-contrast-correction'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type ContrastMode = 'auto' | 'off' | 'custom'
|
||||
|
||||
type Props = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
export function TerminalContrastSetting({ settings, updateSettings }: Props): React.JSX.Element {
|
||||
const value = normalizeTerminalMinimumContrastRatio(settings.terminalMinimumContrastRatio)
|
||||
const mode: ContrastMode = value === undefined ? 'auto' : value === 1 ? 'off' : 'custom'
|
||||
const lastCustomValue = useRef(LIGHT_BG_MIN_CONTRAST)
|
||||
const [draft, setDraft] = useState(value ?? LIGHT_BG_MIN_CONTRAST)
|
||||
const [previousValue, setPreviousValue] = useState(value)
|
||||
if (value !== previousValue) {
|
||||
setPreviousValue(value)
|
||||
setDraft(value ?? LIGHT_BG_MIN_CONTRAST)
|
||||
}
|
||||
const title = translate('auto.components.settings.contrast.title', 'Color Contrast')
|
||||
const description = translate(
|
||||
'auto.components.settings.contrast.description',
|
||||
'Improve text readability or preserve the colors chosen by terminal programs.'
|
||||
)
|
||||
const ratioLabel = translate('auto.components.settings.contrast.ratio', 'Contrast target')
|
||||
const selectMode = (next: ContrastMode): void => {
|
||||
if (mode === 'custom' && value !== undefined) {
|
||||
lastCustomValue.current = value
|
||||
}
|
||||
updateSettings({
|
||||
terminalMinimumContrastRatio:
|
||||
next === 'auto' ? undefined : next === 'off' ? 1 : lastCustomValue.current
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableSetting
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={[
|
||||
'terminal',
|
||||
'contrast',
|
||||
'minimum',
|
||||
'ratio',
|
||||
'readability',
|
||||
'accessibility',
|
||||
'wcag',
|
||||
'powerline',
|
||||
'statusline',
|
||||
'dim',
|
||||
'washed out',
|
||||
'colors'
|
||||
]}
|
||||
>
|
||||
<SettingsRow
|
||||
label={title}
|
||||
description={
|
||||
mode === 'auto'
|
||||
? translate(
|
||||
'auto.components.settings.contrast.autoDescription',
|
||||
'Balances readability with your terminal theme. Recommended.'
|
||||
)
|
||||
: mode === 'off'
|
||||
? translate(
|
||||
'auto.components.settings.contrast.offDescription',
|
||||
'Keeps program colors unchanged, including dim text and Powerline separators.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.contrast.customDescription',
|
||||
'Choose how much to increase contrast between text and its background.'
|
||||
)
|
||||
}
|
||||
className="flex-wrap [&>div:first-child]:min-w-48"
|
||||
control={
|
||||
<SettingsSegmentedControl<ContrastMode>
|
||||
ariaLabel={title}
|
||||
value={mode}
|
||||
onChange={selectMode}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: translate('auto.components.settings.contrast.auto', 'Automatic')
|
||||
},
|
||||
{ value: 'off', label: translate('auto.components.settings.contrast.off', 'Off') },
|
||||
{
|
||||
value: 'custom',
|
||||
label: translate('auto.components.settings.contrast.custom', 'Custom')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{mode === 'custom' && (
|
||||
<div className="space-y-3 pb-4">
|
||||
<NumberField
|
||||
label={ratioLabel}
|
||||
description={translate(
|
||||
'auto.components.settings.contrast.targetDescription',
|
||||
'Higher values increase contrast where possible. Background colors stay unchanged.'
|
||||
)}
|
||||
value={draft}
|
||||
min={MIN_TERMINAL_CONTRAST_RATIO}
|
||||
max={MAX_TERMINAL_CONTRAST_RATIO}
|
||||
step={0.1}
|
||||
suffix=":1"
|
||||
onChange={(ratio) => updateSettings({ terminalMinimumContrastRatio: ratio })}
|
||||
/>
|
||||
<Slider
|
||||
value={[draft]}
|
||||
min={MIN_TERMINAL_CONTRAST_RATIO + 0.1}
|
||||
max={MAX_TERMINAL_CONTRAST_RATIO}
|
||||
step={0.1}
|
||||
thumbLabels={[ratioLabel]}
|
||||
thumbValueLabels={[`${draft}:1`]}
|
||||
onValueChange={([ratio]) => setDraft(ratio)}
|
||||
onValueCommit={([ratio]) => updateSettings({ terminalMinimumContrastRatio: ratio })}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{translate('auto.components.settings.contrast.subtle', 'Subtle')}</span>
|
||||
<span>{translate('auto.components.settings.contrast.strong', 'Strong')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SettingsSubsectionHeader
|
||||
} from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { TerminalContrastSetting } from './TerminalContrastSetting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type TerminalRenderingSectionProps = {
|
||||
@@ -91,6 +92,8 @@ export function TerminalRenderingSection({
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<TerminalContrastSetting settings={settings} updateSettings={updateSettings} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -206,7 +206,8 @@ export function TerminalSettingsPreview({
|
||||
// Why: share applyTerminalAppearance's gating helper (#7934) so the preview can't drift from live panes.
|
||||
terminal.options.minimumContrastRatio = resolveTerminalMinimumContrastRatio(
|
||||
composedTheme.background,
|
||||
effectiveMode
|
||||
effectiveMode,
|
||||
settings.terminalMinimumContrastRatio
|
||||
)
|
||||
// Why: xterm renders an alpha-channel background opaque unless allowTransparency is set (matches applyTerminalAppearance).
|
||||
terminal.options.allowTransparency =
|
||||
@@ -218,7 +219,12 @@ export function TerminalSettingsPreview({
|
||||
// Why reset() not clear(): buffer ends mid-line on the prompt, so clear()+write would duplicate the trailing fragment.
|
||||
terminal.reset()
|
||||
terminal.write(PREVIEW_BUFFER)
|
||||
}, [composedTheme, effectiveMode, settings.terminalBackgroundOpacity])
|
||||
}, [
|
||||
composedTheme,
|
||||
effectiveMode,
|
||||
settings.terminalBackgroundOpacity,
|
||||
settings.terminalMinimumContrastRatio
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SETTING_LABELS: Partial<Record<keyof GlobalSettings, string>> = {
|
||||
terminalFastScrollSensitivity: 'Fast Scroll Speed',
|
||||
terminalTuiScrollSensitivity: 'TUI Scroll Speed',
|
||||
terminalBackgroundOpacity: 'Background Opacity',
|
||||
terminalMinimumContrastRatio: 'Color Contrast',
|
||||
terminalCursorStyle: 'Cursor Style',
|
||||
terminalCursorBlink: 'Cursor Blink',
|
||||
terminalCursorOpacity: 'Cursor Opacity',
|
||||
|
||||
@@ -128,6 +128,55 @@ export const getTerminalRenderingSearchEntries = createLocalizedCatalog(() => [
|
||||
...translateSearchKeyword('auto.components.settings.terminal.search.7d924d870d', 'graphics'),
|
||||
...translateSearchKeyword('auto.components.settings.terminal.search.1abcf4d7de', 'linux')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.terminal.search.minimumContrast.title',
|
||||
'Color Contrast'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.terminal.search.minimumContrast.description',
|
||||
'Improve text readability or preserve the colors chosen by terminal programs.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.contrast',
|
||||
'contrast'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.minimum',
|
||||
'minimum'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.ratio',
|
||||
'ratio'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.readability',
|
||||
'readability'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.wcag',
|
||||
'wcag'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.powerline',
|
||||
'powerline'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.statusline',
|
||||
'statusline'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.dim',
|
||||
'dim'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.terminal.search.minimumContrast.colors',
|
||||
'colors'
|
||||
)
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -268,6 +268,46 @@ describe('applyTerminalAppearance theme assignment', () => {
|
||||
expect(pane.terminal.options.minimumContrastRatio).toBe(4.5)
|
||||
})
|
||||
|
||||
// #10754: a Powerline statusline draws its segment separators in the neighbouring segment's
|
||||
// background color, so the automatic floor turns every invisible seam into a bright line.
|
||||
it('lets the user setting disable contrast correction on a dark theme', () => {
|
||||
const pane = makePane(1)
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: 1 })
|
||||
|
||||
expect(pane.terminal.options.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it('lets the user setting override the light-background floor as well', () => {
|
||||
const pane = makePane(1)
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
apply(pane, { ...settings, theme: 'light', terminalMinimumContrastRatio: 1 })
|
||||
|
||||
expect(pane.terminal.options.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps an out-of-range user setting before it reaches xterm', () => {
|
||||
const pane = makePane(1)
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: 99 })
|
||||
|
||||
expect(pane.terminal.options.minimumContrastRatio).toBe(21)
|
||||
})
|
||||
|
||||
it('returns to the automatic floor when the user setting is cleared live', () => {
|
||||
const pane = makePane(1)
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
|
||||
apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: 1 })
|
||||
expect(pane.terminal.options.minimumContrastRatio).toBe(1)
|
||||
|
||||
apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: undefined })
|
||||
expect(pane.terminal.options.minimumContrastRatio).toBe(3)
|
||||
})
|
||||
|
||||
it('skips the minimumContrastRatio write on a no-op re-apply (preserves xterm contrast cache)', () => {
|
||||
const pane = makePane(1)
|
||||
let writes = 0
|
||||
|
||||
@@ -170,7 +170,8 @@ export function applyTerminalAppearance(
|
||||
// Why value-gated: writing minimumContrastRatio clears xterm's contrast cache, so skip on no-op re-applies.
|
||||
const minimumContrastRatio = resolveTerminalMinimumContrastRatio(
|
||||
theme?.background,
|
||||
appearance.mode
|
||||
appearance.mode,
|
||||
settings.terminalMinimumContrastRatio
|
||||
)
|
||||
if (pane.terminal.options.minimumContrastRatio !== minimumContrastRatio) {
|
||||
pane.terminal.options.minimumContrastRatio = minimumContrastRatio
|
||||
|
||||
@@ -8627,6 +8627,15 @@
|
||||
"fastDescription": "Extra multiplier while scrolling with a modifier key.",
|
||||
"tui": "TUI",
|
||||
"tuiDescription": "Discrete wheel reports for full-screen terminal apps."
|
||||
},
|
||||
"minimumContrast": {
|
||||
"title": "Minimum Contrast Ratio",
|
||||
"description": "Lifts terminal foreground colors that sit too close to the background. Leave blank for automatic, or set 1 to render program colors exactly as sent.",
|
||||
"automatic": "Automatic: {{light}} on light backgrounds, {{dark}} on dark.",
|
||||
"disabled": "Correction off. Programs that rely on low contrast, like Powerline separators, render as sent.",
|
||||
"pinned": "Targets {{ratio}}:1 contrast for foreground colors, where possible.",
|
||||
"placeholder": "Auto",
|
||||
"suffix": "blank = automatic, 1 = off"
|
||||
}
|
||||
},
|
||||
"TerminalSettingsPreview": {
|
||||
@@ -10488,7 +10497,20 @@
|
||||
"agent": "agent",
|
||||
"process": "process",
|
||||
"prompt": "prompt",
|
||||
"stop": "stop"
|
||||
"stop": "stop",
|
||||
"minimumContrast": {
|
||||
"title": "Color Contrast",
|
||||
"description": "Improve text readability or preserve the colors chosen by terminal programs.",
|
||||
"contrast": "contrast",
|
||||
"minimum": "minimum",
|
||||
"ratio": "ratio",
|
||||
"readability": "readability",
|
||||
"wcag": "wcag",
|
||||
"powerline": "powerline",
|
||||
"statusline": "statusline",
|
||||
"dim": "dim",
|
||||
"colors": "colors"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"search": {
|
||||
@@ -11813,6 +11835,20 @@
|
||||
},
|
||||
"NativeChatSupportedAgents": {
|
||||
"label": "Supported agents:"
|
||||
},
|
||||
"contrast": {
|
||||
"title": "Color Contrast",
|
||||
"description": "Improve text readability or preserve the colors chosen by terminal programs.",
|
||||
"ratio": "Contrast target",
|
||||
"autoDescription": "Balances readability with your terminal theme. Recommended.",
|
||||
"offDescription": "Keeps program colors unchanged, including dim text and Powerline separators.",
|
||||
"customDescription": "Choose how much to increase contrast between text and its background.",
|
||||
"auto": "Automatic",
|
||||
"off": "Off",
|
||||
"custom": "Custom",
|
||||
"targetDescription": "Higher values increase contrast where possible. Background colors stay unchanged.",
|
||||
"subtle": "Subtle",
|
||||
"strong": "Strong"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DARK_BG_MIN_CONTRAST,
|
||||
LIGHT_BG_MIN_CONTRAST,
|
||||
MAX_TERMINAL_CONTRAST_RATIO,
|
||||
MIN_TERMINAL_CONTRAST_RATIO,
|
||||
normalizeTerminalMinimumContrastRatio,
|
||||
resolveTerminalMinimumContrastRatio
|
||||
} from './terminal-contrast-correction'
|
||||
import { TERMINAL_THEME_CATALOG } from './terminal-themes'
|
||||
@@ -42,6 +45,63 @@ describe('resolveTerminalMinimumContrastRatio', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// #10754: the automatic floor rewrites deliberately low-contrast TUI output (Powerline seams, dimmed
|
||||
// secondary text), so the user setting has to win over the luminance gate on both backgrounds.
|
||||
describe('resolveTerminalMinimumContrastRatio with a user override', () => {
|
||||
it('lets 1 disable contrast correction on a dark background', () => {
|
||||
expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 1)).toBe(1)
|
||||
})
|
||||
|
||||
it('lets 1 disable contrast correction on a light background too', () => {
|
||||
expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light', 1)).toBe(1)
|
||||
})
|
||||
|
||||
it('honors an intermediate override instead of the automatic floor', () => {
|
||||
expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 1.5)).toBe(1.5)
|
||||
expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light', 7)).toBe(7)
|
||||
})
|
||||
|
||||
it("clamps an out-of-range override to xterm's 1-21 window", () => {
|
||||
expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 0)).toBe(
|
||||
MIN_TERMINAL_CONTRAST_RATIO
|
||||
)
|
||||
expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', -5)).toBe(
|
||||
MIN_TERMINAL_CONTRAST_RATIO
|
||||
)
|
||||
expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 99)).toBe(
|
||||
MAX_TERMINAL_CONTRAST_RATIO
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the automatic floor when the override is unset or unusable', () => {
|
||||
// A hand-edited settings file can carry any of these; xterm throws on a non-finite option.
|
||||
for (const value of [undefined, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', value)).toBe(
|
||||
DARK_BG_MIN_CONTRAST
|
||||
)
|
||||
expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light', value)).toBe(
|
||||
LIGHT_BG_MIN_CONTRAST
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTerminalMinimumContrastRatio', () => {
|
||||
it('returns undefined for anything that is not a usable number', () => {
|
||||
for (const value of [undefined, null, '3', Number.NaN, Number.POSITIVE_INFINITY, {}]) {
|
||||
expect(normalizeTerminalMinimumContrastRatio(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('passes in-range values through and clamps the rest', () => {
|
||||
expect(normalizeTerminalMinimumContrastRatio(1)).toBe(1)
|
||||
expect(normalizeTerminalMinimumContrastRatio(4.5)).toBe(4.5)
|
||||
expect(normalizeTerminalMinimumContrastRatio(21)).toBe(21)
|
||||
expect(normalizeTerminalMinimumContrastRatio(0.5)).toBe(1)
|
||||
expect(normalizeTerminalMinimumContrastRatio(1000)).toBe(21)
|
||||
})
|
||||
})
|
||||
|
||||
// #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.
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast'
|
||||
import { normalizeTerminalMinimumContrastRatio } from '../../../shared/terminal-minimum-contrast-settings'
|
||||
|
||||
export {
|
||||
MAX_TERMINAL_CONTRAST_RATIO,
|
||||
MIN_TERMINAL_CONTRAST_RATIO,
|
||||
normalizeTerminalMinimumContrastRatio
|
||||
} from '../../../shared/terminal-minimum-contrast-settings'
|
||||
|
||||
// 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
|
||||
@@ -13,10 +20,17 @@ 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.
|
||||
// `override` is the user's terminalMinimumContrastRatio; clamped here too so a hand-edited settings
|
||||
// file can't hand xterm an out-of-range or non-finite floor.
|
||||
export function resolveTerminalMinimumContrastRatio(
|
||||
background: string | undefined,
|
||||
appSurface: 'dark' | 'light'
|
||||
appSurface: 'dark' | 'light',
|
||||
override?: number
|
||||
): number {
|
||||
const configured = normalizeTerminalMinimumContrastRatio(override)
|
||||
if (configured !== undefined) {
|
||||
return configured
|
||||
}
|
||||
return isTerminalBackgroundLight(background, { appSurface })
|
||||
? LIGHT_BG_MIN_CONTRAST
|
||||
: DARK_BG_MIN_CONTRAST
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { resolveMobileTerminalTheme } from './mobile-terminal-theme'
|
||||
|
||||
function stateWith(settings: Record<string, unknown> | null): AppState {
|
||||
return { settings } as unknown as AppState
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
terminalThemeDark: 'Ghostty Default Style Dark',
|
||||
terminalThemeLight: 'Builtin Tango Light',
|
||||
terminalUseSeparateLightTheme: true,
|
||||
theme: 'dark'
|
||||
}
|
||||
|
||||
// #10754: mobile mirrors the desktop contrast gate, so an explicit floor has to travel with the
|
||||
// theme payload — otherwise the same session renders differently on the phone.
|
||||
describe('resolveMobileTerminalTheme contrast floor', () => {
|
||||
it('omits the floor when the user has not set one', () => {
|
||||
const theme = resolveMobileTerminalTheme(stateWith(BASE), true)
|
||||
expect(theme?.minimumContrastRatio).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes the user floor so the phone stops lifting low-contrast output', () => {
|
||||
const theme = resolveMobileTerminalTheme(
|
||||
stateWith({ ...BASE, terminalMinimumContrastRatio: 1 }),
|
||||
true
|
||||
)
|
||||
expect(theme?.minimumContrastRatio).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps before publishing so an old client can trust the value', () => {
|
||||
expect(
|
||||
resolveMobileTerminalTheme(stateWith({ ...BASE, terminalMinimumContrastRatio: 99 }), true)
|
||||
?.minimumContrastRatio
|
||||
).toBe(21)
|
||||
expect(
|
||||
resolveMobileTerminalTheme(
|
||||
stateWith({ ...BASE, terminalMinimumContrastRatio: Number.NaN }),
|
||||
true
|
||||
)?.minimumContrastRatio
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns nothing without settings', () => {
|
||||
expect(resolveMobileTerminalTheme(stateWith(null), true)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types'
|
||||
import { graphState } from './graph-state'
|
||||
import { normalizeTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
let clean = hex.replace('#', '')
|
||||
@@ -52,7 +53,15 @@ export function resolveMobileTerminalTheme(
|
||||
theme[key] = value
|
||||
}
|
||||
}
|
||||
return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] }
|
||||
return {
|
||||
mode: appearance.mode,
|
||||
theme: theme as RuntimeMobileTerminalTheme['theme'],
|
||||
// Why publish: mobile mirrors the desktop contrast gate, so an explicit floor has to travel with
|
||||
// the theme or the same session would render differently on the phone (#10754).
|
||||
minimumContrastRatio: normalizeTerminalMinimumContrastRatio(
|
||||
settings.terminalMinimumContrastRatio
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getMobileTerminalTheme(
|
||||
|
||||
@@ -144,6 +144,10 @@ export type GlobalSettings = {
|
||||
terminalPaneOpacityTransitionMs: number
|
||||
terminalDividerThicknessPx: number
|
||||
terminalBackgroundOpacity?: number
|
||||
/** xterm minimumContrastRatio floor for terminal panes (#10754). Undefined keeps the automatic,
|
||||
* background-luminance-gated floor (3 dark / 4.5 light); 1 disables contrast correction so TUIs
|
||||
* that rely on deliberately low contrast (Powerline seams, dimmed secondary text) render as sent. */
|
||||
terminalMinimumContrastRatio?: number
|
||||
terminalColorOverrides?: TerminalColorOverrides
|
||||
terminalPaddingX?: number
|
||||
terminalPaddingY?: number
|
||||
|
||||
@@ -33,6 +33,9 @@ export type RuntimeMobileSessionTerminalTab = {
|
||||
export type RuntimeMobileTerminalTheme = {
|
||||
mode: 'dark' | 'light'
|
||||
theme: TerminalColorOverrides
|
||||
/** Optional desktop terminalMinimumContrastRatio override (#10754). Absent means the client picks
|
||||
* its own background-luminance floor, which is what pre-#10754 clients always do. */
|
||||
minimumContrastRatio?: number
|
||||
}
|
||||
|
||||
export type RuntimeMobileSessionMarkdownTab = {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// xterm's minimumContrastRatio range: 1 disables contrast correction entirely, 21 is the maximum
|
||||
// WCAG ratio (black on white). Shared so main's persistence boundary and the renderer clamp alike.
|
||||
export const MIN_TERMINAL_CONTRAST_RATIO = 1
|
||||
export const MAX_TERMINAL_CONTRAST_RATIO = 21
|
||||
|
||||
/**
|
||||
* Clamps a user-supplied contrast floor (#10754). `undefined` means "unset", so callers fall back to
|
||||
* Orca's automatic background-luminance floor; anything unusable is treated the same way rather than
|
||||
* handed to xterm, which throws on a non-finite option.
|
||||
*/
|
||||
export function normalizeTerminalMinimumContrastRatio(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return undefined
|
||||
}
|
||||
return Math.min(MAX_TERMINAL_CONTRAST_RATIO, Math.max(MIN_TERMINAL_CONTRAST_RATIO, value))
|
||||
}
|
||||
Reference in New Issue
Block a user