Brand Colors

How Nightfall preserves your brand identity during theme transformation with strict perceptual budgets.

The Problem

Naive color inversion turns your brand blue into an ugly orange. Even simple lightness flipping can produce muddy, unrecognizable brand colors. Your brand's primary blue at oklch(0.55 0.22 260) inverted naively would become oklch(0.45 0.22 80) — a completely different color.

Nightfall solves this by treating brand colors as a special classification with strict transformation budgets.

Transformation Budgets

Brand colors are constrained by three hard limits:

  • Hue: never changes — Your blue stays blue. Period. The hue angle is locked during transformation.
  • Chroma: max 15% shift — Saturation may increase slightly to maintain vibrancy on dark backgrounds, or decrease slightly to avoid clipping, but never by more than 15%.
  • Lightness: max 20% shift — The color may lighten to maintain contrast on dark backgrounds, but the change is bounded.
  • Delta-E: max 15 — The overall perceptual distance between original and transformed brand color must be below 15. This is a safety net that catches any combination of changes that would make the color unrecognizable.

How It Works

brand-transform.js
function transformBrandColor(original, direction) {
  const oklch = toOKLCH(original);

  // Hue is LOCKED
  const newH = oklch.h;

  // Lightness shifts just enough for contrast, within budget
  let newL = oklch.l;
  if (direction === 'light-to-dark') {
    // Lighten brand color for dark backgrounds
    newL = Math.min(oklch.l + 0.20, oklch.l * 1.15);
  } else {
    // Darken brand color for light backgrounds
    newL = Math.max(oklch.l - 0.20, oklch.l * 0.85);
  }

  // Chroma adjusts slightly for contrast
  let newC = oklch.c;
  newC = clamp(newC, oklch.c * 0.85, oklch.c * 1.15);

  // Verify delta-E budget
  const result = { l: newL, c: newC, h: newH };
  const distance = deltaE(oklch, result);

  if (distance > 15) {
    // Pull back toward original until within budget
    return interpolate(oklch, result, 15 / distance);
  }

  return result;
}

Example Transformations

brand-examples.css
/* Brand blue — light to dark */
  --brand-primary:  oklch(0.55 0.22 260)  /* Original: #2563eb */;
  --brand-primary:  oklch(0.62 0.20 260)  /* Dark:     #3b82f6 */;
/* ΔL: +0.07 | ΔC: -0.02 | ΔH: 0 | ΔE: 4.2 ✓ */

/* Brand purple — light to dark */
  --brand-accent:  oklch(0.50 0.25 300)   /* Original: #7c3aed */;
  --brand-accent:  oklch(0.58 0.23 300)   /* Dark:     #8b5cf6 */;
/* ΔL: +0.08 | ΔC: -0.02 | ΔH: 0 | ΔE: 5.1 ✓ */

/* Brand green — light to dark */
  --brand-success:  oklch(0.55 0.18 145)  /* Original: #16a34a */;
  --brand-success:  oklch(0.62 0.17 145)  /* Dark:     #22c55e */;
/* ΔL: +0.07 | ΔC: -0.01 | ΔH: 0 | ΔE: 3.8 ✓ */

Registering Brand Colors

Nightfall auto-detects brand colors (saturated, non-gray colors used on interactive elements). You can also register them explicitly for tighter control:

nightfall.config.js
module.exports = {
  brand: {
    // Colors to preserve (hex, rgb, oklch, or CSS variable names)
    preserve: ['#2563eb', '#7c3aed', '#16a34a'],

    // Optional: tighten or loosen budgets
    maxChromaShift: 0.15,    // default: 0.15 (15%)
    maxLightnessShift: 0.20, // default: 0.20 (20%)
    maxDeltaE: 15,           // default: 15
  }
};

Delta-E Scale

For context, here is what delta-E values mean perceptually:

  • 0-1 — Not perceptible to the human eye
  • 1-2 — Perceptible only through close observation
  • 2-5 — Perceptible at a glance
  • 5-10 — Clearly different but still recognizably the same color
  • 10-15 — Different shade of the same hue family
  • 15+ — Starts to feel like a different color entirely

Nightfall's default budget of 15 keeps brand colors firmly within the "same color family" range.