Building tools. Learning to build tools. Learning to build learning tools.
Dimming a light theme into seven darkness profiles through HSL transforms, polarity inversion, and iterative contrast enforcement.
Arch Design
A dark mode is a completely separate color palette — Teams already has one. The dimming engine does something different: it takes the light theme and progressively reduces its brightness while preserving the design system’s structure. The result is a continuum between “overcast office” and “midnight OLED” — seven profiles ranging from a gentle 20% reduction to a near-black 96% stress test.
Why dim instead of switching to dark mode? Because the light and dark themes in Teams use different palettes, not just inverted brightness. Dark mode has its own set of colors, contrast ratios, and accent tuning. Dimming preserves the light palette’s character — its warmth, its accent choices — while just turning down the brightness. It’s the difference between closing the blinds and painting the walls black.
All color transformations happen in HSL (Hue, Saturation, Lightness). This is deliberate. In RGB, dimming means reducing all three channels proportionally, which shifts hue unpredictably. In HSL, we can reduce lightness (L) while leaving hue (H) and saturation (S) largely intact. The result preserves the perceived color while only changing brightness.
HSL decouples three perceptual dimensions: Hue (the color wheel position: 0°=red, 120°=green, 240°=blue), Saturation (color intensity from grey to vivid), and Lightness (brightness from black to white). By manipulating L independently, we can “dim” a color without shifting its identity. HSL isn’t perceptually uniform (the same ΔL doesn’t look the same across hues), but it’s close enough for UI theming and the math is straightforward.
The engine needs to convert between RGB (what CSS uses) and HSL (what we manipulate). These are standard conversions:
// From generate-dimmed.js — RGB to HSL
function rgbToHSL(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic (grey)
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s, l };
}
// HSL back to RGB
function hslToRGB(h, s, l) {
h /= 360;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
};
}
The core background transform is simple: multiply lightness by
(1 - DIMMING_FACTOR). A factor of 0.35 retains 65% of the original
brightness. The DEPTH_BOOST parameter adds extra dimming per layer,
so deeper surfaces (Background3, Background4, …) darken more than the
primary surface, preserving the layer hierarchy:
// From generate-dimmed.js — background dimming
function dimBackground(c, layerDepth = 0) {
if (!c || c.transparent) return c;
const hsl = rgbToHSL(c.r, c.g, c.b);
// Core dimming: reduce lightness
const extraDim = layerDepth * CONFIG.DEPTH_BOOST;
hsl.l = hsl.l * (1 - CONFIG.DIMMING_FACTOR - extraDim);
// Reduce saturation slightly (dim surfaces look more muted)
hsl.s = hsl.s * CONFIG.BG_SATURATION_KEEP;
// Clamp to valid range
hsl.l = Math.max(0.05, Math.min(0.95, hsl.l));
const rgb = hslToRGB(hsl.h, hsl.s, hsl.l);
return { ...rgb, a: c.a };
}
For example, with DIMMING_FACTOR=0.35 and DEPTH_BOOST=0.05:
| Layer | Original | L (original) | L (dimmed) | Dimmed |
|---|---|---|---|---|
| Background1 (depth 0) | #ffffff |
1.000 | 0.650 | #a6a6a6 |
| Background2 (depth 1) | #fafafa |
0.980 | 0.588 | #969696 |
| Background3 (depth 2) | #f5f5f5 |
0.961 | 0.529 | #878787 |
The DEPTH_BOOST ensures that even though the original layers are very close in brightness (1.000, 0.980, 0.961), the dimmed versions maintain distinguishable steps. Without the boost, aggressive dimming would collapse all layers to nearly identical greys.
Here’s where it gets interesting. In light mode, foreground text is
dark on light: black text (#242424, L≈0.14) on white
backgrounds (#ffffff, L=1.0). As we dim the background to L=0.65, the
dark foreground is still readable. Dim it to L=0.50? Still okay, though contrast
is shrinking. Dim it to L=0.35?
Now we have a problem. The background (L=0.35) is approaching the foreground (L=0.14). The text is becoming invisible — dark grey on medium-dark grey. At some point, the foreground must flip polarity: stop being dark-on-light and start being light-on-dark.
The FG_FLIP_THRESHOLD (default: 0.45) controls when foregrounds
switch polarity. When the dimmed background’s lightness drops below this
value, all foreground tokens on that surface are inverted from dark to light:
// From generate-dimmed.js — foreground dimming with polarity awareness
function dimForeground(c, dimmedBg) {
if (!c || c.transparent) return c;
const fgHSL = rgbToHSL(c.r, c.g, c.b);
const bgHSL = dimmedBg
? rgbToHSL(dimmedBg.r, dimmedBg.g, dimmedBg.b)
: null;
const bgL = bgHSL ? bgHSL.l : 0.65;
// ── Decide polarity ──
const shouldFlipLight = bgL < CONFIG.FG_FLIP_THRESHOLD;
if (shouldFlipLight) {
// Background is dark enough that foregrounds must go LIGHT.
// Mirror the fg lightness: the darker the original fg was,
// the lighter the flipped version should be.
fgHSL.l = 0.55 + (1 - fgHSL.l) * 0.40;
// Desaturate slightly — saturated light text looks garish
fgHSL.s = fgHSL.s * 0.75;
} else {
// Background is still above threshold — keep foreground dark,
// just dim proportionally to track the background shift.
fgHSL.l = fgHSL.l * (1 - CONFIG.DIMMING_FACTOR * 0.25);
fgHSL.l = Math.max(0.04, fgHSL.l);
}
// ... contrast enforcement follows ...
}
The line fgHSL.l = 0.55 + (1 - fgHSL.l) * 0.40 deserves unpacking.
The original foreground might be #242424 (L≈0.14, near-black text).
After flipping:
// Original fg: L = 0.14 (dark text)
fgHSL.l = 0.55 + (1 - 0.14) * 0.40
= 0.55 + 0.86 * 0.40
= 0.55 + 0.344
= 0.894
// Result: L ≈ 0.89 → light grey text
The formula ensures that the darkest original foregrounds become the lightest flipped versions (maintaining the relative hierarchy), and that all flipped foregrounds land in the 0.55–0.95 range — light enough to read on dark backgrounds but not pure white.
Why 0.45 as the threshold? At L=0.50 (true mid-grey), neither dark nor light text has good contrast. Setting the threshold slightly below 0.50 keeps dark foregrounds a bit longer, avoiding unnecessary flips in the medium dimming range. The “dimmed-medium” profile (factor 0.50) produces backgrounds right around this threshold — it’s the profile where foregrounds are “deciding” whether to flip, resulting in the most nuanced contrast behavior.
After computing the initial foreground lightness (whether flipped or not), the engine iteratively adjusts until WCAG AA contrast (4.5:1) is met against the dimmed background:
// From generate-dimmed.js — iterative contrast enforcement
if (dimmedBg) {
let ratio = contrastRatio(result, dimmedBg);
let attempts = 0;
// Push in the correct direction:
// lighter if flipped (light-on-dark), darker if not (dark-on-light)
const step = shouldFlipLight ? 0.03 : -0.03;
while (ratio < CONFIG.MIN_CONTRAST && attempts < 30) {
fgHSL.l = Math.max(0.02, Math.min(0.98, fgHSL.l + step));
rgb = hslToRGB(fgHSL.h, fgHSL.s, fgHSL.l);
result = { ...rgb, a: c.a };
ratio = contrastRatio(result, dimmedBg);
attempts++;
// Safety reverse: if we hit the rail without meeting contrast,
// try the opposite direction
if (attempts === 20 && ratio < CONFIG.MIN_CONTRAST) {
const reverseStep = -step;
// Reset and try the other way for remaining attempts
for (let j = 0; j < 10 && ratio < CONFIG.MIN_CONTRAST; j++) {
fgHSL.l = Math.max(0.02, Math.min(0.98, fgHSL.l + reverseStep));
rgb = hslToRGB(fgHSL.h, fgHSL.s, fgHSL.l);
result = { ...rgb, a: c.a };
ratio = contrastRatio(result, dimmedBg);
}
break;
}
}
}
The safety reverse at attempt 20 handles an edge case: if pushing lighter doesn’t achieve contrast (because the background is also very light), the algorithm reverses direction and tries going darker. This prevents infinite loops in degenerate cases. In the Void profile (96% dimming), some token pairs exhaust all 30 attempts and settle on the best achievable ratio — which may be below AA, hence “barely usable.”
Foreground tokens don’t float in a vacuum — each one sits on a specific
background layer. --colorNeutralForeground1 might appear on Background1
(the main content area) or on Background2 (a sidebar). The engine needs to know
which background to contrast-check against.
The generator maps foreground token names to their reference surface. Foreground1 through Foreground4 are checked against Background1 (the brightest surface). ForegroundOnBrand is checked against BrandBackground. Card foregrounds are checked against CardBackground. This per-layer mapping ensures that contrast enforcement uses the correct pairing, not a global average.
The engine generates seven themes from the same source data, each with different configuration overrides:
| Profile | Factor | BG Lightness* | Polarity | Character |
|---|---|---|---|---|
| Subtle | 0.20 | ~0.80 | Dark-on-light | Barely noticeable reduction |
| Dimmed Light | 0.35 | ~0.65 | Dark-on-light | Comfortable “overcast” feel |
| Medium | 0.50 | ~0.50 | Transitional | Near the flip threshold |
| Deep | 0.65 | ~0.35 | Light-on-dark | Dark mode feel, light-derived |
| Dark | 0.80 | ~0.20 | Light-on-dark | Firmly dark, muted brand |
| Abyss | 0.90 | ~0.10 | Light-on-dark | Near-black, aggressive |
| Void | 0.96 | ~0.04 | Light-on-dark | Stress test — barely usable |
* Approximate lightness of Background1 after dimming. Actual values vary slightly due to the original color’s hue and saturation.
// From generate-dimmed.js — profile definitions
const PROFILES = [
{
name: "dimmed-subtle",
label: "Subtle Dim",
description: "A gentle 20% reduction.",
overrides: {
DIMMING_FACTOR: 0.20,
BRAND_DIMMING: 0.08,
SHADOW_OPACITY_MULT: 0.8,
BG_SATURATION_KEEP: 0.85,
},
},
{
name: "dimmed-light",
label: "Dimmed Light",
description: "The default ~35% dim.",
overrides: { DIMMING_FACTOR: 0.35 },
},
// ... medium (0.50), deep (0.65), dark (0.80), abyss (0.90) ...
{
name: "dimmed-void",
label: "Void",
description: "96% reduction — stress test.",
overrides: {
DIMMING_FACTOR: 0.96,
BRAND_DIMMING: 0.55,
SHADOW_OPACITY_MULT: 0.1,
BG_SATURATION_KEEP: 0.1,
DEPTH_BOOST: 0.005,
BRAND_SATURATION_KEEP: 0.3,
ALPHA_BG_MULT: 2.0,
},
},
];
Each profile overrides different knobs. The aggressive profiles (Dark, Abyss, Void)
also reduce DEPTH_BOOST — when backgrounds are already near-black,
large depth steps would push layers below the displayable range. They also increase
ALPHA_BG_MULT to make semi-transparent backgrounds more opaque on
dark surfaces, where transparency against a dark backdrop can make elements disappear.
The Void profile (96% dimming) exists specifically to test the algorithm’s limits. At this extreme:
Void isn’t meant to be usable — it’s a diagnostic tool. If the algorithm produces garbage at Void but clean output at Abyss, the system degrades gracefully. If Void looked the same as Abyss, it would mean the algorithm was silently clamping too early, hiding bugs that would surface with future changes.
Brand colors get a separate, less aggressive dimming function. The brand accent is the most recognizable color in the UI — dimming it as much as backgrounds would make the interface feel colourless:
// From generate-dimmed.js — brand color dimming
function dimBrand(c) {
if (!c || c.transparent) return c;
const hsl = rgbToHSL(c.r, c.g, c.b);
hsl.l = hsl.l * (1 - CONFIG.BRAND_DIMMING); // e.g., 0.15 vs 0.35
hsl.s = hsl.s * CONFIG.BRAND_SATURATION_KEEP; // e.g., 0.85 vs 0.70
hsl.l = Math.max(0.1, Math.min(0.9, hsl.l));
const rgb = hslToRGB(hsl.h, hsl.s, hsl.l);
return { ...rgb, a: c.a };
}
At the default profile (BRAND_DIMMING=0.15), the brand loses only
15% brightness compared to backgrounds’ 35%. This makes brand elements
“pop” more on the dimmed surface — a desirable effect.
Saturation is also preserved more aggressively (BRAND_SATURATION_KEEP=0.85)
to maintain vibrancy.
On a bright white surface, shadows need substantial opacity to be visible. On a dimmed surface, the same shadow opacity creates an overly heavy appearance because the contrast between the shadow and the surface has decreased. The engine scales shadow opacity proportionally:
// From generate-dimmed.js — shadow adjustment
function dimShadow(shadowStr) {
if (!shadowStr) return shadowStr;
return shadowStr.replace(
/rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\)/g,
(match, r, g, b, a) => {
const newA = Math.max(0.02,
parseFloat(a) * CONFIG.SHADOW_OPACITY_MULT
);
return `rgba(${r}, ${g}, ${b}, ${newA.toFixed(2)})`;
}
);
}
// Example: --shadow4 at default profile (SHADOW_OPACITY_MULT = 0.6)
// Original: "0 0 2px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.14)"
// Dimmed: "0 0 2px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.08)"
Each profile is built from a base configuration with per-profile overrides. Here are all the knobs the engine exposes:
const BASE_CONFIG = {
// Overall dimming (0 = no change, 1 = black)
DIMMING_FACTOR: 0.35,
// Extra dimming per background layer (creates depth steps)
DEPTH_BOOST: 0.05,
// Minimum contrast ratio (WCAG AA for normal text)
MIN_CONTRAST: 4.5,
// Background lightness below which foregrounds flip polarity
FG_FLIP_THRESHOLD: 0.45,
// Saturation retention for dimmed backgrounds
BG_SATURATION_KEEP: 0.7,
// Brand color adjustments (less aggressive than BG)
BRAND_DIMMING: 0.15,
BRAND_SATURATION_KEEP: 0.85,
// Shadow opacity multiplier
SHADOW_OPACITY_MULT: 0.6,
// Semi-transparent background opacity boost for dark surfaces
ALPHA_BG_MULT: 1.2,
};
These configuration values were discovered empirically, not derived from theory. The process: generate a profile, inject it into Teams, visually inspect, adjust a parameter, regenerate, reinject. The live-reload capability of the injector makes this iteration loop fast — change a constant, save, see the result in under a second.
Each profile produces a self-contained CSS file with all 498 token overrides.
The file targets .fui-FluentProvider with !important
on every declaration:
/* dimmed-light.css — generated by generate-dimmed.js */
/* Profile: Dimmed Light (factor 0.35) */
.fui-FluentProvider {
/* ── Neutral Backgrounds ── */
--colorNeutralBackground1: #a6a6a6 !important;
--colorNeutralBackground1Hover: #9c9c9c !important;
--colorNeutralBackground1Pressed: #8f8f8f !important;
--colorNeutralBackground1Selected: #949494 !important;
--colorNeutralBackground2: #969696 !important;
/* ... */
/* ── Neutral Foregrounds ── */
--colorNeutralForeground1: #1c1c1c !important;
--colorNeutralForeground2: #2e2e2e !important;
/* ... */
/* ── Brand ── */
--colorBrandBackground: #4e5199 !important;
--colorBrandForeground1: #4e5199 !important;
/* ... */
/* ── Shadows ── */
--shadow4: 0 0 2px rgba(0,0,0,0.07),
0 2px 4px rgba(0,0,0,0.08) !important;
/* ... 490+ more overrides ... */
}
A mapping JSON file is also written, documenting every transformation: original value, dimmed value, which transform was applied, the contrast ratio achieved, and whether the foreground flipped polarity.
The dimming engine completes the core pipeline: observe tokens from Teams, analyze their structure, and generate transformed themes that respect the design system’s layer hierarchy, contrast requirements, and brand identity. The next section covers porting existing VS Code and GitHub themes into the Teams token namespace — bringing entirely new color palettes to Teams rather than just dimming the native ones.