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)) { 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[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[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[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"); }