Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/css-color-parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changes to CSS Color Parser

### Unreleased (patch)

- Fix handling of out of range percentages from math functions in `color-mix()` (they are clamped, not invalid)
- Fix powerless hue detection for `hwb()` colors with negative whiteness or blackness (these values are not clamped)

### 4.2.6

_October 2, 2026_
Expand Down
2 changes: 1 addition & 1 deletion packages/css-color-parser/dist/index.mjs

Large diffs are not rendered by default.

25 changes: 14 additions & 11 deletions packages/css-color-parser/src/color-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ColorNotation } from './color-notation';
import type { TokenNumber} from '@csstools/css-tokenizer';
import { NumberType, TokenType } from '@csstools/css-tokenizer';
import { HSL_to_XYZ_D50, HSL_to_XYZ_D65, HWB_to_XYZ_D50, HWB_to_XYZ_D65, LCH_to_XYZ_D50, LCH_to_XYZ_D65, Lab_to_XYZ_D50, Lab_to_XYZ_D65, OKLCH_to_XYZ_D50, OKLCH_to_XYZ_D65, OKLab_to_XYZ_D50, OKLab_to_XYZ_D65, P3_to_XYZ_D50, P3_to_XYZ_D65, ProPhoto_RGB_to_XYZ_D50, ProPhoto_RGB_to_XYZ_D65, XYZ_D50_to_LCH, XYZ_D50_to_Lab, XYZ_D50_to_ProPhoto, XYZ_D50_to_XYZ_D50, XYZ_D50_to_XYZ_D65, XYZ_D65_to_HSL, XYZ_D65_to_HWB, XYZ_D65_to_OKLCH, XYZ_D65_to_OKLab, XYZ_D65_to_P3, XYZ_D65_to_XYZ_D50, XYZ_D65_to_XYZ_D65, XYZ_D65_to_a98_RGB, XYZ_D65_to_lin_P3, XYZ_D65_to_lin_sRGB, XYZ_D65_to_rec_2020, XYZ_D65_to_sRGB, a98_RGB_to_XYZ_D50, a98_RGB_to_XYZ_D65, lin_P3_to_XYZ_D50, lin_P3_to_XYZ_D65, lin_sRGB_to_XYZ_D50, lin_sRGB_to_XYZ_D65, rec_2020_to_XYZ_D50, rec_2020_to_XYZ_D65, sRGB_to_XYZ_D50, sRGB_to_XYZ_D65 } from '@csstools/color-helpers';
import { normalize } from './functions/normalize';

/**
* A color data object.
Expand Down Expand Up @@ -717,11 +716,14 @@ function convertPowerlessComponentsToMissingComponents(a: Color, colorNotation:
}

break;
case ColorNotation.HWB:
if ((Math.max(0, (Number.isNaN(out[1]) ? 0 : out[1])) + Math.max(0, (Number.isNaN(out[2]) ? 0 : out[2]))) >= 99.999) {
case ColorNotation.HWB: {
const white = Number.isNaN(out[1]) ? 0 : out[1];
const black = Number.isNaN(out[2]) ? 0 : out[2];

if ((white + black) >= 99.999) {
out[0] = Number.NaN;

if ((Math.max(0, (Number.isNaN(out[1]) ? 0 : out[1])) + Math.max(0, (Number.isNaN(out[2]) ? 0 : out[2]))) < 100) {
if ((white + black) < 100) {
if (Number.isNaN(out[1])) {
out[2] = 100;
} else if (Number.isNaN(out[2])) {
Expand All @@ -733,6 +735,7 @@ function convertPowerlessComponentsToMissingComponents(a: Color, colorNotation:
}

break;
}
case ColorNotation.LCH:
if ((Number.isNaN(out[1]) ? 0 : out[1]) <= 0.0015) {
out[2] = Number.NaN;
Expand Down Expand Up @@ -773,11 +776,16 @@ export function convertPowerlessComponentsToZeroValuesForDisplay(a: Color, color
}

break;
case ColorNotation.HWB:
if ((Math.max(0, (Number.isNaN(out[1]) ? 0 : out[1])) + Math.max(0, (Number.isNaN(out[2]) ? 0 : out[2]))) >= 99.999) {
case ColorNotation.HWB: {
const white = Number.isNaN(out[1]) ? 0 : out[1];
const black = Number.isNaN(out[2]) ? 0 : out[2];

if ((white + black) >= 99.999) {
out[0] = Number.NaN;
}

break;
}
case ColorNotation.Lab:
if (reducePrecision(out[0]) <= 0 || reducePrecision(out[0]) >= 100) {
out[1] = Number.NaN;
Expand Down Expand Up @@ -942,11 +950,6 @@ export function noneToZeroInRelativeColorDataChannels(x: Map<string, TokenNumber

function normalizeAfterColorConversion(a: Color, hueIndex: number = -1): Color {
a = convertNaNToZero(a);
a = [
hueIndex === 0 ? a[0] : normalize(a[0], 1, -2_147_483_647, 2_147_483_647),
hueIndex === 1 ? a[1] : normalize(a[1], 1, -2_147_483_647, 2_147_483_647),
hueIndex === 2 ? a[2] : normalize(a[2], 1, -2_147_483_647, 2_147_483_647),
];

if (!Number.isNaN(a[hueIndex]) && !Number.isFinite(a[hueIndex])) {
a[hueIndex] = 0;
Expand Down
27 changes: 20 additions & 7 deletions packages/css-color-parser/src/functions/color-mix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,15 @@ function colorMixComponents(componentValues: Array<ComponentValue>, colorParser:
}
}

if (
!percentage
) {
if (percentage === false) {
let fromMathFunction = false;

if (
isFunctionNode(node) &&
mathFunctionNames.has(toLowerCaseAZ(node.getName()))
) {
fromMathFunction = true;

[[node]] = calcFromComponentValues([[node]], {
censorIntoStandardRepresentableValues: true,
precision: -1,
Expand All @@ -192,11 +194,22 @@ function colorMixComponents(componentValues: Array<ComponentValue>, colorParser:

if (
isTokenNode(node) &&
isTokenPercentage(node.value) &&
node.value[4].value >= 0
isTokenPercentage(node.value)
) {
percentage = node.value[4].value;
continue;
if (fromMathFunction) {
// Math functions with an out of range result do not invalidate the declaration.
// The result is clamped to the allowed range instead.
// https://drafts.csswg.org/css-values-4/#calc-range
percentage = Math.min(100, Math.max(0, node.value[4].value));
continue;
}

// A literal percentage outside of the [0,100] range is invalid.
// https://drafts.csswg.org/css-color-5/#color-mix-percent-norm
if (node.value[4].value >= 0 && node.value[4].value <= 100) {
percentage = node.value[4].value;
continue;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NumberType, TokenType, isTokenIdent, isTokenNumber, isTokenPercentage }
import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_Color_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenIdent(token) && toLowerCaseAZ(token[4].value) === 'none') {
Expand All @@ -30,7 +31,7 @@ export function normalize_Color_ChannelValues(token: CSSToken, index: number, co
if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
value = normalize(token[4].value, 100, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 100, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand All @@ -54,7 +55,7 @@ export function normalize_Color_ChannelValues(token: CSSToken, index: number, co
if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { normalizeHue } from './hue-normalize-channel-value';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_legacy_HSL_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (index === 0) {
Expand Down Expand Up @@ -113,9 +114,9 @@ export function normalize_modern_HSL_ChannelValues(token: CSSToken, index: numbe
if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else if (index === 1) {
value = normalize(token[4].value, 1, 0, 2_147_483_647);
value = normalize(token[4].value, 1, 0, VALUE_FOR_POSITIVE_INFINITY);
} else {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand All @@ -139,9 +140,9 @@ export function normalize_modern_HSL_ChannelValues(token: CSSToken, index: numbe
if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else if (index === 1) {
value = normalize(token[4].value, 1, 0, 2_147_483_647);
value = normalize(token[4].value, 1, 0, VALUE_FOR_POSITIVE_INFINITY);
} else {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { normalizeHue } from './hue-normalize-channel-value';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_HWB_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenIdent(token) && toLowerCaseAZ(token[4].value) === 'none') {
Expand Down Expand Up @@ -46,7 +47,7 @@ export function normalize_HWB_ChannelValues(token: CSSToken, index: number, colo
if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand All @@ -70,7 +71,7 @@ export function normalize_HWB_ChannelValues(token: CSSToken, index: number, colo
if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NumberType, TokenType, isTokenIdent, isTokenNumber, isTokenPercentage }
import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_Lab_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenIdent(token) && toLowerCaseAZ(token[4].value) === 'none') {
Expand All @@ -28,7 +29,7 @@ export function normalize_Lab_ChannelValues(token: CSSToken, index: number, colo

let value;
if (index === 1 || index === 2) {
value = normalize(token[4].value, 0.8, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 0.8, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
Expand All @@ -54,7 +55,7 @@ export function normalize_Lab_ChannelValues(token: CSSToken, index: number, colo

let value;
if (index === 1 || index === 2) {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { normalizeHue } from './hue-normalize-channel-value';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_LCH_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenIdent(token) && toLowerCaseAZ(token[4].value) === 'none') {
Expand Down Expand Up @@ -42,7 +43,7 @@ export function normalize_LCH_ChannelValues(token: CSSToken, index: number, colo

let value;
if (index === 1) {
value = normalize(token[4].value, (100 / 150), 0, 2_147_483_647);
value = normalize(token[4].value, (100 / 150), 0, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
Expand All @@ -68,7 +69,7 @@ export function normalize_LCH_ChannelValues(token: CSSToken, index: number, colo

let value;
if (index === 1) {
value = normalize(token[4].value, 1, 0, 2_147_483_647);
value = normalize(token[4].value, 1, 0, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NumberType, TokenType, isTokenIdent, isTokenNumber, isTokenPercentage }
import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_OKLab_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenIdent(token) && toLowerCaseAZ(token[4].value) === 'none') {
Expand All @@ -28,7 +29,7 @@ export function normalize_OKLab_ChannelValues(token: CSSToken, index: number, co

let value;
if (index === 1 || index === 2) {
value = normalize(token[4].value, 250, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 250, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
Expand All @@ -54,7 +55,7 @@ export function normalize_OKLab_ChannelValues(token: CSSToken, index: number, co

let value;
if (index === 1 || index === 2) {
value = normalize(token[4].value, 1, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 1, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { normalizeHue } from './hue-normalize-channel-value';
import { toLowerCaseAZ } from '../util/to-lower-case-a-z';
import { VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_OKLCH_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenIdent(token) && toLowerCaseAZ(token[4].value) === 'none') {
Expand Down Expand Up @@ -42,7 +43,7 @@ export function normalize_OKLCH_ChannelValues(token: CSSToken, index: number, co

let value;
if (index === 1) {
value = normalize(token[4].value, 250, 0, 2_147_483_647);
value = normalize(token[4].value, 250, 0, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
Expand All @@ -68,7 +69,7 @@ export function normalize_OKLCH_ChannelValues(token: CSSToken, index: number, co

let value;
if (index === 1) {
value = normalize(token[4].value, 1, 0, 2_147_483_647);
value = normalize(token[4].value, 1, 0, VALUE_FOR_POSITIVE_INFINITY);
} else if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CSSToken} from '@csstools/css-tokenizer';
import { NumberType, TokenType, isTokenIdent, isTokenNumber, isTokenPercentage } from '@csstools/css-tokenizer';
import { SyntaxFlag } from '../color-data';
import { normalize } from './normalize';
import { VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY } from '../util/infinity-constants';

export function normalize_legacy_sRGB_ChannelValues(token: CSSToken, index: number, colorData: ColorData): CSSToken | false {
if (isTokenPercentage(token)) {
Expand Down Expand Up @@ -78,7 +79,7 @@ export function normalize_modern_sRGB_ChannelValues(token: CSSToken, index: numb
if (index === 3) {
value = normalize(token[4].value, 100, 0, 1);
} else {
value = normalize(token[4].value, 100, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 100, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand All @@ -102,7 +103,7 @@ export function normalize_modern_sRGB_ChannelValues(token: CSSToken, index: numb
if (index === 3) {
value = normalize(token[4].value, 1, 0, 1);
} else {
value = normalize(token[4].value, 255, -2_147_483_647, 2_147_483_647);
value = normalize(token[4].value, 255, VALUE_FOR_NEGATIVE_INFINITY, VALUE_FOR_POSITIVE_INFINITY);
}

return [
Expand Down
1 change: 0 additions & 1 deletion packages/css-color-parser/src/serialize/hsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export function serializeHSL(color: ColorData, gamutMapping = true): FunctionNod
hsl = XYZ_D65_to_HSL(colorData_to_XYZ_D65(color).channels);
}

// Needs to be done twice because `xyz.XYZ_D65_to_HSL` can return `NaN` values.
hsl = hsl.map((x) => Number.isNaN(x) ? 0 : x);

const h = Math.min(360, Math.max(0, Math.round(toPrecision(hsl[0])))) % 360;
Expand Down
2 changes: 2 additions & 0 deletions packages/css-color-parser/src/util/infinity-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const VALUE_FOR_NEGATIVE_INFINITY = -2_147_483_647;
export const VALUE_FOR_POSITIVE_INFINITY = 2_147_483_647;
19 changes: 19 additions & 0 deletions packages/css-color-parser/test/basic/color-mix-function.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ const tests = [
['color-mix(in hsl longer hue, hsl(50deg 0% 50%), hsl(50deg 0% 50%))', canonicalize('hsl(230deg 0% 50%)')],
['color-mix(in hsl, color-mix(in hsl longer hue, hsl(50deg 0% 50%), hsl(50deg 0% 50%)), hsl(180deg 100% 50%))', canonicalize('hsl(205deg 50% 50%)')],

// A powerless hue is `missing` after conversion to a different interpolation space,
// so the nested mix resolves to the same result as mixing the original color directly.
// https://github.com/w3c/csswg-drafts/issues/14046
['color-mix(in lch, color-mix(in lch longer hue, hsl(50deg 0% 50%), hsl(50deg 0% 50%)), lch(50% 100% 180deg))', canonicalize('lch(51.69448237055716 75 180deg)')],
['color-mix(in lch, hsl(50deg 0% 50%), lch(50% 100% 180deg))', canonicalize('lch(51.69448237055716 75 180deg)')],

['color-mix(in hsl, hsl(30deg 40% 80% / 25%) 0%, hsl(90deg none none / none))', canonicalize('hsl(90deg 40% 80% / 25%)')],
['color-mix(in hwb, hwb(30deg 30% 40% / 25%) 0%, hwb(90deg none none / 0.5))', canonicalize('hwb(90deg 30% 40% / 0.5)')],
['color-mix(in hsl, hsl(from hsl(none 50% 50%) h s l), hsl(from hsl(120deg 50% 50%) h s l))', canonicalize('hsl(120deg 50% 50%)')],
Expand All @@ -135,6 +141,19 @@ const tests = [
// TODO : `none` containing support
['color-mix(in hsl, hsl(calc(none) 30% 40%), hsl(20deg 30% 40%))', ''],

// Percentages outside of the [0,100] range
// https://drafts.csswg.org/css-color-5/#color-mix-percent-norm
['color-mix(in srgb, red -10%, blue)', ''], // literal percentages are invalid
['color-mix(in srgb, red 150%, blue)', ''], // literal percentages are invalid
['color-mix(in srgb, red 0% 20%, blue)', ''], // only one percentage per color
// Math functions with an out of range result are clamped instead of being invalid.
// https://drafts.csswg.org/css-values-4/#calc-range
['color-mix(in srgb, red calc(-10%), blue)', canonicalize('blue')], // clamped to 0%
['color-mix(in srgb, red calc(10% - 20%), blue)', canonicalize('blue')], // clamped to 0%
['color-mix(in srgb, red calc(150%), blue)', canonicalize('red')], // clamped to 100%
['color-mix(in oklch, red calc(-10%), blue)', canonicalize('blue')], // clamped to 0%
['color-mix(in oklch, red calc(150%), blue)', canonicalize('red')], // clamped to 100%

// 0 percentage sum
['color-mix(in hsl, hsl(120deg 10% 20%) 0%, hsl(30deg 30% 40%) 0%)', canonicalize('hsl(75.48deg 20.26% 30% / 0)')], // sum of percentages is 0
['color-mix(in hwb, hwb(40deg 30% 40%) 0%, hwb(40deg 30% 40%) 0%)', canonicalize('hwb(40.26deg 30.2% 40% / 0%)')], // sum of percentages is 0
Expand Down
Loading
Loading