w3c / csswg-drafts

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

[css-color-4] Clarify that `none` is preserved in calculations #10211

Open LeaVerou opened 6 months ago

LeaVerou commented 6 months ago

(This came out of my comment here: https://github.com/w3c/csswg-drafts/issues/10151#issuecomment-2052685363 and subsequent comments)

The spec is currently unclear about this, though @svgeesus made the case that this is editorial. @romainmenke thinks it's a substantive change. Regardless, we need to fix it ASAP to avoid web compat roadblocks.

Currently, css-color-4 is a little unclear on what happens with none values in authorland calculations, and implementations are currently converting none to 0 if used in calc() and other math functions, which comes up a lot in Relative Color Syntax. This was never our intent, the only reason converting none to 0 exists is that we don't want to be exposing color space conversion math, and/or sometimes you literally need to actually display a color that includes none components so you need to do something.

Even when converting to different color spaces, the spec already includes the concept of analogous components, to minimize none0 conversions, and in #10210 I proposed expanding it a bit.

Note that while none was originally conceived to express achromatic colors and the chroma of white & black, it is actually useful way beyond that, as it allows expressing parameterizable colors, in a way that decouples the calculation from the color (unlike RCS which requires them both at the same time). You only specify the bits that don't change (e.g. hue), leave the rest none, and let normal CSS operations take their course. E.g. interpolating any (polar) color with oklch(calc(none - 0.4) none none) interpolates with a darker version of that color. Sure, you can do all these things with pure RCS, but this decouples the parameter from the modification, so you don't even need to know what you’re interpolating with, it just works.

Converting to 0 if used in calculations serves no purpose other than simplifying implementations, reduces none’s usefulness in creating dynamic colors that can be passed around, and introduces several problems:

I propose we introduce the concept of none-containing component and clarify that:

One thing we need to sort out is what is the precedence is when a function also accepts none is used in color components. E.g. we recently resolved to allow none for the upper and lower bound of clamp(). In those cases, what does none mean when used in a color component? I would vote for giving precedence to the color-related meaning of none since that serves a unique purpose, whereas none in other places is is simply syntactic sugar.

svgeesus commented 6 months ago

Where would calc-mix() be defined?

LeaVerou commented 6 months ago

Where would calc-mix() be defined?

That is already in css-values-5: https://drafts.csswg.org/css-values-5/#calc-mix

svgeesus commented 6 months ago

TIL

emilio commented 6 months ago

cc @tiaanl

css-meeting-bot commented 6 months ago

The CSS Working Group just discussed [css-color-4][Editorial?] Clarify that `none` is preserved in calculations, and agreed to the following:

The full IRC log of that discussion <fantasai> lea: Awhile back we invented 'none' value for color components to represent achromatic colors (that don't have a particular hue)
<fantasai> lea: when converting gray to a polar format, which hue do you use? none are relevant
<fantasai> lea: then we expanded to chroma and saturation for same reason
<fantasai> lea: eventually it turns out once it was deployed, very useful in many more use cases
<fantasai> lea: lets you represent colors that are parameterizable, that get meaning based on where you use them
<fantasai> lea: e.g. color with nones and lightness, and mix with it to get tints
<fantasai> lea: with relative color syntax becomes even more useful
<fantasai> lea: when we wrote the spec, what do you output a color that has 'none'?
<fantasai> lea: so we decided to make it convert to zero
<dholbert> scribe+
<dholbert> lea: when you're converting from one color space to another, e.g. rgb to lch
<dholbert> ... you don't want to expose color space conversion math
<ChrisL> q+ to agree that coercing none to 0 should be deferred as much as possible, ie used value time
<dholbert> ... this conversion to zero is like a last ditch effort. last thing you do if there's nothing else reasonable to do
<dholbert> ... the way this was interpreted by impls, you also convert to zero when you can't do a computation, e.g. calc(h)
<dholbert> ... this is against author intent; h and calc(h) should really be the same
<dholbert> ... you could imagine having a color in relative color syntax and using calc or calc-mix to ?
<dholbert> ... all of these would be solved if we resolved that 'none' was preserved during calculations. don't convert nones to anything, nones remain nones
<dholbert> ... I've written details about how this might work in the issue
<dholbert> ... interpolation operations have the behavior of the current value of none, only have this behavior if the other value is not "none-containing"
<dholbert> ... if you're interpolating between a value with none to some other value, you get the non-none-containing value
<dholbert> ... [missed some]
<dholbert> ... this reduces magic/discontinuities.
<Rossen__> q?
<dholbert> ... Also need to sort out how to handle this for a syntax that allows 'none', e.g. clamp's 1st or 3rd argument. Is it the 'none' that means no bound, or 'none' that means the color meaning of 'none'?
<dholbert> ... we just need to define precedence. I'd argue for using the color meaning of 'none', but I don't care too much
<Romain> q+
<dholbert> ... main thing I care about is that 'none' values are not dropped
<dholbert> .... ChrisL suggests that this is just an editorial change, but Romain said it's editorial; hence, running by the group
<emilio> q+
<astearns> ack ChrisL
<Zakim> ChrisL, you wanted to agree that coercing none to 0 should be deferred as much as possible, ie used value time
<dholbert> ChrisL: I no longer thing this is editorial. main thing is preserving author intent. Coercing none to zero is an option of last resort if you're literally trying to draw 'none'
<dholbert> ChrisL: other cases, hue of 'none', does that mean they really want a bright magenta red? no
<Rossen__> ack ChrisL
<dholbert> ChrisL: we're seeing people comment on this in implementations, people complaining about why-are-they-getting-a-red-arbitrarily
<TabAtkins> +1 to preserving none. I think it's probably fine to treat `clamp()`'s none as the color none in RCS.
<lea> +q to say let's not conflate preserving none in calculations with preserve none during color space conversions
<TabAtkins> (Because, as noted, you can always write `clamp(-infinity, val, infinity)` instead.)
<dholbert> ChrisL: regardless of the mechanics, the general thing of "we need to preserve this value unless we can get rid of it" is the main thing. we can talk about ways to achieve it separately if needed
<Rossen__> ack Romain
<lea> qq+ to reply to romain
<dholbert> Romain: I was wondering if this is implementable. I assume browsers have an internal representation of color that's channel values, but with this proposal you need to preserve much more information about how the color was specified
<Romain> calc(none + 0.2)
<dholbert> Romain: I think this would be very surprising when you move this across color spaces. When you write: [...]
<dholbert> Romain: if you write this^ in LCH or oklch, the 0.2 has a different scale in both. But if you switch color spaces, it gets preserved and not converted, and that can be surprising
<dholbert> Romain: not an absolute blocker, but does have sharp edges
<dholbert> lea: RE implementability, browsers have to do this with variable references already
<dholbert> emilio: that's not how variable reference works; they're substituted before parsing
<dholbert> emilio: you don't have a concept of a calc with variable references; you have a string with variable references substituted
<dholbert> lea: my point is that not every component actually resolves. Even in rel color syntax, you keep the values of the base color [??]
<ChrisL> right but you don't have an immediately displayable color
<dholbert> lea: let's not conflate preserving none in color space conversions & in calculations
<dholbert> lea: converting btwn color spaces is a whole different beast; that's one of the reasons resolving to 0 was invented
<dholbert> lea: don't want to convert none in a way that exposes color space conversion math
<Romain> q+
<Rossen__> ack lea
<Zakim> lea, you wanted to react to Romain to reply to romain
<ChrisL> https://drafts.csswg.org/css-color-4/#analogous-components
<dholbert> lea: the spec also has a part where if you're converting between two color spaces, where one component of first color space is largely orthogonal to the rest and related to a component in color space b, then swap the none out for zero, and then replace the none in that component [??]
<chrishtr> again+
<chrishtr> q+
<dholbert> lea: let's discuss the color space conversion separately; it's hairy
<dholbert> emilio: I agree that preserving 'none' in some way makes sense
<Rossen__> ack emilio
<chrishtr> q-
<dholbert> emilio: I was going to say something similar to Romain
<dholbert> emilio: when you mix 'none' in more complex calculations... have you given thought to making 'none' more similar to how NaN works? as soon as you mix something with 'none', it remains 'none' instead of being an expression involving 'None'?
<chrishtr> q+
<ChrisL> We invented none because the spec said NaN and tab told us not to :)
<lea> q+
<dholbert> emilio: that would be less complex to implement. Interpolating without having to [...] transforms have such a thing, where you have to keep them as a mix that isn't simplified
<ChrisL> qq+
<dholbert> emilio: that seems like a less confusing model, where doing math with 'None' gives you 'None'
<dholbert> ChrisL: That's what the spec used to say; it used NaN explicitly
<dholbert> ChrisL: Tab suggested we use a special value, which is where None came from
<dholbert> TabAtkins: My argument against NaN was for other reasons, unrelated to infectiousness
<Rossen__> ack ChrisL
<Zakim> ChrisL, you wanted to react to emilio
<lea> Lea: infectious none is actually worse than converting it to 0. E.g. clamp(50, none, 60) is conceptually very different than none, you're basically saying "I want the component of the other color, but it needs to be within this range. Converting the whole component to none in that case could even harm accessibility, if the constraint was there to e.g. ensure sufficient contrast.
<Rossen__> ack lea
<Zakim> lea, you wanted to say let's not conflate preserving none in calculations with preserve none during color space conversions and to
<dholbert> lea: infections 'none' is worse than converting it to zero
<dholbert> lea: if you have an expression clamping 'none' to 50 or 60, the author wants it to be in this range
<ChrisL> https://github.com/w3c/csswg-drafts/issues/6107
<dholbert> lea: if you convert the whole thing to none, then that can harm a11y
<dholbert> Romain: this is still related to color space conversion because the spec says you have to convert to the same color space when doing interpolation
<dholbert> ChrisL: that's correct, because of missing components
<TabAtkins> (We shouldn't use NaN to indicate "no channel value" because NaN *also* comes from simple math errors. We didn't want "author made a math error" to accidentally also trigger "this channel is powerless and should be taken from the opposing color". The infectiousness wasn't a part of this.)
<Rossen__> ack chrishtr
<Rossen__> ack Rossen__
<Rossen__> ack Romain
<dholbert> chris-harrelson: what does calc(none + 20) resolve to?
<dholbert> lea: that's well-defined in the issue. calc(none+0) -- the none would become the value of that component in another color, and then you add 20
<dholbert> lea: get the lightness of the other color, and then subtract 20 from it
<dholbert> lea: e.g. if you're trying to interpolate from a certain color to a darker version of it, regardless of what color you're starting with
<dholbert> chris-harrelson: so that means zero is not the right number, in those use-cases
<ChrisL> So setting hue to calc(none+180) gives you the complement of the other color, when interpolating for example
<dholbert> lea: correct, zero is almost never the right number
<emilio> q+
<emilio> ack emilio
<dholbert> ChrisL: I've got a suggested resolution for what we want to achieve, it sounds like we have consensus on that?
<dholbert> ChrisL: "don't get rid of none and replace it with zero unless you absolutely have to, e.g. if you're forced to use it as a used value"
<dholbert> lea: that might be all we need?
<dholbert> emilio: I agree
<lea> I wonder if implementations could even internally rewrite to RCS, in some ways it's like a late-resolving RCS
<dholbert> emilio: I sort of want to review the proposed changes with more detail; the details of how you do that might be a bit tricky
<dholbert> lea: proposed: None is preserved in calculations involved in css math functions
<ChrisL> s/none+0/none+20
<dholbert> s/involved in/involving/
<dholbert> lea: ...and also when interpolating between values containing 'none' and values not-containing-none
<ChrisL> +1
<dholbert> RESOLVED: None is preserved in calculations involving css math functions
<lea> s/None is preserved in calculations involved in css math functions/`none` is preserved in calculations involving CSS math functions
<lea> When interpolating between two none-containing values, the result is a calc-mix() expression preserving the `none` values in both values
<dholbert> PROPOSED: ^
<ChrisL> +1
<lea> PROPOSED: When interpolating between two none-containing values, the result is a calc-mix() expression preserving the `none` keywords in both values
<dholbert> dholbert: do we need to cover interpolating between none/not-none?
<dholbert> lea: that's already defined
<flackr> q+
<Rossen__> ack flackr
<dholbert> flackr: I see in the issue description that there's also a proposal that 'none' resolves to the other component, if the other component is not none-containing
<Romain> q+
<dholbert> lea: that's already how none works / what none does
<dholbert> Romain: that's not true; it's not a placeholder for the other channel. It makes a channel missing
<dholbert> Romain: it's not like the nesting selector where it's a placeholder for the parent
<dholbert> Romain: this would be a new mechanic. I'm fine with it being a new mechanic, but we should call it that
<dholbert> ChrisL: that's already in the spec?
<dholbert> Romain: that's related to color components
<dholbert> flackr: I'd like to know what's used in the other end of the interpolation. pulling the color from the other end isn't what authors would expect
<TabAtkins> That is actually intended behavior, yes
<dholbert> flackr: if you're pulling from 2 color values and a none in the middle, pulling from two color values at the ends isn't what authors would expect
<dholbert> lea: that's an orthogonal issue and what happens today
<dholbert> Rossen__: objections?
<dholbert> RESOLVED: When interpolating between two none-containing values, the result is a calc-mix() expression preserving the `none` keywords in both values
<schenney> present -
<dholbert> [end of meeting]
romainmenke commented 6 months ago

@LeaVerou

The point I was trying to clarify is that none is not a missing component in the current specification. And none itself is not filled in with an analogous component.

However none as a component value makes that component missing. And missing components are filled in with analogous components. This might seem like a meaningless distinction, but it does have impact given the number of complex steps involved. This distinction is also why it is a meaningful change.

The proposed change, as I understand it:

Exactly when this extra filling in should happen should be part of the specification changes.

LeaVerou commented 6 months ago

@romainmenke I still don't understand the distinction you’re drawing. It may help to provide a code example that showcases the difference between the two different designs you are seeing.

Wrt your point about gradients, is this the issue you were talking about? https://dabblet.com/gist/7d15728036b6008f0fa7acf4a275adad If so, @svgeesus said he'd link to the relevant csswg thread.

flackr commented 6 months ago

This interpolation behavior is expected to be the same for animations and transitions right? I notice that at least in chrome the transition does not have the sudden change seen in the gradient interpolation: e.g. https://codepen.io/flackr/pen/LYvqdzg

I think this is the part that was confusing me.

svgeesus commented 6 months ago

This interpolation behavior is expected to be the same for animations and transitions right? I

That is because the issues around adding a color space for animations and for transitions are still open, there is nothing specc'ed yet.

svgeesus commented 6 months ago

The point I was trying to clarify is that none is not a missing component in the current specification.

But “Missing” Color Components and the none Keyword says

If a color with a missing component is serialized or otherwise presented directly to an author, then for legacy color syntax it represents that component as a zero value; otherwise, it represents that component as being the none keyword.

svgeesus commented 6 months ago

On the call, it was mentioned that color space conversion happens when interpolating two colors, even if a color is already in the right color space. This was, naturally, surprising to some, because it sounds like makes no difference, right?

The spec used to say "conversion (if required)" and this was changed to "always convert" specifically so that powerless components get changed to missing components

12. Color Interpolation:

  1. converting them to a given color space which will be referred to as the interpolation color space below. If one or both colors are already in the interpolation colorspace, this conversion changes any powerless components to missing values

This is further explained in 12.2. Interpolating with Missing Components

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.

romainmenke commented 6 months ago

I think of missing-ness as a flag on a color component.

rgb(none 100 100) has the missing flag set to true for r because it was written with an explicit none.

For hsl(50deg 0 50%) the h component has a value of 50deg that is actually powerless as a result of having 0 saturation. During color space conversion and interpolation it has the imaginary missing flag set to true.

But I would argue that h in hsl(calc(none * 2) 50%) is not missing. It is none containing (as you described it). For me this is potentially a distinct aspect, separate from missing components.

It would largely align, especially in simple cases:

color-mix(in hsl, hsl(calc(20 + none) 50 50), hsl(50 50 50))

But does it align with missing components in non-trivial cases? :

color-mix(in lch, hsl(30deg calc(none + 10) 50%), oklch(50% 50% 30deg))
color-mix(in lch, hsl(30deg 50% 50%), oklch(50% 0.2 30deg))

The interesting results we are after with this change are a result of how missing components behave during interpolation. Or at least as currently specified.

Maybe this is an entirely new step in the interpolation process?

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

Around here:

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.


none and missing components lose a lot of their utility in color space conversions when there aren't analogous components.

none containing values might be extra sensitive to this because the calculation is carried forward and the value scales aren't the same for all color spaces.

calc(none + 1) means something completely different in lch for c than it does in oklch.

Do we want carry forward to align between missing components and none containing components in color space conversion?

romainmenke commented 6 months ago

The spec used to say "conversion (if required)" and this was changed to "always convert" specifically so that powerless components get changed to missing components

But we could change this again if needed, right? I know there was a good reason to do it this way, and separating them would add complexity.

But I don't remember it as a hard requirement, more that this was the most elegant way to specify the desired outcome.

LeaVerou commented 6 months ago

On the call, it was mentioned that color space conversion happens when interpolating two colors, even if a color is already in the right color space. This was, naturally, surprising to some, because it sounds like makes no difference, right?

The spec used to say "conversion (if required)" and this was changed to "always convert" specifically so that powerless components get changed to missing components [...]

That’s very weird.

I think instead of framing this as color space conversion we need to define two algorithms:

  1. Converting powerless components to missing
  2. Converting to another color space. This either calls 1 at the end, or if the two color spaces are the same, it only calls 1.
romainmenke commented 6 months ago

There is another scenario in which color space conversion happens.

hsl, rgb, hwb serialize as color(srgb ...) to aid in preserving wide gamut colors.

For example : rgb(from rgb(250 120 120) calc(r + 20) g b) if serialized as rgb(270 120 120) it would be clamped when roundtripping To prevent that, it serialized as color(srgb 1.05882 0.470588 0.470588) instead

Chrome has implemented this and you can see how it erases none with for example hsl : https://codepen.io/romainmenke/pen/VwNRBgg?editors=1111

I would have expected them to only do this conversion to color(srgb ...) when absolutely needed, but seems to happen quite eagerly.

Compared to something like lch which doesn't have the same legacy and back-compat concerns and therefore lacks this type of serialization : https://codepen.io/romainmenke/pen/dyLrgdv?editors=1111

LeaVerou commented 6 months ago

There is another scenario in which color space conversion happens.

hsl, rgb, hwb serialize as color(srgb ...) to aid in preserving wide gamut colors.

For example : rgb(from rgb(250 120 120) calc(r + 20) g b) if serialized as rgb(270 120 120) it would be clamped when roundtripping

I have a faint memory that we changed this a while back and now rgb() doesn't clamp. @svgeesus ?

To prevent that, it serialized as color(srgb 1.05882 0.470588 0.470588) instead

rgb() to color(srgb) should not drop any nones since all three components carry forwards. But this makes me wonder if the spec is even clear that none should be preserved during serialization!

Chrome has implemented this and you can see how it erases none with for example hsl : codepen.io/romainmenke/pen/VwNRBgg?editors=1111

I would have expected them to only do this conversion to color(srgb ...) when absolutely needed, but seems to happen quite eagerly.

I don’t quite understand what this demo is showing, but in general any conversion should not be done when not needed.

romainmenke commented 6 months ago

But this makes me wonder if the spec is even clear that none should be preserved during serialization!

It is :)

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

For syntactic forms which support missing color components, the value none (equivalently NONE, nOnE, etc), shall be serialized in all-lowercase as the string "none".

But if colors go through color space conversion none is lost even before that.


That demo is showing how these work as expected :

color-mix(
    in hsl,
    hsl(none 70% 70%),
    hsl(50deg 50% 50%)
)

color-mix(
    in lch,
    lch(70% 20% none),
    lch(50% 20% 50deg)
)

none works and you get 50deg in both cases, not 25deg.

But with relative color syntax they start to differ.

This, conceptually should match the first example with hsl. It has the same channel values, and yet it has a hue of 25deg in Chrome.

This happens because it is interpolating with color(srgb 0.84 0.56 0.36) converted back to hsl, not with hsl(from red none 70% 70%).

color-mix(
    in hsl,
    hsl(from red none 70% 70%),
    hsl(50deg 50% 50%)
)

lch() is not affected by this

romainmenke commented 6 months ago

I have a faint memory that we changed this a while back and now rgb() doesn't clamp

They are still specified to clamp.

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

Values outside these ranges are not invalid, but are clamped to the ranges defined here at parsed-value time.


I used rgb to clarify why the behavior to serialize as color(srgb ...) exists.

But converting rgb to color(srgb ...) has carry forward of analogous components. So rgb(from red none 255 255) must be converted to color(srgb none 1 1).

hsl and hwb do not have this when converting to color(srgb ...)

nex3 commented 6 months ago

Minor note: when updating the spec, I recommend changing the wording of § 4.4. Specifically:

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

For all other purposes, a missing component behaves as a zero value, in the appropriate unit for that component: 0, 0%, or 0deg.

This strongly implies that none should be converted to zero eagerly when any procedure accesses it, which it sounds like is no longer the case with this update.

LeaVerou commented 6 months ago

Minor note: when updating the spec, I recommend changing the wording of § 4.4. Specifically:

For handling of missing component in color interpolation, see § 12.2 Interpolating with Missing Components. For all other purposes, a missing component behaves as a zero value, in the appropriate unit for that component: 0, 0%, or 0deg.

This strongly implies that none should be converted to zero eagerly when any procedure accesses it, which it sounds like is no longer the case with this update.

Nice catch!!

romainmenke commented 6 months ago

cc @weinig

romainmenke commented 6 months ago

rgb() with value ranges of [0,255] vs. color(srgb) with value ranges of [0,1] will also be surprising.

color-mix(
  in srgb
  rgb(calc(none + 50) 50% 0),
  color(srgb 0.5 0.5 0),
)

calc(none + 50) is perfectly reasonable in the [0,255] value range. But wildly out of gamut in [0,1].

So not only color space conversions are a hurdle, but also the different color notations and value ranges.

LeaVerou commented 6 months ago

Oof, that is actually a problem (though not sure how frequently people add/subtract absolute values rather than multiplying by a factor).

In this particular case it could be fixed by making carry forward smarter, but not sure what would happen if both components were none-containing. @svgeesus any thoughts?

emilio commented 6 months ago

I still think that making none "infectious" / like NaN is probably a simpler approach to this fwiw, which doesn't suffer from problems like ^.

LeaVerou commented 6 months ago

As I mentioned in the call, making none infectious is actually worse than converting to 0. Consider e.g. oklch(clamp(0.5, none, 0.6) 0.05 180). By coercing none to 0, the constraint that lightness needs to be within 0.5 - 0.6 is maintained, whereas by making the whole component none it is not, potentially leading to a11y issues.

Worst case we can solve issues like what @romainmenke pointed out by allowing the coercion to 0 to happen in more cases, but infectiousness is out of the question.