w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.43k stars 656 forks source link

[css-color-4] Inconsistent results with `none` and interpolation color spaces. #8563

Closed romainmenke closed 7 months ago

romainmenke commented 1 year ago

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

Interpolation between values occurs by first 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.

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

In the course of converting the two colors to the interpolation color space, any missing components will be replaced with the value 0.

This seems to lead to unexpected results as currently implemented in Safari and Chrome.

In these examples I am using color-mix with the same color twice to illustrate to unexpected outcome. But the same is true with any combination of colors.


color-mix(in hsl, hsl(90deg 100% none), hsl(90deg 100% 50%));

The source color space, interpolation color space and the color function happen to be the same.

The result is hsl(90deg 100% 50%)


color-mix(in oklch, oklch(none 0.265 135.9), oklch(89% 0.265 135.9));

The source color space, interpolation color space and the color function happen to be the same.

The result is oklch(0.89 0.265 135.9) or roughly hsl(89.91 100% 50%)


color-mix(in oklch, hsl(90deg 100% none), hsl(90deg 100% 50%));

The source color space, interpolation color space and the color function are not all the same.

The result is oklch(0.445225 0.132279 135.943) or roughly hsl(92.45deg 100% 19%)

The hue angle and the lightness have changed, even when the inputs haven't changed and both inputs have the same hue.


Is this behavior intentional?

To me it is very surprising that you can unexpectedly hit or miss this special case where the color spaces and color functions align.

Keeping in mind that CSS authors might be using CSS variables, JavaScript, ... to dynamically combine and alter colors.

tabatkins commented 1 year ago

The issue is that there's a minor conflict between the original purpose of none (don't do weird things like pretending that black has a red hue when interpolating in hsl), and the "eh, if we have the ability, might as well expose it" purpose of an explicit none keyword (since you could force the behavior in a hacky way, might as well bless it with a non-hacky syntax instead). The former must be based on the results post-conversion, when we see what the actual color is and if there are any powerless components. But the latter looks like it could be based on pre-conversion, and as long as you're interpolating in the same space as you're specifying, you can't tell the difference (because there's no conversion anyway).

So currently, the spec's position is that this is somewhat intentional, and if you're using none explicitly, you better be in control of both the specifying and interpolating color spaces.

But this could theoretically be different - we could do a check before conversion and do none-substitution (if both the start and end points are indeed using identical functions), and then do another none check after conversion, as currently specified. But this would be somewhat fragile - it wouldn't persist thru, say, a transition being interrupted and a new transition starting from the current color to a new endpoint. And in the case of dynamic colors being passed around in JS or variables, there's no guarantee that the start and end colors are going to use the same function anyway, so the "pre-substitution" check would be unreliable anyway.

I think the spec's current approach is probably the best overall here, then. Explicit none is only useful when you're controlling the entire transition and can make sure things align; in all other cases conversions are gonna happen and get confusing anway, so sometimes making it work slightly better probably isn't worthwhile.

romainmenke commented 1 year ago

Good to have this background! Yeah, it makes sense to keep this as is.

With relative color syntax, authors also have one more tool to control the outcome here. I think the none keyword will be used mostly to affect interpolation, so this might make sense :

color-mix(in oklch, oklch(from var(--some-color-a), l c none), oklch(from var(--other-color-a), l c h))

As long as this behaviour is known and everyone is ok with it, we can close this issue.

svgeesus commented 1 year ago

The result is oklch(0.89 0.265 135.9)

The result is oklch(0.445225 0.132279 135.943) or roughly hsl(92.45deg 100% 19%)

The hue angle and the lightness have changed, even when the inputs haven't changed and both inputs have the same hue.

The lightness has certainly changed, but a hue angle changing from 135.9 to 135.943 is insignificant and entirely invisible.

svgeesus commented 1 year ago

Explicit none is only useful when you're controlling the entire transition and can make sure things align; in all other cases conversions are gonna happen and get confusing anway, so sometimes making it work slightly better probably isn't worthwhile.

That should probably be called out explicitly in a note in the spec.

romainmenke commented 1 year ago

After giving it more thought and continuing with implementation I am still seeing some unexpected results.


Are these steps correct for color-mix(in oklch, hsl(90deg 100% none), oklch(89% 0.265 135.9))?

starting with hsl(90deg 100% none)

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

In the course of converting the two colors to the interpolation color space, any missing components will be replaced with the value 0.

After replacing none with 0 :

hsl(90deg 100% 0%)


After conversion to oklch :

oklch(0 0 0)


L is 0 and C and H would become powerless. But I am unsure if C and H would need to be handled as such.

Handling them as missing components would result in oklch(none none none) later on.


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

Thus, the first stage in interpolating two colors is to classify any missing components in the input colors, and compare them to the components of the interpolation color space. If any analogous components which are missing components are found, they will be carried forward and re-inserted in the converted color before linear interpolation takes place.

Lightness is missing but has an analogous component in oklch.

oklch(none 0 0)


https://drafts.csswg.org/css-color-4/#ex-analogous-hue

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.

When mixing with oklch(89% 0.265 135.9)

oklch(89% 0 0)

After linear interpolation :

oklch(89% 0.1325 67.95)

The actual result in Chrome is :

oklch(44.5% 0.1325 135.9)

The actual result in Safari is :

oklch(44.5% 0.1325 67.95)

Safari's implementation is still behind a flag and might be behind the spec

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


I am definitely missing a step where I should be handling powerless components, this explains the difference in H between Chrome and results on my end.

But I don't get why the missing L component wasn't carried over. Are these not analogous?

tabatkins commented 1 year ago

Handling them as missing components would result in oklch(none none none) later on.

Hm, I think the spec isn't super clear here, wrt the ordering of powerless->none and none-carrover.

One possible interpretation is that powerless->none happens first. The conversion produces black, which has powerless chroma and hue, so they become none, then the analogous channel kicks in carries forward the none for lightness, so you'll end up with an all-none color. The interpolation will then just always be the other color.

However, I suspect it makes more sense to do the opposite and process the carryover first, then check for powerlessness. This would mean the conversion initially produces oklch(0 0 0), then carryover changes it to oklch(none 0 0), then the powerless check notices a 0 chroma and gives oklch(none 0 none). Interpolation will then interpolate the chroma from 0 to 0.265, while taking the L and H as constants from the other color.

Neither browser is correct per either interpretation of the spec currently - we should add this as a test once we decide which ordering was intended. @svgeesus ?

Edit: actually, let me raise that as a separate issue.

tabatkins commented 1 year ago

And yeah, while writing out #8602 I tried to define a third option that carries over components from the source if the missing->default process renders them powerless (so your example would carry over the specified HSL saturation and hue into OkLCH chroma and hue), but unfortunately there's no generally-doable way to convert a specific value across analogous components.

So this confirms that using none manually is just an authoring convenience that requires control over the entire interpolation process. If conversions happen there's just no smart thing for us to do.

romainmenke commented 1 year ago

Thank you opening the second issue 🙇

So this confirms that using none manually is just an authoring convenience that requires control over the entire interpolation process. If conversions happen there's just no smart thing for us to do.

True and that is fine I think.


Just to be sure, in either case from https://github.com/w3c/csswg-drafts/issues/8602, L in the converted oklch color would become none and should be treated as having the other color’s component value? Assuming that L in HSL and OKLCH are analogous?

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.

The analogous components are as follows:

Category    Components
Reds            r,x
Greens          g,y
Blues           b,z
Lightness   L
Colorfulness    C, S
Hue         H
tabatkins commented 1 year ago

Yes, the carryover definitely requires the color to have a missing lightness after conversion. (And the two L channels are indeed analogous.)

svgeesus commented 7 months ago

So I believe the spec is now clear:

In certain cases, a color can have one or more missing color components.

In this specification, this happens automatically due to hue-based interpolation for some colors (such as white); other specifications can define additional situations in which components are automatically missing.

It can also be specified explicitly, by providing the keyword none for a component in a color function. All color functions (with the exception of those using the legacy color syntax) allow any of their components to be specified as none.

This should be done with care, and only when the particular effect of doing so is desired.

For handling of missing component in color interpolation, see § 12.2 Interpolating with Missing Components.

romainmenke commented 7 months ago

I agree. Thank you for these edits @svgeesus 🙇