Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions generator/colorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, test } from "bun:test";
import {
adjustBrightness,
contrastRatio,
ensureContrast,
hexToRgb,
hslToRgb,
relativeLuminance,
rgbToHex,
rgbToHsl,
} from "./colorUtils";
Expand Down Expand Up @@ -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
});
});
75 changes: 75 additions & 0 deletions generator/colorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
49 changes: 49 additions & 0 deletions generator/targets/neovim.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
43 changes: 31 additions & 12 deletions generator/targets/neovim.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ensureContrast, rgbToHex } from "../colorUtils";
import { colorToHex, resolveColor } from "../palette";
import { resolveSyntaxColor } from "../syntaxMapping";
import type {
Expand Down Expand Up @@ -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<string, string> = {
comment: "Comment",
"comment.block": "Comment",
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string, string> = {
Expand Down
47 changes: 47 additions & 0 deletions generator/targets/vscode.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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");
});
});
Loading
Loading