fix(terminal): use xterm 6's selection theme keys so light skins show a selection

xterm 6 reads `selectionBackground`; the pre-6 name was `selection`, and it is
now silently ignored (the vendored xterm.min.js contains zero occurrences of
that key — no alias, no fallback). All seven skins still set the old name, so no
skin's tuned selection colour has ever taken effect; every one of them fell back
to xterm's own default of rgba(255,255,255,0.3).

The consequence split by skin, which is why it survived so long. On dark skins
white at 30% happens to be a reasonable selection colour and nothing looks
wrong. On light skins white over white composites to within 3/255 of the
background — invisible. The drag really did select, the screen just never
changed, which reads as "text here cannot be selected".

The skins' own colour choices were fine, they were simply never read, so this
only renames the keys and adds `selectionInactiveBackground` (alpha 55%) so a
selection stays visible after the terminal loses focus. Minimum channel delta
after the change is 38, maximum 47.

The guard test composites each skin's selection colour over that skin's own
background and requires a visible delta of >= 12. Asserting "the key exists"
would not catch this — a bad colour passes that just as happily — and the
absence of this test is why the bug survived.
This commit is contained in:
d fei
2026-09-03 02:01:44 -07:00
parent 1cb6bde891
commit cd1114cfe6
2 changed files with 41 additions and 7 deletions
+7 -7
View File
@@ -180,13 +180,13 @@
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
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)', selectionInactiveBackground: 'rgba(255,255,255,0.165)', 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)', selectionInactiveBackground: 'rgba(47,211,170,0.121)', 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)', selectionInactiveBackground: 'rgba(56,182,240,0.121)', 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)', selectionInactiveBackground: 'rgba(9,105,218,0.11)', 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)', selectionInactiveBackground: 'rgba(38,139,210,0.11)', 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)', selectionInactiveBackground: 'rgba(30,102,245,0.099)', 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)', selectionInactiveBackground: 'rgba(40,105,131,0.11)', 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() {
+34
View File
@@ -32,6 +32,16 @@ function luminance(hex: string): number {
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
/** Composite an `rgba()` over an opaque `#rrggbb` and return the largest per-channel shift. */
function channelDelta(background: string, overlay: string): number {
const bg = [1, 3, 5].map((i) => parseInt(background.slice(i, i + 2), 16));
const m = /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/.exec(overlay);
if (!m) throw new Error(`not an rgba() colour: ${overlay}`);
const [r, g, b] = [1, 2, 3].map((i) => Number(m[i]));
const a = Number(m[4]);
return Math.max(...[r, g, b].map((c, i) => Math.abs(Math.round(a * c + (1 - a) * bg[i]) - bg[i])));
}
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);
@@ -64,6 +74,30 @@ describe('Codeman light skins', () => {
expect(terminal.isLight('daylight-blue')).toBe(false);
});
it('paints a selection every skin can actually see', () => {
// xterm 6 reads `selectionBackground`; the pre-6 key was `selection`, which it
// now IGNORES SILENTLY. Every skin used to carry the old name, so no skin's
// tuned selection colour ever applied and xterm fell back to its own default
// of white at 30%. On the DARK skins that happens to look right, which is why
// it survived — but composited over a light skin's near-white background it
// lands ~3/255 away from the background, i.e. dragging highlights nothing and
// the terminal reads as "text cannot be selected here".
//
// Asserting the KEY alone would not have caught it (a badly chosen colour
// passes), so this composites the real value over the skin's own background
// and demands a visible difference.
for (const [skin, theme] of Object.entries(terminal.themes)) {
expect(theme.selectionBackground, `${skin} must use xterm 6's selectionBackground`).toBeDefined();
expect(theme.selection, `${skin} must not carry the ignored pre-6 'selection' key`).toBeUndefined();
expect(theme.selectionInactiveBackground, `${skin} keeps the selection visible unfocused`).toBeDefined();
for (const key of ['selectionBackground', 'selectionInactiveBackground'] as const) {
expect(channelDelta(theme.background, theme[key]), `${skin}.${key} over its background`).toBeGreaterThanOrEqual(
key === 'selectionBackground' ? 12 : 6
);
}
}
});
it('provides readable dark-on-light terminal foregrounds', () => {
for (const skin of LIGHT_SKINS) {
const theme = terminal.themes[skin];