mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 22:03:45 +00:00
Improve light theme color contrast for WCAG compliance (#682)
Adjust base colors (teal, blue, green, red, yellow, dimGray) to meet 4.5:1 contrast ratio against white backgrounds. Update thinking level colors to reference theme vars for consistency. Refactor test-theme-colors.ts into a CLI with contrast, test, and theme commands for easier color validation. Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
parent
922b0a4668
commit
907fa937e6
3 changed files with 245 additions and 70 deletions
|
|
@ -1,75 +1,246 @@
|
|||
import fs from "fs";
|
||||
import { initTheme, theme } from "../src/modes/interactive/theme/theme.js";
|
||||
|
||||
// Initialize with dark theme explicitly
|
||||
process.env.COLORTERM = "truecolor";
|
||||
initTheme("dark");
|
||||
// --- Color utilities ---
|
||||
|
||||
console.log("\n=== Foreground Colors ===\n");
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [0, 0, 0];
|
||||
}
|
||||
|
||||
// Core UI colors
|
||||
console.log("accent:", theme.fg("accent", "Sample text"));
|
||||
console.log("border:", theme.fg("border", "Sample text"));
|
||||
console.log("borderAccent:", theme.fg("borderAccent", "Sample text"));
|
||||
console.log("borderMuted:", theme.fg("borderMuted", "Sample text"));
|
||||
console.log("success:", theme.fg("success", "Sample text"));
|
||||
console.log("error:", theme.fg("error", "Sample text"));
|
||||
console.log("warning:", theme.fg("warning", "Sample text"));
|
||||
console.log("muted:", theme.fg("muted", "Sample text"));
|
||||
console.log("dim:", theme.fg("dim", "Sample text"));
|
||||
console.log("text:", theme.fg("text", "Sample text"));
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) =>
|
||||
Math.round(Math.max(0, Math.min(255, x)))
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n=== Message Text Colors ===\n");
|
||||
console.log("userMessageText:", theme.fg("userMessageText", "Sample text"));
|
||||
console.log("toolTitle:", theme.fg("toolTitle", "Sample text"));
|
||||
console.log("toolOutput:", theme.fg("toolOutput", "Sample text"));
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
let h = 0,
|
||||
s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
console.log("\n=== Markdown Colors ===\n");
|
||||
console.log("mdHeading:", theme.fg("mdHeading", "Sample text"));
|
||||
console.log("mdLink:", theme.fg("mdLink", "Sample text"));
|
||||
console.log("mdCode:", theme.fg("mdCode", "Sample text"));
|
||||
console.log("mdCodeBlock:", theme.fg("mdCodeBlock", "Sample text"));
|
||||
console.log("mdCodeBlockBorder:", theme.fg("mdCodeBlockBorder", "Sample text"));
|
||||
console.log("mdQuote:", theme.fg("mdQuote", "Sample text"));
|
||||
console.log("mdQuoteBorder:", theme.fg("mdQuoteBorder", "Sample text"));
|
||||
console.log("mdHr:", theme.fg("mdHr", "Sample text"));
|
||||
console.log("mdListBullet:", theme.fg("mdListBullet", "Sample text"));
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
let r: number, g: number, b: number;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||
}
|
||||
|
||||
console.log("\n=== Tool Diff Colors ===\n");
|
||||
console.log("toolDiffAdded:", theme.fg("toolDiffAdded", "Sample text"));
|
||||
console.log("toolDiffRemoved:", theme.fg("toolDiffRemoved", "Sample text"));
|
||||
console.log("toolDiffContext:", theme.fg("toolDiffContext", "Sample text"));
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
const lin = (c: number) => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
}
|
||||
|
||||
console.log("\n=== Thinking Border Colors ===\n");
|
||||
console.log("thinkingOff:", theme.fg("thinkingOff", "Sample text"));
|
||||
console.log("thinkingMinimal:", theme.fg("thinkingMinimal", "Sample text"));
|
||||
console.log("thinkingLow:", theme.fg("thinkingLow", "Sample text"));
|
||||
console.log("thinkingMedium:", theme.fg("thinkingMedium", "Sample text"));
|
||||
console.log("thinkingHigh:", theme.fg("thinkingHigh", "Sample text"));
|
||||
function getContrast(rgb: [number, number, number], bgLum: number): number {
|
||||
const fgLum = getLuminance(...rgb);
|
||||
const lighter = Math.max(fgLum, bgLum);
|
||||
const darker = Math.min(fgLum, bgLum);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
console.log("\n=== Background Colors ===\n");
|
||||
console.log("userMessageBg:", theme.bg("userMessageBg", " Sample background text "));
|
||||
console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample background text "));
|
||||
console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample background text "));
|
||||
console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample background text "));
|
||||
function adjustColorToContrast(hex: string, targetContrast: number, againstWhite: boolean): string {
|
||||
const rgb = hexToRgb(hex);
|
||||
const [h, s] = rgbToHsl(...rgb);
|
||||
const bgLum = againstWhite ? 1.0 : 0.0;
|
||||
|
||||
console.log("\n=== Raw ANSI Codes ===\n");
|
||||
console.log("thinkingMedium ANSI:", JSON.stringify(theme.getFgAnsi("thinkingMedium")));
|
||||
console.log("accent ANSI:", JSON.stringify(theme.getFgAnsi("accent")));
|
||||
console.log("muted ANSI:", JSON.stringify(theme.getFgAnsi("muted")));
|
||||
console.log("dim ANSI:", JSON.stringify(theme.getFgAnsi("dim")));
|
||||
let lo = againstWhite ? 0 : 0.5;
|
||||
let hi = againstWhite ? 0.5 : 1.0;
|
||||
|
||||
console.log("\n=== Direct RGB Test ===\n");
|
||||
console.log("Gray #6c6c6c: \x1b[38;2;108;108;108mSample text\x1b[0m");
|
||||
console.log("Gray #444444: \x1b[38;2;68;68;68mSample text\x1b[0m");
|
||||
console.log("Gray #303030: \x1b[38;2;48;48;48mSample text\x1b[0m");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const testRgb = hslToRgb(h, s, mid);
|
||||
const contrast = getContrast(testRgb, bgLum);
|
||||
|
||||
console.log("\n=== Hex Color Test ===\n");
|
||||
console.log("Direct #00d7ff test: \x1b[38;2;0;215;255mBRIGHT CYAN\x1b[0m");
|
||||
console.log("Theme cyan (should match above):", theme.fg("accent", "BRIGHT CYAN"));
|
||||
if (againstWhite) {
|
||||
if (contrast < targetContrast) hi = mid;
|
||||
else lo = mid;
|
||||
} else {
|
||||
if (contrast < targetContrast) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== Environment ===\n");
|
||||
console.log("TERM:", process.env.TERM);
|
||||
console.log("COLORTERM:", process.env.COLORTERM);
|
||||
console.log("Color mode:", theme.getColorMode());
|
||||
const finalL = againstWhite ? lo : hi;
|
||||
return rgbToHex(...hslToRgb(h, s, finalL));
|
||||
}
|
||||
|
||||
console.log("\n");
|
||||
function fgAnsi(hex: string): string {
|
||||
const rgb = hexToRgb(hex);
|
||||
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
|
||||
}
|
||||
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
// --- Commands ---
|
||||
|
||||
function cmdContrast(targetContrast: number): void {
|
||||
const baseColors = {
|
||||
teal: "#5f8787",
|
||||
blue: "#5f87af",
|
||||
green: "#87af87",
|
||||
yellow: "#d7af5f",
|
||||
red: "#af5f5f",
|
||||
};
|
||||
|
||||
console.log(`\n=== Colors adjusted to ${targetContrast}:1 contrast ===\n`);
|
||||
|
||||
console.log("For LIGHT theme (vs white):");
|
||||
for (const [name, hex] of Object.entries(baseColors)) {
|
||||
const adjusted = adjustColorToContrast(hex, targetContrast, true);
|
||||
const rgb = hexToRgb(adjusted);
|
||||
const contrast = getContrast(rgb, 1.0);
|
||||
console.log(` ${name.padEnd(8)} ${fgAnsi(adjusted)}Sample${reset} ${adjusted} (${contrast.toFixed(2)}:1)`);
|
||||
}
|
||||
|
||||
console.log("\nFor DARK theme (vs black):");
|
||||
for (const [name, hex] of Object.entries(baseColors)) {
|
||||
const adjusted = adjustColorToContrast(hex, targetContrast, false);
|
||||
const rgb = hexToRgb(adjusted);
|
||||
const contrast = getContrast(rgb, 0.0);
|
||||
console.log(` ${name.padEnd(8)} ${fgAnsi(adjusted)}Sample${reset} ${adjusted} (${contrast.toFixed(2)}:1)`);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdTest(filePath: string): void {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
const vars = data.vars || data;
|
||||
|
||||
console.log(`\n=== Testing ${filePath} ===\n`);
|
||||
|
||||
for (const [name, hex] of Object.entries(vars as Record<string, string>)) {
|
||||
if (!hex.startsWith("#")) continue;
|
||||
const rgb = hexToRgb(hex);
|
||||
const vsWhite = getContrast(rgb, 1.0);
|
||||
const vsBlack = getContrast(rgb, 0.0);
|
||||
const passW = vsWhite >= 4.5 ? "AA" : vsWhite >= 3.0 ? "AA-lg" : "FAIL";
|
||||
const passB = vsBlack >= 4.5 ? "AA" : vsBlack >= 3.0 ? "AA-lg" : "FAIL";
|
||||
console.log(
|
||||
`${name.padEnd(14)} ${fgAnsi(hex)}Sample text${reset} ${hex} white: ${vsWhite.toFixed(2)}:1 ${passW.padEnd(5)} black: ${vsBlack.toFixed(2)}:1 ${passB}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdTheme(themeName: string): void {
|
||||
process.env.COLORTERM = "truecolor";
|
||||
initTheme(themeName);
|
||||
|
||||
const parseAnsiRgb = (ansi: string): [number, number, number] | null => {
|
||||
const match = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
|
||||
return match ? [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)] : null;
|
||||
};
|
||||
|
||||
const getContrastVsWhite = (colorName: string): string => {
|
||||
const ansi = theme.getFgAnsi(colorName as Parameters<typeof theme.getFgAnsi>[0]);
|
||||
const rgb = parseAnsiRgb(ansi);
|
||||
if (!rgb) return "(default)";
|
||||
const ratio = getContrast(rgb, 1.0);
|
||||
const pass = ratio >= 4.5 ? "AA" : ratio >= 3.0 ? "AA-lg" : "FAIL";
|
||||
return `${ratio.toFixed(2)}:1 ${pass}`;
|
||||
};
|
||||
|
||||
const getContrastVsBlack = (colorName: string): string => {
|
||||
const ansi = theme.getFgAnsi(colorName as Parameters<typeof theme.getFgAnsi>[0]);
|
||||
const rgb = parseAnsiRgb(ansi);
|
||||
if (!rgb) return "(default)";
|
||||
const ratio = getContrast(rgb, 0.0);
|
||||
const pass = ratio >= 4.5 ? "AA" : ratio >= 3.0 ? "AA-lg" : "FAIL";
|
||||
return `${ratio.toFixed(2)}:1 ${pass}`;
|
||||
};
|
||||
|
||||
const logColor = (name: string): void => {
|
||||
const sample = theme.fg(name as Parameters<typeof theme.fg>[0], "Sample text");
|
||||
const cw = getContrastVsWhite(name);
|
||||
const cb = getContrastVsBlack(name);
|
||||
console.log(`${name.padEnd(20)} ${sample} white: ${cw.padEnd(12)} black: ${cb}`);
|
||||
};
|
||||
|
||||
console.log(`\n=== ${themeName} theme (WCAG AA = 4.5:1) ===`);
|
||||
|
||||
console.log("\n--- Core UI ---");
|
||||
["accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim"].forEach(logColor);
|
||||
|
||||
console.log("\n--- Markdown ---");
|
||||
["mdHeading", "mdLink", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdListBullet"].forEach(logColor);
|
||||
|
||||
console.log("\n--- Diff ---");
|
||||
["toolDiffAdded", "toolDiffRemoved", "toolDiffContext"].forEach(logColor);
|
||||
|
||||
console.log("\n--- Thinking ---");
|
||||
["thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh"].forEach(logColor);
|
||||
|
||||
console.log("\n--- Backgrounds ---");
|
||||
console.log("userMessageBg:", theme.bg("userMessageBg", " Sample "));
|
||||
console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample "));
|
||||
console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample "));
|
||||
console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample "));
|
||||
console.log();
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const [cmd, arg] = process.argv.slice(2);
|
||||
|
||||
if (cmd === "contrast") {
|
||||
cmdContrast(parseFloat(arg) || 4.5);
|
||||
} else if (cmd === "test") {
|
||||
cmdTest(arg);
|
||||
} else if (cmd === "light" || cmd === "dark") {
|
||||
cmdTheme(cmd);
|
||||
} else {
|
||||
console.log("Usage:");
|
||||
console.log(" npx tsx test-theme-colors.ts light|dark Test built-in theme");
|
||||
console.log(" npx tsx test-theme-colors.ts contrast 4.5 Compute colors at ratio");
|
||||
console.log(" npx tsx test-theme-colors.ts test file.json Test any JSON file");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue