fix(logs): adapt extended foreground colors to light pages

This commit is contained in:
bincxz committed 2026-09-05 13:05:11 +08:00
1 parent 4f998ba7b4
commit 50d117ff6f
4 files changed
+48 -6

No files matched your search

+30
View File
@@ -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 {
+13 -4
View File
@@ -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/);
});
@@ -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}`);
@@ -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", () => {