Building tools. Learning to build tools. Learning to build learning tools.
Theme profiles, HSL-to-hex conversion, and deriving a full palette from a single hue.
VS Code reports four possible theme kinds: Dark, Light, High Contrast (dark), and High Contrast Light. Color Identity defines a profile for each — a set of saturation and lightness parameters that produce colors appropriate for that theme category:
export function getThemeProfile(kind: vscode.ColorThemeKind): ThemeProfile {
switch (kind) {
case vscode.ColorThemeKind.Light:
return {
baseSaturation: 30,
baseLightness: 88,
fgLightness: 20,
inactiveLightnessShift: 4,
};
case vscode.ColorThemeKind.HighContrastLight:
return {
baseSaturation: 50,
baseLightness: 85,
fgLightness: 10,
inactiveLightnessShift: 5,
};
case vscode.ColorThemeKind.HighContrast:
return {
baseSaturation: 55,
baseLightness: 18,
fgLightness: 95,
inactiveLightnessShift: -5,
};
case vscode.ColorThemeKind.Dark:
default:
return {
baseSaturation: 35,
baseLightness: 22,
fgLightness: 90,
inactiveLightnessShift: -4,
};
}
}
Let’s compare the Dark and Light profiles to understand the reasoning:
| Parameter | Dark | Light | Why |
|---|---|---|---|
baseSaturation |
35 | 30 | Dark themes can handle slightly more vibrancy; light themes need subtlety to avoid looking garish. |
baseLightness |
22 | 88 | Dark backgrounds need low lightness; light backgrounds need high lightness. These values were tuned to sit comfortably alongside each theme’s native colors. |
fgLightness |
90 | 20 | Foreground (text) must contrast with the background. Dark bg → light fg. Light bg → dark fg. |
inactiveLightnessShift |
−4 | +4 | Inactive windows dim slightly. In dark themes, “dimmer” means darker (negative shift). In light themes, “dimmer” means lighter (positive shift toward white). |
The High Contrast profiles push saturation higher (50–55 vs. 30–35) and increase the lightness shift for inactive windows. This is intentional: users who choose high contrast themes need more visual differentiation, not less. The extension adapts to serve accessibility preferences rather than fighting them.
VS Code’s workbench.colorCustomizations expects #rrggbb hex strings.
We need to convert from HSL. Here’s the implementation:
/** Convert HSL to a #rrggbb hex string. s and l are percentages 0–100. */
export function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * Math.max(0, Math.min(1, color)))
.toString(16)
.padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
This is a compact implementation of the standard HSL-to-RGB formula. Let’s unpack it:
s and l are converted from percentages (0–100)
to fractions (0–1).
a = s * Math.min(l, 1 - l). This represents
the maximum displacement from gray for the given saturation and lightness. At the extremes
(L=0 or L=1), chroma is zero regardless of saturation — pure black and pure white have no color.
f(n) computes one RGB channel.
It takes a phase offset (n) that shifts where on the hue cycle each channel starts.
Red is at phase 0, Green at phase 8, Blue at phase 4.
This formula avoids the traditional six-sector if/else chain found in most
HSL-to-RGB implementations. It’s mathematically equivalent but more elegant —
the modular arithmetic handles sector selection implicitly. You don’t need to understand
the math deeply to use it; treat it as a well-tested utility function.
With hashing, theme profiles, and HSL-to-hex in hand, the generateColors function
assembles the complete palette:
export function generateColors(
workspaceName: string,
themeKind: vscode.ColorThemeKind,
config: ColorIdentityConfig
): WorkspaceColors {
const hue = config.hueOverride ?? hashToHue(workspaceName);
const profile = getThemeProfile(themeKind);
const sat = clamp(profile.baseSaturation + config.saturationAdjustment, 0, 100);
const lit = clamp(profile.baseLightness + config.lightnessAdjustment, 0, 100);
const fgLit = profile.fgLightness;
const inactiveLit = clamp(lit + profile.inactiveLightnessShift, 0, 100);
const bg = hslToHex(hue, sat, lit);
const fg = hslToHex(hue, Math.max(sat - 15, 0), fgLit);
const inactiveBg = hslToHex(hue, Math.max(sat - 10, 0), inactiveLit);
const inactiveFg = hslToHex(hue, Math.max(sat - 20, 0),
clamp(fgLit - 20, 0, 100));
const accentBorder = hslToHex(hue, clamp(sat + 15, 0, 100),
clamp(lit + 10, 0, 100));
Step by step:
?? (nullish coalescing) operator makes this clean.
Notice how every derived color reduces saturation from the base: foreground is sat - 15,
inactive background is sat - 10, inactive foreground is sat - 20.
This is deliberate — text and inactive states should be less vivid than the background.
Only the accent border increases saturation, because its job is to draw attention.
The function continues with status bar and tab bar variations:
// Status bar gets a slightly different lightness for visual separation
const statusLit = clamp(lit - 3, 0, 100);
const statusBg = hslToHex(hue, sat, statusLit);
// Tab bar uses a very subtle tint
const tabLit = themeKind === vscode.ColorThemeKind.Dark ||
themeKind === vscode.ColorThemeKind.HighContrast
? clamp(lit + 3, 0, 100)
: clamp(lit - 2, 0, 100);
const tabBg = hslToHex(hue, Math.max(sat - 10, 0), tabLit);
The status bar is 3 lightness points darker than the title bar, giving it visual separation without a harsh boundary. The tab bar goes the opposite direction from the status bar — slightly lighter in dark themes, slightly darker in light themes — creating a gentle gradient effect across the editor chrome.
Finally, the function assembles the WorkspaceColors object, respecting which
elements the user wants colorized:
const colors: WorkspaceColors = {};
if (config.affectTitleBar) {
colors.titleBarActiveBackground = bg;
colors.titleBarActiveForeground = fg;
colors.titleBarInactiveBackground = inactiveBg;
colors.titleBarInactiveForeground = inactiveFg;
}
if (config.affectActivityBar) {
colors.activityBarBackground = bg;
colors.activityBarForeground = fg;
colors.activityBarActiveBorder = accentBorder;
}
if (config.affectStatusBar) {
colors.statusBarBackground = statusBg;
colors.statusBarForeground = fg;
}
if (config.affectTabBar) {
colors.tabsBackground = tabBg;
}
return colors;
}
The generateColors function is pure — it takes inputs and returns
a value, with no side effects. It doesn’t read settings (that’s readConfig’s
job), doesn’t write to VS Code (that’s applyColors’s job), and
doesn’t touch the UI. This makes it easy to test and easy to reason about.
The color pipeline is now complete: workspace name → djb2 hash → hue →
theme profile + user adjustments → five HSL colors → hex strings →
WorkspaceColors object. Next, we’ll see how those hex strings get
written into VS Code’s workspace settings.