Building tools. Learning to build tools. Learning to build learning tools.
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
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 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.
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.
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
}
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.
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.
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.
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.
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.
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.
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");
}
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.
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).
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.
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.
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:
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 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.
buildTokenMap() maps palette roles to 400+ tokens, generating interaction states automaticallyextractPalette() auto-extracts palettes from VS Code theme JSON filesstripJSONC() handles JSONC comments with a string-aware state machine (not regex)port-all.js batch-ports all installed VS Code themes, resolving localized labelsThe 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.