mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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.
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() };
|
||||||
|
|||||||
Reference in New Issue
Block a user