From 76d10f1367334e9bc448bde551effe8321105806 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Fri, 30 Oct 2026 16:24:47 -0700 Subject: [PATCH 1/3] fix: improve monochrome selection contrast Ensure active and inactive Monaco and xterm selections meet WCAG AA contrast in the monochrome themes, with runtime regression checks for both surfaces. --- scripts/e2e-settings-visual-theme.test.mjs | 3 +++ scripts/smoke-browser.mjs | 24 ++++++++++++++++++++-- src/ui/themes.mjs | 4 ++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs index 0871c1f..b17512c 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), [ diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index 3d3c547..068a110 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': '#000000', + 'monochrome-dark': '#ffffff', }[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/themes.mjs b/src/ui/themes.mjs index a20295c..e5726fe 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: '#ffffff', background: '#000000', inactiveBackground: '#666666' }, 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: '#000000', background: '#ffffff', inactiveBackground: '#999999' }, 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' }, }), From 475304e2ef8f9a5bfbc61d0ed2a114e4dbde9feb Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Fri, 30 Oct 2026 16:32:48 -0700 Subject: [PATCH 2/3] fix: override monochrome Monaco selection text Monaco preserves token foregrounds inside selected text, so apply explicit monochrome selected-text colors and validate a real editor selection in the smoke hook. --- scripts/e2e-settings-visual-theme.test.mjs | 13 +++++++++++++ src/ui/editor.js | 22 +++++++++++++--------- src/ui/styles.css | 10 ++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs index b17512c..23606a9 100644 --- a/scripts/e2e-settings-visual-theme.test.mjs +++ b/scripts/e2e-settings-visual-theme.test.mjs @@ -94,6 +94,19 @@ test('monochrome themes separate panels and outline buttons', async () => { assert.match(css, /monochrome-dark[^}]*border-color: var\(--blue\);/); }); +test('monochrome Monaco selections override token foreground colors', async () => { + const css = await readFile(new URL('../src/ui/styles.css', import.meta.url), 'utf8'); + assert.match(css, /:root\[data-visual-theme="monochrome-light"\]\s+\.monaco-editor\s+\.selected-text\s*\{[^}]*color:\s*#fff\s*!important;/s); + assert.match(css, /:root\[data-visual-theme="monochrome-dark"\]\s+\.monaco-editor\s+\.selected-text\s*\{[^}]*color:\s*#000\s*!important;/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-line \.selected-text'\)/); + 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/src/ui/editor.js b/src/ui/editor.js index eee011c..6958b62 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -156,23 +156,27 @@ 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-line .selected-text'); + if (!selected) return null; const background = toHex(getComputedStyle(selected).backgroundColor); const foreground = toHex(getComputedStyle(selected).color); - viewLine.classList.remove('focused'); + editorNode.querySelector('textarea.inputarea')?.blur(); + _editor.render(true); const inactiveBackground = toHex(getComputedStyle(selected).backgroundColor); - viewLine.remove(); + _editor.setValue(originalValue); + if (originalSelections?.length) _editor.setSelections(originalSelections); + _editor.render(true); return { background, foreground, inactiveBackground }; }; } diff --git a/src/ui/styles.css b/src/ui/styles.css index 54d736d..1f1b155 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -601,6 +601,16 @@ select:focus { background: var(--panel-editor); } +/* Monaco preserves token colors inside selections, so monochrome themes must + * explicitly paint selected glyphs over their configured selection background. */ +:root[data-visual-theme="monochrome-light"] .monaco-editor .selected-text { + color: #fff !important; +} + +:root[data-visual-theme="monochrome-dark"] .monaco-editor .selected-text { + color: #000 !important; +} + /* ── Terminal panel ───────────────────────────────────────────────────────── */ #terminal-panel { From 14bac095b4d9324e3307d389a94b19a50d30d21c Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Fri, 30 Oct 2026 16:38:54 -0700 Subject: [PATCH 3/3] fix: contrast Monaco selection overlays Use grayscale selection overlays that contrast with Monaco's unchanged source text, and inspect the real overlay and source span in the smoke hook. --- scripts/e2e-settings-visual-theme.test.mjs | 13 +++++++++---- scripts/smoke-browser.mjs | 4 ++-- src/ui/editor.js | 11 +++++++---- src/ui/styles.css | 10 ---------- src/ui/themes.mjs | 4 ++-- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs index 23606a9..557146b 100644 --- a/scripts/e2e-settings-visual-theme.test.mjs +++ b/scripts/e2e-settings-visual-theme.test.mjs @@ -94,16 +94,21 @@ test('monochrome themes separate panels and outline buttons', async () => { assert.match(css, /monochrome-dark[^}]*border-color: var\(--blue\);/); }); -test('monochrome Monaco selections override token foreground colors', async () => { +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'); - assert.match(css, /:root\[data-visual-theme="monochrome-light"\]\s+\.monaco-editor\s+\.selected-text\s*\{[^}]*color:\s*#fff\s*!important;/s); - assert.match(css, /:root\[data-visual-theme="monochrome-dark"\]\s+\.monaco-editor\s+\.selected-text\s*\{[^}]*color:\s*#000\s*!important;/s); + 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-line \.selected-text'\)/); + 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'\)/); }); diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index 068a110..b9b35d0 100644 --- a/scripts/smoke-browser.mjs +++ b/scripts/smoke-browser.mjs @@ -1241,8 +1241,8 @@ int main() { const expectedSelectionBackground = { vs: '#0969da', 'hc-light': '#0037da', - 'monochrome-light': '#000000', - 'monochrome-dark': '#ffffff', + '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`); diff --git a/src/ui/editor.js b/src/ui/editor.js index 6958b62..9ebf073 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -167,13 +167,16 @@ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) { _editor.setSelection(new monaco.Range(1, 1, 1, 6)); _editor.focus(); _editor.render(true); - const selected = editorNode.querySelector('.view-line .selected-text'); - if (!selected) return null; + 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); + const foreground = toHex(getComputedStyle(selectedText).color); editorNode.querySelector('textarea.inputarea')?.blur(); _editor.render(true); - const inactiveBackground = toHex(getComputedStyle(selected).backgroundColor); + 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); diff --git a/src/ui/styles.css b/src/ui/styles.css index 1f1b155..54d736d 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -601,16 +601,6 @@ select:focus { background: var(--panel-editor); } -/* Monaco preserves token colors inside selections, so monochrome themes must - * explicitly paint selected glyphs over their configured selection background. */ -:root[data-visual-theme="monochrome-light"] .monaco-editor .selected-text { - color: #fff !important; -} - -:root[data-visual-theme="monochrome-dark"] .monaco-editor .selected-text { - color: #000 !important; -} - /* ── Terminal panel ───────────────────────────────────────────────────────── */ #terminal-panel { diff --git a/src/ui/themes.mjs b/src/ui/themes.mjs index e5726fe..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: '#000000', inactiveBackground: '#666666' }, + 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: '#ffffff', inactiveBackground: '#999999' }, + 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' }, }),