DEV Community

Cover image for Building a cross-IDE theme compiler in TypeScript
Tiago Peter
Tiago Peter

Posted on

Building a cross-IDE theme compiler in TypeScript

Building a cross-IDE theme compiler in TypeScript

Port a VS Code theme to JetBrains and watch it fall apart. comment and string render at the same brightness. The sidebar glows radioactive. A color that was a "soft blue" in one editor becomes a "flat teal" in another. Not because the port was lazy — because editor themes are hand-tuned constants, and constants don't port.

This post walks through an engine that treats a theme as a compiler target problem: one palette, a normalized intermediate representation, a set of pure per-target compilers, and a constraint solver that picks lightness per semantic role. TypeScript throughout. No frameworks.


The problem: themes are code, we just pretend they aren't

TL;DR — every theme file is the output of a hand-run mental compiler. Formalize it.

A VS Code theme is ~400 key–value pairs across two schemas: colors (UI chrome) and tokenColors (TextMate scopes). A JetBrains theme is XML with a completely different shape. Zed is TOML. Neovim is Lua. They all encode the same mental model — keywords look like this, strings look like that — but in six different dialects with six different token vocabularies.

The temptation is to write each theme by hand per editor. The better move is to describe the theme once in a normalized form, then compile.

// The input: 8 OKLCH anchors + a background.
interface CorePalette {
  background: OKLCH
  foreground: OKLCH
  hues: [CoreHue, CoreHue, CoreHue, CoreHue,
         CoreHue, CoreHue, CoreHue, CoreHue]
}

interface OKLCH { l: number; c: number; h: number }
Enter fullscreen mode Exit fullscreen mode

Nothing in here is an editor concept yet. Just perceptual color.


Step 1: a normalized token tree

TL;DR — name the roles, not the tokens. 30 slots, not 400.

The intermediate representation has one job: express the full semantic surface of an editor theme without referring to any editor. I landed on 30 roles, each with four pieces of metadata:

type ContrastTier =
  | 'critical'    // errors, warnings                 APCA Lc 75
  | 'structural'  // keywords, operators, brackets   APCA Lc 65
  | 'semantic'    // types, functions, strings       APCA Lc 60
  | 'contextual'  // params, properties, labels      APCA Lc 50
  | 'ambient'     // comments, guides, punctuation   APCA Lc 38

interface SemanticRole {
  id: string               // 'keyword', 'string', 'comment', ...
  tier: ContrastTier       // drives the contrast target
  temperature: 'cool' | 'warm' | 'neutral'
  defaultHue: number       // 0–360°, identity hue
}
Enter fullscreen mode Exit fullscreen mode

The key move is tiers. Hue tells you what a token is; lightness tells you how loud it is. Two keywords at the same tier should have different hues but similar perceived brightness. A comment should be quieter than a string no matter what color it is. Baking that into the IR means every downstream compiler inherits the hierarchy for free.

Once you have the 30 roles, the normalized theme is just:

interface NormalizedTheme {
  meta: { name: string; appearance: 'light' | 'dark' }
  background: OKLCH
  foreground: OKLCH
  roles: Record<RoleId, OKLCH>   // 30 entries, all solved
}
Enter fullscreen mode Exit fullscreen mode

That's the entire IR. Every compiler consumes this; nothing else.


Step 2: one pure function per target

TL;DR — compile(theme) → string. No I/O, no mutation, no shared state.

Each target editor gets exactly one file exporting one function:

// lib/engine/compiler/vscode.ts
export function compileVSCode(theme: NormalizedTheme): string { ... }

// lib/engine/compiler/zed.ts
export function compileZed(theme: NormalizedTheme): string { ... }

// lib/engine/compiler/neovim.ts
export function compileNeovim(theme: NormalizedTheme): string { ... }
Enter fullscreen mode Exit fullscreen mode

Pure. Deterministic. No config lookups, no filesystem, no globals. The inputs are the theme and the target's own mapping table; the output is a string you can fs.writeFile or zip into a .vsix.

The mapping tables are where the editor-specific knowledge lives:

// vscode-mappings.ts — 'keyword' role → TextMate scopes VS Code wants
export const SCOPE_MAP: Record<RoleId, string[]> = {
  keyword: [
    'keyword.control',
    'storage.type',
    'storage.modifier',
  ],
  string: ['string', 'string.quoted'],
  comment: ['comment', 'punctuation.definition.comment'],
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The JetBrains compiler has a different table (IntelliJ attribute keys). The Neovim compiler has a different table again (Treesitter capture names plus LSP semantic tokens). The compiler itself is trivial — it walks the role map, looks up the target's token names, and emits the target's syntax. All the hard work is upstream.

export function compileVSCode(theme: NormalizedTheme) {
  const tokenColors = ROLE_IDS.flatMap(id => ({
    scope: SCOPE_MAP[id],
    settings: { foreground: oklchToHex(theme.roles[id]) },
  }))
  return JSON.stringify(
    { name: theme.meta.name, colors: uiColors(theme), tokenColors },
    null, 2,
  )
}
Enter fullscreen mode Exit fullscreen mode

Adding a new editor means adding one file with one function and one mapping table. No changes to the IR, no changes to other compilers. That's the whole point of the split.


Step 3: the constraint solver

TL;DR — given a palette and a background, pick the lightness per role so every tier hits its APCA target.

This is where the engine earns its keep. Naive themes set colors first and hope contrast works out. The solver does the opposite: it treats contrast as a hard constraint and solves for lightness.

Three passes, all on OKLCH values.

Pass 1 — Hue selection. Each role gets matched to one of the 8 palette hues based on temperature fit and hue-angle distance. A keyword role (cool, default 260°) picks the nearest cool hue; a string role (warm, default 55°) picks the nearest warm one. This assigns identity — the theme's personality — to every slot.

function pickHueForRole(role: SemanticRole, palette: CorePalette): OKLCH {
  const candidates = palette.hues.filter(h =>
    temperatureMatches(h.role, role.temperature)
  )
  return minBy(candidates, h => hueDistance(h.h, role.defaultHue))
}
Enter fullscreen mode Exit fullscreen mode

Pass 2 — Lightness calibration. Hue is now frozen. The solver adjusts L until APCA contrast against the background meets the role's tier target. If the palette's hue started at L=0.75 but structural tier requires Lc 65 against a dark background, lightness slides up (or down on a light theme) until the target is hit.

function calibrateLightness(color: OKLCH, bg: OKLCH, targetLc: number): OKLCH {
  let { l, c, h } = color
  const direction = bg.l < 0.5 ? +1 : -1   // dark bg → go lighter
  while (Math.abs(apcaContrast({ l, c, h }, bg)) < targetLc) {
    l += direction * 0.01
    if (l <= 0 || l >= 1) break
  }
  return { l, c, h }
}
Enter fullscreen mode Exit fullscreen mode

Why lightness and not chroma? Because chroma shifts change identity — a "soft blue" desaturated to meet contrast becomes a "flat gray-blue," a different color. Lightness changes preserve identity within the tolerance of human perception. Hue is never touched in this pass; chroma only as a last resort when lightness saturates.

Pass 3 — distance enforcement. Within each tier, two roles that landed on the same palette hue will look identical. Human eyes stop distinguishing OKLCH points closer than ~0.04 in perceptual distance, so the solver nudges collisions apart:

function enforceTierDistance(roles: OKLCH[], minDelta = 0.04): OKLCH[] {
  for (let i = 0; i < roles.length; i++) {
    for (let j = i + 1; j < roles.length; j++) {
      if (oklchDistance(roles[i], roles[j]) < minDelta) {
        roles[j] = shiftHue(roles[j], 8)    // small hue nudge
      }
    }
  }
  return roles
}
Enter fullscreen mode Exit fullscreen mode

After pass 3, re-run pass 2 on anything that moved. Two iterations converge in practice.


Step 4: the pipeline

TL;DR — palette in, six files out.

With the IR and the three passes in place, the full pipeline is short:

export function compileTheme(palette: CorePalette, meta: Meta) {
  const roles = solveRoles(palette)          // 3-pass constraint solver
  const theme: NormalizedTheme = { meta, background: palette.background,
                                   foreground: palette.foreground, roles }

  return {
    vscode:    compileVSCode(theme),
    zed:       compileZed(theme),
    jetbrains: compileJetBrains(theme),
    neovim:    compileNeovim(theme),
    sublime:   compileSublime(theme),
    helix:     compileHelix(theme),
  }
}
Enter fullscreen mode Exit fullscreen mode

Every compiler is pure and independent. Every target gets the same contrast guarantees because the solver ran before any of them saw the theme. Add a target by writing one function; change the solver once and every editor benefits.


What this buys you

TL;DR — themes become a solved problem, not a per-editor craft.

  • Fewer bugs. The "comment is brighter than keyword in JetBrains only" class of bug disappears. The solver hit the same targets for every compiler.
  • Portable identity. A "Nord" and a "Gruvbox" keep their character across editors because hue is preserved while lightness adapts to each background.
  • Testable. Every compiler is IR → string. Snapshot the output. Done.
  • Extensible. Add Helix, Kakoune, Emacs — one file each. The solver is reused.
  • Accessibility becomes a property, not a chore. APCA is a constraint in the solver, not a lint pass after the fact.

The hardest part of this project wasn't the compilers — they're a few hundred lines each, mostly mapping tables. It was committing to the IR. Once you decide that a theme is 30 OKLCH values with tier metadata and nothing else, every downstream problem becomes tractable.

If you want to see this running end-to-end — palette in the browser, six exports out, constraint solver live — I built it at themery.dev. Source is available on request. The post is about the architecture; the product is the receipt that the architecture works.

Top comments (0)