Skip to content

Commit cd3ff34

Browse files
committed
css-color-parser: fix handling of hue in interpolation when both parts have a missing hue component
1 parent 2af6191 commit cd3ff34

5 files changed

Lines changed: 144 additions & 44 deletions

File tree

packages/css-color-parser/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changes to CSS Color Parser
22

3+
### Unreleased (patch)
4+
5+
- Fix handling of `hue` in interpolation when both parts have a missing `hue` component
6+
37
### 4.1.4
48

59
_June 13, 2026_

packages/css-color-parser/dist/index.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/css-color-parser/src/color-data.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ const predefinedRGB_or_XYZ_Spaces = new Set([
198198
]);
199199

200200
export function colorDataTo(colorData: ColorData, toNotation: ColorNotation): ColorData {
201+
colorData = {
202+
...colorData,
203+
};
204+
205+
// Change any powerless components in src to missing components
206+
colorData.channels = convertPowerlessComponentsToMissingComponents(colorData.channels, colorData.colorNotation);
207+
201208
const outputColorData: ColorData = {
202209
...colorData,
203210
};
@@ -443,8 +450,6 @@ export function colorDataTo(colorData: ColorData, toNotation: ColorNotation): Co
443450
}
444451
}
445452

446-
outputColorData.channels = convertPowerlessComponentsToMissingComponents(outputColorData.channels, toNotation);
447-
448453
return outputColorData;
449454
}
450455

packages/css-color-parser/src/functions/color-mix.ts

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -577,57 +577,59 @@ function colorMixPolarPair(colorNotation: ColorNotation, hueInterpolationMethod:
577577
break;
578578
}
579579

580-
a_hue = fillInMissingComponent(a_hue, b_hue);
581-
if (Number.isNaN(a_hue)) {
582-
a_hue = 0;
583-
}
584-
585-
b_hue = fillInMissingComponent(b_hue, a_hue);
586-
if (Number.isNaN(b_hue)) {
587-
b_hue = 0;
588-
}
589-
590580
a_first = fillInMissingComponent(a_first, b_first);
591581
b_first = fillInMissingComponent(b_first, a_first);
592582

593583
a_second = fillInMissingComponent(a_second, b_second);
594584
b_second = fillInMissingComponent(b_second, a_second);
595585

596-
const angleDiff = b_hue - a_hue;
586+
a_hue = fillInMissingComponent(a_hue, b_hue);
587+
b_hue = fillInMissingComponent(b_hue, a_hue);
588+
if (Number.isNaN(a_hue) && Number.isNaN(b_hue)) {
589+
// noop
590+
} else {
591+
if (Number.isNaN(a_hue)) {
592+
a_hue = 0;
593+
} else if (Number.isNaN(b_hue)) {
594+
b_hue = 0;
595+
}
597596

598-
switch (hueInterpolationMethod) {
599-
case 'shorter':
600-
if (angleDiff > 180) {
601-
a_hue += 360;
602-
} else if (angleDiff < -180) {
603-
b_hue += 360;
604-
}
597+
const angleDiff = b_hue - a_hue;
605598

606-
break;
607-
case 'longer':
608-
if (-180 < angleDiff && angleDiff < 180) {
609-
if (angleDiff > 0) {
599+
switch (hueInterpolationMethod) {
600+
case 'shorter':
601+
if (angleDiff > 180) {
610602
a_hue += 360;
611-
} else {
603+
} else if (angleDiff < -180) {
612604
b_hue += 360;
613605
}
614-
}
615606

616-
break;
617-
case 'increasing':
618-
if (angleDiff < 0) {
619-
b_hue += 360;
620-
}
607+
break;
608+
case 'longer':
609+
if (-180 < angleDiff && angleDiff < 180) {
610+
if (angleDiff > 0) {
611+
a_hue += 360;
612+
} else {
613+
b_hue += 360;
614+
}
615+
}
621616

622-
break;
623-
case 'decreasing':
624-
if (angleDiff > 0) {
625-
a_hue += 360;
626-
}
617+
break;
618+
case 'increasing':
619+
if (angleDiff < 0) {
620+
b_hue += 360;
621+
}
627622

628-
break;
629-
default:
630-
throw new Error('Unknown hue interpolation method');
623+
break;
624+
case 'decreasing':
625+
if (angleDiff > 0) {
626+
a_hue += 360;
627+
}
628+
629+
break;
630+
default:
631+
throw new Error('Unknown hue interpolation method');
632+
}
631633
}
632634

633635
a_first = premultiply(a_first, a_alpha);

packages/css-color-parser/test/basic/color-mix-function.mjs

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,29 @@ const tests = [
8181
['color-mix(in hsl shorter hue, hsl(none 50% 50%), hsl(90deg 50% 50%)', canonicalize('hsl(90deg 50% 50%)')],
8282
['color-mix(in hsl increasing hue, hsl(none 50% 50%), hsl(90deg 50% 50%)', canonicalize('hsl(90deg 50% 50%)')],
8383
['color-mix(in hsl decreasing hue, hsl(none 50% 50%), hsl(90deg 50% 50%)', canonicalize('hsl(90deg 50% 50%)')],
84-
['color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%)', canonicalize('hsl(180deg 50% 50%)')],
85-
['color-mix(in hsl shorter hue, hsl(none 50% 50%), hsl(none 50% 50%)', canonicalize('hsl(0deg 50% 50%)')],
86-
['color-mix(in hsl increasing hue, hsl(none 50% 50%), hsl(none 50% 50%)', canonicalize('hsl(0deg 50% 50%)')],
87-
['color-mix(in hsl decreasing hue, hsl(none 50% 50%), hsl(none 50% 50%)', canonicalize('hsl(0deg 50% 50%)')],
84+
85+
['color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%))', canonicalize('hsl(none 50% 50%)')],
86+
['color-mix(in hsl shorter hue, hsl(none 50% 50%), hsl(none 50% 50%)', canonicalize('hsl(none 50% 50%)')],
87+
['color-mix(in hsl increasing hue, hsl(none 50% 50%), hsl(none 50% 50%))', canonicalize('hsl(none 50% 50%)')],
88+
['color-mix(in hsl decreasing hue, hsl(none 50% 50%), hsl(none 50% 50%))', canonicalize('hsl(none 50% 50%)')],
89+
90+
['color-mix(in hsl, color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
91+
['color-mix(in hsl, color-mix(in hsl shorter hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
92+
['color-mix(in hsl, color-mix(in hsl increasing hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
93+
['color-mix(in hsl, color-mix(in hsl decreasing hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
94+
95+
['color-mix(in hsl longer hue, color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(0deg 50% 50%)')],
96+
['color-mix(in hsl shorter hue, color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
97+
['color-mix(in hsl increasing hue, color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
98+
['color-mix(in hsl decreasing hue, color-mix(in hsl longer hue, hsl(none 50% 50%), hsl(none 50% 50%)), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
99+
100+
['color-mix(in hsl longer hue, hsl(180deg 50% 50%), hsl(180deg 50% 50%))', canonicalize('hsl(0deg 50% 50%)')],
101+
['color-mix(in hsl shorter hue, hsl(180deg 50% 50%), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
102+
['color-mix(in hsl increasing hue, hsl(180deg 50% 50%), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
103+
['color-mix(in hsl decreasing hue, hsl(180deg 50% 50%), hsl(180deg 50% 50%))', canonicalize('hsl(180deg 50% 50%)')],
104+
105+
['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%)')],
106+
88107
['color-mix(in hsl, hsl(30deg 40% 80% / 25%) 0%, hsl(90deg none none / none))', canonicalize('hsl(90deg 40% 80% / 25%)')],
89108
['color-mix(in hwb, hwb(30deg 30% 40% / 25%) 0%, hwb(90deg none none / 0.5))', canonicalize('hwb(90deg 30% 40% / 0.5)')],
90109
['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%)')],
@@ -187,3 +206,73 @@ assert.deepStrictEqual(
187206
syntaxFlags: new Set(['color-mix', 'color-mix-variadic']),
188207
},
189208
);
209+
210+
assert.deepStrictEqual(
211+
color(parse('color-mix(in lch, lch(100 0 40deg), lch(100 0 60deg))')),
212+
{
213+
colorNotation: 'lch',
214+
channels: [100, 0, Number.NaN],
215+
alpha: 1,
216+
syntaxFlags: new Set(['color-mix']),
217+
},
218+
);
219+
220+
assert.deepStrictEqual(
221+
color(parse('oklch(from lch(100 0 0deg) l c h)')),
222+
{
223+
colorNotation: 'oklch',
224+
channels: [1, 4.996003610813204e-16, Number.NaN],
225+
alpha: 1,
226+
syntaxFlags: new Set(['relative-color-syntax', 'has-number-values']),
227+
},
228+
);
229+
230+
assert.deepStrictEqual(
231+
color(parse('lch(from oklch(100 0 0deg) l c h)')),
232+
{
233+
colorNotation: 'lch',
234+
channels: [100, 1.1102230246251565e-13, Number.NaN],
235+
alpha: 1,
236+
syntaxFlags: new Set(['relative-color-syntax', 'has-number-values']),
237+
},
238+
);
239+
240+
assert.deepStrictEqual(
241+
color(parse('color-mix(in oklch, lch(100 0 40deg), lch(100 0 60deg))')),
242+
{
243+
colorNotation: 'oklch',
244+
channels: [1.0000000000000002, 4.996003610813204e-16, Number.NaN],
245+
alpha: 1,
246+
syntaxFlags: new Set(['color-mix']),
247+
},
248+
);
249+
250+
assert.deepStrictEqual(
251+
color(parse('color-mix(in lch, lch(none 20 none), lch(none 40 none))')),
252+
{
253+
colorNotation: 'lch',
254+
channels: [Number.NaN, 30, Number.NaN],
255+
alpha: 1,
256+
syntaxFlags: new Set(['color-mix']),
257+
},
258+
);
259+
260+
assert.deepStrictEqual(
261+
color(parse('color-mix(in hsl, lch(50% 50% 30), hsl(50 50% 50%))')),
262+
{
263+
colorNotation: 'hsl',
264+
channels: [383.202603842081, 60.179695614531866, 51.44446807428794],
265+
alpha: 1,
266+
syntaxFlags: new Set(['color-mix']),
267+
},
268+
);
269+
270+
assert.deepStrictEqual(
271+
color(parse('color-mix(in hsl, lch(none 0 30), hsl(50 0 none))')),
272+
{
273+
colorNotation: 'hsl',
274+
channels: [Number.NaN, 0, Number.NaN],
275+
alpha: 1,
276+
syntaxFlags: new Set(['color-mix']),
277+
},
278+
);

0 commit comments

Comments
 (0)