Compare commits

...
4 changed files with 207 additions and 16 deletions

No files matched your search

+93 -6
View File
@@ -77,7 +77,41 @@ 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 linearChannels = channels.map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
const luminance = linearChannels[0] * 0.2126 + linearChannels[1] * 0.7152 + linearChannels[2] * 0.0722;
const maxLuminance = 1.05 / 4.5 - 0.05;
let adjusted = channels;
// A single linear-light scaling reaches the contrast target without an
// iterative search for every color on each streaming snapshot.
if (luminance > maxLuminance) {
const scale = maxLuminance / luminance;
adjusted = linearChannels.map((channel) => {
const value = channel * scale;
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
return Math.floor(srgb * 255);
});
}
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);
@@ -86,23 +120,76 @@ function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
<html>
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>Session Log - ${safeHostLabel}</title>
<style>
:root {
color-scheme: light dark;
/* Day palette (default): readable on light browser/OS themes. */
--term-default-bg: #ffffff;
--term-default-fg: #24292f;
--term-header-fg: #6a737d;
--term-border: #d0d7de;
/* ANSI palette (basic 0-7, bright 8-15) tuned for the light background. */
--ansi-0: #000000;
--ansi-1: #cd3131;
--ansi-2: #008000;
--ansi-3: #747700;
--ansi-4: #0451a5;
--ansi-5: #bc05bc;
--ansi-6: #007c9b;
--ansi-7: #555555;
--ansi-8: #666666;
--ansi-9: #cd3131;
--ansi-10: #008500;
--ansi-11: #797600;
--ansi-12: #0451a5;
--ansi-13: #bc05bc;
--ansi-14: #007c9b;
--ansi-15: #767676;
${extendedPalette.light}
}
@media (prefers-color-scheme: dark) {
:root {
--term-default-bg: #1e1e1e;
--term-default-fg: #d4d4d4;
--term-header-fg: #888888;
--term-border: #444444;
--ansi-0: #000000;
--ansi-1: #cd3131;
--ansi-2: #0dbc79;
--ansi-3: #e5e510;
--ansi-4: #2472c8;
--ansi-5: #bc3fbc;
--ansi-6: #11a8cd;
--ansi-7: #e5e5e5;
--ansi-8: #666666;
--ansi-9: #f14c4c;
--ansi-10: #23d18b;
--ansi-11: #f5f543;
--ansi-12: #3b8eea;
--ansi-13: #d670d6;
--ansi-14: #29b8db;
--ansi-15: #ffffff;
${extendedPalette.dark}
}
}
body {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'JetBrains Mono', 'SF Mono', Monaco, Menlo, monospace;
background: var(--term-default-bg);
color: var(--term-default-fg);
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', 'SF Mono',
Consolas, Monaco, Menlo, monospace;
font-size: 13px;
line-height: 1.4;
line-height: 1.5;
padding: 20px;
white-space: pre-wrap;
word-wrap: break-word;
}
.header {
border-bottom: 1px solid #444;
border-bottom: 1px solid var(--term-border);
padding-bottom: 10px;
margin-bottom: 20px;
color: #888;
color: var(--term-header-fg);
}
</style>
</head>
@@ -919,3 +919,45 @@ test("manual session log rejects renderer-supplied filePath without selection to
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("html wrapper adapts to light/dark browser color scheme", () => {
const bridge = loadBridgeWithDialog({});
const html = bridge.wrapTerminalHtmlContent("hello", "host-1", 0);
// Day palette is the default; dark applies only via prefers-color-scheme.
assert.match(html, /--term-default-bg:\s*#ffffff/);
assert.match(html, /color-scheme:\s*light dark/);
assert.match(html, /<meta name="color-scheme" content="light dark">/);
assert.match(html, /@media \(prefers-color-scheme: dark\)/);
assert.match(html, /--term-default-bg:\s*#1e1e1e/);
// Fonts remain monospace with a platform-appropriate stack.
assert.match(html, /font-family:[^;]*ui-monospace[^;]*monospace/);
});
test("all light-mode ANSI foregrounds remain readable on the default background", () => {
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;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
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+|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/);
});
+30 -8
View File
@@ -12,6 +12,9 @@
*/
const CSI_FINAL_RE = /[@-~]/;
// ANSI palette colors are emitted as CSS variables (with hex fallbacks) so the
// wrapping HTML page can provide light and dark palettes. Indexes 0-7 are the
// basic colors, 8-15 the bright colors.
const DEFAULT_FOREGROUND = "#d4d4d4";
const DEFAULT_BACKGROUND = "#1e1e1e";
const BASIC_COLORS = [
@@ -35,6 +38,11 @@ const BRIGHT_COLORS = [
"#ffffff",
];
function ansiColorVar(index) {
const fallback = index < 8 ? BASIC_COLORS[index] : BRIGHT_COLORS[index - 8];
return `var(--ansi-${index}, ${fallback})`;
}
class TerminalTextRenderer {
constructor(options = {}) {
this.lines = [[]];
@@ -307,17 +315,17 @@ class TerminalTextRenderer {
} else if (code === 27) {
this.style.inverse = false;
} else if (code >= 30 && code <= 37) {
this.style.fg = BASIC_COLORS[code - 30];
this.style.fg = ansiColorVar(code - 30);
} else if (code === 39) {
this.style.fg = null;
} else if (code >= 40 && code <= 47) {
this.style.bg = BASIC_COLORS[code - 40];
this.style.bg = ansiColorVar(code - 40);
} else if (code === 49) {
this.style.bg = null;
} else if (code >= 90 && code <= 97) {
this.style.fg = BRIGHT_COLORS[code - 90];
this.style.fg = ansiColorVar(8 + code - 90);
} else if (code >= 100 && code <= 107) {
this.style.bg = BRIGHT_COLORS[code - 100];
this.style.bg = ansiColorVar(8 + code - 100);
} else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
const color = colorFromAnsi256(codes[i + 2]);
if (color) {
@@ -585,8 +593,23 @@ function getTrimmedLineLength(line) {
function styleToCss(style) {
if (!style) return "";
const declarations = [];
const fg = style.inverse ? (style.bg || DEFAULT_BACKGROUND) : style.fg;
const bg = style.inverse ? (style.fg || DEFAULT_FOREGROUND) : style.bg;
let fg = style.inverse
? (style.bg || `var(--term-default-bg, ${DEFAULT_BACKGROUND})`)
: style.fg;
let bg = style.inverse
? (style.fg || `var(--term-default-fg, ${DEFAULT_FOREGROUND})`)
: style.bg;
// Theme the normal page foreground, but preserve the original color pair
// when output supplies a background (including a foreground inverted into
// one). Darkening foregrounds independently can make these runs unreadable.
if (style.bg || (style.inverse && style.fg)) {
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}`);
if (style.bold) declarations.push("font-weight: 700");
@@ -608,8 +631,7 @@ function stylesEqual(a, b) {
function colorFromAnsi256(value) {
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
if (value < 8) return BASIC_COLORS[value];
if (value < 16) return BRIGHT_COLORS[value - 8];
if (value < 16) return ansiColorVar(value);
if (value < 232) {
const n = value - 16;
const r = Math.floor(n / 36);
+42 -2
View File
@@ -214,8 +214,8 @@ test("RIS resets SGR so post-reset text is not colored from pre-entry style", ()
const html = renderer.toHtmlContent();
assert.match(html, /shell-after/);
// Pre-entry red SGR must not wrap post-RIS shell text.
assert.equal(/color:\s*#cd3131[^"]*"[^>]*>shell-after/.test(html), false);
assert.match(html, /color:\s*#cd3131[^"]*"[^>]*>before/);
assert.equal(/color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>shell-after/.test(html), false);
assert.match(html, /color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>before/);
});
test("RIS rebases screen so later cursor-home does not overwrite history", () => {
@@ -248,3 +248,43 @@ test("split C1 CSI alternate-screen enter still omits TUI paint", () => {
renderer.feed("\x9b?1049l$ done\n");
assert.equal(renderer.finish(), "before\n$ done");
});
test("SGR colors are emitted as themeable CSS variables with hex fallbacks", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("\x1b[31mred \x1b[91mbright-red \x1b[44mbg-blue \x1b[0mplain\n");
const html = renderer.toHtmlContent();
// Basic / bright / background colors map to --ansi-N variables so the
// wrapping HTML page can swap palettes for light and dark themes.
assert.match(html, /color:\s*var\(--ansi-1, #cd3131\)[^"]*"[^>]*>red/);
assert.match(html, /color:\s*var\(--ansi-9, #f14c4c\)[^"]*"[^>]*>bright-red/);
assert.match(html, /color:\s*#f14c4c; background-color:\s*#2472c8/);
});
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*var\(--term-custom-ff8700, #ff8700\)[^"]*"[^>]*>cube/);
});
test("inverse text falls back to themeable default background/foreground", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("\x1b[7minversed\x1b[0m\n");
const html = renderer.toHtmlContent();
assert.match(
html,
/color:\s*var\(--term-default-bg, #1e1e1e\); background-color:\s*var\(--term-default-fg, #d4d4d4\)/,
);
});
test("explicit backgrounds preserve original foreground/background pairs across themes", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("\x1b[37;41mERROR\x1b[0m \x1b[40mTEXT\x1b[0m \x1b[38;5;15;48;5;1mindexed\x1b[0m \x1b[31;7minverse\x1b[0m \x1b[48;2;0;0;0mRGB background\x1b[0m");
const html = renderer.toHtmlContent();
assert.match(html, /color: #e5e5e5; background-color: #cd3131[^>]*>ERROR/);
assert.match(html, /color: #d4d4d4; background-color: #000000[^>]*>TEXT/);
assert.match(html, /color: #ffffff; background-color: #cd3131[^>]*>indexed/);
assert.match(html, /color: #1e1e1e; background-color: #cd3131[^>]*>inverse/);
assert.match(html, /color: #d4d4d4; background-color: #000000[^>]*>RGB background/);
});