130 Widgets

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

4. Theme-Aware Color Generation

Theme profiles, HSL-to-hex conversion, and deriving a full palette from a single hue.

Theme Profiles

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).
Color Science

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.

HSL-to-Hex Conversion

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:

  1. Normalize: s and l are converted from percentages (0–100) to fractions (0–1).
  2. Compute chroma factor: 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.
  3. Channel function: The inner function 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.
  4. Hex encoding: Each channel is scaled to 0–255, clamped, rounded, and formatted as a two-digit hex string.
Tip

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.

Generating the Full Palette

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:

  1. Resolve the hue: Use the override if set; otherwise hash the workspace name. The ?? (nullish coalescing) operator makes this clean.
  2. Get the theme profile: Four numbers that encode the visual rules for this theme kind.
  3. Apply user adjustments: The user’s saturation and lightness adjustments are added to the profile’s base values, then clamped to valid ranges.
  4. Derive five colors: All from the same hue, with varying saturation and lightness:
    • bg — the main background color
    • fg — foreground text (lower saturation for readability)
    • inactiveBg — background when the window isn’t focused
    • inactiveFg — dimmed foreground for inactive state
    • accentBorder — brighter accent for active indicators
Color Science

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;
}
Architecture Note

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.

Checkpoint

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.