mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7d5d46c3 | ||
|
|
ae15afc4da | ||
|
|
50d117ff6f | ||
|
|
4f998ba7b4 | ||
|
|
133c09b34d |
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user