w3c / csswg-drafts

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

[css-color-5] Should colors nested in a parent color function (RCS, color-mix, light-dark, color-contrast) serialize in their most precision saving form #10328

Open weinig opened 6 months ago

weinig commented 6 months ago

In CSS Color 5, the serialization of relative colors is defined with the following:

The serialization of the declared value of a relative color function is a string identifying the color function in all-lowercase, followed by "(from ", followed by the serialization of the declared value of the origin color, followed by a single space, followed by a singly-space-separated list of the arguments to the color function, followed by ")".

(emphasis added)

If taken at face value, this would mean that relative colors do not round trip faithfully when the origin color has values outside the reference range, as the processing model for relative colors states:

When relative color syntax is used, color channel values, whether directly specified or arising from color space conversion, are not clamped to the reference ranges but are retained as-is. This preserves out of gamut values, if the destination color space is capable of representing them.

So, in an example like:

hsl(from hsl(127.9 302% 25.33%) h s l)

my reading is that the declared value would serialize as: hsl(from rgb(0, 255, 0) h s l), but the computed value would be color(srgb -0.511666 1.018266 -0.310225)

I would like to propose that nested colors, both for RCS and the other color functions that take color arguments, the nested colors serialize in a way that preserves the behavior of the parent. The most straightforward way I can think to have that work would be to serialize the declared value according to normal (not encumbered by the legacy of color serialization) CSS serialization rules for declared values - aka minimal needed, calc() preserving, etc.

In the case above, that would mean serializing the declared value exactly as written:

hsl(from hsl(127.9 302% 25.33%) h s l)

To expand with another example:

rgb(from hsl(calc(30 + 60) 50% none) r g b)

would serialize the declared value as:

rgb(from hsl(calc(90) 50% none) r g b)
annevk commented 6 months ago

cc @svgeesus @LeaVerou

LeaVerou commented 6 months ago

This makes total sense to me. Agenda+ so we can resolve ASAP.

cdoublev commented 6 months ago

My reading is that channel values must never be clamped in a relative color. But I agree that it should also apply to colors nested in other color functions. I would still resolve <hue> and <percentage> to <number> though.

weinig commented 6 months ago

The question isn't really about clamping per-se, but rather about serialization of those unclamped values.

cdoublev commented 6 months ago

Ah yes. I was thinking of converting hsl to rgb without clamping, to be consistent with how top-level absolute color functions serialize, and you propose to serialize the list of component values (with simplified math functions, omitted optional values, etc).

Your proposition is also my preference but, and to expand on this https://github.com/w3c/csswg-drafts/issues/10327#issuecomment-2122542445, I do not know/understand why all absolute color functions are partially or entirely resolved when serializing them as components of a declared value, which seems to also be your point in #10305. Therefore my reasoning is confused (sorry).

svgeesus commented 6 months ago

@weinig could you propose replacement spec text for the part starting

The serialization of the declared value of a relative color function

I think we would come to consensus on a call more rapidly if there is specific proposed text, rather than a general point whose wording then needs to be discussed and agreed upon.

weinig commented 6 months ago

@svgeesus I can give it a shot, but I'm not entirely confident. I spent a while looking at specs to find rules or guidance (CSSOM, CSS Values and Units, CSS Cascade) but it left me a bit more unclear than I would have liked. That said, here is my, likely overly verbose, take:


I think we should probably will want to have a sub-definition of the serialization of a color used as the origin color in another color. That might look like this:

The serialization of a the declared value of a color used as the origin color inside of another color function (color-mix, RCS, color-contrast) is:

For rgb(),rgba(),hsl(),hsla() - the string identifying the canonical color function, "rgb" for "rgb()" and "rgba()", "hsl" for "hsl()" and hsla()", in all-lowercase, followed by "(", followed by a space separated list of the non-alpha components as specified (numbers serializing as numbers, percentages serializing as percentages, angles serializing as canonicalized angles in degrees, calc() serializing in its simplified form) with no clamping applied, followed by " / " and the alpha component as specified (using the same rules as the color components) if an alpha component is present, followed by ")". (NOTE: the same serialization is used regardless of whether the modern or legacy syntax was used).

For hwb(),lab(),lch(),oklab(),oklch() - the string identifying the color function in all-lowercase, followed by "(", followed by a space separated list of the non-alpha components as specified (numbers serializing as numbers, percentages serializing as percentages, angles serializing as canonicalized angles in degrees, calc() serializing in its simplified form) with no clamping applied, followed by " / " and the alpha component as specified (using the same rules as the color components) if an alpha component is present, followed by ")".

For color() - the string "color(" followed by the canonical colorspace ("xyz-d65" for "xyz") in all-lowercase followed by a space, followed by a space separated list of the non-alpha components as specified (numbers serializing as numbers, percentages serializing as percentages, angles serializing as canonicalized angles in degrees, calc() serializing in its simplified form) with no clamping applied, followed by " / " and the alpha component as specified (using the same rules as the color components) if an alpha component is present, followed by ")".

Then, for relative colors, we could have:

The serialization of the declared value of a relative color is:

For rgb(),rgba(),hsl(),hsla() - the string identifying the canonical color function, "rgb" for "rgb()" and "rgba()", "hsl" for "hsl()" and hsla()", in all-lowercase, followed by "(from ", followed by the serialization of the origin color using the rules for serializing nested origin colors, followed by a single space, followed by space separated list of the non-alpha channel arguments as specified (identifiers serializing as identifiers, numbers serializing as numbers, percentages serializing as percentages, angles serializing as canonicalized angles in degrees, calc() serializing in its simplified form), followed by " / " and the alpha component as specified (using the same rules as the color channel arguments) if an alpha component is present, followed by ")".

For hwb(),lab(),lch(),oklab(),oklch() - the string identifying the color function in all-lowercase, followed by "(from ", followed by the serialization of the origin color using the rules for serializing nested origin colors, followed by a single space, followed by space separated list of the non-alpha channel arguments as specified (identifiers serializing as identifiers, numbers serializing as numbers, percentages serializing as percentages, angles serializing as canonicalized angles in degrees, calc() serializing in its simplified form), followed by " / " and the alpha component as specified (using the same rules as the color channel arguments) if an alpha component is present, followed by ")".

For color() - the string "color(from ", followed by the serialization of the origin color using the rules for serializing nested origin colors, followed by a single space, followed by the canonical colorspace ("xyz-d65" for "xyz") in all-lowercase, followed by a single space, followed by space separated list of the non-alpha channel arguments as specified (identifiers serializing as identifiers, numbers serializing as numbers, percentages serializing as percentages, angles serializing as canonicalized angles in degrees, calc() serializing in its simplified form), followed by " / " and the alpha component as specified (using the same rules as the color channel arguments) if an alpha component is present, followed by ")".

No doubt there is some factoring out that can be done to reduce redundancy, but for my initial take, being verbose seemed like the right choice.

In addition to the text, I have a set of examples I was trying to work from that I am including in a details section here

Details

Tablestakes. Given the following HTML snippet: ```html

``` The output of following is said to be the "declared value serialization". ```js document.getElementById("example").style["background-color"] ``` And the output of following is said to be the "computed value serialization". ```js window.getComputedStyle(document.getElementById("example"))["background-color"] ``` ---- Example 1: Basics ```html
``` Result "declared value serialization" ```css lab(from red l a b) ``` Result "computed value serialization" ```css lab(54.290539 80.804947 69.890961) ``` Notes: Declared value retains written form. Computed value serializes the resolved value. ---- Example 2: Spelling, whitespace and capitalization ```html
``` Result "declared value serialization" ```css rgb(from red r g 1) ``` Result "computed value serialization" ```css color(srgb 1 0 1) ``` Notes: Declared value retains structure, but uses canonicalized spelling and capitalization (I would propose "rgba" -> "rgb", "hsla" -> "hsl", and "xyz" -> "xyz-d65") and one space between items. Computed value serializes the resolved value. ---- Example 3: color(...) ```html
``` Result "declared value serialization" ```css color(from red srgb r g b) ``` Result "computed value serialization" ```css color(srgb 1 0 0) ``` Notes: Declared value serializes colorspace after origin color like the grammar. Computed value serializes the resolved value. ---- Example 4: Percentages and angles ```html
``` Result "declared value serialization" ```css oklch(from white 10% c 40deg) ``` Result "computed value serialization" ```css oklch(0.1 0 40) ``` Notes: Declared value serializes retains use of percentages and angles. Computed value serializes the resolved value, which always canonicalizes to numbers. ---- Example 5: calc() ```html
``` Result "declared value serialization" ```css rgb(from red calc(0.5 * r) g calc(30%)) ``` Result "computed value serialization" ```css color(srgb 0.5 0 0.3) ``` Notes: Declared value serializes calc() usages in their simplified form. Computed value serializes the resolved value. ---- Example 6: none ```html
``` Result "declared value serialization" ```css rgb(from red none g b) ``` Result "computed value serialization" ```css color(srgb none 0 0) ``` Notes: Declared value serializes retains use of none in channels. Computed value serializes the resolved value, which also serializes the none values. ---- Example 7: Nested none ```html
``` Result "declared value serialization" ```css hsl(from hsl(none 10% 50%) h s l) ``` Result "computed value serialization" ```css color(srgb 0.55 0.45 0.45) ``` Notes: Declared value serializes the origin at full fedelity, no conversion to "rgb(140, 115, 115)". Computed value serializes the resolved value. ---- Example 8: Nested out-of-gamut rgb/hsl ```html
``` Result "declared value serialization" ```css hsl(from hsl(127.9 302% 25.33%) h s l) ``` Result "computed value serialization" ```css color(srgb -0.511666 1.018266 -0.310225) ``` Notes: Declared value serializes unclamped origin, no-clamping values to range, no conversion to "rgb(0, 255, 0)". Computed value serializes the resolved value. ---- Example 9: alpha ```html
``` Result "declared value serialization" ```css rgb(from red r g b / 50%) ``` Result "computed value serialization" ```css color(srgb 1 0 0) ``` Notes: Declared value serializes alpha only if it is specified explicitly in a channel. Computed value serializes the resolved value. ---- Example 10: currentcolor (NOTE: extra "color" declaration in HTML snippet) ```html
``` Result "declared value serialization" ```css rgb(from currentcolor r g calc(b * 2)) ``` Result "computed value serialization" ```css color(srgb 0 0 0.5) ``` Notes: Declared value serializes the same as elsewhere. Computed value serializes the **used** value, same as if "currentcolor" had been used on its own.

svgeesus commented 6 months ago

Wow, thanks for the very thorough response!

css-meeting-bot commented 5 months ago

The CSS Working Group just discussed [css-color-5] Should colors nested in a parent color function (RCS, color-mix, light-dark, color-contrast) serialize in their most precision saving form, and agreed to the following:

The full IRC log of that discussion <fantasai> scribenick: fantasai
<fantasai> ChrisL: The nesting of things with color-mix()/light-dark()/ etc. was not fully specified
<fantasai> ChrisL: We discussed some, and we have some proposed replacement text that looks extremely detailed and accurate to me
<fantasai> ChrisL: So I'd like to adopt into the spec
<ChrisL> https://github.com/w3c/csswg-drafts/issues/10328#issuecomment-2135948165
<fantasai> RESOLVED: Adopt the proposed text
cdoublev commented 5 months ago

The serialization of a the declared value of a color used as the origin color inside of another color function (color-mix, RCS, color-contrast) is: [...]

I suggest The serialization of a color function nested inside another color function, as components of a declared value, is: [...].

But is it actually needed to serialize this way in other color functions than relative colors? (and possibly color-mix(), #10414)

That is, is it needed to preserved unclamped channel values and unresolved color functions in contrast-color() and light-dark()?


Parsing a <color> Value* might need to be updated because it currently requires resolving <color> at parse time (ie. before serialization), which means <percentage> and <angle> are resolved to <number>, etc.


[...] followed by the canonical colorspace ("xyz-d65" for "xyz") [...]

I am not sure it is necessary: CSS Colors 4 says xyz is an alias of xyz-d65 and CSS Cascade 5 says that legacy value aliases are converted at parse time...


Why not apply the shortest serialization principle and omit unitary alpha?

kbabbitt commented 3 months ago

When we took up this issue, I did not realize that the changes contradicted behavior that was already shipped in Blink and Gecko and tested by WPTs. See color-valid-relative-color.html, the history timelines for that test, and the WPT changes made for this resolution.

FWIW I think the new behavior is better, and I'll undertake a compat analysis to make sure it can stick. But I wanted to get it on the radar that there's a possibility the changes turn out to be not Web compatible and this resolution might need to be revisited.

cc @emilio

svgeesus commented 3 months ago

So, I am unsure what do do now. We have a WG resolution and WPT has been updated, but it results in a lot of failing tests. I also think the new behavior is better, but am reluctant to apply the agreed spec changes if we are going to have to back them out again.

Meanwhile WPT is testing what we agreed to, but the definitive text is in this issue not in the spec. There are also the comments from @cdoublev to consider.

@kbabbitt @emilio @nt1m @weinig

weinig commented 3 months ago

(sorry for the lengthy delay in reply)

The serialization of a the declared value of a color used as the origin color inside of another color function (color-mix, RCS, color-contrast) is: [...]

I suggest The serialization of a color function nested inside another color function, as components of a declared value, is: [...].

But is it actually needed to serialize this way in other color functions than relative colors? (and possibly color-mix(), #10414)

That is, is it needed to preserved unclamped channel values and unresolved color functions in contrast-color() and light-dark()?

My position is that if we could, we would want to preserve full precision in all cases, but due to historical content, we can't in a few places. So, in all new cases where, where historical content is not an issue, we should aim to be as precise as possible.

Parsing a <color> Value* might need to be updated because it currently requires resolving <color> at parse time (ie. before serialization), which means <percentage> and <angle> are resolved to <number>, etc.

I am not sure I follow what the concern is here. That part of the spec requires fully resolving the color for use in things like canvas, so I am not sure where serialization would come in.

[...] followed by the canonical colorspace ("xyz-d65" for "xyz") [...]

I am not sure it is necessary: CSS Colors 4 says xyz is an alias of xyz-d65 and CSS Cascade 5 says that legacy value aliases are converted at parse time...

Ok. I had it there just to resolve ambiguity, as using "xyz" would be shorter, and some implementations might want to argue that the "shortest serialization principle" should hold.

Why not apply the shortest serialization principle and omit unitary alpha?

I intended the text "if an alpha component is present" to imply that, but I think you are right, more clarity there would be better.

weinig commented 3 months ago

@svgeesus @kbabbitt, it seems quite unlikely that any content has come to depend on these edge cases in the time since this has been shipped. Ultimately, this is about serialization, and serialization is something that browsers have historically not aligned on very well so depending on specifics is pretty uncommon.

cdoublev commented 3 months ago

While I agree with your position on preserving the full precision of the specified color channels wherever possible, I do not think this historical content prevented preserving a channel value specified as a <percentage>/<angle>, or an out of range <alpha-value>, in ok?lab(), ok?lch(), color().

So consistency might also need to be considered now, at least for normalizing channel values to <number>.


Sorry, I figured out some time ago from reading another issue, that the parse a CSS <color> value algo is not intended for parsing <color> in CSS... (I will open an issue to clarify this.)

weinig commented 3 months ago

While I agree with your position on preserving the full precision of the specified color channels wherever possible, I do not think this historical content prevented preserving a channel value specified as a <percentage>/<angle>, or an out of range <alpha-value>, in ok?lab(), ok?lch(), color().

So consistency might also need to be considered now, at least for normalizing channel values to <number>.

I think this is a fair critique. I could be persuaded that normalizing to an unclamped is the right way to go for the channels, assuming there are no sharp edges I am forgetting (we don't have to worry about legacy rgb() percent turning into a number and becoming un-parsable, because we always serialize using the modern syntax which allows either in all channel slots). For alpha, we should just link to the alpha serialization section.

(overall, I think more standardization across CSS on how these serializations should work, and some mechanization of the process of specifying the rules would go a long way).

kbabbitt commented 3 months ago

@weinig @svgeesus

it seems quite unlikely that any content has come to depend on these edge cases in the time since this has been shipped. Ultimately, this is about serialization, and serialization is something that browsers have historically not aligned on very well so depending on specifics is pretty uncommon.

That's my intuition and hope as well, but there have been many surprises in the past on what existing content is dependent on.

So, I am unsure what do do now. We have a WG resolution and WPT has been updated, but it results in a lot of failing tests. I also think the new behavior is better, but am reluctant to apply the agreed spec changes if we are going to have to back them out again.

Can we sit on the spec changes for a bit longer? I'm right now in the process of landing changes in Blink to support currentcolor in relative color syntax. Not far behind that will be a change to defer resolving all relative colors until used-value time, to align to the new test expectations. How well that change sticks should give us more data.


I also had one question on the new test expectations. For cases such as:

fuzzy_test_valid_color(`${rgbFunction}(from rebeccapurple r calc(g * 2) 10)`, `rgb(from rebeccapurple r calc(2 * g) 10)`);
fuzzy_test_valid_color(`${rgbFunction}(from rebeccapurple b calc(r * .5) 10)`, `rgb(from rebeccapurple b calc(0.5 * r) 10)`);
fuzzy_test_valid_color(`${rgbFunction}(from rebeccapurple r calc(g * .5 + g * .5) 10)`, `rgb(from rebeccapurple r calc((0.5 * g) + (0.5 * g)) 10)`);
fuzzy_test_valid_color(`${rgbFunction}(from rebeccapurple r calc(b * .5 - g * .5) 10)`, `rgb(from rebeccapurple r calc((0.5 * b) - (0.5 * g)) 10)`);

The expectation has the order of terms in the calc() expressions flipped from what was specified. The new spec text refers to "calc() serializing in its simplified form" but looking at Simplification in Values and Units 4 I didn't see anything that would cover changing the order of the terms like this. Is there spec text elsewhere about this? Or should the test harness be changed to accept any ordering?

cdoublev commented 3 months ago

I didn't see anything that would cover changing the order of the terms like this.

It happens at serialization time: numbers then percentages then dimensions then any remaining items.