130 Widgets

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

3. The Dimming Engine — Polarity-Aware Color Math

Dimming a light theme into seven darkness profiles through HSL transforms, polarity inversion, and iterative contrast enforcement.

Arch Design

The Concept: Dimming, Not Dark Mode

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.

HSL Color Space: The Right Model

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.

Color Science Concept

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.

RGB ↔ HSL Conversion

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),
  };
}

dimBackground(): Reducing Lightness with Depth

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.

The Polarity Problem

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.

Background Lightness vs. Foreground Polarity L=1.0 ┤ ██ BG: White FG: Dark (#242424) L=0.8 ┤ ██ BG: Light grey FG: Dark — still fine L=0.65 ┤ ██ BG: Medium grey FG: Dark — contrast shrinking L=0.50 ┤ ██ BG: Mid-grey FG: Dark — getting risky │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ FG_FLIP_THRESHOLD (0.45) ─ ─ ─ L=0.40 ┤ ██ BG: Dark-ish FG: FLIPPED → Light (#c5c5c5) L=0.25 ┤ ██ BG: Dark FG: Light — good contrast L=0.10 ┤ ██ BG: Near-black FG: Light — high contrast L=0.02 ┤ ██ BG: VOID FG: Light — crushing limits

FG_FLIP_THRESHOLD: The Crossover Point

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 Flip Formula

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.

Architecture Decision

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.

Contrast Enforcement Loop

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;
    }
  }
}
Warning

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.”

Per-Layer Reference Backgrounds

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.

Seven Dimming Profiles

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: Where Does It Break?

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 Color Dimming

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.

Shadow Opacity Reduction

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)"

The Complete Configuration

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,
};
Tip

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.

End-to-End Dimming Flow

┌──────────────────────────────────────────────────────┐ │ generate-dimmed.js │ │ │ │ Input: observed-tokens.json (498 light-mode tokens) │ │ │ │ For each of 7 profiles: │ │ ┌──────────────────────────────────────────────┐ │ │ │ For each token: │ │ │ │ │ │ │ │ Is it a background? │ │ │ │ → dimBackground(color, layerDepth) │ │ │ │ → Reduce L by factor, boost by layer depth │ │ │ │ │ │ │ │ Is it a foreground? │ │ │ │ → dimForeground(color, refBackground) │ │ │ │ → Check bgL vs FG_FLIP_THRESHOLD │ │ │ │ → If below: flip formula │ │ │ │ → If above: proportional dim │ │ │ │ → Iterate until contrast ≥ 4.5:1 │ │ │ │ │ │ │ │ Is it a brand color? │ │ │ │ → dimBrand(color) — less aggressive │ │ │ │ │ │ │ │ Is it a shadow? │ │ │ │ → dimShadow(value) — reduce opacity │ │ │ │ │ │ │ │ Is it a status/palette color? │ │ │ │ → dimBackground or dimForeground as needed │ │ │ │ → Preserve hue, reduce lightness │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ Output: themes/dimmed-{profile}.css │ │ output/dimmed-{profile}-mapping.json │ └──────────────────────────────────────────────────────┘

The Generated CSS

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.

What We Built

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.