Spot the bug - #140: Color Parser Utility

Why is my custom color parser shifting hue angles unpredictably?

function normalizeColorChannel(input) {
  const trimmed = input.trim();
  const isPercentage = trimmed.endsWith('%');
  const rawNum = parseFloat(trimmed);
  
  if (Number.isNaN(rawNum)) return 0;
  
  if (isPercentage) {
    return Math.min(100, Math.max(0, rawNum)) / 100;
  }
  return Math.min(255, Math.max(0, rawNum)) / 255;
}

function parseHslString(hslStr) {
  const parts = hslStr.replace(/hsla?\(|\)/gi, '').split(',');
  if (parts.length < 3) return null;
  
  const hue = parseFloat(parts[0]) % 360;
  const sat = normalizeColorChannel(parts[1]);
  const light = normalizeColorChannel(parts[2]);
  
  return { h: hue, s: sat, l: light };
}

Reply with what is broken and how you would fix it.

Yo this is a classic one. the normalizeColorChannel function is the problem, but not exactly how you’re thinking. it’s because saturation and lightness are always percentages in HSL, even if you write hsl(200, 50, 75) instead of hsl(200, 50%, 75%). your normalizeColorChannel function only treats it as a percentage if it sees the % symbol. so when it gets 50 for saturation, it thinks it’s a 0-255 value and divides it by 255, which is wrong. it should be dividing by 100. you gotta make normalizeColorChannel smarter about what kind of channel it’s parsing. maybe pass in an isPercentageChannel flag.

function normalizeColorChannel(input, isPercentageChannel = false) {
  const trimmed = input.trim();
  const isPercentage = trimmed.endsWith('%') || isPercentageChannel;
  const rawNum = parseFloat(trimmed);
  if (Number.isNaN(rawNum)) return 0;
  if (isPercentage) {
    return Math.min(100, Math.max(0, rawNum)) / 100;
  }
  return Math.min(255, Math.max(0, rawNum)) / 255;
}

function parseHslString(hslStr) {
  const parts = hslStr.replace(/hsla?\(|\)/gi, '').split(',');
  if (parts.length < 3) return null;

  const hue = parseFloat(parts[0]) % 360;
  // Saturation and lightness are always percentages
  const sat = normalizeColorChannel(parts[1], true);
  const light = normalizeColorChannel(parts[2], true);

  return { h: hue, s: sat, l: light };
}

Spot the Bug answer: The hue value is not normalized to be positive after the modulo operation.

The fix:

Change `const hue = parseFloat(parts[0]) % 360;` to `const hue = (parseFloat(parts[0]) % 360 + 360) % 360;`

Why:
The modulo operator (%) in JavaScript can return a negative result if the dividend is negative. For example, -30 % 360 is -30. This means a hue like -30 degrees would remain negative instead of being correctly mapped to 330 degrees, causing unpredictable shifts when used in color calculations that expect positive hue values.

First-answer leaderboard

  1. @Apexcodes - 6 (firsts) :trophy:
  2. @kirupa - 6 (firsts) :trophy:
  3. @emmawalter5 - 4 (firsts)
  4. @adnanahmed - 2 (firsts)

The main problem is that normalizeColorChannel() is for RGB values, but you’re using it for HSL saturation and lightness. HSL s and l should be treated as percentages.

Also, % returns negative values for negative hues.

Fix it by using a separate percentage parser and proper hue normalization:

const hue = ((parseFloat(parts[0]) % 360) + 360) % 360;

const sat = Math.min(100, Math.max(0, parseFloat(parts[1]))) / 100;

const light = Math.min(100, Math.max(0, parseFloat(parts[2]))) / 100;

So the issue isn’t really the hue calculation alone the incorrect HSL s/l parsing can make the final color appear to have an unpredictable hue shift.

okay, so you’re seeing a few different things in there. it’s definitely tricky when the parsing logic for one color model bleeds into another. good catch on the hue normalization, that’s a classic one. the answer goes up later today!

You’re right, using normalizeColorChannel for HSL saturation and lightness is the main problem here. It’s designed for RGB values, not percentages.

This article on kirupa.com shows how to properly parse HSL values: https://www.kirupa.com/html5/parsing_colors.htm

Look. Using an RGB normalization function on HSL percentages will always break. That’s a fundamental mismatch.