Open LeaVerou opened 6 months ago
Where would calc-mix()
be defined?
Where would
calc-mix()
be defined?
That is already in css-values-5: https://drafts.csswg.org/css-values-5/#calc-mix
TIL
cc @tiaanl
The CSS Working Group just discussed [css-color-4][Editorial?] Clarify that `none` is preserved in calculations
, and agreed to the following:
RESOLVED: None is preserved in calculations involving css math functions
RESOLVED: When interpolating between two none-containing values, the result is a calc-mix() expression preserving the `none` keywords in both values
@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:
none
containing values are preserved as much as possiblenone
containing values when possiblenone
in none
containing values with analogous components during interpolationExactly when this extra filling in should happen should be part of the specification changes.
@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.
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.
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.
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.
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
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.
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?
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.
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:
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
There is another scenario in which color space conversion happens.
hsl
,rgb
,hwb
serialize ascolor(srgb ...)
to aid in preserving wide gamut colors.For example :
rgb(from rgb(250 120 120) calc(r + 20) g b)
if serialized asrgb(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 none
s 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 examplehsl
: codepen.io/romainmenke/pen/VwNRBgg?editors=1111I 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.
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
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 ...)
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%
, or0deg
.
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.
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%
, or0deg
.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!!
cc @weinig
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.
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?
I still think that making none
"infectious" / like NaN is probably a simpler approach to this fwiw, which doesn't suffer from problems like ^.
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.
(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 convertingnone
to0
if used incalc()
and other math functions, which comes up a lot in Relative Color Syntax. This was never our intent, the only reason convertingnone
to0
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 includesnone
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
none
→0
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 restnone
, and let normal CSS operations take their course. E.g. interpolating any (polar) color withoklch(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, reducesnone
’s usefulness in creating dynamic colors that can be passed around, and introduces several problems:h
andcalc(h)
are not the same,calc()
or evencalc-mix()
+ RCS.I propose we introduce the concept of
none
-containing component and clarify that:none
. Meaning,calc(h + 20)
in RCS would becomecalc(none + 20)
. Ifh
is already an expression containingnone
, it can be simplified, but only in ways that do not alter its meaning. E.g. ifh
iscalc(none - 10)
,calc(h + 20)
can becomecalc(none + 10)
but can also just staycalc(calc(h - 10) + 20)
orcalc(h - 10 + 20)
.color-mix()
,calc-mix()
, gradients etc) resolvenone
to the other component if the other component is notnone
-containing. If the other component isnone
-containing, they resolve to acalc-mix()
expression containing both values. E.g. interpolating between a chroma ofnone
and a chroma ofclamp(.1, none, .2)
at 50% would producecalc-mix(50%, none, clamp(.1, none, .2))
. If this color is later interpolated with a color that has a chroma of 0.15, thenone
s would become 0.15, so the component would becomecalc-mix(50%, 0.15, clamp(.1, 0.15, .2))
= 0.15.clamp(50, none, 70)
directly.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 allownone
for the upper and lower bound ofclamp()
. In those cases, what doesnone
mean when used in a color component? I would vote for giving precedence to the color-related meaning ofnone
since that serves a unique purpose, whereasnone
in other places is is simply syntactic sugar.