From 907fa937e6adf67174411f0cf714d9ee2e78cbd0 Mon Sep 17 00:00:00 2001 From: scutifer Date: Tue, 13 Jan 2026 17:33:04 +0530 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/interactive/theme/light.json | 18 +- .../coding-agent/test/test-theme-colors.ts | 293 ++++++++++++++---- 3 files changed, 245 insertions(+), 70 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 35a13503..07a1f3f6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Light theme colors adjusted for WCAG AA compliance (4.5:1 contrast ratio against white backgrounds) + ### Added - Extension example: `summarize.ts` for summarizing conversations using custom UI and an external model diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 9154b161..6d791f74 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -2,13 +2,13 @@ "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json", "name": "light", "vars": { - "teal": "#5f8787", - "blue": "#5f87af", - "green": "#87af87", - "red": "#af5f5f", - "yellow": "#d7af5f", + "teal": "#5a8080", + "blue": "#547da7", + "green": "#588458", + "red": "#aa5555", + "yellow": "#9a7326", "mediumGray": "#6c6c6c", - "dimGray": "#8a8a8a", + "dimGray": "#767676", "lightGray": "#b0b0b0", "selectedBg": "#d0d0e0", "userMsgBg": "#e8e8e8", @@ -68,9 +68,9 @@ "syntaxPunctuation": "#000000", "thinkingOff": "lightGray", - "thinkingMinimal": "#9e9e9e", - "thinkingLow": "#5f87af", - "thinkingMedium": "#5f8787", + "thinkingMinimal": "#767676", + "thinkingLow": "blue", + "thinkingMedium": "teal", "thinkingHigh": "#875f87", "thinkingXhigh": "#8b008b", diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts index 39aa3b8b..a3317026 100644 --- a/packages/coding-agent/test/test-theme-colors.ts +++ b/packages/coding-agent/test/test-theme-colors.ts @@ -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)) { + 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"); +}