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
4 changes: 4 additions & 0 deletions packages/css-color-parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changes to CSS Color Parser

### Unreleased (patch)

- Preserve powerless hue in `color-mix()` when the input color space equals the interpolation color space, matching relative color syntax.

### 4.2.0

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

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions packages/css-color-parser/src/color-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ const predefinedRGB_or_XYZ_Spaces = new Set([
]);

export function colorDataToForInterpolation(colorData: ColorData, toNotation: ColorNotation): ColorData {
// https://drafts.csswg.org/css-color-4/#interpolation
// Steps 1-4 equate to a noop if the input color is already in the interpolation color space
if (colorData.colorNotation === toNotation) {
return {
...colorData,
};
}

// https://drafts.csswg.org/css-color-4/#interpolation
// 1. checking the two colors for analogous components and analogous sets which will be carried forward
const originalChannelValues = [...colorData.channels] as Color;
Expand All @@ -319,11 +327,11 @@ export function colorDataToForInterpolation(colorData: ColorData, toNotation: Co
};

// https://drafts.csswg.org/css-color-4/#interpolation
// 2. changing any powerless components to missing values
// 2. (if conversion is required) changing any powerless components to missing values
outputColorData.channels = convertPowerlessComponentsToMissingComponents(outputColorData.channels, colorData.colorNotation);

// https://drafts.csswg.org/css-color-4/#interpolation
// 3. converting them both to a given color space which will be referred to as the interpolation color space below.
// 3. (if required) converting them both to a given color space which will be referred to as the interpolation color space below.
//
// https://drafts.csswg.org/css-color-4/#color-conversion
// To convert a color...
Expand Down
30 changes: 24 additions & 6 deletions packages/css-color-parser/src/functions/color-mix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,8 @@ function colorMixRectangularPair(colorNotation: ColorNotation, a_color: ColorDat
const a_channels = colorDataToForInterpolation(a_color, colorNotation).channels;
const b_channels = colorDataToForInterpolation(b_color, colorNotation).channels;

// https://drafts.csswg.org/css-color-4/#interpolation
// 5. fill in missing components with the other color’s component values
a_channels[0] = fillInMissingComponent(a_channels[0], b_channels[0]);
b_channels[0] = fillInMissingComponent(b_channels[0], a_channels[0]);

Expand All @@ -381,6 +383,12 @@ function colorMixRectangularPair(colorNotation: ColorNotation, a_color: ColorDat
a_channels[2] = fillInMissingComponent(a_channels[2], b_channels[2]);
b_channels[2] = fillInMissingComponent(b_channels[2], a_channels[2]);

// https://drafts.csswg.org/css-color-4/#interpolation
// 6. (if required) fixing up the hues, depending on the selected <hue-interpolation-method>
// -> not required

// https://drafts.csswg.org/css-color-4/#interpolation
// 7. changing the color components to premultiplied form
a_channels[0] = premultiply(a_channels[0], a_alpha);
a_channels[1] = premultiply(a_channels[1], a_alpha);
a_channels[2] = premultiply(a_channels[2], a_alpha);
Expand All @@ -389,6 +397,9 @@ function colorMixRectangularPair(colorNotation: ColorNotation, a_color: ColorDat
b_channels[1] = premultiply(b_channels[1], b_alpha);
b_channels[2] = premultiply(b_channels[2], b_alpha);

// https://drafts.csswg.org/css-color-4/#interpolation
// 8. linearly interpolating each component of the computed value of the color separately
// 9. undoing premultiplication
const alpha = interpolate(a_alpha, b_alpha, progress);
const outputChannels: Color = [
un_premultiply(interpolate(a_channels[0], b_channels[0], progress), alpha),
Expand Down Expand Up @@ -547,6 +558,8 @@ function colorMixPolarPair(colorNotation: ColorNotation, hueInterpolationMethod:
a_alpha = Number.isNaN(a_alpha) ? b_alpha : a_alpha;
b_alpha = Number.isNaN(b_alpha) ? a_alpha : b_alpha;

// https://drafts.csswg.org/css-color-4/#interpolation
// 5. fill in missing components with the other color’s component values
const a_channels = colorDataToForInterpolation(a_color, colorNotation).channels;
const b_channels = colorDataToForInterpolation(b_color, colorNotation).channels;

Expand Down Expand Up @@ -579,6 +592,8 @@ function colorMixPolarPair(colorNotation: ColorNotation, hueInterpolationMethod:
break;
}

// https://drafts.csswg.org/css-color-4/#interpolation
// 5. fill in missing components with the other color’s component values
a_first = fillInMissingComponent(a_first, b_first);
b_first = fillInMissingComponent(b_first, a_first);

Expand All @@ -587,15 +602,12 @@ function colorMixPolarPair(colorNotation: ColorNotation, hueInterpolationMethod:

a_hue = fillInMissingComponent(a_hue, b_hue);
b_hue = fillInMissingComponent(b_hue, a_hue);

// https://drafts.csswg.org/css-color-4/#interpolation
// 6. (if required) fixing up the hues, depending on the selected <hue-interpolation-method>
if (Number.isNaN(a_hue) && Number.isNaN(b_hue)) {
// noop
} else {
if (Number.isNaN(a_hue)) {
a_hue = 0;
} else if (Number.isNaN(b_hue)) {
b_hue = 0;
}

const angleDiff = b_hue - a_hue;

switch (hueInterpolationMethod) {
Expand Down Expand Up @@ -634,11 +646,17 @@ function colorMixPolarPair(colorNotation: ColorNotation, hueInterpolationMethod:
}
}

// https://drafts.csswg.org/css-color-4/#interpolation
// 7. changing the color components to premultiplied form
a_first = premultiply(a_first, a_alpha);
a_second = premultiply(a_second, a_alpha);
b_first = premultiply(b_first, b_alpha);
b_second = premultiply(b_second, b_alpha);


// https://drafts.csswg.org/css-color-4/#interpolation
// 8. linearly interpolating each component of the computed value of the color separately
// 9. undoing premultiplication
let outputChannels: Color = [0, 0, 0];
const alpha = interpolate(a_alpha, b_alpha, progress);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { parse } from '../util/parse.mjs';
import { serialize_OKLCH_data } from '../util/serialize.mjs';

const tests = [
['color-mix(in oklch, oklch(100% 0% 60deg), oklch(50% 50% 0deg))', 'oklch(0.75 0.1 0)'],
['color-mix(in oklch, oklch(100% 0% 60deg), oklch(50% 50% 0deg))', 'oklch(0.75 0.1 30)'],
['color-mix(in oklch, oklch(100% 0% none), oklch(50% 50% 0deg))', 'oklch(0.75 0.1 0)'],
['color-mix(in oklch, rgb(255, 255, 255), rgb(180, 6, 95))', 'oklch(0.75031 0.10016 359.858)'],
['color-mix(in lch, oklch(75% 0% 60deg), oklch(75% 50% 0deg))', 'oklch(0.74979 0.09824 0.10588)'],
['color-mix(in oklch, oklch(100% 0% none), oklch(50% 50% 0deg))', 'oklch(0.75 0.1 0)'],
['color-mix(in oklch, oklch(100% none 60deg), oklch(50% 50% 0deg))', 'oklch(0.75 0.2 0)'],
['color-mix(in oklch, oklch(100% none 60deg), oklch(50% 50% 0deg))', 'oklch(0.75 0.2 30)'],

// Analogous sets
['color-mix(in oklch, rgb(none none none), oklch(0.5 0.2 50))', 'oklch(0.5 0.2 50)'],
Expand Down
10 changes: 5 additions & 5 deletions packages/css-color-parser/test/basic/color-mix-function.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ const tests = [
['color-mix(in hsl decreasing hue, hsl(180deg 50% 50%), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],

['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(180deg 50% 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%)')],

['color-mix(in hsl, hsl(30deg 40% 80% / 25%) 0%, hsl(90deg none none / none))', canonicalize('hsl(30deg 40% 80% / 25%)')],
['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%)')],
['color-mix(in hsl, hsl(from hsl(0deg 50% 50%) none s l), hsl(from hsl(120deg 50% 50%) h s l))', canonicalize('hsl(120deg 50% 50%)')],
Expand Down Expand Up @@ -193,7 +193,7 @@ assert.deepStrictEqual(
color(parse('color-mix(in lch, lch(50% 50% 180deg), lch(100% 0% 0deg))')),
{
colorNotation: 'lch',
channels: [75, 37.5, 180],
channels: [75, 37.5, 90],
alpha: 1,
syntaxFlags: new Set(['color-mix']),
},
Expand Down Expand Up @@ -233,7 +233,7 @@ assert.deepStrictEqual(
color(parse('color-mix(in lch, lch(100 0 40deg), lch(100 0 60deg))')),
{
colorNotation: 'lch',
channels: [100, 0, Number.NaN],
channels: [100, 0, 50],
alpha: 1,
syntaxFlags: new Set(['color-mix']),
},
Expand Down Expand Up @@ -293,7 +293,7 @@ assert.deepStrictEqual(
color(parse('color-mix(in hsl, lch(none 0 30), hsl(50 0 none))')),
{
colorNotation: 'hsl',
channels: [Number.NaN, 0, Number.NaN],
channels: [50, 0, Number.NaN],
alpha: 1,
syntaxFlags: new Set(['color-mix']),
},
Expand Down
14 changes: 7 additions & 7 deletions packages/css-color-parser/test/basic/none-exhaustive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { parse } from '../util/parse.mjs';
// https://github.com/w3c/csswg-drafts/issues/14100
['lch(from orchid l 0 h)', 'lch(62.75256542 0 326.96909222)'],
['lch(from lch(from orchid l 0 h) l c h)', 'lch(62.75256542 0 326.96909222)'],
['color-mix(in lch, lch(from orchid l 0 h))', 'lch(62.75256542 0 none)'],
['color-mix(in lch, lch(from orchid l 0 h))', 'lch(62.75256542 0 326.96909222)'],

// exhaustive tests
// HSL - RCS
Expand Down Expand Up @@ -71,10 +71,10 @@ import { parse } from '../util/parse.mjs';

// HSL - color-mix
['color-mix(in hsl, hsl(none 50% 50%), hsl(11 33 44))', 'color(srgb 0.66505 0.34646833 0.27495)', 'hsl(11 41.5% 47%)'],
['color-mix(in hsl, hsl(180 none 50%), hsl(11 33 44))', 'color(srgb 0.6251 0.37177 0.3149)', 'hsl(11 33% 47%)'],
['color-mix(in hsl, hsl(180 none 50%), hsl(11 33 44))', 'color(srgb 0.441565 0.6251 0.3149)', 'hsl(95.5 33% 47%)'],
['color-mix(in hsl, hsl(180 50% none), hsl(11 33 44))', 'color(srgb 0.40652333 0.6226 0.2574)', 'hsl(95.5 41.5% 44%)'],
['color-mix(in hsl, hsl(none none 50%), hsl(11 33 44))', 'color(srgb 0.6251 0.37177 0.3149)', 'hsl(11 33% 47%)'],
['color-mix(in hsl, hsl(180 none none), hsl(11 33 44))', 'color(srgb 0.5852 0.34804 0.2948)', 'hsl(11 33% 44%)'],
['color-mix(in hsl, hsl(180 none none), hsl(11 33 44))', 'color(srgb 0.41338 0.5852 0.2948)', 'hsl(95.5 33% 44%)'],
['color-mix(in hsl, hsl(none 50% none), hsl(11 33 44))', 'color(srgb 0.6226 0.32435333 0.2574)', 'hsl(11 41.5% 44%)'],
['color-mix(in hsl, hsl(none none none), hsl(11 33 44))', 'color(srgb 0.5852 0.34804 0.2948)', 'hsl(11 33% 44%)'],

Expand Down Expand Up @@ -295,9 +295,9 @@ import { parse } from '../util/parse.mjs';
['color-mix(in lch, hwb(none none none), lch(11 33 44))', 'lch(11 33 44)'],

['color-mix(in lch, lch(none 20 180), lch(11 33 44))', 'lch(11 26.5 112)'],
['color-mix(in lch, lch(20 none 180), lch(11 33 44))', 'lch(15.5 33 44)'],
['color-mix(in lch, lch(20 none 180), lch(11 33 44))', 'lch(15.5 33 112)'],
['color-mix(in lch, lch(20 20 none), lch(11 33 44))', 'lch(15.5 26.5 44)'],
['color-mix(in lch, lch(none none 180), lch(11 33 44))', 'lch(11 33 44)'],
['color-mix(in lch, lch(none none 180), lch(11 33 44))', 'lch(11 33 112)'],
['color-mix(in lch, lch(20 none none), lch(11 33 44))', 'lch(15.5 33 44)'],
['color-mix(in lch, lch(none 20 none), lch(11 33 44))', 'lch(11 26.5 44)'],
['color-mix(in lch, lch(none none none), lch(11 33 44))', 'lch(11 33 44)'],
Expand Down Expand Up @@ -407,9 +407,9 @@ import { parse } from '../util/parse.mjs';
['color-mix(in oklch, lch(none none none), oklch(0.11 0.33 44))', 'oklch(0.11 0.33 44)'],

['color-mix(in oklch, oklch(none 0.2 180), oklch(0.11 0.33 44))', 'oklch(0.11 0.265 112)'],
['color-mix(in oklch, oklch(0.2 none 180), oklch(0.11 0.33 44))', 'oklch(0.155 0.33 44)'],
['color-mix(in oklch, oklch(0.2 none 180), oklch(0.11 0.33 44))', 'oklch(0.155 0.33 112)'],
['color-mix(in oklch, oklch(0.2 0.2 none), oklch(0.11 0.33 44))', 'oklch(0.155 0.265 44)'],
['color-mix(in oklch, oklch(none none 180), oklch(0.11 0.33 44))', 'oklch(0.11 0.33 44)'],
['color-mix(in oklch, oklch(none none 180), oklch(0.11 0.33 44))', 'oklch(0.11 0.33 112)'],
['color-mix(in oklch, oklch(0.2 none none), oklch(0.11 0.33 44))', 'oklch(0.155 0.33 44)'],
['color-mix(in oklch, oklch(none 0.2 none), oklch(0.11 0.33 44))', 'oklch(0.11 0.265 44)'],
['color-mix(in oklch, oklch(none none none), oklch(0.11 0.33 44))', 'oklch(0.11 0.33 44)'],
Expand Down
Loading
Loading