facelessuser / coloraide

A library to aid in using colors
https://facelessuser.github.io/coloraide
MIT License
194 stars 12 forks source link

CSS Hue handling for longer #366

Closed facelessuser closed 1 year ago

facelessuser commented 1 year ago

EDIT: The title has been updated as the issue is actually related in general to hue handling and has nothing to do with carry forward or auto powerless handling.

Reference: https://github.com/w3c/csswg-drafts/issues/9224

From the beginning, undefined values have been resolved at interpolation time. This occurred post-hue fix-up (longer, shorter, etc.). We had confirmed this behavior with the color.js that was meant to represent the CSS spec. We've seen other libraries implement this in the same manner, for instance, culori.

color.js ```js > new Color("hsl(90deg 50% 50%)").mix('hsl(none 50% 50%)', {space: 'hsl', hue: 'longer'}); Color { space: ColorSpace { id: 'hsl', name: 'HSL', base: RGBColorSpace { id: 'srgb', name: 'sRGB', base: [RGBColorSpace], aliases: undefined, fromBase: [Function: fromBase], toBase: [Function: toBase], coords: [Object], white: [Array], formats: [Object], referred: 'display', path: [Array] }, aliases: undefined, fromBase: [Function: fromBase], toBase: [Function: toBase], coords: { h: [Object], s: [Object], l: [Object] }, white: [ 0.9504559270516716, 1, 1.0890577507598784 ], formats: { hsl: [Object], hsla: [Object] }, referred: undefined, path: [ [ColorSpace], [RGBColorSpace], [RGBColorSpace], [Circular *1] ] }, coords: [ 90, 50, 50 ], alpha: 1 } ```
culori.js ```js > culori.interpolate(["hsl(90deg 50% 50%)", "hsl(none 50% 50%)"], 'hsl', {h: {fixup: culori.fixupHueLonger}})(0.5) { mode: 'hsl', h: 90, s: 0.5, l: 0.5 } ```

The above CSS issue seems to claim that such a case should resolve as 270deg for a 50% mix. This is because undefined values are resolved prior to hue fix-up, which is quite surprising.

It appears that CSS handling of carry forward / powerless is likely more complicated than originally thought, so much so that implementing it properly may disrupt interpolation flow as it currently stands if we attempt to implement them as the CSSWG is currently advocating for.

It sounds like during carrying forward, undefined values are resolved before powerless resolution, but then alpha must be retained as undefined for premultiplication but then resolved as normal for interpolation. This is more complicated and would require us to rethink our flow if we want to mimic them. This seems unnecessarily complicated and a deviation from the simplicity of interpolation prior to these CSS carryforward and powerless steps.

If this truly is the way they expect things, and we want to mimic their approach, it is likely we may not allow these options to be globally enabled. Instead, we may only allow these during simple linear interpolation mode as undefined handling in bspline and continuous cases are much more complicated that the overly simplistic CSS linear case.

Or maybe we will instead have to implement a CSS interpolation mode along with a refactoring of when and how hue interpolation is handled.

facelessuser commented 1 year ago

What the most recent CSS is doing really doesn't make sense the more I think about it.

If we are interpolating between a hue and an undefined hue, we would expect that no hue interpolation would take place. So if we mix 90deg and none, the resolution of any percentage of mixing should result in 90deg. This is because when we interpolate, we have no choice assume the first hue as the second, and interpolating between the two would give you the exact same value. This is the same as thinking that since we have no second value to interpolate, we have no choice but to accept the first as the resolution of interpolation. As this is exactly what they implemented in their own library from the beginning, this makes sense. So whether you are doing 'longer', 'shorter, etc. the result is the same, interpolation between90degandnoneis90deg`.

But with all the new confusing additions with carrying forward and such, they are now resolving the hue before interpolation and such that now we are not evaluating 90deg and none, but 90deg and none. During carry forward, we decided to have the undefined hue accept the value of the defined hue, then we do hue fix up, is backwards from the previous logic. By time we actually interpolate, we are assuming that both colors are 90deg hues, and if interpolating with 'longer', we interpolate around the wheel giving a rainbow of colors. This fundamentally changes how we think about undefined hues. What was once intuitive is now not. There is this magic happening now that I think is still ill defined.

While before, the interpolation logic had no choice but to return the only defined hue during interpolation as there was nothing to interpolate against, CSS is now implying, "no, by time you interpolate, both hues are actually defined implicitly" (due to magic of now inheriting the opposing hue during carry forward), so we are interpolating between two real hues.

facelessuser commented 1 year ago

The good news is that we clearly state both of these implementations (carryforward/powerless) are experimental as I was certain we were going to run into something like this. We have no obligation to provide a stable implementation if we do not want to, and we can remove the experimental implementation if we so choose.

If we want to support whatever CSS is doing, we have to restructure how steps are handled in the interpolation plugin. As mentioned earlier, we would most likely push CSS handling into a css-linear plugin or something. This means hue fixup logic may be performed in each plugin.

As far as I can tell, the impact may only affect longer. In most other cases, the outcome would probably be identical to what we already do as the interpolation in almost every case between 90deg and 90deg is 90deg. Only with 'longer' do we go around the color wheel.

If I'm being honest, I don't really like how CSS does this. It causes interpolation between a hue that simply does not exist, but if that is what they want to do, and if we want to provide some compatibility for it, we'll have to adapt. If we don't want to provide a bridge, that is fine as well.

facelessuser commented 1 year ago

After thinking about this a little more, I think CSS is going in a confusing direction that I'm not willing to follow. If I'm interpolating between red and black, if I set the hue to "longer", it should not give me a rainbow of colors that slowly blends into black. Black has no hue, it just doesn't make sense.

This may be unpopular for anyone who wants to directly replicate CSS interpolation, but I think they've made a mistake.

If we are not mimicking CSS in this regard, I wonder if it makes sense to provide the carry forward and powerless options to mimic CSS as we are no longer mimicking them. We would have to decide if there is value in keeping what we have and rebrand it such that it isn't "targeting CSS compatibility".

facelessuser commented 1 year ago

In the end, I now have clarification via https://github.com/w3c/csswg-drafts/issues/9436. CSS treats achromatic interpolation as having an arc when using longer.

This will likely be where we deviate, but we will document why as I think this is a confusing direction.

I think we might be able to have the interpolation plugins absorb the logic of hue fix-up. If I can cleanly do this, then we may be able to provide a CSS interpolation that provides this logic, but this is not a requirement or a priority, it would be an extra if we do expose CSS compatible interpolation. And even then, we would document why we have relegated it to a separate implementation.

I don't think anything is gained by allowing "longer" to behave this way.

facelessuser commented 1 year ago

I've looked into this and we are able to push hue handling safely into the interpolation plugin. This allows us to provide a CSS-specific interpolation mode.

As you can see, here is the behavior of interpolation in HSL with red and an achromatic gray using longer with what we call normal interpolation and then CSS interpolation:

normal-linear

Figure_1

I can't quite understand how this would be useful behavior, but I do recognize that some people may want to have parity with CSS.

We can see that we now get what CSS specifies (a hue of 270) as mentioned in the OP:

>>> Color("hsl(90deg 50% 50%)").mix('hsl(none 50% 50%)', space='hsl', hue='longer', method='css-linear')
color(--hsl 270 0.5 0.5 / 1)