Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-color-4] [css-color-5] inconsistent mentions of powerless components in white. #8609

Closed
romainmenke opened this issue Mar 17, 2023 · 73 comments
Labels
css-color-4 Current Work

Comments

@romainmenke
Copy link
Member

romainmenke commented Mar 17, 2023

https://drafts.csswg.org/css-color-4/#specifying-oklab-oklch

If the lightness of an Oklch color is 0% or 0, or 100% or 1.0, both the hue and chroma components are powerless.

So white should be equivalent to oklch(100% none none).

https://drafts.csswg.org/css-color-5/#ex-mix-blue-white

Example 6
white is rgb(100% 100% 100%) which is lch(100% 0 none) which is oklch(100% 0 none)
blue is rgb(0% 0% 100%) which is lch(29.5683% 131.201 301.364) which is oklch(45.201% 0.31321 264.052)

When mixing in oklch: color-mix(in oklch, white, blue);


Chrome :

oklch(0.725987 0.15663 323.92)

Screenshot 2023-03-17 at 22 51 53

Safari :

oklch(0.72600687 0.15660721 177.02602)

Screenshot 2023-03-17 at 22 52 19

Specification :

oklch(72.601% 0.15661 264.052)

Screenshot 2023-03-17 at 22 57 15

What I think it should actually be :

oklch(72.601% 0.31321 264.052)

Screenshot 2023-03-17 at 22 57 44


I think we are seeing the same implementation issues as in :

But this might require different specification and test edits to resolve.

@facelessuser
Copy link

This is because the OkLCh algorithm does not resolve to a perfect zero (except maybe for black) when a color is achromatic. There are some color spaces that do even worse, but OkLCh is the worse offender in CSS, and it really isn't that bad.

Compared to LCh though, OkLCh does a worse job and is actually more sensitive to the hue because chroma doesn't get as low as it does with LCh. The ideal hue for OkLCh is somewhere around 90˚ based on the CSS matrices, but using zero ends up being "close enough" and introduces only minimal error, but may slightly impact the round trip.

>>> Color('white').convert('oklch').set('hue', 0).convert('srgb')[:]
[1.000000056645113, 0.9999999632010229, 1.0000001023697391, 1.0]
>>> Color('white').convert('oklch', undef=False).convert('srgb')[:]
[1.0000000000000049, 0.9999999999999981, 0.9999999999999999, 1.0]
>>> Color('white').convert('oklch', undef=False)[:]
[0.9999999935000001, 3.729999997759137e-08, 90.00000025580869, 1.0]
>>> Color('white').convert('oklch')[:]
[0.9999999935000001, 3.729999997759137e-08, nan, 1.0]

The CSS spec does mention the powerless when "close to zero" for things like LCh and OkLCh, but yes, it gives no specific directions beyond that. It also doesn't mention this for HSL, so conversions from OkLCh to HSL, could leave you with a color close to achromatic, but not quite in HSL.

For extremely small values of a and b (near-zero Chroma), although the visual color does not change from being on the neutral axis, small changes to the values can result in the reported hue angle swinging about wildly and being essentially random. In CSS, this means the hue is powerless, and treated as missing when converted into LCH or Oklch; in non-CSS contexts, this might be reflected as a missing value, such as NaN.

Calculating some perfect threshold for each cylindrical space that resolves to identify achromatic exactly the same in all spaces when you convert to them may prove difficult as each color space has different levels of accuracy for chroma/saturation equaling zero, and the scaling is a bit different. I'm not sure how much CSS cares about this, at least with the current included color spaces, if most achromatic colors are firmly in the achromatic pocket, it is likely you could pick a threshold tailored to each space that is close enough.

You could eliminate a lot of these achromatic detection differences between spaces by identifying achromatic colors in a common space, like maybe taking the cross product of the color vector against the white point vector in the XYZ D65 color spaces (with some threshold for deviation away from zero). Then "closeness" for achromatic colors would be the same for each space. This would require an XYZ conversion when checking achromatic colors though.

@romainmenke
Copy link
Member Author

romainmenke commented Mar 18, 2023

That is not really what is happening here.
You can get the same result with a white defined in oklch

This should be true white mixed with blue in oklch:

color-mix(in oklch, oklch(100% 0 0), blue);

vs.

color-mix(in oklch, oklch(100% none none), blue);

Browsers just aren't handling powerless/missing components correctly.

But the specification is also inconsistent when referring to these powerless/missing components:

  • css-color-4: white is oklch(100% none none)
  • css-color-5: white is oklch(100% 0 none)

This is not a math/floating point issue, it is a specification and implementation issue :)

@facelessuser
Copy link

Ah, well the spec does mention it, just no real specifics :). If implementations do choose to rely on a hard 0, the previously mentioned issues may arise as well, but I get what you are saying.

@facelessuser
Copy link

facelessuser commented Mar 29, 2023

So, I've actually taken a closer look at this. If powerless means that interpolation should treat the components as missing, I think the current CSS spec is heading in a confusing direction. Let's take a look.

Here we create two OkLCh colors oklch(1 0 none) and oklch(1 none none). The first white yields the results in the CSS spec which I believe was added before the stipulation that 0% and 100% lightness make chroma powerless. The latter matches the powerless suggestions in the spec and also matches @romainmenke's assertion that the proper OkLCh output should be oklch(72.601% 0.31321 264.052). But notices how the powerless hue makes the blue more purple.

Screenshot 2023-03-29 at 11 23 28 AM

But let's look at the entire interpolation:

Screenshot 2023-03-29 at 11 26 55 AM

Oh no, now we have a huge shift in color. This defeats the whole purpose of using OkLCh! What happens if we use black?

Screenshot 2023-03-29 at 11 30 57 AM

Just as bad.

So, why do we want chroma as powerless treated as missing for black and white during interpolation?

@facelessuser
Copy link

I guess I'd make the same argument for forcing a and b to be powerless (assuming powerless actually means undefined) in Lab color spaces as well.

@svgeesus
Copy link
Contributor

Right, clearly for achromatic colors a and b and C are zero or near zero, not undefined. Hue on the other hand is ill conditioned and could swing all over the place from small shifts in a and b, so it genuinely is powerless.

@svgeesus
Copy link
Contributor

This part is just plain wrong:

For example, a gray color converted into lch() may, due to numerical errors, have an extremely small chroma rather than precisely 0%; this can, at the user agent’s discretion, still treat the hue component as powerless.

Saying that missing components get treated as zero is fine (but assuming that always happens is not fine; in interpolation the missing component is treated as the analogous component in the other color). Saying that zero-valued components are always missing, on the other hand, is just wrong.

@romainmenke
Copy link
Member Author

@facelessuser Are you sure there aren't any bugs on your end?
Those results do not match what I am seeing in our implementation.

I don't understand why you would get a hue shift here.

.foo {
	background: linear-gradient(to right, rgb(255, 255, 255) 0%, color(display-p3 0.57766 0.85014 1.67992), color(display-p3 0.51024 0.77327 1.59733), color(display-p3 0.44387 0.69646 1.51535), color(display-p3 0.37854 0.61944 1.43399), color(display-p3 0.31424 0.54177 1.35327), color(display-p3 0.25084 0.4627 1.27319), color(display-p3 0.18808 0.38082 1.19377), color(display-p3 0.12528 0.29307 1.11502), color(display-p3 0.05998 0.19058 1.03695), rgb(0, 0, 255) 100%); /* generated fallback */
	background: linear-gradient(to right in oklch, white 0%, blue 100%); /* original */
}

Should look like :

Screenshot 2023-03-29 at 23 29 16


.foo {
	background: linear-gradient(to right, rgb(255, 255, 255) 0%, color(display-p3 0.89543 0.92911 1.00686), color(display-p3 0.79238 0.85738 1.01077), color(display-p3 0.69089 0.78448 1.01198), color(display-p3 0.59093 0.70993 1.01068), color(display-p3 0.49247 0.63302 1.00706), color(display-p3 0.39536 0.55257 1.0013), color(display-p3 0.29925 0.46652 0.99353), color(display-p3 0.20328 0.37049 0.9839), color(display-p3 0.10457 0.25236 0.97254), rgb(0, 0, 255) 100%); /* generated fallback */
	background: linear-gradient(to right in oklch, oklch(100% 0 none) 0%, blue 100%); /* original */
}

Should look like :

Screenshot 2023-03-29 at 23 37 21


The second example with 0 chroma is slightly less saturated, but there isn't any difference in hue between these two.


It is not unlikely that what I am doing here isn't a good way to verify this part of the specification.

But using the tools I have, to try and illustrate how we've interpreted and implemented the specification.

@facelessuser
Copy link

facelessuser commented Mar 29, 2023

@romainmenke, I was still gamut mapping with LCh, so that's where the purple shift comes from 🤦🏻. This just affects the color that is displayed, not the computed values.

But it still doesn't make sense to resolve chroma to missing. Again, let's take a look, this time doing things just like browsers. So, now using clip to keep in the gamut like browsers do, I don't get the purple shift in display (the computed values are still identical), but because we set chroma to missing, we we aren't interpolating from white to blue, it ends up as a super bright blue to normal blue as white adopts the blue chroma and hue.

Screenshot 2023-03-29 at 4 17 04 PM

And before we say that there must be something wrong with what I am doing, let's look at Safari technical preview in Codepen.

Screenshot 2023-03-29 at 4 22 50 PM

How are these results better? We lose the white and end up with an interpolation between bright blue and blue. That's not what the intent was.

@facelessuser
Copy link

In short, what I'm saying is that the spec example yielding oklch(72.601% 0.15661 264.052) seems correct for a 50% mix as chroma is treated as 0, such that the interpolation is properly between white and blue. But oklch(72.601% 0.31321 264.052) is wrong because that is the product of white being treated as oklch(1 none none) causing it to adopt blue's chroma and hue giving an interpolation that does not match the intent.

@romainmenke
Copy link
Member Author

romainmenke commented Mar 30, 2023

@facelessuser are you sure that what you are seeing aren't implementation bugs :)
I don't think we can use any browser implementation as a reference at this time.

A gradient that has a starting color stop of white shouldn't be blue.
If it renders what is essentially color-mix(in oklch, oklch(100% none none) 100%, blue 0%) as blue it is definitely bugged. The end result of that interpolation must be a color with 100% lightness and that should always be achromatic white.

https://codepen.io/romainmenke/pen/ZEMNYLO

Chrome today :

Screenshot 2023-03-30 at 09 21 25

Safari today :

Screenshot 2023-03-30 at 09 20 52

Firefox nightly today (is missing needed features) :

Screenshot 2023-03-30 at 09 27 33

It is clear that no browser currently has a good/working implementation for "interpolate between white and blue in oklch".

There are so many bugs here around color space conversion, handling of powerless components and out of gamut clipping that it is safe to say that all are just plain broken.

generated fallbacks might still output display-3 which can express out of gamut colors. These generated fallbacks are sensitive to the same out of gamut clipping issues.

@facelessuser
Copy link

@facelessuser are you sure that what you are seeing aren't implementation bugs :)
I don't think we can use any browser implementation as a reference at this time.

Yes, I am certain. Mathematically, it makes no sense. Forget what it looks like. I use references only because visually can sometimes be easier to talk about, but in this case, let's just look at the math of the models.

Let's say I have oklch(0 none none) and blue which is color(--oklch 0.45201 0.31321 264.05 / 1). Since black has two missing components, white adopts the blue's chroma and hue making it oklch(0 0.31321 264.05). What do you think that color is?

Let's convert it to LCh for instance, we get a color with negative lightness.

>>> Color('oklch(0 0.31321 264.05)').convert('lch')
color(--lch -5.3831 104.75 315.62 / 1)

The fact that the model allows you to convert it, doesn't mean it is a real, visible, and useful color. It's not only out gamut, it's out of the visible spectrum. It has a negative luminance.

>>> Color('oklch(0 0.31321 264.05)').convert('xyz-d65')['y']
-0.004737023320937883

Whatever it happens to map to visually once you coerce it into the real visible spectrum is irrelevant.

There is a reason that my implementation matches Safari, it's because what they are doing (in this specific interpolation case) makes sense. It's a nonsense color that they clip, and yes, you happen to get blue in OkLCh if visualized after forcing it to be visible via clipping, but whatever it looks like, it's a negative luminance color, it's a nonsense color.

@romainmenke
Copy link
Member Author

Let's convert it to LCh for instance, we get a color with negative lightness.

But the specification is perfectly clear on what to do in this case.

  • you are converting from one color space to another.
  • you start with oklch(0 0.31321 264.05)
  • your first step is to convert to oklch(0 none none) because L is 0

The end result is lch(0 0 0)

I don't understand why you would get lch(-5.3831 104.75 315.62)

@facelessuser
Copy link

But the specification is perfectly clear on what to do in this case.

Yes, CSS has conventions for undefined values when converting to other colors, but I'm talking about the color model. I'm trying to illustrate why what you are suggesting makes no sense. But. let's take one more approach and let's incorporate the conversion logic.

When we are interpolating oklch(1 none none) and color(--oklch 0.45201 0.31321 264.05 / 1), they are already in the OkLCh color space, and since we are interpolating in OkLCH, no conversion is needed. They are taken as is. The none will be respected by interpolation. The left side of the interpolation now becomes oklch(1 0.31321 264.05) after resolving none per the interpolation rules. We interpolate everything. Now we need to convert out of OkLCh. All values now have super high chroma, but when the lightness is close to 1, we can assume chroma is powerless and make it 0. I don't know what a good value is, so let's assume the lightness is 1e-3 just to make the results visible.

We get a small band of white at the beginning.

Screenshot 2023-03-30 at 6 47 25 AM

At this point, I think I've illustrated my reservations in many different ways. The summary is that if you want to interpolate, only adjusting lightness, yes, oklch(1 none none) will do that. But if you want to interpolate white with blue, oklch(1 0 none) will do that. These are two very different interpolations. white from any color space, converting to OkLCh, should resolve to oklch(1 0 none) during interpolation, not oklch(1 none none) as that will not follow the intent.

At this point, I'll let others comment as I'm not sure I can rephrase my point in many other different ways 🙂 .

@romainmenke
Copy link
Member Author

romainmenke commented Mar 30, 2023

@facelessuser I think I am starting to get what you are saying.

Interpolation as specified can lead to achromatic colors with high chroma.
These are not real colors but purely intermediary results.

As specified :

  1. convert white from srgb to oklch
    1.1. the result is oklch(1 none none)
  2. convert white from srgb to oklch
    2.1. the result is oklch(0.45 0.31 264.05)
  3. fill in missing components
    3.1. the result is oklch(1 0.31 264.05)

If oklch(1 0.31 264.05) is used as-is it will be pure white because L is 1
But what if L is not 1 or 0?

color-mix(in oklch, oklch(1 none none) 99.99%, oklch(0.45201 0.31321 264.05))

That will indeed be a super saturated, near 100% lightness blue-isch.

But I still fail to see where the issue is in having super saturated colors that are near 100% lightness?


The visual examples are indeed adding confusion.
Is it correct that what we are seeing there is clipping of out of gamut colors?


why what you are suggesting makes no sense

To clarify, I am not suggesting anything here :)

I found an inconsistency between two specification.
This inconsistency is meaningful and resolving this in either direction affects examples and implementations.

Aside from this inconsistency in the spec text, the implementations in browsers also happen to be bogus and wildly different.
This is important because that means there isn't a web compat issue, the specification can be changed.

@facelessuser
Copy link

Is it correct that what we are seeing there is clipping of out of gamut colors?

Yes, there is no other way to visualize it. Many of the super bright blues are probably out of gamut and forced back into the gamut with clipping.

Aside from this inconsistency in the spec text, the implementations in browsers also happen to be bogus and wildly different.

This may be so, and I'm obviously not attempting to provide a solution there, I'll let the spec writers do that, but I wanted to mention that the example in the spec, even if the powerless part is confusing, seems to make the most sense. Forcing chroma to be none with max and min lightness would be a bad direction IMHO.

But I still fail to see where the issue is in having super saturated colors that are near 100% lightness?

There is nothing wrong with interpolating super saturated colors, but my argument is white and black should not normalize by default, when converted to an LCh space, to none for both chroma and hue because then you are not interpolating between white and a color or black and a color, but are interpolating lightness only. If you want to interpolate lightness only, by all means, set chroma and hue manually to none. Further, I would not normalize white and black to have both a and b set to none unless explicitly, done so.

@romainmenke
Copy link
Member Author

Thank you for clarifying, yes I get what you mean now.

I have no strong opinion about this and it was also not the focus of this issue :)
I only want this to be consistent across specifications and I want to know what I should be implementing.

Currently the specification requires powerless components to become missing when converting between color spaces and states that both c and h are powerless for an L of 0/1 in oklch.

@facelessuser
Copy link

I have no strong opinion about this and it was also not the focus of this issue :)

Well, it's related to the fact that it would help steer the resolution on a consistent powerless approach. I feel others may come to a similar conclusion that you did, so being clear about what is ideal and not ideal is important when attempting to resolve the confusion and get everyone on board with the same convention. CSS may go against my personal opinions in this regard, but I think it is important to lay out the reasons.

Currently the specification requires powerless components to become missing when converting between color spaces and states that both c and h are powerless for an L of 0/1 in oklch.

Yep, and I think that is a mistake. I think "powerless" for LCh chroma or Lab a and b, if such a thing must be enforced, should mean zero. Hue is a bit special and makes sense to be missing for interpolation of achromatic colors.

There are some color spaces where zero doesn't always make sense for chroma, JzCzhz actually has an exponential chroma response for achromatic colors as lightness increases, though it is still low enough that you can probably get away with clamping it at the loss of only a little accuracy. Google's new HCT color space gets really high (as far as chroma goes) because it's based on CAM16 without discounting, but I'm getting off-topic now 🙂.

>>> Color('white').convert('jzczhz', norm=False)
color(--jzczhz 0.22207 0.0002 216.08 / 1)
>>> Color('white').convert('cam16-jmh', norm=False)
color(--cam16-jmh 100 2.2369 209.53 / 1)

@svgeesus
Copy link
Contributor

But the specification is perfectly clear on what to do in this case.

  • you are converting from one color space to another.
  • you start with oklch(0 0.31321 264.05)
  • your first step is to convert to oklch(0 none none) because L is 0

I agree the current specification is perfectly clear to do this; but as I also said earlier the current specification is completely incorrect to make Chroma powerless here. It is zero, not unknown or missing. Hue is missing, sure (although that then gives a discontinuity when L rises to, say, 0.001).

@romainmenke
Copy link
Member Author

Can we then check and fix these parts of css-color-4?

  • which components become powerless and when?
  • which components are analogous to which other components

@svgeesus
Copy link
Contributor

which components become powerless and when?

I think that is a mistake. I think "powerless" for LCh chroma or Lab a and b, if such a thing must be enforced, should mean zero.

Done (for achromatic colors, a b and C are zero, not powerless) with the two edits d6cae6d and b2580aa

which components are analogous to which other components

What problem do you see with the analogous components?

@romainmenke
Copy link
Member Author

Done

Thank you 🙇


For analogous components it would be good to verify if a and b have analogous components. For example between lab and oklab.

As currently specified these do not and therefor there is no carry over when a or b is missing. (e.g. color-mix(in lab, oklab(50% none none), lab(...)))

This seemed relevant since the powerlessness of a component and analogous components determine the outcome of interpolation.

Verifying the correctness of both and having WPT tests of the expected outcomes should help to resolve the implementation bugs we are currently seeing.


This change also needs to be reverted : #8631

@tabatkins
Copy link
Member

This part is just plain wrong: [...] Saying that missing components get treated as zero is fine (but assuming that always happens is not fine; in interpolation the missing component is treated as the analogous component in the other color). Saying that zero-valued components are always missing, on the other hand, is just wrong.

I don't understand what you mean by this. The spec does not say that zero-valued components are missing. It says that certain components, when zero-valued, can cause other components to be powerless (and thus missing, after conversions). Your comment appears to be a non sequitur?

I agree the current specification is perfectly clear to do this; but as I #8609 (comment) the current specification is completely incorrect to make Chroma powerless here. It is zero, not unknown or missing. Hue is missing, sure (although that then gives a discontinuity when L rises to, say, 0.001).

Chroma is powerless when lightness is 0% because any chroma value results in the exact same color (black). (And maybe the same is true of white, I forget the outcome of our conversation about this earlier.)

Powerless is not missing or unknown. The component is zero, and also powerless (because of the lightness, not because of its own value). I sincerely don't understand what in the spec you're objecting to, here.

@tabatkins
Copy link
Member

(although that then gives a discontinuity when L rises to, say, 0.001)

Yes, the "powerless" concept has a sharp boundary. That's unfortunate, but there's not a great way to handle this gradually, like we can with premultiplied alpha. (Or at least doing so was more complicated than we thought worthwhile.)

@romainmenke
Copy link
Member Author

@svgeesus @tabatkins When there is time it would be good to revisit this.
Some edits were made but it doesn't seem that everyone agrees on these?

Currently the specification, WPT tests and implementations all differ.

I don't mind spending time to submit patches if I know in which direction to go.

@svgeesus
Copy link
Contributor

svgeesus commented Apr 18, 2023

For analogous components it would be good to verify if a and b have analogous components. For example between lab and oklab.

As currently specified these do not

Ah, good point. Lets see: lab(50 0 100) is oklab(0.564 -0.03 0.156) while lab(50 100 0) is oklab(0.603 0.316 0) so it does seem reasonable to treat a and b as analogous.

Chroma is powerless when lightness is 0% because any chroma value results in the exact same color (black). (And maybe the same is true of white, I forget the outcome of our conversation about this earlier.)

Once we got rid of the ill-fated attempt to deal with CIE Lightness as potentially going up to 400% then yes, the same is true of white. And we have WPT for that (which didn't get tagged with Interop2022 because it had already started, and which all browsers fail):

Some edits were made but it doesn't seem that everyone agrees on these?

Firstly, it seems that Powerless components should include the example @tab gave above with Chroma becoming powerless when Lightness is at the ends of the range.

Then, this is what the spec currently says, and it should say powerless for saturation/chroma (and add examples):

If the saturation of an HSL color is ''0%'',
then the hue component is [=powerless=].
If the lightness of an HSL color is ''0%'' or ''100%'',
the hue component is [=powerless=]
and the saturation is ''0%''.

If the chroma of an LCH color is ''0%'',
the hue component is [=powerless=].
If the lightness of an LCH color (after clamping) is ''0%'',
or ''100%'',
the hue component is [=powerless=]
and the chroma is ''0''.

  If the chroma of an Oklch color is ''0%'' or 0,
	the hue component is [=powerless=].
	If the lightness of an Oklch color is ''0%'' or 0,
	or ''100%'' or 1.0,
	the hue component is [=powerless=]
	and the chroma is ''0''.

Lets get consensus on the spec for this aspect, then pull WPT into alignment with that.

@romainmenke
Copy link
Member Author

I've also confirmed these results with a second library (https://colorjs.io/notebook/) and came to the same conclusion.

Are these tools you mention up to date with the latest specification?

Can you provide reproducible examples?

@romainmenke
Copy link
Member Author

I don't think colorjs is up to date :

Screenshot 2023-08-22 at 15 05 58

If it were up to date it wouldn't go through pink.

@facelessuser
Copy link

facelessuser commented Aug 22, 2023

As far as the topic of demonstrating proper conversion and interpolation, both libraries support the features to demonstrate correct results if configured correctly. They may not demonstrate browser "automatic" behavior unless you configure the statements correctly. So don't let your assumptions about browsers fool you into thinking they are not correct, they just don't automatically do what browsers do by default. I don't maintain Color.js and only use Color.js casually, so I cannot speak to whether there are bugs, but as far as the current topic is concerned, it appears to be correct.

The specification states that hue is powerless when chroma is 0%.
I don't understand how you get a hue of 30deg here :

Let me clear something up. My library will not assume hue is powerless like CSS does when an explicit hue is provided (conversion may cause a hue to go powerless). So I am not demonstrating how a browser should handle chroma. What I am demonstrating is the claim that your results are treating hue has powerless is not correct. If it was treating hue as powerless you would have had something approximately close to rgb(226.44 146.99 171.23), not rgb(230.28 149.72 136.2).

That is my statement. I demonstrate this by showing an example with an explicit hue that is not being treated as powerless and one where it is.

Can you provide reproducible examples?

Yes, put this into https://colorjs.io/notebook/. And you will get your RGB results, but with an OkLCh hue of 30. The second example will give you a powerless example.

let color1 = new Color("oklch(75% 0% 60deg)");
let color2 = new Color("oklch(75% 50% 0deg)");
let notPowerless = color1.mix(color2, {space: 'oklch'});
notPowerless.to('srgb').toGamut({method: 'oklch.chroma'});
color1.set('hue', NaN);
let powerless = color1.mix(color2, {space: 'oklch'});
powerless.to('srgb').toGamut({method: 'oklch.chroma'});

You will have to convert the percent values to non-percent, but you will see it matches my results.

Are these tools you mention up to date with the latest specification?

It depends on what part of the specification you are talking about.

Color.js behaves in a similar way as my library, so explicit, user defined hues will not be treated as undefined unless a conversion causes them to be undefined. So don't assume browser behavior in this sense, but the logic of the statements is correct.

Color.js does not support none yet, but my library does. Color.js does not support carryforward when interpolation, mine does as an experimental, undocumented feature that must be enabled.

While my goal is not to mirror the browser behavior, I am able to demonstrate it when required.

@facelessuser
Copy link

@romainmenke Even in your own tool, the only way to get rgb(230, 150, 136) is to use oklch(0.75 0.1 30)

Screenshot 2023-08-22 at 7 40 21 AM

It is possible that you made a typo?

@romainmenke
Copy link
Member Author

romainmenke commented Aug 22, 2023

Let me clear something up. My library will not assume hue is powerless like CSS does when an explicit hue is provided (conversion may cause a hue to go powerless). So I am not demonstrating how a browser should handle chroma.

Isn't it confusing to go beyond the scope of CSS and expected browser behavior?

To me this feels like comparing apples and oranges and it keeps throwing me off balance.

I will gladly admit that I am often incorrect here and that I make many mistakes both in statements here and in implementations. So I welcome anything that challenges my assumptions.

But I think it would be best to keep everything focussed on CSS and expected browser behavior.

I also try to avoid comparing with tools that go beyond the scope of CSS because it is too easy to get lost in what is essentially user error.


It is possible that you made a typo?

That is always possible


As far as I know these things are true :

  • when interpolating with a missing component you should take the component from the other color
  • powerless components can become missing components at specific points
  • hue becomes powerless when chroma is 0

So when applying this by hand :

  • oklch(75% 0% 60deg)
  • oklch(75% 50% 0deg)
  • lightness is 75% in both colors, so outcome is 75%
  • chroma is 50% and 0%, so outcome is 25%
  • hue is 60deg and 0deg, but 60deg is powerless given the 0% chroma, outcome is 0deg
  • final result is oklch(75% 25% 0deg)

Ignore any tool, library, browser implementation.
This is simply taking the sum of components and dividing by 2,
or even simpler, just taking the component from one of the source colors.

Are these steps correct for the current latest versions of all relevant specifications?

@facelessuser
Copy link

facelessuser commented Aug 22, 2023

Isn't it confusing to go beyond the scope of CSS and expected browser behavior?

I'm not going beyond the scope of CSS, but I'm using a tool to demonstrate the behavior. So, let me summarize as I think I understand now where you went wrong. I was focused on your RGB results. You were both right and wrong, so I'm thinking maybe you made a typo?

Here is your original statement.

color-mix(in oklch, oklch(75% 0% 60deg), oklch(75% 50% 0deg))
hue is powerless
hue isn't interpolated, instead it is taken from the other color
result is oklch(75% 25% 0deg) or roughly rgb(230, 150, 136)

While oklch(75% 25% 0deg) is correct, rgb(230, 150, 136) is not correct. The RGB value can only be acquired by treating the hues as not powerless.

The correct result is rgb(226, 147, 171) which your tool also demonstrates.

Screenshot 2023-08-22 at 8 10 34 AM

EDIT: using percent notation:

@svgeesus
Copy link
Contributor

I don't think colorjs is up to date :

Correct. We don't currently support none, on input or output and thus, lack support for missing values in color interpolation hence the spurious zero-hued pinks in some examples.

@romainmenke
Copy link
Member Author

romainmenke commented Aug 22, 2023

I'm not going beyond the scope of CSS, but I'm using a tool to demonstrate the behavior.

I think I am often confused because I don't know exactly which values you are challenging. In this case I thought you were questioning the validity of the oklch results when in fact you where looking mostly at the rgb values.


I think I know what we are seeing.

In the current version of the specification hue does indeed become powerless, but it does not become missing.

Setting powerless components to missing only happens when converting to a different color space.

So the rgb result is roughly accurate (there is still gamut mapping), but the manual interpolation I did for oklch is incorrect.


This unfortunately means that users still can not mix a random color with some other color in the same color space where one of them has no chroma/saturation. In those cases there will be an unexpected hue shift.

Users would need to :

  • set the correct hue, even for values with chroma of 0
  • set hue to none manually
  • mix different color spaces to trigger conversion to missing components

I think this is problematic because the input colors might not be known to the user. They might be the result of relative color syntax or color-mix

https://drafts.csswg.org/css-color/#powerless

If a powerless component is manually specified, it acts as normal; the fact that it’s powerless has no effect. However, if a color is automatically produced by color space conversion, then any powerless components in the result must instead be set to missing, instead of whatever value was produced by the conversion process.

@facelessuser
Copy link

I apologize as I guess I'm not always as clear as I should be. I often don't use browsers to demonstrate as they are a bit all over the place in implementations, though they are getting better. But that means I have to demonstrate with tools, and people obviously are not familiar with how those tools behave. I often think what I'm showing is clear, but that is because I know how what I'm showing works.

@romainmenke
Copy link
Member Author

romainmenke commented Aug 22, 2023

I appreciate that @facelessuser 🙇
I could have taken more time to try and understand your feedback and will do so in the future.


To circle back to the question:

So for example, oklch(100% 50% 0deg) will display as white but will interpolate as though its chroma is 50% and its hue matches the other color's?

No, this was recently edited so that only 0 chroma results in a powerless hue component.

Even then, powerless components are not treated as missing unless there is also color space conversion.

If a powerless component is manually specified, it acts as normal; the fact that it’s powerless has no effect. However, if a color is automatically produced by color space conversion, then any powerless components in the result must instead be set to missing, instead of whatever value was produced by the conversion process.


current specification :

  • 0% or 100% lightness doesn't make any component powerless
  • 0 chroma makes the hue component powerless
  • only during color conversion do components become missing

100% lightness :

  • color-mix(in oklch, oklch(100% 50% 60deg), oklch(50% 50% 0deg))
  • nothing is powerless and nothing is missing
  • is a simple linear interpolation of components
  • result is oklch(75% 50% 30deg) or roughly rgb(255, 125, 104)

0% chroma :

  • color-mix(in oklch, oklch(75% 0% 60deg), oklch(75% 50% 0deg))
  • hue is powerless, but not missing
  • everything is already oklch
  • result is oklch(75% 25% 30deg) or roughly rgb(230, 150, 136)

0% chroma and explicit none :

  • color-mix(in oklch, oklch(75% 0% none), oklch(75% 50% 0deg))
  • hue is missing
  • everything is already oklch
  • result is oklch(0.75 0.1 0) or roughly rgb(226, 147, 171)

0% chroma and different color space :

  • color-mix(in oklch, rgb(255, 255, 255), rgb(180, 6, 95))
  • inputs are rgb and converted to oklch
  • for rgb(255, 255, 255) the hue is powerless and becomes missing during color space conversion
  • hue isn't interpolated, instead it is taken from the other color
  • result is oklch(0.75031 0.10016 359.858) or roughly rgb(227, 147, 171)

0% chroma and different color space :

  • color-mix(in lch, oklch(75% 0% 60deg), oklch(75% 50% 0deg))
  • inputs are oklch and converted to lch
  • for oklch(75% 0% 60deg) the hue is powerless and becomes missing during color space conversion
  • hue isn't interpolated, instead it is taken from the other color
  • result is oklch(0.74979 0.09824 0.1059) or roughly rgb(226, 147, 171)

I am fairly certain that this is correct for the current latest specification, but as stated previously, I might be wrong about stuff.

Please let me know if I missed something else that also triggers the conversion from powerless to missing in the current specifications.

@facelessuser
Copy link

@romainmenke our results match 🙂. Live example here

The only thing that could change this is if CSS requires an OkLCh (or any cylindrical space) hue to become missing during interpolation even if an explicit hue is defined and chroma is zero. I don't recall exactly what CSS specifies here, and isn't something I would automatically do in a generic color library by default, but if browsers need to do this, or need to in the future, obviously some results would be different after normalizing those hues to "missing".

Screenshot 2023-08-22 at 9 29 39 AM

@svgeesus
Copy link
Contributor

@facelessuser wrote:

The only thing that could change this is if CSS requires an OkLCh (or any cylindrical space) hue to become missing during interpolation even if an explicit hue is defined and chroma is zero. I don't recall exactly what CSS specifies here,

It does require it. The spec says:

If a color with a carried forward missing component is interpolated with another color which is not missing that component, the missing component is treated as having the other color’s component value.
Therefore, the carrying-forward step must be performed before any powerless component handling.

and also says, for Oklch:

If the chroma of an Oklch color is 0% or 0, the hue component is powerless.

and, for CIE LCH:

If the chroma of an LCH color is 0%, the hue component is powerless.

and says, for HSL:

If the saturation of an HSL color is 0%, then the hue component is powerless.

HWB is polar but doesn't have a Chroma or Saturation analog so the HWB doesn't have powerless hues. Which is odd, but I don't see an obvious way around that.

It also says, regarding hues (my emphasis):

components are the most common components to become powerless; any achromatic color will have a powerless hue component.

Lastly, and allowing some implementation-defined wiggle room it says:

User agents may treat a component as powerless if the color is "sufficiently close" to the precise conditions specified. For example, a gray color converted into lch() may, due to numerical errors, have an extremely small chroma rather than precisely 0%; this can, at the user agent’s discretion, still treat the hue component as powerless. It is intentionally unspecified exactly what "sufficiently close" means for this purpose.

@romainmenke
Copy link
Member Author

romainmenke commented Aug 22, 2023

@svgeesus But powerless is not the same as missing, correct?

And the specification currently also says that powerless itself has no effect, except when conversion is involved :

If a powerless component is manually specified, it acts as normal; the fact that it’s powerless has no effect. However, if a color is automatically produced by color space conversion, then any powerless components in the result must instead be set to missing, instead of whatever value was produced by the conversion process.

@facelessuser
Copy link

From my understanding powerless means a value is present, but its influence on things is non-existent. missing means it was not specified (either by the user or the conversion algorithm) and is undefined; therefore, it has no effect because it does not exist. They are different, but they behave in a similar manner.

I was also aware that carryforward needed to be handled before powerless handling.

I had a vague recollection that during interpolation CSS may ignore explicit hues if chroma is zero (or near zero). It's also not something I've implemented in my color library on purpose as I want users to have explicit hues respected. If conversions occur, all bets are off. But I understand why CSS would do this: to make it so people don't have to think about things when they interpolate with achromatic colors. My goals are a bit different than CSS. I imagine I could always add a CSS normalization flag if needed.

Does an undefined chroma cause hue to be powerless as well? I know zero chroma does, and I know when undefined is normalized it becomes zero, but technically none could be anything...

@svgeesus
Copy link
Contributor

Has no effect on the rendered color, as it says right above your quoted text:

Individual color syntaxes can specify that, in some cases, a given component of their syntax becomes a powerless color component. This indicates that the value of the component doesn’t affect the rendered color; any value you give it will result in the same color displayed in the screen.

But yes, I see your point: if one or both of the colors to be interpolated is already in the interpolation colorspace, it will have a powerless component not a missing component. The current text assumes that all colors are converted into the interpolation colorspace, and the part about classifying missing components doesn't quite capture that.

@facelessuser
Copy link

But yes, I see your point: if one or both of the colors to be interpolated is already in the interpolation colorspace, it will have a powerless component not a missing component. The current text assumes that all colors are converted into the interpolation colorspace, and the part about classifying missing components doesn't quite capture that.

I guess that answered my question as well. If all colors are assumed to be converted, then none chroma becomes zero and makes hue powerless and then chroma is carried forward and replaced as none again.

@facelessuser
Copy link

Oh, I see now. This conversion assumption (all colors are converted for interpolation) makes carryforward mandatory or it would wipe out user-defined none values.

@romainmenke
Copy link
Member Author

Also see : #9220

@facelessuser
Copy link

Yeah, getting all of this clear is important. Unfortunately, if colors already in the color space have hue normalized, and if chroma is none makes hue none, then you can't do things like color-mix(in oklch, oklch(none none 40), oklch(0.5 0.3 270)) to just mix the hue of any color. as hue would also become none in this case. I guess relative color syntax could be used to maybe get around this, but it makes what I thought was intuitive less so.

@nex3
Copy link
Contributor

nex3 commented Aug 22, 2023

But yes, I see your point: if one or both of the colors to be interpolated is already in the interpolation colorspace, it will have a powerless component not a missing component. The current text assumes that all colors are converted into the interpolation colorspace, and the part about classifying missing components doesn't quite capture that.

I think I brought this issue up with @tabatkins a while ago, and the conclusion was that the text (source)

Interpolation between <color> values occurs by first converting them to a given color space

was interpreted to mean that conversion always happened for interpolation, even when it was to the same color space, and so powerless components became missing. Tab may have a better source for that than my memory.

@facelessuser
Copy link

I've updated the example to use CSS's "powerless" hues and carryforward (though carryforward doesn't apply to any of the previous cases). Only the second case is different:

0% chroma :

  • color-mix(in oklch, oklch(75% 0% 60deg), oklch(75% 50% 0deg))
  • hue is powerless, but not missing
  • everything is already oklch
  • result is oklch(75% 25% 0) and rgb(226.44 146.99 171.23)

Assuming I haven't made a misstep in interpreting how powerless hues act, this should be correct. I guess this is subject to change if I'm way off 🙃.

Live example here.

@romainmenke
Copy link
Member Author

romainmenke commented Aug 28, 2023

was interpreted to mean that conversion always happened for interpolation, even when it was to the same color space, and so powerless components became missing. Tab may have a better source for that than my memory.

This however isn't correct for the current specification, which literally states that conversion only happens when required. So maybe the wording needs to change?

https://drafts.csswg.org/css-color/#interpolation

emphasis mine :

Interpolation between values occurs by first checking the two colors for analogous components which will be carried forward; then (if required) converting them to a given color space which will be referred to as the interpolation color space below, and then linearly interpolating each component of the computed value of the color separately.

I've updated our implementation so that conversion always happens, even when the input and output color space are the same. This effectively removes the difference between powerless and missing in the context of interpolation.

As far as I can tell this gives better results when interpolating between any color and white/black/... when both are described in the same cylindrical polar color model.

You can play around with this here : https://preset-env.cssdb.org/blog/css-color-parser-v1.0.0/

I've also added an output in oklch format.


@facelessuser For the second case I now have :

0% chroma :

  • color-mix(in oklch, oklch(75% 0% 60deg), oklch(75% 50% 0deg))
  • hue is powerless and becomes missing during conversion
  • everything is already oklch
  • result is oklch(0.75 0.1 0) or roughly rgb(226, 147, 171)

I think this matches your results, right?
ignoring percentage vs. number notation

@facelessuser
Copy link

I think this matches your results, right?
ignoring percentage vs. number notation

@romainmenke Yep, as far as I can see, it looks like an exact match.

@svgeesus
Copy link
Contributor

svgeesus commented Oct 2, 2023

I don't think colorjs is up to date :

Correct. We don't currently support none, on input or output and thus, lack support for missing values in color interpolation hence the spurious zero-hued pinks in some examples.

Color.js now accepts none on input and produces it on output.

@svgeesus
Copy link
Contributor

I've updated our implementation so that conversion always happens, even when the input and output color space are the same. This effectively removes the difference between powerless and missing in the context of interpolation.

Agreed, and now required with 1b3a4bf

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
css-color-4 Current Work
Projects
None yet
Development

No branches or pull requests

6 participants