From 9acc5aad50c8a3ca8f6007d01252791573e4d82a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 23:33:36 +0200 Subject: [PATCH] fix(terminal): name the selection colour the way xterm 6 does Every per-skin xterm palette declared its selection layer as `selection`, the key xterm.js renamed to `selectionBackground` in v5. An ITheme is a plain object handed straight to the terminal, so an unknown key is not an error, it is dropped: all seven skins have been drawing xterm's built-in default, rgba(255,255,255,0.3), rather than the colour sitting next to it in the palette. Nobody saw it on the dark skins, where white at 30% is close to what those palettes asked for. On the four light skins it is white over a near-white background: blended, Paper Gray's selection differs from its own background by 3/255. That is not a subtle highlight, it is no highlight, and it looks exactly like a selection gesture that failed, which is part of what #360 reports on Android Chrome. test/skin-themes.test.ts pins both halves: the key name, and that the blended selection stays at least 16/255 from the background on every skin, plus the light-skin fallback landing under that floor, which is what makes this a fix rather than a rename. Refs #360 Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/xterm6-selection-background.md | 5 ++ src/web/public/terminal-ui.js | 23 +++++--- test/skin-themes.test.ts | 65 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/xterm6-selection-background.md diff --git a/.changeset/xterm6-selection-background.md b/.changeset/xterm6-selection-background.md new file mode 100644 index 000000000..6b1cf65f7 --- /dev/null +++ b/.changeset/xterm6-selection-background.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Fix an invisible terminal text selection on the light skins (#360). Every xterm palette declared its selection colour under the key `selection`, which xterm.js renamed to `selectionBackground` in v5. An `ITheme` is a plain object, so the unknown key was dropped without an error and every skin fell back to xterm's own default of `rgba(255,255,255,0.3)`: unnoticeable on the dark skins, which wanted roughly that anyway, and effectively invisible on Paper Gray, Solarized Light, Catppuccin Latte and Rosé Pine Dawn, where white at 30% over a near-white background moves a channel by about 3/255. Selecting text on those skins now highlights it, with desktop drag-select and the mobile long-press both fixed by the same rename. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 85c133506..3fd2c3852 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -179,14 +179,23 @@ // theme, so default behavior is unchanged. Shared at module scope and exported on the // global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals, // a separate IIFE) can read the current skin's palette. + // + // ⚠️ The selection key is `selectionBackground`, xterm's name for it since v5 (#360). + // An ITheme is a plain object handed straight to xterm, so an unknown key is not an + // error, it is silently dropped: every palette here carried `selection`, so every skin + // drew xterm's built-in default instead, rgba(255,255,255,0.3). On the four light skins + // that is white at 30% over a near-white background, a delta of about 3/255 — the + // highlight was effectively invisible, which is what a long-press selection that + // "did nothing" actually looked like. A key only works here if xterm knows its name; + // test/skin-themes.test.ts pins the name AND that the blend stays visible. const CODEMAN_XTERM_THEMES = { - og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' }, - 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, - 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, - 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, - 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, - 'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' }, - 'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' }, + og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selectionBackground: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' }, + 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selectionBackground: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, + 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selectionBackground: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, + 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selectionBackground: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, + 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selectionBackground: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, + 'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selectionBackground: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' }, + 'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selectionBackground: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' }, }; const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']); function currentSkin() { diff --git a/test/skin-themes.test.ts b/test/skin-themes.test.ts index 59e9d28f8..6608d3fe0 100644 --- a/test/skin-themes.test.ts +++ b/test/skin-themes.test.ts @@ -32,6 +32,35 @@ function luminance(hex: string): number { return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; } +/** Parse the `rgba(r,g,b,a)` form the palettes use for the selection layer. */ +function rgba(value: string): { rgb: [number, number, number]; alpha: number } { + const match = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); + if (!match) throw new Error(`Expected an rgba() color, got ${value}`); + return { + rgb: [Number(match[1]), Number(match[2]), Number(match[3])], + alpha: Number(match[4]), + }; +} + +/** + * What xterm actually paints: `selectionBackgroundOpaque = blend(background, selection)`. + * Comparing the raw rgba string against the background says nothing, since a 0.2-alpha + * layer over a near-white surface is still near-white. + */ +function blendOverHex(base: string, layer: string): string { + const [br, bg, bb] = hexRgb(base); + const { rgb, alpha } = rgba(layer); + const mix = (b: number, l: number) => Math.round(b * (1 - alpha) + l * alpha); + return `#${[mix(br, rgb[0]), mix(bg, rgb[1]), mix(bb, rgb[2])].map((c) => c.toString(16).padStart(2, '0')).join('')}`; +} + +/** Largest per-channel distance, in 0-255 units. */ +function channelDelta(first: string, second: string): number { + const a = hexRgb(first); + const b = hexRgb(second); + return Math.max(...a.map((channel, index) => Math.abs(channel - b[index]))); +} + function contrastRatio(first: string, second: string): number { const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a); return (lighter + 0.05) / (darker + 0.05); @@ -71,6 +100,42 @@ describe('Codeman light skins', () => { } }); + // Issue #360. xterm renamed this key in v5 and an ITheme is a plain object, so the + // old `selection` key was silently dropped and every skin fell back to xterm's own + // default of rgba(255,255,255,0.3). Nobody noticed on the dark skins, where white at + // 30% is roughly what the palettes asked for anyway; on the light ones it made the + // highlight invisible, which reads as "selecting text does nothing". + it('names the selection layer the way xterm 6 does, and keeps it visible', () => { + const XTERM_DEFAULT_SELECTION = 'rgba(255, 255, 255, 0.3)'; + const themeBlock = terminalSource.slice( + terminalSource.indexOf('const CODEMAN_XTERM_THEMES = {'), + terminalSource.indexOf('const CODEMAN_LIGHT_SKINS') + ); + // The renderer only knows this spelling (xterm.js >= 5, and package.json pins ^6); + // a palette key it does not know is dropped without an error, so the name IS the fix. + // Asserted against package.json rather than the bundle, which is gitignored and only + // exists after an install step. + expect(JSON.parse(readFileSync(resolve('package.json'), 'utf8')).dependencies['@xterm/xterm']).toMatch( + /\^?[6-9]\./ + ); + expect(themeBlock).not.toMatch(/\bselection: '/); + + for (const [skin, theme] of Object.entries(terminal.themes)) { + expect(theme.selectionBackground, skin).toBeDefined(); + const painted = blendOverHex(theme.background, theme.selectionBackground); + expect(channelDelta(theme.background, painted), skin).toBeGreaterThanOrEqual(16); + } + + // And the half that proves the rename was the fix rather than a tidy-up: on the + // light skins xterm's own fallback lands under that floor (about 3/255), so those + // palettes were declaring a selection colour nothing ever read. + for (const skin of LIGHT_SKINS) { + const background = terminal.themes[skin].background; + const fallback = blendOverHex(background, XTERM_DEFAULT_SELECTION); + expect(channelDelta(background, fallback), skin).toBeLessThan(16); + } + }); + it('switches live terminals between light and dark contrast policies', () => { const main = { options: {} as Record, rows: 24, refresh: vi.fn() }; const teammate = { options: {} as Record, rows: 12, refresh: vi.fn() };