130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

4. Porting VS Code Themes — Palette to Tokens

Replacing 400+ manual token assignments with a 25-role semantic palette and a structural mapping engine that auto-extracts themes from VS Code.

Arch Design

The v1 Problem: One Token at a Time

In v1, porting a theme to Teams meant opening a CSS file and manually assigning a color to each of 400+ Fluent UI tokens. --colorNeutralBackground1? Pick a shade. --colorNeutralForeground2Hover? Slightly brighter. --colorBrandBackgroundPressed? Something that looks right. Repeat four hundred times.

The result was tedious, error-prone, and impossible to maintain. Every token was an independent decision. If you changed the base background color, you had to chase through hundreds of related tokens — hover states, pressed states, selected states — to keep everything consistent. Porting a single theme took hours. Porting twenty was unthinkable.

The v2 Approach: Semantic Palette Roles

The insight from the analysis phase (Section 2) was that Fluent UI tokens aren’t random. They follow a structured hierarchy: five background layers, five foreground levels, brand colors with interaction states, status colors, strokes. Instead of mapping each token individually, we can define a palette with ~25 semantic roles and let an engine map them structurally to every token.

A palette defines what the colors mean, not which token gets which hex value. The mapping engine knows the structure of Fluent UI and assigns each token to the correct palette role automatically.

The Palette Definition Format

Every palette is a JavaScript object with these semantic groups:

Group Roles Purpose
bg (5 levels) deep, base, elevated, subtle, wash Surface hierarchy from darkest inset to lightest wash
fg (5 levels) emphasis, primary, muted, subtle, faint Text from bright headings to disabled placeholders
brand (6 shades) primary, fg, hover, light, muted, deep Accent/action color ramp for buttons, links, badges
stroke (3 levels) default, muted, subtle Borders and dividers from prominent to invisible
status danger, success, warning (each: fg, light, bg, bgDeep) Semantic status colors with foreground and background variants
accents purple, teal, orange (each: fg, light, bg) Additional accent colors for palette variety
special overlay, scrollbar, titleBar, titleBarBorder, myMessageBg One-off values for specific UI elements

Here’s what the Atom One Dark palette looks like in practice:

// From port-theme.js — Atom One Dark palette definition
"atom-one-dark": {
  label: "Atom One Dark",
  credit: "One Dark Pro: https://marketplace.visualstudio.com/...",

  // ── Surface hierarchy (darkest → lightest) ──
  bg: {
    deep:     "#1b1f23",  // Deepest inset (panel bg, status bar)
    base:     "#21252b",  // Primary canvas (editor bg, sidebar)
    elevated: "#282c34",  // Elevated surface (cards, hover, content)
    subtle:   "#2c313a",  // Subtle elevation (selected items)
    wash:     "#3e4451",  // Wash / muted border fill
  },

  // ── Foreground ramp (brightest → dimmest) ──
  fg: {
    emphasis: "#d7dae0",  // Bright (headings, hover text)
    primary:  "#abb2bf",  // Default text
    muted:    "#7f848e",  // Secondary / muted text
    subtle:   "#636d83",  // Tertiary text
    faint:    "#5c6370",  // Disabled / faintest
  },

  // ── Brand / accent ──
  brand: {
    primary:  "#528bff",  // Button bg, primary action
    fg:       "#61afef",  // Accent foreground, links
    hover:    "#74a7e0",  // Brand hover
    light:    "#82c4f8",  // Light accent
    muted:    "#2d4a7a",  // Muted brand bg (badge)
    deep:     "#1e3460",  // Deepest brand bg
  },

  // ── Status, accents, special omitted for brevity ──
}

Notice the structure: each group provides a ramp of related colors. The background group goes from deep (darkest) to wash (lightest). The foreground group goes from emphasis (brightest) to faint (dimmest). This mirrors the layer hierarchy that Fluent UI expects.

buildTokenMap(): Structural Mapping

The buildTokenMap() function takes a palette and maps it to every Fluent UI token. Rather than a lookup table, it’s a series of explicit assignments that encode the semantics of each token:

// From port-theme.js — the mapping engine
function buildTokenMap(palette, observedTokens) {
  const P = palette;
  const map = {};
  function set(token, value) { map[token] = value; }

  // ── Backgrounds ──
  // Background1 = primary canvas
  set("--colorNeutralBackground1",         P.bg.base);
  set("--colorNeutralBackground1Hover",    P.bg.elevated);
  set("--colorNeutralBackground1Pressed",  P.bg.deep);
  set("--colorNeutralBackground1Selected", P.bg.elevated);

  // Background2 = deeper insets (sidebar, panel)
  set("--colorNeutralBackground2",         P.bg.deep);
  set("--colorNeutralBackground2Hover",    P.bg.base);
  set("--colorNeutralBackground2Pressed",  darken(P.bg.deep, 0.02));
  set("--colorNeutralBackground2Selected", P.bg.base);

  // Background3-5: progressively elevated surfaces
  set("--colorNeutralBackground3",         P.bg.elevated);
  set("--colorNeutralBackground5",         P.bg.subtle);

  // ── Foregrounds ──
  set("--colorNeutralForeground1",         P.fg.primary);
  set("--colorNeutralForeground1Hover",    P.fg.emphasis);
  set("--colorNeutralForeground2",         P.fg.muted);
  set("--colorNeutralForeground3",         P.fg.subtle);
  set("--colorNeutralForegroundDisabled",  P.fg.faint);

  // ... ~400 more mappings following the same pattern
}

Interaction State Generation

A key insight from the analysis phase: Fluent UI generates hover, pressed, and selected states by shifting brightness relative to a base color. In light mode, hover darkens slightly; pressed darkens more. In dark mode, the pattern is the same but adapted for dark surfaces.

Instead of requiring the palette author to define every interaction state, the mapping engine generates them automatically using lighten() and darken() functions:

// Interaction states are derived from the base color
set("--colorNeutralBackground1",         P.bg.base);
set("--colorNeutralBackground1Hover",    P.bg.elevated);    // one step lighter
set("--colorNeutralBackground1Pressed",  P.bg.deep);        // one step darker
set("--colorNeutralBackground1Selected", P.bg.elevated);    // same as hover

// For brand colors, we lighten/darken from the primary
set("--colorBrandBackground",            P.brand.primary);
set("--colorBrandBackgroundHover",        P.brand.hover);
set("--colorBrandBackgroundPressed",      darken(P.brand.primary, 0.05));
set("--colorBrandBackgroundSelected",     P.brand.hover);

This means the palette author defines ~25 base colors, and the engine produces 400+ token assignments with consistent, systematically-derived interaction states.

Why Not Just Calculate Everything?

You might wonder: why not compute every palette role from a single base color? Because good themes aren’t uniform gradients. Atom One Dark’s background levels aren’t evenly spaced — the jump from #1b1f23 to #21252b is different from #21252b to #282c34. Each step is tuned for the theme’s visual character. The palette system preserves this intentional spacing while still automating the 400-token expansion.

Identifying VS Code Theme Colors

VS Code themes use a colors object in their JSON files that maps named “color keys” to hex values. These keys describe what each color is for: editor.background is the editor’s background, sideBar.background is the sidebar, textLink.foreground is the link color.

The auto-extraction maps these VS Code keys to our palette roles:

VS Code Color Key Palette Role Rationale
editor.background bg.deep Deepest surface in the editor
sideBar.background bg.base Primary canvas (slightly lighter)
editorWidget.background bg.elevated Popups, menus, dropdowns
list.hoverBackground bg.subtle Hover highlight
editorGroup.border bg.wash Borders and separators
editor.foreground fg.primary Default text color
descriptionForeground fg.muted Secondary text
disabledForeground fg.faint Disabled / placeholder text
button.background brand.primary Primary action button
textLink.foreground brand.fg Link / accent foreground
errorForeground danger.fg Error / danger indicator
editorGutter.addedBackground success.fg Success / added indicator
list.warningForeground warning.fg Warning indicator

Not every VS Code theme defines every key. The extraction function cascades through fallbacks: if sideBar.background is missing, try activityBar.background, then derive from editor.background with a slight lightening.

Auto-Palette Extraction: extractPalette()

The extractPalette() function in port-all.js reads a VS Code theme’s colors object and maps it to our palette format automatically:

// From port-all.js — auto-extraction of palette from VS Code colors
function extractPalette(colors, tokenColors, label, isDark) {
  const c = (key) => stripAlpha(colors[key]) || null;

  // ── Dark theme extraction ──
  const bgDeep     = c("editor.background") || "#1e1e1e";
  const bgBase     = c("sideBar.background") || lighten(bgDeep, 0.03);
  const bgElevated = c("editorWidget.background") || lighten(bgBase, 0.03);
  const bgSubtle   = c("list.hoverBackground") || lighten(bgElevated, 0.02);
  const bgWash     = c("editorGroup.border") || lighten(bgSubtle, 0.03);

  // Foreground ramp
  const fgEmphasis = c("list.activeSelectionForeground") || "#e0e0e0";
  const fgPrimary  = c("editor.foreground") || "#cccccc";
  const fgMuted    = c("descriptionForeground") || "#8c8c8c";

  // Brand / accent
  const brandPrimary = c("button.background") || "#007acc";
  const brandFg      = c("textLink.foreground") || lighten(brandPrimary, 0.10);

  // Status — from git decoration and error colors
  const dangerFg  = c("errorForeground") || "#f48771";
  const successFg = c("editorGutter.addedBackground") || "#73c991";
  const warningFg = c("list.warningForeground") || "#e5c07b";

  // ... assemble into palette object
}

The function also handles a subtle edge case: some themes have editor.background lighter than sideBar.background (e.g., Dark Modern). The extractor detects this by comparing brightness values and swaps them so bg.deep is always the darkest surface.

Accent Color Extraction from Syntax Tokens

Beyond the colors object, VS Code themes also define tokenColors — syntax highlighting rules that assign colors to language constructs. The extractor mines these for accent colors:

// Extract accent colors by classifying syntax token foregrounds by hue
let purpleFg = null, tealFg = null, orangeFg = null;
for (const tc of tokenColors) {
  const fg = tc.settings?.foreground;
  if (!fg) continue;
  const parsed = parseHexColor(fg);
  if (!parsed) continue;
  const hsl = rgbToHSL(parsed.r, parsed.g, parsed.b);

  // Classify by hue range
  if (!purpleFg && hsl.h > 250 && hsl.h < 310 && hsl.s > 0.3)
    purpleFg = fg;
  else if (!tealFg && hsl.h > 160 && hsl.h < 210 && hsl.s > 0.3)
    tealFg = fg;
  else if (!orangeFg && hsl.h > 15 && hsl.h < 45 && hsl.s > 0.3)
    orangeFg = fg;
}

This scans through every syntax color, converts to HSL, and picks the first color that falls in the purple (250°–310°), teal (160°–210°), or orange (15°–45°) hue range with reasonable saturation. These become the palette’s accent colors — used for notification dots, presence indicators, and other UI elements that need distinct hues.

JSONC Parsing: The Naive Regex Trap

VS Code theme files use JSONC — JSON with Comments. Both // line comments and /* */ block comments are legal. The naive approach is a regex:

// ❌ The naive approach — fails on edge cases
const clean = raw
  .replace(/\/\/.*/g, '')          // strip line comments
  .replace(/\/\*[\s\S]*?\*\//g, '');  // strip block comments

This works until a theme includes a comment-like pattern inside a string:

{
  "name": "// Heading",
  "foreground": "#ff0000"
}

The regex doesn’t know it’s inside a string. It strips // Heading" and the resulting JSON is broken. This is a well-known limitation: you can’t parse context-free grammars with regular expressions.

stripJSONC(): Character-by-Character Walking

The solution is a simple state machine that walks the input character by character, tracking whether we’re inside a string:

// From port-all.js — string-aware JSONC comment stripping
function stripJSONC(text) {
  let result = "";
  let i = 0;
  let inString = false;
  let escape = false;

  while (i < text.length) {
    const ch = text[i];
    const next = text[i + 1];

    if (inString) {
      result += ch;
      if (escape) { escape = false; }
      else if (ch === "\\") { escape = true; }
      else if (ch === '"') { inString = false; }
      i++;
    } else if (ch === '"') {
      inString = true;
      result += ch;
      i++;
    } else if (ch === "/" && next === "/") {
      // Line comment — skip to end of line
      i += 2;
      while (i < text.length && text[i] !== "\n") i++;
    } else if (ch === "/" && next === "*") {
      // Block comment — skip to */
      i += 2;
      while (i < text.length &&
             !(text[i] === "*" && text[i + 1] === "/")) i++;
      i += 2;  // skip closing */
    } else {
      result += ch;
      i++;
    }
  }
  // Also strip trailing commas before } or ]
  return result.replace(/,\s*([}\]])/g, "$1");
}
Why Not Use a Library?

You could use json5, jsonc-parser, or VS Code’s own JSONC parser. But for this use case — stripping comments so JSON.parse() works — the 30-line state machine is simpler, has zero dependencies, and handles every case we’ve encountered in the wild across hundreds of theme files. The trailing-comma cleanup at the end handles the other common JSONC extension.

Theme Inheritance: Resolving Include Chains

VS Code themes can inherit from other themes via an include field. For example, Dark+ includes the base dark theme, which itself might include a default color set. The loader resolves this recursively:

// From port-all.js — resolve theme inheritance
function loadThemeColors(themePath, depth = 0) {
  if (depth > 5) return {};  // prevent infinite recursion
  if (!fs.existsSync(themePath)) return {};

  const raw = fs.readFileSync(themePath, "utf8");
  const clean = stripJSONC(raw);
  const theme = JSON.parse(clean);

  // Resolve `include` inheritance
  let baseColors = {};
  if (theme.include) {
    const includePath = path.resolve(
      path.dirname(themePath), theme.include
    );
    baseColors = loadThemeColors(includePath, depth + 1);
  }

  // Overlay this theme's colors on top of the base
  return { ...baseColors, ...(theme.colors || {}) };
}

The function uses JavaScript’s spread operator to overlay: the child theme’s colors win over the parent’s. The recursion depth limit of 5 prevents infinite loops from circular includes (which shouldn’t happen in practice, but defensive coding is cheap).

Batch Porting with port-all.js

The port-all.js script ties everything together. It discovers every installed VS Code theme, extracts a palette from each, and runs the porter engine:

// From port-all.js — theme discovery
function discoverThemes() {
  const builtinDir = findVSCodeInstall();  // built-in extensions
  const userDir = path.join(
    process.env.USERPROFILE, ".vscode", "extensions"
  );

  const themes = [];
  function scanDir(dir, source) {
    if (!fs.existsSync(dir)) return;
    for (const ext of fs.readdirSync(dir)) {
      const pkgPath = path.join(dir, ext, "package.json");
      if (!fs.existsSync(pkgPath)) continue;
      const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));

      // Resolve localized labels (e.g. "%dark_plus%" → "Dark+")
      resolveLabels(path.join(dir, ext), pkg);

      for (const t of pkg.contributes?.themes || []) {
        themes.push({
          label: t.label,
          uiTheme: t.uiTheme || "vs-dark",
          path: path.join(dir, ext, t.path),
          source,
        });
      }
    }
  }
  scanDir(builtinDir, "builtin");
  scanDir(userDir, "installed");
  return themes;
}

A subtle detail: VS Code localizes theme labels using package.nls.json. The theme’s label field might be %dark_vs% — a localization key, not the actual name. resolveLabels() reads the NLS file and replaces these keys with their English labels so the output filenames are human-readable.

WCAG Contrast Verification

After mapping all tokens, the engine verifies that critical text/background pairs meet WCAG AA contrast requirements (minimum 4.5:1 for normal text). The porter uses the same contrastRatio() and relativeLuminance() functions from the analysis phase:

// Contrast check — WCAG AA requires 4.5:1 for normal text
function contrastRatio(c1, c2) {
  const l1 = relativeLuminance(c1);
  const l2 = relativeLuminance(c2);
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}

// Check foreground1 against background1
const fg = parseColor(palette.fg.primary);
const bg = parseColor(palette.bg.base);
const ratio = contrastRatio(fg, bg);
if (ratio < 4.5) {
  console.warn(`  ⚠ Low contrast: ${ratio.toFixed(2)}:1`);
}

All 20+ auto-ported themes pass WCAG AA for their primary text/background combination. This isn’t guaranteed by the extraction — it works because VS Code themes are designed for code readability, which demands high contrast.

Results: 20+ Themes, Automatically

Running node port-all.js discovers every dark theme installed in VS Code and produces a ready-to-inject CSS file for each. The output on a typical developer machine:

$ node port-all.js Discovering VS Code themes... Built-in: C:\...\Microsoft VS Code\resources\app\extensions User: C:\Users\...\.vscode\extensions Found 48 themes (31 dark, 12 light, 5 high contrast) Porting dark themes... ✓ Atom One Dark → themes/atom-one-dark.css ✓ Dark (Visual Studio) → themes/dark-visual-studio.css ✓ Dark+ → themes/dark.css ✓ Dark High Contrast → themes/dark-high-contrast.css ✓ Dark Modern → themes/dark-modern.css ✓ Default Dark 2026 → themes/default-dark-2026.css ✓ GitHub Dark → themes/github-dark.css ✓ GitHub Dark Dimmed → themes/github-dark-dimmed.css ✓ Kimbie Dark → themes/kimbie-dark.css ✓ Monokai → themes/monokai.css ✓ One Monokai → themes/one-monokai.css ✓ Red → themes/red.css ✓ Solarized Dark → themes/solarized-dark.css ✓ Tomorrow Night Blue → themes/tomorrow-night-blue.css ... Ported 20 themes. All passing WCAG AA contrast.

Each output file contains ~600 lines of CSS: the full token map plus hardcoded overrides (covered in the next section). Adding a new theme is now a matter of installing it in VS Code and re-running the script — or defining a 30-color palette object manually for themes that don’t come from VS Code.

The Power of Structure

The palette system transforms theme porting from an O(n) problem (n = number of tokens) to an O(1) problem (define ~25 colors). This is possible because the analysis phase (Section 2) revealed the structure of Fluent UI’s token system. Without understanding the layer hierarchy, the foreground ramp pattern, and the interaction state derivation, you’d still be assigning tokens one by one.

Summary

The palette-to-token mapping produces clean, consistent themes — but tokens alone aren’t enough. The next section covers why some Teams elements resist token overrides and how hardcoded CSS selectors fill the gaps.