diff --git a/electron/bridges/sessionLogsBridge.cjs b/electron/bridges/sessionLogsBridge.cjs index a59f169f0..75b55a90d 100644 --- a/electron/bridges/sessionLogsBridge.cjs +++ b/electron/bridges/sessionLogsBridge.cjs @@ -77,7 +77,35 @@ function terminalPlainTextToHtml(plainText, hostLabel, timestamp) { return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp); } +function buildExtendedForegroundPalette(htmlContent) { + const colors = new Set(Array.from( + htmlContent.matchAll(/var\(--term-custom-([a-f0-9]{6}), #[a-f0-9]{6}\)/g), + (match) => match[1], + )); + const light = []; + const dark = []; + for (const hex of colors) { + const channels = hex.match(/../g).map((channel) => parseInt(channel, 16)); + const luminance = (rgb) => rgb.reduce((sum, channel, index) => { + const value = channel / 255; + const linear = value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + return sum + linear * [0.2126, 0.7152, 0.0722][index]; + }, 0); + let adjusted = channels; + // Scale all channels together to retain the hue. Stop at normal-text + // contrast against the light page; dark mode retains the exact SGR color. + for (let scale = 0.99; 1.05 / (luminance(adjusted) + 0.05) < 4.5; scale -= 0.01) { + adjusted = channels.map((channel) => Math.floor(channel * Math.max(0, scale))); + } + const lightHex = adjusted.map((channel) => channel.toString(16).padStart(2, "0")).join(""); + light.push(`--term-custom-${hex}: #${lightHex};`); + dark.push(`--term-custom-${hex}: #${hex};`); + } + return { light: light.join("\n "), dark: dark.join("\n ") }; +} + function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) { + const extendedPalette = buildExtendedForegroundPalette(htmlContent); const dateStr = new Date(timestamp).toLocaleString(); const safeHostLabel = escapeHtml(hostLabel || "Unknown"); const safeDateStr = escapeHtml(dateStr); @@ -113,6 +141,7 @@ function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) { --ansi-13: #bc05bc; --ansi-14: #007c9b; --ansi-15: #767676; + ${extendedPalette.light} } @media (prefers-color-scheme: dark) { :root { @@ -136,6 +165,7 @@ function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) { --ansi-13: #d670d6; --ansi-14: #29b8db; --ansi-15: #ffffff; + ${extendedPalette.dark} } } body { diff --git a/electron/bridges/sessionLogsBridge.test.cjs b/electron/bridges/sessionLogsBridge.test.cjs index 47999af34..2aa323cf2 100644 --- a/electron/bridges/sessionLogsBridge.test.cjs +++ b/electron/bridges/sessionLogsBridge.test.cjs @@ -934,8 +934,12 @@ test("html wrapper adapts to light/dark browser color scheme", () => { }); test("all light-mode ANSI foregrounds remain readable on the default background", () => { - const { wrapTerminalHtmlContent } = loadBridgeWithDialog({}); - const lightCss = wrapTerminalHtmlContent("hello", "host", 0).split("@media")[0]; + const { terminalDataToHtml } = loadBridgeWithDialog({}); + let data = ""; + for (let index = 0; index < 256; index++) data += `\x1b[38;5;${index}mcolor ${index}\x1b[0m\n`; + data += "\x1b[38;2;255;255;255mwhite\x1b[0m\n\x1b[38;2;255;255;0myellow\x1b[0m\n\x1b[38;2;18;52;86mdark\x1b[0m"; + const html = terminalDataToHtml(data, "host", 0); + const [lightCss, darkCss] = html.split("@media"); const luminance = (hex) => { const channels = hex.match(/[a-f0-9]{2}/gi).map((channel) => { const value = parseInt(channel, 16) / 255; @@ -944,11 +948,16 @@ test("all light-mode ANSI foregrounds remain readable on the default background" return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; }; const background = luminance(lightCss.match(/--term-default-bg:\s*(#[a-f0-9]{6})/i)[1]); - const palette = [...lightCss.matchAll(/--ansi-(\d+):\s*(#[a-f0-9]{6})/gi)]; - assert.equal(palette.length, 16); + const palette = [...lightCss.matchAll(/--(ansi-\d+|term-custom-[a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)]; + assert.equal(palette.length, 257); for (const [, index, color] of palette) { const foreground = luminance(color); const contrast = (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05); assert.ok(contrast >= 4.5, `ANSI ${index} (${color}) contrast ${contrast.toFixed(2)} is below 4.5:1`); } + for (const [, hex, color] of lightCss.matchAll(/--term-custom-([a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)) { + assert.ok(darkCss.includes(`--term-custom-${hex}: #${hex};`)); + if (1.05 / (luminance(hex) + 0.05) >= 4.5) assert.equal(color, `#${hex}`); + } + assert.match(html, /color: var\(--term-custom-ffffff, #ffffff\)[^>]*>white/); }); diff --git a/electron/bridges/terminalLogSanitizer.cjs b/electron/bridges/terminalLogSanitizer.cjs index 2035124a6..f07382b01 100644 --- a/electron/bridges/terminalLogSanitizer.cjs +++ b/electron/bridges/terminalLogSanitizer.cjs @@ -606,6 +606,9 @@ function styleToCss(style) { const originalColor = (color) => color?.replace(/var\(--[\w-]+, (#[a-f0-9]{6})\)/g, "$1"); fg = originalColor(fg) || DEFAULT_FOREGROUND; bg = originalColor(bg); + } else if (fg?.startsWith("#")) { + // The wrapper supplies a readable light variant for extended/true colors. + fg = `var(--term-custom-${fg.slice(1)}, ${fg})`; } if (fg) declarations.push(`color: ${fg}`); if (bg) declarations.push(`background-color: ${bg}`); diff --git a/electron/bridges/terminalLogSanitizer.test.cjs b/electron/bridges/terminalLogSanitizer.test.cjs index 2fa464a0f..ec38ea44b 100644 --- a/electron/bridges/terminalLogSanitizer.test.cjs +++ b/electron/bridges/terminalLogSanitizer.test.cjs @@ -260,12 +260,12 @@ test("SGR colors are emitted as themeable CSS variables with hex fallbacks", () assert.match(html, /color:\s*#f14c4c; background-color:\s*#2472c8/); }); -test("256-color base palette uses CSS variables; cube colors stay hex", () => { +test("256-color base and cube foregrounds use themeable variables", () => { const renderer = createTerminalTextRenderer(); renderer.feed("\x1b[38;5;1mbase\x1b[0m \x1b[38;5;208mcube\n"); const html = renderer.toHtmlContent(); assert.match(html, /color:\s*var\(--ansi-1, #cd3131\)[^"]*"[^>]*>base/); - assert.match(html, /color:\s*#ff8700[^"]*"[^>]*>cube/); + assert.match(html, /color:\s*var\(--term-custom-ff8700, #ff8700\)[^"]*"[^>]*>cube/); }); test("inverse text falls back to themeable default background/foreground", () => {