diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs index 0871c1f..557146b 100644 --- a/scripts/e2e-settings-visual-theme.test.mjs +++ b/scripts/e2e-settings-visual-theme.test.mjs @@ -31,6 +31,9 @@ test('theme registry exposes the requested Monaco themes and safe fallback', () assert.deepEqual(theme.terminalTheme.selectionBackground, theme.selection.background); assert.deepEqual(theme.terminalTheme.selectionInactiveBackground, theme.selection.inactiveBackground); assert.ok(contrastRatio(theme.selection.foreground, theme.selection.background) >= 4.5); + if (theme.id.startsWith('monochrome-')) { + assert.ok(contrastRatio(theme.selection.foreground, theme.selection.inactiveBackground) >= 4.5); + } assert.notEqual(theme.selection.background, theme.terminalTheme.background); } assert.deepEqual(VISUAL_THEMES.map(({ id }) => id), [ @@ -91,6 +94,24 @@ test('monochrome themes separate panels and outline buttons', async () => { assert.match(css, /monochrome-dark[^}]*border-color: var\(--blue\);/); }); +test('monochrome Monaco selection backgrounds contrast with normal code text', async () => { + const css = await readFile(new URL('../src/ui/styles.css', import.meta.url), 'utf8'); + for (const theme of VISUAL_THEMES.filter(({ id }) => id.startsWith('monochrome-'))) { + assert.equal(theme.selection.foreground, theme.cssTokens.text); + assert.ok(contrastRatio(theme.cssTokens.text, theme.selection.background) >= 4.5); + assert.ok(contrastRatio(theme.cssTokens.text, theme.selection.inactiveBackground) >= 4.5); + } + assert.doesNotMatch(css, /\.monaco-editor\s+\.selected-text\s*\{[^}]*color:/s); +}); + +test('Monaco selection smoke hook reads a real editor selection', async () => { + const editor = await readFile(new URL('../src/ui/editor.js', import.meta.url), 'utf8'); + assert.match(editor, /_editor\.setSelection\(new monaco\.Range\(/); + assert.match(editor, /editorNode\.querySelector\('\.view-overlays \.selected-text'\)/); + assert.match(editor, /editorNode\.querySelector\('\.view-lines \.view-line span'\)/); + assert.doesNotMatch(editor, /document\.createElement\('div'\)/); +}); + function contrastRatio(foreground, background) { const luminance = (color) => { const channels = color.match(/[0-9a-f]{2}/gi).map((channel) => parseInt(channel, 16) / 255); diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index 3d3c547..b9b35d0 100644 --- a/scripts/smoke-browser.mjs +++ b/scripts/smoke-browser.mjs @@ -448,6 +448,18 @@ function parseRgbColor(color) { return rgb ? rgb.slice(1, 4).map(Number) : null; } +function wcagContrastRatio(foreground, background) { + const colors = [parseRgbColor(foreground), parseRgbColor(background)]; + if (colors.some((color) => !color)) return 0; + const luminances = colors.map((color) => { + const channels = color.map((channel) => channel / 255).map((channel) => ( + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + )); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }).sort((a, b) => b - a); + return (luminances[0] + 0.05) / (luminances[1] + 0.05); +} + async function openExtensionPage(cdp, extensionId) { const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', @@ -1052,6 +1064,10 @@ int main() { assert(monacoColors?.background && monacoColors.foreground && monacoColors.inactiveBackground, `${themeId} Monaco selection styles were not computed`); if (themeId.startsWith('monochrome-')) { + assert(wcagContrastRatio(monacoColors.foreground, monacoColors.background) >= 4.5, + `${themeId} active Monaco selection fails WCAG AA contrast`); + assert(wcagContrastRatio(monacoColors.foreground, monacoColors.inactiveBackground) >= 4.5, + `${themeId} inactive Monaco selection fails WCAG AA contrast`); const editorTextColors = await evaluate(cdp, sessionId, `(() => { const spans = [...document.querySelectorAll('.monaco-editor .view-lines .view-line span')]; return spans.map((element) => getComputedStyle(element).color).filter(Boolean); @@ -1225,8 +1241,8 @@ int main() { const expectedSelectionBackground = { vs: '#0969da', 'hc-light': '#0037da', - 'monochrome-light': '#666666', - 'monochrome-dark': '#999999', + 'monochrome-light': '#999999', + 'monochrome-dark': '#555555', }[themeId]; assert(selection.theme.selectionBackground, `${themeId} terminal selection background is missing`); assert(selection.theme.selectionForeground, `${themeId} terminal selection foreground is missing`); @@ -1239,6 +1255,10 @@ int main() { selection.theme.selectionBackground, selection.theme.selectionForeground]; assert(colors.every((color) => /^#([\da-f]{2})\1\1$/i.test(color)), `${themeId} terminal colors are not grayscale: ${colors.join(', ')}`); + assert(wcagContrastRatio(selection.theme.selectionForeground, selection.theme.selectionBackground) >= 4.5, + `${themeId} active terminal selection fails WCAG AA contrast`); + assert(wcagContrastRatio(selection.theme.selectionForeground, selection.theme.selectionInactiveBackground) >= 4.5, + `${themeId} inactive terminal selection fails WCAG AA contrast`); } const currentTerminal = await evaluate( cdp, diff --git a/src/ui/editor.js b/src/ui/editor.js index eee011c..9ebf073 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -156,23 +156,30 @@ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) { container.__browserCppReadSelectionColors = () => { const editorNode = _editor.getDomNode(); if (!editorNode) return null; - const viewLine = document.createElement('div'); - viewLine.className = 'view-line focused'; - const selected = document.createElement('span'); - selected.className = 'selected-text inline-selected-text'; - selected.textContent = 'theme color probe'; - viewLine.append(selected); - editorNode.append(viewLine); const toHex = (value) => { const channels = value.match(/[\d.]+/g)?.map(Number); if (!channels || channels.length < 3) return value; return `#${channels.slice(0, 3).map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`; }; + const originalValue = _editor.getValue(); + const originalSelections = _editor.getSelections(); + _editor.setValue('theme color probe'); + _editor.setSelection(new monaco.Range(1, 1, 1, 6)); + _editor.focus(); + _editor.render(true); + const selected = editorNode.querySelector('.view-overlays .selected-text'); + const selectedText = editorNode.querySelector('.view-lines .view-line span'); + if (!selected || !selectedText) return null; const background = toHex(getComputedStyle(selected).backgroundColor); - const foreground = toHex(getComputedStyle(selected).color); - viewLine.classList.remove('focused'); - const inactiveBackground = toHex(getComputedStyle(selected).backgroundColor); - viewLine.remove(); + const foreground = toHex(getComputedStyle(selectedText).color); + editorNode.querySelector('textarea.inputarea')?.blur(); + _editor.render(true); + const inactiveSelected = editorNode.querySelector('.view-overlays .selected-text'); + if (!inactiveSelected) return null; + const inactiveBackground = toHex(getComputedStyle(inactiveSelected).backgroundColor); + _editor.setValue(originalValue); + if (originalSelections?.length) _editor.setSelections(originalSelections); + _editor.render(true); return { background, foreground, inactiveBackground }; }; } diff --git a/src/ui/themes.mjs b/src/ui/themes.mjs index a20295c..dee6d5e 100644 --- a/src/ui/themes.mjs +++ b/src/ui/themes.mjs @@ -77,13 +77,13 @@ export const VISUAL_THEMES = Object.freeze([ }), visualTheme({ id: 'monochrome-light', label: 'Monochrome Light', monacoTheme: 'browser-cpp-monochrome-light', - selection: { foreground: '#ffffff', background: '#666666', inactiveBackground: '#999999' }, + selection: { foreground: '#000000', background: '#999999', inactiveBackground: '#bbbbbb' }, cssTokens: { crust: '#ffffff', mantle: '#ffffff', base: '#ffffff', surface0: '#ffffff', surface1: '#666666', overlay0: '#666666', text: '#000000', subtext0: '#666666', green: '#000000', red: '#666666', yellow: '#666666', blue: '#000000', mauve: '#000000', peach: '#666666' }, terminalColors: { background: '#dddddd', foreground: '#000000', accent: '#666666', ansi: monochromeAnsi('#000000', '#666666') }, preview: { background: '#ffffff', foreground: '#000000', accent: '#666666' }, }), visualTheme({ id: 'monochrome-dark', label: 'Monochrome Dark', monacoTheme: 'browser-cpp-monochrome-dark', - selection: { foreground: '#000000', background: '#999999', inactiveBackground: '#666666' }, + selection: { foreground: '#ffffff', background: '#555555', inactiveBackground: '#707070' }, cssTokens: { crust: '#000000', mantle: '#000000', base: '#000000', surface0: '#000000', surface1: '#999999', overlay0: '#999999', text: '#ffffff', subtext0: '#999999', green: '#ffffff', red: '#999999', yellow: '#999999', blue: '#ffffff', mauve: '#ffffff', peach: '#999999' }, terminalColors: { background: '#222222', foreground: '#ffffff', accent: '#999999', ansi: monochromeAnsi('#ffffff', '#999999') }, preview: { background: '#000000', foreground: '#ffffff', accent: '#999999' }, }),