color-js / color.js

Color conversion & manipulation library by the editors of the CSS Color specifications
https://colorjs.io
MIT License
1.92k stars 82 forks source link

`none` should be turned into `null`, not `NaN` #409

Closed LeaVerou closed 5 months ago

LeaVerou commented 8 months ago

This was a resolution in a past breakout with @svgeesus but I’m opening this so we can track it.

Color.js currently uses NaN values to represent CSS none (e.g. for achromatic colors). However, CSS also now has a NaN value, which is currently impossible to represent in Color.js in a distinct way.

Therefore, In the next non-minor version, we will start using null to represent none.

Authors can actually handle both, by detecting which value is used via:

const NONE_COORD = new Color("rgb(none none none)").coords[0].valueOf()

They can then use NONE_COORD instead of a hardcoded NaN so that nothing breaks in their code once they upgrade.

I therefore plan to announce this change in the release notes for v.0.5.0 and recommend they handle it that way to prepare.

LeaVerou commented 7 months ago

So now we’ve announced that the change is coming. How should we go about with this? How much effort should we put in making the migration gentler?

  1. Should we just make the change and just announce it in the changelog, since we gave people a heads up? This involves the least effort on our side.
  2. Should we print out a one-time warning about the change if NaN is used, and silently convert it to null?
  3. Should we handle (and preserve) both NaN and null when found in coords, and print out a one-time warning for NaN?
  4. Something else?
facelessuser commented 7 months ago

So, I'm curious, are NaN and none (null) to behave identically and only serialize differently? Are NaN values meant to be disallowed from the future syntax of Color CSS? Or are both going to be allowed?

Mathematically, they will not behave the same. I guess it will be easier to miss when null handling isn't preserved as null participates as 0 in math, but NaN is infectious and always yields NaN in math. But I guess also less likely to break in big ways, probably more subtle.

facelessuser commented 7 months ago

The more I think about it the more questions I have. If both are preserved and you are interpolating between both NaN and null, which is returned, NaN or null?

This seems like an unfortunate complication in CSS colors. It doesn't seem to provide anything extra, only confusion. It's a shame none can't just be an alias for NaN in colors, or that NaN just wasn't used from the beginning.

Is there value in preserving both NaN and null in colors? Especially if they are meant to behave the same? I guess I would opt for a solution that normalizes these two values. I'm not sure how having both null and NaN helps users.

LeaVerou commented 7 months ago

So, I'm curious, are NaN and none (null) to behave identically and only serialize differently?

Nope, they mean different things. I was only suggesting they behave identically in v0.6.0 as a way to ease the transition.

The more I think about it the more questions I have.

These are excellent questions for CSS Color! Pinging the other editors, @svgeesus @tabatkins

NaN is not color specific, it's a css-values thing: https://drafts.csswg.org/css-values/#calc-error-constants That said, since it exists in the language, it could be specified in a color. I would expect it behaves the same as any other number, meaning…

If both are preserved and you are interpolating between both NaN and null, which is returned, NaN or null?

NaN, since any number interpolated with none returns that number.

This seems like an unfortunate complication in CSS colors. It doesn't seem to provide anything extra, only confusion. It's a shame none can't just be an alias for NaN in colors, or that NaN just wasn't used from the beginning.

I believe @tabatkins has strong opinions on why that would have been a mistake, and I hope he is willing to explain or point to his comments on the matter (FWIW Chris and I had your view and he convinced us 😬 )

Is there value in preserving both NaN and null in colors? Especially if they are meant to behave the same? I guess I would opt for a solution that normalizes these two values. I'm not sure how having both null and NaN helps users.

They are not meant to behave the same, nor do they represent the same thing, see above.

facelessuser commented 7 months ago

They are not meant to behave the same, nor do they represent the same thing, see above.

The representation difference I get. One is number thing and maybe stuff like calc() can return NaN, and in colors, none represent undefined values, but how do they differ in behavior?

So, functionality-wise, none is not infectious like NaN or is it? I get specifically in interpolation that none takes on the value of the other color. But if not in interpolation what happens? If it isn't infectious, I imagine it is very easy for it lose its meaning accidentally if you aren't careful.

Does NaN no longer obey the same interpolation rules as none? Does it just infect the other color with itself during interpolation? I guess this would mean it isn't checked for and just allowed to pass through as a normal number to wreck havoc?

Are NaNs no longer resolved to a real value at any time? The color spec doesn't speak to any of this currently. As far as Im aware, it still speaks to NaNs representing undefined coordinates.

facelessuser commented 7 months ago

I had remembered the discussion about using NaN vs none and found it here: https://github.com/w3c/csswg-drafts/issues/6107. I think it didn't bother me at the time because, functionality-wise, the end result was going to be the same, or at least seemed to be. It seemed more of a syntax issue difference making clear the difference of what NaN meant vs none, but I guess it didn't clear up behavior.

At the time, there was no NaN in CSS. I think now that NaN is being introduced with none, now things become murky, and I don't think the spec really clears up how one behaves vs the other in colors. Or maybe I was always wrong and none was not meant to behave like NaNs.

facelessuser commented 7 months ago

Okay, I did find this in the Color 5 spec:

However, if calculations are done on missing values, none is treated as 0.

I guess this explains the treatment of undefined as a null. So, I guess that answers my question. Undefined is no longer infectious (which is a shame because it made sense), but I get its context when looking at CSS. And NaN just behaves as NaN. So this change will be a surprising change in Color.js.

LeaVerou commented 7 months ago

So, functionality-wise, none is not infectious like NaN or is it? I get specifically in interpolation that none takes on the value of the other color. But if not in interpolation what happens?

It's exactly the opposite actually: none + anything that is not none = not none

When doing math (e.g. calc(h + 10)) or color space conversions, none gets coerced to 0 IIRC (@svgeesus confirm?)

If it isn't infectious, I imagine it is very easy for it lose its meaning accidentally if you aren't careful.

Precisely! That's why it's important not to do conversions we don't need (such as converting colors that are in-gamut in a GMA that doesn't modify in-gamut colors).

Does NaN no longer obey the same interpolation rules as none? Does it just infect the other color with itself during interpolation? I guess this would mean it isn't checked for and just allowed to pass through as a normal number to wreck havoc?

I would imagine that interpolating NaN with none would just make the whole component NaN. @svgeesus we need to make sure the spec is clear here.

Are NaNs no longer resolved to a real value at any time? The color spec doesn't speak to any of this currently. As far as Im aware, it still speaks to NaNs representing undefined coordinates.

NaN is a first-class value in CSS, it resolves to itself.

I guess this explains the treatment of undefined as a null. So, I guess that answers my question. Undefined is no longer infectious (which is a shame because it made sense), but I get its context when looking at CSS. And NaN just behaves as NaN. So this change will be a surprising change in Color.js.

I don't understand what you're saying here, could you elaborate a bit more? Why is it a shame that undefined is no longer infectious? How are we treating undefined as null? (we shouldn't!) And why would it be a surprising change?

facelessuser commented 7 months ago

I don't understand what you're saying here, could you elaborate a bit more? Why is it a shame that undefined is no longer infectious? How are we treating undefined as null? (we shouldn't!) And why would it be a surprising change?

Undefined is no longer NaN, we are using null, quite literally, and the behavior is exactly described as null. Their behavior is very different. We are replacing one concept with the other, which is fine. It is a sizeable breaking change, but I get where it is coming from.

Before, undefined operated as NaN, at least how it was implemented. Originally that was intentional, obviously not anymore. It had no value; therefore, you could not do calculations with it, not until you explicitly defined it. This made sense, at least to me. How can I add to something that doesn't exist?

> a = NaN
NaN
> a + 3
NaN
> a = 0
0
> a + 3
3

Now it is null, and we will quite literally be using null. It is uniquely different, and has its own identity up until you do anything with it, then it is zero (at least in most languages). This describes the behavior in the spec and it quite literally mimics how null behaves.

> a = null
null
> a + 3
3

It's a change in concept and any library that implemented the NaN behavior will have to consider, do we require 100% parity with CSS or do we support parsing CSS color syntax only, which is fine. Color.js wants to represent CSS, not only in syntax, but behavior.

As a side note, I've personally found the very specific behavior of undefined == 0 (whether mimicking NaN or null) to sometimes be lacking in the user experience, from a general purpose color library standpoint. Consider below.

>>> Color('black').convert('acescc')
color(--acescc -0.35845 -0.35845 -0.35845 / 1)
>>> Color('black').convert('acescct')
color(--acescct 0.07291 0.07291 0.07291 / 1)

Neither of these two spaces define black with zeros , not that undefined has to resolve to black, per se, but if we apply them, we get out of gamut results for acescct. Not very helpful.

>>> Color('acescct', [0, 0, 0]).convert('srgb')
color(srgb -0.07781 -0.07781 -0.07781 / 1)

These cases are rare, but this inflexibility can break in certain aspects. I initially had this issue with spaces like HCT and JzCzhz, has zero chroma and zero hue no longer mean white, gray, etc. (at least not pure white and gray). At least with these spaces, I think I'm starting to come to the realization that they are accounting for adapting luminance and background luminance, and when you do this, the achromatic colors appear different than pure white, pure gray, etc. If you "discount the luminance", you get a more traditional achromatic response of pure achromatics. So maybe the concept of zero chroma and zero hue must be pure achromatic was flawed initially, at least when looking at JzCzhz, CAM16, ZCAM, etc.

facelessuser commented 7 months ago

I may be incorrectly lumping JzCzhz in with CAM16 and ZCAM, it has a similar achromatic response, but maybe its response is simply based off of less accurate matrices 🤷🏻.

tabatkins commented 7 months ago

The arguments against lumping NaN with none in CSS were in https://github.com/w3c/csswg-drafts/issues/6107, especially this comment. In short: while NaN can serve as a reasonable sentinel value in many situations (because it's not part of the normal value space), you don't actually want author-produced NaNs (which come from math errors) to trigger this behavior. CSS can easily avoid the sentinel value issue and just use a different value (a keyword, in this case), thus treating any NaNs that are accidentally fed into the functions as an error case.

However, @LeaVerou, note that the color functions never see a NaN. A NaN gets censored into infinity when it exits the the math function, and so the color function only ever sees infinity (which it then arbitrarily treats as 0deg). So there's no CSS-compat reason to support reflecting NaN in colors.

facelessuser commented 7 months ago

However, @LeaVerou, note that the color functions never see a NaN. A NaN gets censored into infinity when it exits the the math function, and so the color function only ever sees infinity (which it then arbitrarily treats as 0deg). So there's no CSS-compat reason to support reflecting NaN in colors.

Ah, that is correct, we can't directly use NaN or Infinity in colors as things are currently, not as far as CSS syntax is concerned. They would have to be wrapped in calc(), something they can't escape.

So really, assuming Color.js really prefers nulls in general, it could just convert NaNs to null and call it a day. If a change must be made, I imagine this would be the least breaking. So what this really boils down to is whether color.js prefers NaN behavior or null behavior.

LeaVerou commented 7 months ago

@tabatkins I thought users can also directly specify NaN?

@facelessuser Even if NaNs don't naturally appear in CSS colors, they can absolutely appear in JS when math is involved, and they should be distinct than a value that means "take the other component when interpolating".

facelessuser commented 7 months ago

@facelessuser Even if NaNs don't naturally appear in CSS colors, they can absolutely appear in JS when math is involved.

Yep, that is certainly true.

facelessuser commented 7 months ago

Putting my opinions aside, I think I understand the situation and how the behavior will change now, and I now understand the reasoning behind it.

Because this is still a < 1.0 version, and I'd hate to see the overhead required to validate every value on every set, I'm kind of leaning more towards just making the change, with a bold note in the changelog of the breaking change.

tabatkins commented 7 months ago

I thought users can also directly specify NaN?

Nope, they can say calc(NaN), but that's still identical to calc(infinity).

LeaVerou commented 5 months ago

This issue is a blocker for v0.6.0 so I’m keen to resolve it ASAP.

Thinking back to…

So now we’ve announced that the change is coming. How should we go about with this? How much effort should we put in making the migration gentler?

  1. Should we just make the change and just announce it in the changelog, since we gave people a heads up? This involves the least effort on our side.
  2. Should we print out a one-time warning about the change if NaN is used, and silently convert it to null?
  3. Should we handle (and preserve) both NaN and null when found in coords, and print out a one-time warning for NaN?
  4. Something else?

It seems like there was no resolution?

If so, I think this what I’ll do:

  1. Parse none as null
  2. Parse NaN as NaN
  3. Serialize null as none
  4. Serialize NaN as calc(NaN)
  5. That’s it. No warnings, no migration code, no assumptions that if NaN is used you really meant null, they both have distinct purposes.
lloydk commented 5 months ago

Should NaN be converted to 0 when converting between colors or is it left as NaN?

LeaVerou commented 5 months ago

Should NaN be converted to 0 when converting between colors or is it left as NaN?

I think it's fine to leave it as NaN, but feel free to open another issue to discuss that.

sidewayss commented 2 months ago

1) I just came back to fiddling with Color.js and I noticed that .coords returns null where it had previously returned NaN, equivalent to CSS none. Based on this discussion, I see that NaN as a coordinate value now has the standard meaning of erroneous numeric value. In terms of coordinates, are those the only two changes?

2) I can't conceive of a situation where I'd intentionally put NaN anywhere in a CSS rule, so it must apply to function return values, like calc(). That would quasi-align with the use of calc(NaN) earlier in this discussion. Is that correct?