Terminal Themes, and the Rabbit Hole That Produced Them

speckx1 pts0 comments

545 Terminal Themes, and the Rabbit Hole That Produced Them - William Zujkowski #f5ede4, dark = oklch(0.14 0.015 140) -> #060b05.<br>Recompute (OKLCH -> linear sRGB -> hex) whenever --color-bg changes. -->Skip to main content<br>Terminal color schemes are a genre unto themselves: hobbyist projects named Dracula, Nord, Catppuccin, Gruvbox, each one twenty hex codes and a small devoted following. Somewhere past the two-hundredth one I’d installed, I stopped picking favorites and started wondering what they had in common as data. oklch-terminal-themes is what came out of that question. Every terminal theme I could find, scraped from a dozen open-source repos, run through a color space that behaves the way eyes actually do, and republished as a JSON dataset, an npm package, and — the part that makes the rest of it worth doing — the swatch picker in this site’s own header.

Click that swatch icon next to the light/dark toggle and the whole site repaints: body text, code blocks, links, borders, twelve options deep. None of it is hand-tuned CSS. Every color on that menu is derived, at build time, from the same dataset this post is about, and it’s the same color story I described building into Remarque, this site’s design system, back in April.

545 ways to paint a terminal

Where 545 themes come from, and why the count keeps moving

Terminal theme authors have never agreed on a file format, and nobody seems bothered by it. iTerm2 wants XML. Alacritty wants TOML. Windows Terminal wants JSON. Ghostty wants a config file with no extension at all, as if extensions were for people with something to prove. sources.json lists twelve upstream sources, each pinned to a commit SHA and MIT- or Apache-2.0-licensed — mbadolato/iTerm2-Color-Schemes supplies the bulk of it, with the rest from Neovim plugin repos, a couple of Ghostty-native packs, and Warp’s special editions. fetch-upstream.ts sparse-clones each source and records the SHA it landed on; a weekly GitHub Actions cron reruns the whole pipeline every Monday at 06:00 UTC and opens a PR only when something upstream actually changed. Nobody has to remember to go check; the robot checks.

That cron is why the count is 545 as of the last successful sync, up from 485 back in April when the pipeline only pulled from two sources. It’s also why nothing on the project’s public face agrees on the number: the README and the source package.json both say 485, the published npm listing and the GitHub repo’s own “About” blurb both still say “450+”, and none of the four is 545. Nobody is lying. Each figure is a snapshot frozen at whatever sync was current the last time someone happened to be editing that particular file, and nothing recomputes them automatically. I found this out by running gh api against the live dataset while writing this sentence, which is either due diligence or a mild indictment of how long those numbers sat there uncorrected. Possibly both.

Hex in, OKLCH out, checked against itself

The conversion is satisfying the way sorting a junk drawer is satisfying: tedious, mechanical, weirdly calming once you’re in it. Each upstream scheme is twenty color slots — background, foreground, cursor, selection, sixteen ANSI colors — each a hex string. convert.ts runs every one through culori’s OKLCH converter, clamping lightness to [0,1] and chroma to [0,0.5]. An undefined hue (culori’s answer for genuinely achromatic grays — there’s no such thing as “the hue of gray”) gets coerced to 0, so the JSON stays finite instead of growing NaNs:

export function convertHexToColor(hex: string): ColorValue {<br>const normalizedHex = hex.toLowerCase();<br>const ok = toOklch(parse(normalizedHex));<br>const oklch = {<br>l: round(clamp(ok.l, 0, 1), 4),<br>c: round(clamp(ok.c, 0, 0.5), 4),<br>h: ok.h !== undefined && Number.isFinite(ok.h) ? round(ok.h, 1) : 0,<br>};<br>return { hex: normalizedHex, oklch, oklchCss: `oklch(${oklch.l} ${oklch.c} ${oklch.h})` };<br>I don’t trust that conversion just because it compiled. validate.ts converts every OKLCH value straight back to sRGB and measures the round-trip difference with CIEDE2000; anything over ΔE 1.0 — often treated as around the just-noticeable difference under controlled conditions — fails the build. Belt-and-suspenders for a function six lines long, which sounds excessive until a culori upgrade silently changes a rounding behavior and this is the thing that notices before a reader does.

The actual reason OKLCH is fun, not just correct

Here’s the fact that got me into this in the first place, and it’s a genuinely fun one, not a homework assignment: in OKLCH, a given lightness value is designed to look close to equally bright regardless of the hue it’s attached to. HSL doesn’t have that property, and it’s not a subtle miss:

Color spaceTwo colors, same “lightness”Relative luminanceContrast between themHSLhsl(60 100% 50%) yellow vs hsl(240 100% 50%) blue0.928 vs 0.0728.0 : 1 OKLCHoklch(0.70 0.15 90) vs oklch(0.70 0.15 260)0.342 vs 0.3391.01 : 1

Two HSL colors that both...

oklch terminal color from json themes

Related Articles