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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 23:33:36 +02:00
parent c03714eb74
commit 9acc5aad50
3 changed files with 86 additions and 7 deletions
@@ -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.
+16 -7
View File
@@ -179,14 +179,23 @@
// theme, so default behavior is unchanged. Shared at module scope and exported on the // 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, // global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette. // 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 = { 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' }, 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', 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-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', 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' }, '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', 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' }, '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', 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' }, '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', 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' }, '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', 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' }, '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']); const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
function currentSkin() { function currentSkin() {
+65
View File
@@ -32,6 +32,35 @@ function luminance(hex: string): number {
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; 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 { function contrastRatio(first: string, second: string): number {
const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a); const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a);
return (lighter + 0.05) / (darker + 0.05); 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', () => { it('switches live terminals between light and dark contrast policies', () => {
const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() }; const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() };
const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() }; const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() };