mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 15:03:31 +00:00
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>
246 lines
7.6 KiB
TypeScript
246 lines
7.6 KiB
TypeScript
import fs from "fs";
|
|
import { initTheme, theme } from "../src/modes/interactive/theme/theme.js";
|
|
|
|
// --- Color utilities ---
|
|
|
|
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];
|
|
}
|
|
|
|
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("")
|
|
);
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
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)];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
|
|
let lo = againstWhite ? 0 : 0.5;
|
|
let hi = againstWhite ? 0.5 : 1.0;
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
const mid = (lo + hi) / 2;
|
|
const testRgb = hslToRgb(h, s, mid);
|
|
const contrast = getContrast(testRgb, bgLum);
|
|
|
|
if (againstWhite) {
|
|
if (contrast < targetContrast) hi = mid;
|
|
else lo = mid;
|
|
} else {
|
|
if (contrast < targetContrast) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
}
|
|
|
|
const finalL = againstWhite ? lo : hi;
|
|
return rgbToHex(...hslToRgb(h, s, finalL));
|
|
}
|
|
|
|
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");
|
|
}
|