diff --git a/generator/colorUtils.test.ts b/generator/colorUtils.test.ts index f76373e..63270bc 100644 --- a/generator/colorUtils.test.ts +++ b/generator/colorUtils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test"; import { adjustBrightness, + contrastRatio, + ensureContrast, hexToRgb, hslToRgb, + relativeLuminance, rgbToHex, rgbToHsl, } from "./colorUtils"; @@ -52,3 +55,76 @@ describe("rgbToHsl / hslToRgb", () => { expect(b).toBeCloseTo(112, -1); }); }); + +describe("relativeLuminance", () => { + test("black is 0, white is 1", () => { + expect(relativeLuminance([0, 0, 0])).toBeCloseTo(0, 5); + expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1, 5); + }); + + test("a lighter color has higher luminance than a darker one", () => { + expect(relativeLuminance([232, 196, 104])).toBeGreaterThan( + relativeLuminance([46, 26, 36]), + ); + }); +}); + +describe("contrastRatio", () => { + test("black on white is 21:1", () => { + expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 1); + }); + + test("is symmetric", () => { + const a = contrastRatio([90, 192, 154], [255, 248, 245]); + const b = contrastRatio([255, 248, 245], [90, 192, 154]); + expect(a).toBeCloseTo(b, 6); + }); + + test("identical colors are 1:1", () => { + expect(contrastRatio([120, 120, 120], [120, 120, 120])).toBeCloseTo(1, 6); + }); +}); + +describe("ensureContrast", () => { + const LIGHT_BG: [number, number, number] = [255, 248, 245]; // #FFF8F5 + + test("raises a failing color to at least the target ratio", () => { + const yellow: [number, number, number] = [232, 196, 104]; // #E8C468, ~1.6:1 on light bg + expect(contrastRatio(yellow, LIGHT_BG)).toBeLessThan(3); + const fixed = ensureContrast(yellow, LIGHT_BG, 3); + expect(contrastRatio(fixed, LIGHT_BG)).toBeGreaterThanOrEqual(3); + }); + + test("is a no-op when the color already meets the target", () => { + const text: [number, number, number] = [46, 26, 36]; // #2E1A24, passes easily on light bg + expect(contrastRatio(text, LIGHT_BG)).toBeGreaterThanOrEqual(3); + expect(ensureContrast(text, LIGHT_BG, 3)).toEqual(text); + }); + + test("preserves hue while darkening against a light background", () => { + const green: [number, number, number] = [90, 192, 154]; // #5AC09A + const fixed = ensureContrast(green, LIGHT_BG, 3); + const [hIn] = rgbToHsl(...green); + const [hOut] = rgbToHsl(...fixed); + expect(hOut).toBeCloseTo(hIn, 2); + // darkened (moved away from the light background) + expect(relativeLuminance(fixed)).toBeLessThan(relativeLuminance(green)); + }); + + test("lightens against a dark background", () => { + const DARK_BG: [number, number, number] = [30, 16, 40]; // #1E1028 + const dim: [number, number, number] = [60, 40, 70]; // low contrast on dark bg + expect(contrastRatio(dim, DARK_BG)).toBeLessThan(3); + const fixed = ensureContrast(dim, DARK_BG, 3); + expect(contrastRatio(fixed, DARK_BG)).toBeGreaterThanOrEqual(3); + expect(relativeLuminance(fixed)).toBeGreaterThan(relativeLuminance(dim)); + }); + + test("minimal nudge: a color just under target ends up near the target, not maxed out", () => { + const red: [number, number, number] = [255, 84, 112]; // #FF5470, ~2.85:1 on light bg + const fixed = ensureContrast(red, LIGHT_BG, 3); + const ratio = contrastRatio(fixed, LIGHT_BG); + expect(ratio).toBeGreaterThanOrEqual(3); + expect(ratio).toBeLessThan(4); // didn't slam to black + }); +}); diff --git a/generator/colorUtils.ts b/generator/colorUtils.ts index e8ef2c7..4207e10 100644 --- a/generator/colorUtils.ts +++ b/generator/colorUtils.ts @@ -94,3 +94,78 @@ export function adjustBrightness( number, ]; } + +function clampRgb(rgb: number[]): [number, number, number] { + return rgb.map((c) => Math.max(0, Math.min(255, Math.round(c)))) as [ + number, + number, + number, + ]; +} + +/** + * WCAG 2.x relative luminance of an sRGB color (0 = black, 1 = white). + */ +export function relativeLuminance(rgb: [number, number, number]): number { + const channel = (c: number): number => { + const cs = c / 255; + return cs <= 0.03928 ? cs / 12.92 : ((cs + 0.055) / 1.055) ** 2.4; + }; + const [r, g, b] = rgb; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +/** + * WCAG contrast ratio between two colors (1:1 .. 21:1). Order-independent. + */ +export function contrastRatio( + a: [number, number, number], + b: [number, number, number], +): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * Nudge a color's lightness just enough to reach `minRatio` contrast against + * `bg`, preserving hue and saturation. Darkens on a light background and + * lightens on a dark one. Returns the color unchanged when it already passes. + */ +export function ensureContrast( + rgb: [number, number, number], + bg: [number, number, number], + minRatio = 3, +): [number, number, number] { + if (contrastRatio(rgb, bg) >= minRatio) return rgb; + + const [h, s, l] = rgbToHsl(...rgb); + // Move lightness away from the background: toward black on a light bg, + // toward white on a dark one. + const targetL = relativeLuminance(bg) > 0.5 ? 0 : 1; + const ratioAt = (lightness: number): number => + contrastRatio(clampRgb(hslToRgb(h, s, lightness)), bg); + + // If even the extreme can't reach the target (e.g. a very saturated hue + // against a same-luminance bg), return the most-contrasting option we have. + if (ratioAt(targetL) < minRatio) { + return clampRgb(hslToRgb(h, s, targetL)); + } + + // Binary-search the smallest lightness change from `l` toward `targetL` + // that satisfies `minRatio`. `fail` anchors the side below target, `pass` + // the side at/above it; they converge on the boundary. + let fail = l; + let pass = targetL; + for (let i = 0; i < 24; i++) { + const mid = (fail + pass) / 2; + if (ratioAt(mid) >= minRatio) { + pass = mid; + } else { + fail = mid; + } + } + return clampRgb(hslToRgb(h, s, pass)); +} diff --git a/generator/targets/neovim.test.ts b/generator/targets/neovim.test.ts index 33b79e7..e61e021 100644 --- a/generator/targets/neovim.test.ts +++ b/generator/targets/neovim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadFontStyles, @@ -17,6 +18,14 @@ import { const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); +// terminal[] indices: 0 black, 1-6 chromatic (red..cyan), 7 white, +// 8 bright_black, 9-14 bright chromatic, 15 bright_white. +const CHROMATIC_TERMINAL_INDICES = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14]; + +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + function loadFixtures(variant: VariantName) { const palette = loadPalette(PALETTE_DIR, variant); const uiMapping = loadUIMapping(PALETTE_DIR); @@ -76,6 +85,46 @@ describe("generateNeovimPalette", () => { expect(result.terminal).toHaveLength(16); }); + for (const variant of ["pastel-light", "neon-light"] as VariantName[]) { + test(`${variant} chromatic terminal colors meet 3:1 contrast on background`, () => { + const { palette, uiMapping, syntaxMapping, scopes, fontStyles } = + loadFixtures(variant); + const result = generateNeovimPalette( + variant, + palette, + uiMapping, + syntaxMapping, + scopes, + fontStyles, + ); + + const bg = result.ui.Normal.bg as string; + for (const i of CHROMATIC_TERMINAL_INDICES) { + expect(contrastWithBg(result.terminal[i], bg)).toBeGreaterThanOrEqual( + 3, + ); + } + }); + } + + test("dark chromatic terminal colors are unchanged by the contrast floor", () => { + const { palette, uiMapping, syntaxMapping, scopes, fontStyles } = + loadFixtures("pastel-dark"); + const result = generateNeovimPalette( + "pastel-dark", + palette, + uiMapping, + syntaxMapping, + scopes, + fontStyles, + ); + + // Already legible on the dark background — palette values preserved. + expect(result.terminal[2]).toBe("#7FD7B5"); // green + expect(result.terminal[3]).toBe("#FFE4B5"); // yellow + expect(result.terminal[6]).toBe("#5ED4E0"); // cyan + }); + test("pastel-dark has @comment with correct fg and italic", () => { const { palette, uiMapping, syntaxMapping, scopes, fontStyles } = loadFixtures("pastel-dark"); diff --git a/generator/targets/neovim.ts b/generator/targets/neovim.ts index 5a2d894..92be7a8 100644 --- a/generator/targets/neovim.ts +++ b/generator/targets/neovim.ts @@ -1,3 +1,4 @@ +import { ensureContrast, rgbToHex } from "../colorUtils"; import { colorToHex, resolveColor } from "../palette"; import { resolveSyntaxColor } from "../syntaxMapping"; import type { @@ -49,6 +50,19 @@ function pc(palette: Palette, colorName: string, alpha?: number): string { return colorToHex(color, alpha); } +// Resolve a color and nudge it just enough to stay legible against the +// terminal background. No-op when it already clears the floor (dark themes). +function floorPc( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + minRatio = 3, +): string { + const color = resolveColor(palette, colorName); + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; +} + const TS_TO_CLASSIC: Record = { comment: "Comment", "comment.block": "Comment", @@ -436,18 +450,23 @@ export function generateNeovimPalette( fontStyles, ); + // Terminal background is the editor background (base); floor chromatic ANSI + // text colors against it so they stay legible (no-op on dark themes). + const terminalBgRgb = resolveColor(palette, "base").rgb; + const t = uiMapping.terminal; + const terminal: string[] = []; if (isDark) { terminal.push(pc(palette, "crust")); } else { terminal.push(pc(palette, "text")); } - terminal.push(uc(uiMapping, palette, "terminal", "red")); - terminal.push(uc(uiMapping, palette, "terminal", "green")); - terminal.push(uc(uiMapping, palette, "terminal", "yellow")); - terminal.push(uc(uiMapping, palette, "terminal", "blue")); - terminal.push(uc(uiMapping, palette, "terminal", "magenta")); - terminal.push(uc(uiMapping, palette, "terminal", "cyan")); + terminal.push(floorPc(palette, t.red, terminalBgRgb)); + terminal.push(floorPc(palette, t.green, terminalBgRgb)); + terminal.push(floorPc(palette, t.yellow, terminalBgRgb)); + terminal.push(floorPc(palette, t.blue, terminalBgRgb)); + terminal.push(floorPc(palette, t.magenta, terminalBgRgb)); + terminal.push(floorPc(palette, t.cyan, terminalBgRgb)); if (isDark) { terminal.push(uc(uiMapping, palette, "terminal", "white")); } else { @@ -458,12 +477,12 @@ export function generateNeovimPalette( } else { terminal.push(pc(palette, "subtext1")); } - terminal.push(uc(uiMapping, palette, "terminal", "bright_red")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_green")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_yellow")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_blue")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_magenta")); - terminal.push(uc(uiMapping, palette, "terminal", "bright_cyan")); + terminal.push(floorPc(palette, t.bright_red, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_green, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_yellow, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_blue, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_magenta, terminalBgRgb)); + terminal.push(floorPc(palette, t.bright_cyan, terminalBgRgb)); terminal.push(uc(uiMapping, palette, "terminal", "bright_white")); const lspLinks: Record = { diff --git a/generator/targets/vscode.test.ts b/generator/targets/vscode.test.ts index bef2998..a3adec2 100644 --- a/generator/targets/vscode.test.ts +++ b/generator/targets/vscode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadFontStyles, @@ -13,6 +14,28 @@ import { generateVSCodeTheme } from "./vscode"; const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); +const CHROMATIC_ANSI = ["Red", "Green", "Yellow", "Blue", "Magenta", "Cyan"]; + +function buildTheme(variant: VariantName) { + const palette = loadPalette(PALETTE_DIR, variant); + const uiMapping = loadUIMapping(PALETTE_DIR); + const syntaxMapping = loadSyntaxMapping(PALETTE_DIR); + const scopes = loadScopes(PALETTE_DIR); + const fontStyles = loadFontStyles(PALETTE_DIR); + return generateVSCodeTheme( + variant, + palette, + uiMapping, + syntaxMapping, + scopes, + fontStyles, + ); +} + +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + describe("generateVSCodeTheme", () => { test("produces a valid dark theme structure", () => { const variant: VariantName = "pastel-dark"; @@ -64,4 +87,28 @@ describe("generateVSCodeTheme", () => { expect(theme.type).toBe("light"); expect(theme.colors["editor.background"]).toBe("#FFF8F5"); }); + + for (const variant of ["pastel-light", "neon-light"] as VariantName[]) { + test(`${variant} terminal ANSI colors meet 3:1 contrast on background`, () => { + const theme = buildTheme(variant); + const bg = theme.colors["terminal.background"]; + + for (const name of CHROMATIC_ANSI) { + expect( + contrastWithBg(theme.colors[`terminal.ansi${name}`], bg), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(theme.colors[`terminal.ansiBright${name}`], bg), + ).toBeGreaterThanOrEqual(3); + } + }); + } + + test("dark terminal ANSI colors are unchanged by the contrast floor", () => { + const theme = buildTheme("pastel-dark"); + // Already legible on the dark background — palette values preserved. + expect(theme.colors["terminal.ansiGreen"]).toBe("#7FD7B5"); + expect(theme.colors["terminal.ansiYellow"]).toBe("#FFE4B5"); + expect(theme.colors["terminal.ansiCyan"]).toBe("#5ED4E0"); + }); }); diff --git a/generator/targets/vscode.ts b/generator/targets/vscode.ts index f2254bb..eb2f106 100644 --- a/generator/targets/vscode.ts +++ b/generator/targets/vscode.ts @@ -1,4 +1,4 @@ -import { adjustBrightness } from "../colorUtils"; +import { adjustBrightness, ensureContrast, rgbToHex } from "../colorUtils"; import { OPACITY_RULES } from "../opacity"; import { colorToHex, resolveColor } from "../palette"; import { resolveSyntaxColor } from "../syntaxMapping"; @@ -47,10 +47,35 @@ function pc(palette: Palette, colorName: string, alpha?: number): string { return colorToHex(color, alpha); } -function bright(palette: Palette, colorName: string): string { +// Resolve a color and nudge it just enough to stay legible against the +// terminal background. No-op when it already clears the floor (dark themes). +function floor( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + minRatio = 3, +): string { const color = resolveColor(palette, colorName); - const brightRgb = adjustBrightness(color.rgb, 0.2, "lighten"); - return `#${brightRgb.map((c) => c.toString(16).padStart(2, "0").toUpperCase()).join("")}`; + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; +} + +// "Bright" = more emphasized than the base: lighter on a dark background, +// darker on a light one, then floored for legibility. +function bright( + palette: Palette, + colorName: string, + isDark: boolean, + bgRgb: [number, number, number], +): string { + const color = resolveColor(palette, colorName); + const emphasized = adjustBrightness( + color.rgb, + 0.2, + isDark ? "lighten" : "dim", + ); + const adjusted = ensureContrast(emphasized, bgRgb, 3); + return `#${rgbToHex(...adjusted)}`; } export function generateVSCodeTheme( @@ -393,22 +418,25 @@ export function generateVSCodeTheme( ); colors["terminal.foreground"] = uc(uiMapping, palette, "text", "primary"); + // Terminal background matches the editor background; floor chromatic ANSI + // text colors against it so they stay legible (no-op on dark themes). + const terminalBgRgb = resolveColor(palette, "base").rgb; + const ansiFloor = (colorName: string) => + floor(palette, colorName, terminalBgRgb); + const ansiBright = (colorName: string) => + bright(palette, colorName, isDark, terminalBgRgb); + if (isDark) { colors["terminal.ansiBlack"] = pc(palette, "crust"); } else { colors["terminal.ansiBlack"] = pc(palette, "text"); } - colors["terminal.ansiRed"] = uc(uiMapping, palette, "terminal", "red"); - colors["terminal.ansiGreen"] = uc(uiMapping, palette, "terminal", "green"); - colors["terminal.ansiYellow"] = uc(uiMapping, palette, "terminal", "yellow"); - colors["terminal.ansiBlue"] = uc(uiMapping, palette, "terminal", "blue"); - colors["terminal.ansiMagenta"] = uc( - uiMapping, - palette, - "terminal", - "magenta", - ); - colors["terminal.ansiCyan"] = uc(uiMapping, palette, "terminal", "cyan"); + colors["terminal.ansiRed"] = ansiFloor(uiMapping.terminal.red); + colors["terminal.ansiGreen"] = ansiFloor(uiMapping.terminal.green); + colors["terminal.ansiYellow"] = ansiFloor(uiMapping.terminal.yellow); + colors["terminal.ansiBlue"] = ansiFloor(uiMapping.terminal.blue); + colors["terminal.ansiMagenta"] = ansiFloor(uiMapping.terminal.magenta); + colors["terminal.ansiCyan"] = ansiFloor(uiMapping.terminal.cyan); colors["terminal.ansiWhite"] = uc(uiMapping, palette, "terminal", "white"); colors["terminal.ansiBrightBlack"] = uc( uiMapping, @@ -416,25 +444,20 @@ export function generateVSCodeTheme( "terminal", "bright_black", ); - colors["terminal.ansiBrightRed"] = bright(palette, uiMapping.terminal.red); - colors["terminal.ansiBrightGreen"] = bright( - palette, - uiMapping.terminal.green, - ); - colors["terminal.ansiBrightYellow"] = bright( - palette, - uiMapping.terminal.yellow, - ); - colors["terminal.ansiBrightBlue"] = bright(palette, uiMapping.terminal.blue); - colors["terminal.ansiBrightMagenta"] = bright( - palette, - uiMapping.terminal.magenta, - ); - colors["terminal.ansiBrightCyan"] = bright(palette, uiMapping.terminal.cyan); - colors["terminal.ansiBrightWhite"] = bright( - palette, - uiMapping.terminal.white, - ); + colors["terminal.ansiBrightRed"] = ansiBright(uiMapping.terminal.red); + colors["terminal.ansiBrightGreen"] = ansiBright(uiMapping.terminal.green); + colors["terminal.ansiBrightYellow"] = ansiBright(uiMapping.terminal.yellow); + colors["terminal.ansiBrightBlue"] = ansiBright(uiMapping.terminal.blue); + colors["terminal.ansiBrightMagenta"] = ansiBright(uiMapping.terminal.magenta); + colors["terminal.ansiBrightCyan"] = ansiBright(uiMapping.terminal.cyan); + // Structural "white" slot — stays light (no contrast floor / direction flip). + colors["terminal.ansiBrightWhite"] = `#${rgbToHex( + ...adjustBrightness( + resolveColor(palette, uiMapping.terminal.white).rgb, + 0.2, + "lighten", + ), + )}`; colors["input.background"] = uc(uiMapping, palette, "surface", "default"); colors["input.foreground"] = uc(uiMapping, palette, "text", "primary"); diff --git a/generator/targets/wezterm.test.ts b/generator/targets/wezterm.test.ts index 3257b14..e9001a1 100644 --- a/generator/targets/wezterm.test.ts +++ b/generator/targets/wezterm.test.ts @@ -1,11 +1,20 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadTerminalMapping } from "../terminalMapping"; import type { VariantName } from "../types"; import { loadUIMapping } from "../uiMapping"; import { generateWeztermTheme, tomlStringify } from "./wezterm"; +// ANSI slots that carry chromatic foreground text (red, green, yellow, blue, +// magenta, cyan). Indices 0 (black) and 7 (white) are structural and excluded. +const CHROMATIC_INDICES = [1, 2, 3, 4, 5, 6]; + +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); @@ -152,6 +161,61 @@ describe("generateWeztermTheme", () => { expect(theme.brights[7]).toBe("#2E1A24"); // text (bright white) }); + test("pastel-light chromatic ANSI colors meet 3:1 contrast on background", () => { + const { palette, uiMapping, terminalMapping } = + loadFixtures("pastel-light"); + const theme = generateWeztermTheme( + "pastel-light", + palette, + uiMapping, + terminalMapping, + ); + + for (const i of CHROMATIC_INDICES) { + expect( + contrastWithBg(theme.ansi[i], theme.background), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(theme.brights[i], theme.background), + ).toBeGreaterThanOrEqual(3); + } + }); + + test("neon-light chromatic ANSI colors meet 3:1 contrast on background", () => { + const { palette, uiMapping, terminalMapping } = loadFixtures("neon-light"); + const theme = generateWeztermTheme( + "neon-light", + palette, + uiMapping, + terminalMapping, + ); + + for (const i of CHROMATIC_INDICES) { + expect( + contrastWithBg(theme.ansi[i], theme.background), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(theme.brights[i], theme.background), + ).toBeGreaterThanOrEqual(3); + } + }); + + test("dark chromatic ANSI colors are unchanged by the contrast floor", () => { + const { palette, uiMapping, terminalMapping } = loadFixtures("pastel-dark"); + const theme = generateWeztermTheme( + "pastel-dark", + palette, + uiMapping, + terminalMapping, + ); + + // Already legible on the dark background, so the floor is a no-op: + // these exact palette-derived values must be preserved. + expect(theme.ansi[2]).toBe("#7FD7B5"); // green + expect(theme.ansi[3]).toBe("#FFE4B5"); // yellow + expect(theme.ansi[6]).toBe("#5ED4E0"); // cyan + }); + test("scrollbar and split colors are set", () => { const { palette, uiMapping, terminalMapping } = loadFixtures("pastel-dark"); const theme = generateWeztermTheme( diff --git a/generator/targets/wezterm.ts b/generator/targets/wezterm.ts index 97e95d5..874067e 100644 --- a/generator/targets/wezterm.ts +++ b/generator/targets/wezterm.ts @@ -1,4 +1,4 @@ -import { adjustBrightness } from "../colorUtils"; +import { adjustBrightness, ensureContrast, rgbToHex } from "../colorUtils"; import { colorToHex, resolveColor } from "../palette"; import type { Palette, @@ -41,40 +41,58 @@ function hex(palette: Palette, colorName: string): string { return colorToHex(color); } +// Resolve a color and darken/lighten it just enough to stay legible against the +// terminal background. A no-op when it already clears the contrast floor (so +// dark themes, which already pass, are untouched). +function floorHex( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + minRatio = 3, +): string { + const color = resolveColor(palette, colorName); + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; +} + +// "Bright" means more emphasized than the base color: lighter on a dark +// background, darker on a light one. Then floored for legibility. function brightHex( palette: Palette, colorName: string, - factor: number = 0.2, + isDark: boolean, + bgRgb: [number, number, number], + factor = 0.2, + minRatio = 3, ): string { const color = resolveColor(palette, colorName); - const brightRgb = adjustBrightness(color.rgb, factor, "lighten"); - return `#${brightRgb - .map((c) => - Math.max(0, Math.min(255, Math.round(c))) - .toString(16) - .padStart(2, "0") - .toUpperCase(), - ) - .join("")}`; + const emphasized = adjustBrightness( + color.rgb, + factor, + isDark ? "lighten" : "dim", + ); + const adjusted = ensureContrast(emphasized, bgRgb, minRatio); + return `#${rgbToHex(...adjusted)}`; } function buildAnsiColors( palette: Palette, isDark: boolean, uiMapping: UIMapping, + bgRgb: [number, number, number], ): string[] { const t = uiMapping.terminal; const ansi0 = isDark ? hex(palette, "crust") : hex(palette, t.black); - const ansi7 = isDark ? hex(palette, t.white) : hex(palette, t.white); + const ansi7 = hex(palette, t.white); return [ ansi0, - hex(palette, t.red), - hex(palette, t.green), - hex(palette, t.yellow), - hex(palette, t.blue), - hex(palette, t.magenta), - hex(palette, t.cyan), + floorHex(palette, t.red, bgRgb), + floorHex(palette, t.green, bgRgb), + floorHex(palette, t.yellow, bgRgb), + floorHex(palette, t.blue, bgRgb), + floorHex(palette, t.magenta, bgRgb), + floorHex(palette, t.cyan, bgRgb), ansi7, ]; } @@ -83,6 +101,7 @@ function buildBrightColors( palette: Palette, isDark: boolean, uiMapping: UIMapping, + bgRgb: [number, number, number], ): string[] { const t = uiMapping.terminal; const bright0 = isDark ? hex(palette, "subtext0") : hex(palette, "subtext1"); @@ -90,12 +109,12 @@ function buildBrightColors( return [ bright0, - brightHex(palette, t.red), - brightHex(palette, t.green), - brightHex(palette, t.yellow), - brightHex(palette, t.blue), - brightHex(palette, t.magenta), - brightHex(palette, t.cyan), + brightHex(palette, t.red, isDark, bgRgb), + brightHex(palette, t.green, isDark, bgRgb), + brightHex(palette, t.yellow, isDark, bgRgb), + brightHex(palette, t.blue, isDark, bgRgb), + brightHex(palette, t.magenta, isDark, bgRgb), + brightHex(palette, t.cyan, isDark, bgRgb), bright7, ]; } @@ -108,6 +127,7 @@ export function generateWeztermTheme( ): WeztermTheme { const isDark = VARIANT_TYPE[variant] === "dark"; const tm = terminalMapping; + const bgRgb = resolveColor(palette, "base").rgb; const selectionRgb = resolveColor(palette, tm.selection.bg).rgb; const selectionBg = `rgba(${selectionRgb.join(", ")}, ${tm.selection.bg_alpha})`; @@ -138,8 +158,8 @@ export function generateWeztermTheme( fg_color: hex(palette, tm.tab_bar.new_tab.fg), }, }, - ansi: buildAnsiColors(palette, isDark, uiMapping), - brights: buildBrightColors(palette, isDark, uiMapping), + ansi: buildAnsiColors(palette, isDark, uiMapping, bgRgb), + brights: buildBrightColors(palette, isDark, uiMapping, bgRgb), }; } diff --git a/generator/targets/zed.test.ts b/generator/targets/zed.test.ts index 8f55ea1..3e719fc 100644 --- a/generator/targets/zed.test.ts +++ b/generator/targets/zed.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { contrastRatio, hexToRgb } from "../colorUtils"; import { loadPalette } from "../palette"; import { loadFontStyles, loadSyntaxMapping } from "../syntaxMapping"; import { loadUIMapping } from "../uiMapping"; @@ -8,6 +9,31 @@ import { generateZedTheme } from "./zed"; const PROJECT_DIR = path.resolve(import.meta.dir, "../.."); const PALETTE_DIR = path.join(PROJECT_DIR, "palette"); +const CHROMATIC_ANSI = ["red", "green", "yellow", "blue", "magenta", "cyan"]; + +// 8-digit "#RRGGBBaa" -> contrast against another 8-digit color (alpha ignored). +function contrastWithBg(color: string, background: string): number { + return contrastRatio(hexToRgb(color), hexToRgb(background)); +} + +function buildPastelTheme() { + const darkPalette = loadPalette(PALETTE_DIR, "pastel-dark"); + const lightPalette = loadPalette(PALETTE_DIR, "pastel-light"); + const uiMapping = loadUIMapping(PALETTE_DIR); + const syntaxMapping = loadSyntaxMapping(PALETTE_DIR); + const fontStyles = loadFontStyles(PALETTE_DIR); + return generateZedTheme( + "synthpunk-pastel", + "Synthpunk Pastel", + "Synthpunk", + darkPalette, + lightPalette, + uiMapping, + syntaxMapping, + fontStyles, + ); +} + describe("generateZedTheme", () => { test("produces two theme variants (dark and light)", () => { const darkPalette = loadPalette(PALETTE_DIR, "pastel-dark"); @@ -185,6 +211,20 @@ describe("generateZedTheme", () => { expect(style["terminal.ansi.red"]).toBe("#FF5470ff"); }); + test("light theme chromatic terminal colors meet 3:1 contrast on background", () => { + const style = buildPastelTheme().themes[1].style; // light variant + const bg = style["terminal.background"] as string; + + for (const name of CHROMATIC_ANSI) { + expect( + contrastWithBg(style[`terminal.ansi.${name}`] as string, bg), + ).toBeGreaterThanOrEqual(3); + expect( + contrastWithBg(style[`terminal.ansi.bright_${name}`] as string, bg), + ).toBeGreaterThanOrEqual(3); + } + }); + test("schema and author are set correctly", () => { const darkPalette = loadPalette(PALETTE_DIR, "pastel-dark"); const lightPalette = loadPalette(PALETTE_DIR, "pastel-light"); diff --git a/generator/targets/zed.ts b/generator/targets/zed.ts index d004c81..9ad4dc9 100644 --- a/generator/targets/zed.ts +++ b/generator/targets/zed.ts @@ -1,4 +1,4 @@ -import { adjustBrightness } from "../colorUtils"; +import { adjustBrightness, ensureContrast } from "../colorUtils"; import { resolveColor } from "../palette"; import type { FontStyleMapping, @@ -69,15 +69,38 @@ function hex8rgb(rgb: [number, number, number], alpha: number = 1): string { return `#${hex}${a}`; } +// Resolve a color and nudge it just enough to stay legible against the +// terminal background. No-op when it already clears the floor (dark themes). +function floor8( + palette: Palette, + colorName: string, + bgRgb: [number, number, number], + alpha: number = 1, + minRatio = 3, +): string { + const color = resolveColor(palette, colorName); + const adjusted = ensureContrast(color.rgb, bgRgb, minRatio); + return hex8rgb(adjusted, alpha); +} + +// "Bright" = more emphasized than the base: lighter on a dark background, +// darker on a light one, then floored for legibility. function bright8( palette: Palette, colorName: string, + isDark: boolean, + bgRgb: [number, number, number], factor: number = 0.2, alpha: number = 1, ): string { const color = resolveColor(palette, colorName); - const brightRgb = adjustBrightness(color.rgb, factor, "lighten"); - return hex8rgb(brightRgb, alpha); + const emphasized = adjustBrightness( + color.rgb, + factor, + isDark ? "lighten" : "dim", + ); + const adjusted = ensureContrast(emphasized, bgRgb, 3); + return hex8rgb(adjusted, alpha); } function dim8( @@ -110,6 +133,9 @@ function buildUIStyle( ): Record { const s = (colorName: string, alpha?: number) => hex8(palette, colorName, alpha); + // Terminal background is the editor background; floor chromatic ANSI text + // against it so it stays legible (no-op on dark themes). + const bgRgb = resolveColor(palette, "base").rgb; const result: Record = { border: s("surface1"), @@ -184,23 +210,23 @@ function buildUIStyle( "terminal.ansi.dim_black": isDark ? dim8(palette, "text", 0.3) : dim8(palette, "text", 0.3), - "terminal.ansi.red": s("red"), - "terminal.ansi.bright_red": bright8(palette, "red"), + "terminal.ansi.red": floor8(palette, "red", bgRgb), + "terminal.ansi.bright_red": bright8(palette, "red", isDark, bgRgb), "terminal.ansi.dim_red": dim8(palette, "red"), - "terminal.ansi.green": s("green"), - "terminal.ansi.bright_green": bright8(palette, "green"), + "terminal.ansi.green": floor8(palette, "green", bgRgb), + "terminal.ansi.bright_green": bright8(palette, "green", isDark, bgRgb), "terminal.ansi.dim_green": dim8(palette, "green"), - "terminal.ansi.yellow": s("yellow"), - "terminal.ansi.bright_yellow": bright8(palette, "yellow"), + "terminal.ansi.yellow": floor8(palette, "yellow", bgRgb), + "terminal.ansi.bright_yellow": bright8(palette, "yellow", isDark, bgRgb), "terminal.ansi.dim_yellow": dim8(palette, "yellow"), - "terminal.ansi.blue": s("blue"), - "terminal.ansi.bright_blue": bright8(palette, "blue"), + "terminal.ansi.blue": floor8(palette, "blue", bgRgb), + "terminal.ansi.bright_blue": bright8(palette, "blue", isDark, bgRgb), "terminal.ansi.dim_blue": dim8(palette, "blue"), - "terminal.ansi.magenta": s("pink"), - "terminal.ansi.bright_magenta": bright8(palette, "pink"), + "terminal.ansi.magenta": floor8(palette, "pink", bgRgb), + "terminal.ansi.bright_magenta": bright8(palette, "pink", isDark, bgRgb), "terminal.ansi.dim_magenta": dim8(palette, "pink"), - "terminal.ansi.cyan": s("teal"), - "terminal.ansi.bright_cyan": bright8(palette, "teal"), + "terminal.ansi.cyan": floor8(palette, "teal", bgRgb), + "terminal.ansi.bright_cyan": bright8(palette, "teal", isDark, bgRgb), "terminal.ansi.dim_cyan": dim8(palette, "teal"), "terminal.ansi.white": isDark ? s("subtext1") : s("subtext0"), "terminal.ansi.bright_white": s("text"), diff --git a/themes/neovim/lua/synthpunk/theme.lua b/themes/neovim/lua/synthpunk/theme.lua index 6420411..5194d03 100644 --- a/themes/neovim/lua/synthpunk/theme.lua +++ b/themes/neovim/lua/synthpunk/theme.lua @@ -1546,20 +1546,20 @@ local variants = { }, terminal = { "#2E1A24", - "#FF5470", - "#5AC09A", - "#E8C468", - "#8BA4FF", - "#FF7DB0", - "#5ED4E0", + "#FF516E", + "#3EA27C", + "#B68A1C", + "#6989FF", + "#FF4A91", + "#219FAC", "#6B4F5E", "#6B4F5E", - "#FF5470", - "#5AC09A", - "#E8C468", - "#8BA4FF", - "#FF7DB0", - "#5ED4E0", + "#FF516E", + "#3EA27C", + "#B68A1C", + "#6989FF", + "#FF4A91", + "#219FAC", "#2E1A24" } }, @@ -3107,19 +3107,19 @@ local variants = { terminal = { "#2A1A30", "#E04058", - "#40C080", - "#D8B030", + "#36A26C", + "#AD8B21", "#8050C8", "#D04080", - "#30C0C8", + "#289EA5", "#6B5A78", "#6B5A78", "#E04058", - "#40C080", - "#D8B030", + "#36A26C", + "#AD8B21", "#8050C8", "#D04080", - "#30C0C8", + "#289EA5", "#2A1A30" } }, diff --git a/themes/vscode/themes/synthpunk-neon-light-color-theme.json b/themes/vscode/themes/synthpunk-neon-light-color-theme.json index d405d46..e57ee0c 100644 --- a/themes/vscode/themes/synthpunk-neon-light-color-theme.json +++ b/themes/vscode/themes/synthpunk-neon-light-color-theme.json @@ -74,19 +74,19 @@ "terminal.foreground": "#2A1A30", "terminal.ansiBlack": "#2A1A30", "terminal.ansiRed": "#E04058", - "terminal.ansiGreen": "#40C080", - "terminal.ansiYellow": "#D8B030", + "terminal.ansiGreen": "#36A26C", + "terminal.ansiYellow": "#AD8B21", "terminal.ansiBlue": "#8050C8", "terminal.ansiMagenta": "#D04080", - "terminal.ansiCyan": "#30C0C8", + "terminal.ansiCyan": "#289EA5", "terminal.ansiWhite": "#6B5A78", "terminal.ansiBrightBlack": "#A898B0", - "terminal.ansiBrightRed": "#E66679", - "terminal.ansiBrightGreen": "#66CD99", - "terminal.ansiBrightYellow": "#E0C059", - "terminal.ansiBrightBlue": "#9973D3", - "terminal.ansiBrightMagenta": "#D96699", - "terminal.ansiBrightCyan": "#56D0D6", + "terminal.ansiBrightRed": "#C62039", + "terminal.ansiBrightGreen": "#339A66", + "terminal.ansiBrightYellow": "#AE8B20", + "terminal.ansiBrightBlue": "#6436AA", + "terminal.ansiBrightMagenta": "#AF2B65", + "terminal.ansiBrightCyan": "#269AA0", "terminal.ansiBrightWhite": "#897698", "input.background": "#F0E8F5", "input.foreground": "#2A1A30", diff --git a/themes/vscode/themes/synthpunk-pastel-light-color-theme.json b/themes/vscode/themes/synthpunk-pastel-light-color-theme.json index 69cd170..35009ac 100644 --- a/themes/vscode/themes/synthpunk-pastel-light-color-theme.json +++ b/themes/vscode/themes/synthpunk-pastel-light-color-theme.json @@ -73,20 +73,20 @@ "terminal.background": "#FFF8F5", "terminal.foreground": "#2E1A24", "terminal.ansiBlack": "#2E1A24", - "terminal.ansiRed": "#FF5470", - "terminal.ansiGreen": "#5AC09A", - "terminal.ansiYellow": "#E8C468", - "terminal.ansiBlue": "#8BA4FF", - "terminal.ansiMagenta": "#FF7DB0", - "terminal.ansiCyan": "#5ED4E0", + "terminal.ansiRed": "#FF516E", + "terminal.ansiGreen": "#3EA27C", + "terminal.ansiYellow": "#B68A1C", + "terminal.ansiBlue": "#6989FF", + "terminal.ansiMagenta": "#FF4A91", + "terminal.ansiCyan": "#219FAC", "terminal.ansiWhite": "#6B4F5E", "terminal.ansiBrightBlack": "#997A8A", - "terminal.ansiBrightRed": "#FF768D", - "terminal.ansiBrightGreen": "#7BCDAE", - "terminal.ansiBrightYellow": "#EDD086", - "terminal.ansiBrightBlue": "#A2B6FF", - "terminal.ansiBrightMagenta": "#FF97C0", - "terminal.ansiBrightCyan": "#7EDDE6", + "terminal.ansiBrightRed": "#FF1037", + "terminal.ansiBrightGreen": "#3DA27D", + "terminal.ansiBrightYellow": "#B68A1C", + "terminal.ansiBrightBlue": "#3C66FF", + "terminal.ansiBrightMagenta": "#FF3182", + "terminal.ansiBrightCyan": "#219FAC", "terminal.ansiBrightWhite": "#906B7F", "input.background": "#F0DEE8", "input.foreground": "#2E1A24", diff --git a/themes/wezterm/synthpunk-neon-light.toml b/themes/wezterm/synthpunk-neon-light.toml index db738f1..e777cbc 100644 --- a/themes/wezterm/synthpunk-neon-light.toml +++ b/themes/wezterm/synthpunk-neon-light.toml @@ -27,6 +27,6 @@ fg_color = "#6B5A78" bg_color = "#F5F0F5" fg_color = "#6B5A78" -ansi = ["#2A1A30", "#E04058", "#40C080", "#D8B030", "#8050C8", "#D04080", "#30C0C8", "#6B5A78"] +ansi = ["#2A1A30", "#E04058", "#36A26C", "#AD8B21", "#8050C8", "#D04080", "#289EA5", "#6B5A78"] -brights = ["#6B5A78", "#E66679", "#66CD99", "#E0C059", "#9973D3", "#D96699", "#56D0D6", "#2A1A30"] +brights = ["#6B5A78", "#C62039", "#339A66", "#AE8B20", "#6436AA", "#AF2B65", "#269AA0", "#2A1A30"] diff --git a/themes/wezterm/synthpunk-pastel-light.toml b/themes/wezterm/synthpunk-pastel-light.toml index c9e6b10..ed8def3 100644 --- a/themes/wezterm/synthpunk-pastel-light.toml +++ b/themes/wezterm/synthpunk-pastel-light.toml @@ -27,6 +27,6 @@ fg_color = "#6B4F5E" bg_color = "#FFF0EB" fg_color = "#6B4F5E" -ansi = ["#2E1A24", "#FF5470", "#5AC09A", "#E8C468", "#8BA4FF", "#FF7DB0", "#5ED4E0", "#6B4F5E"] +ansi = ["#2E1A24", "#FF516E", "#3EA27C", "#B68A1C", "#6989FF", "#FF4A91", "#219FAC", "#6B4F5E"] -brights = ["#6B4F5E", "#FF768D", "#7BCDAE", "#EDD086", "#A2B6FF", "#FF97C0", "#7EDDE6", "#2E1A24"] +brights = ["#6B4F5E", "#FF1037", "#3DA27D", "#B68A1C", "#3C66FF", "#FF3182", "#219FAC", "#2E1A24"] diff --git a/themes/zed/themes/synthpunk-neon.json b/themes/zed/themes/synthpunk-neon.json index 7bbb39f..879e17f 100644 --- a/themes/zed/themes/synthpunk-neon.json +++ b/themes/zed/themes/synthpunk-neon.json @@ -480,22 +480,22 @@ "terminal.ansi.bright_black": "#6B5A78ff", "terminal.ansi.dim_black": "#1D1222ff", "terminal.ansi.red": "#E04058ff", - "terminal.ansi.bright_red": "#E66679ff", + "terminal.ansi.bright_red": "#C62039ff", "terminal.ansi.dim_red": "#AD1C32ff", - "terminal.ansi.green": "#40C080ff", - "terminal.ansi.bright_green": "#66CD99ff", + "terminal.ansi.green": "#36A26Cff", + "terminal.ansi.bright_green": "#339A66ff", "terminal.ansi.dim_green": "#2C875Aff", - "terminal.ansi.yellow": "#D8B030ff", - "terminal.ansi.bright_yellow": "#E0C059ff", + "terminal.ansi.yellow": "#AD8B21ff", + "terminal.ansi.bright_yellow": "#AE8B20ff", "terminal.ansi.dim_yellow": "#9C7D1Dff", "terminal.ansi.blue": "#8050C8ff", - "terminal.ansi.bright_blue": "#9973D3ff", + "terminal.ansi.bright_blue": "#6436AAff", "terminal.ansi.dim_blue": "#582F95ff", "terminal.ansi.magenta": "#D04080ff", - "terminal.ansi.bright_magenta": "#D96699ff", + "terminal.ansi.bright_magenta": "#AF2B65ff", "terminal.ansi.dim_magenta": "#992659ff", - "terminal.ansi.cyan": "#30C0C8ff", - "terminal.ansi.bright_cyan": "#56D0D6ff", + "terminal.ansi.cyan": "#289EA5ff", + "terminal.ansi.bright_cyan": "#269AA0ff", "terminal.ansi.dim_cyan": "#22868Cff", "terminal.ansi.white": "#A898B0ff", "terminal.ansi.bright_white": "#2A1A30ff", diff --git a/themes/zed/themes/synthpunk-pastel.json b/themes/zed/themes/synthpunk-pastel.json index fc66bf6..36d7d6f 100644 --- a/themes/zed/themes/synthpunk-pastel.json +++ b/themes/zed/themes/synthpunk-pastel.json @@ -479,23 +479,23 @@ "terminal.ansi.black": "#2E1A24ff", "terminal.ansi.bright_black": "#6B4F5Eff", "terminal.ansi.dim_black": "#201219ff", - "terminal.ansi.red": "#FF5470ff", - "terminal.ansi.bright_red": "#FF768Dff", + "terminal.ansi.red": "#FF516Eff", + "terminal.ansi.bright_red": "#FF1037ff", "terminal.ansi.dim_red": "#ED0027ff", - "terminal.ansi.green": "#5AC09Aff", - "terminal.ansi.bright_green": "#7BCDAEff", + "terminal.ansi.green": "#3EA27Cff", + "terminal.ansi.bright_green": "#3DA27Dff", "terminal.ansi.dim_green": "#378F6Eff", - "terminal.ansi.yellow": "#E8C468ff", - "terminal.ansi.bright_yellow": "#EDD086ff", + "terminal.ansi.yellow": "#B68A1Cff", + "terminal.ansi.bright_yellow": "#B68A1Cff", "terminal.ansi.dim_yellow": "#CC9B1Fff", - "terminal.ansi.blue": "#8BA4FFff", - "terminal.ansi.bright_blue": "#A2B6FFff", + "terminal.ansi.blue": "#6989FFff", + "terminal.ansi.bright_blue": "#3C66FFff", "terminal.ansi.dim_blue": "#1547FFff", - "terminal.ansi.magenta": "#FF7DB0ff", - "terminal.ansi.bright_magenta": "#FF97C0ff", + "terminal.ansi.magenta": "#FF4A91ff", + "terminal.ansi.bright_magenta": "#FF3182ff", "terminal.ansi.dim_magenta": "#FF0B6Bff", - "terminal.ansi.cyan": "#5ED4E0ff", - "terminal.ansi.bright_cyan": "#7EDDE6ff", + "terminal.ansi.cyan": "#219FACff", + "terminal.ansi.bright_cyan": "#219FACff", "terminal.ansi.dim_cyan": "#24ADBBff", "terminal.ansi.white": "#997A8Aff", "terminal.ansi.bright_white": "#2E1A24ff",