w3c / csswg-drafts

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

[css-color-4] Parse-time clip of HSL negative saturation for modern syntax? #9222

Closed romainmenke closed 8 months ago

romainmenke commented 1 year ago

https://github.com/w3c/csswg-drafts/blob/61da22ed62e83bf41caa5bde73912209ff45cc2e/css-color-4/rgbToHsl.js#L16

rgbToHsl(1.5, 1, 1)
// (3) [0, -100, 125]

This only happens when the sum of all channels exceeds 3.

I get better results when I clamp saturation to 0.


I don't know if clamping to 0 had unintended side effects here.

facelessuser commented 1 year ago

I will note that not every algorithm for a color space is limitless, sometimes there are simply inherent limits to a color space algorithm, and what you've stumbled on is simply the limit of HSL. HSL cannot properly handle such a color. It simply cannot round trip it.

>>> Color('srgb', [1.5, 1, 0.5]).convert('hsl').convert('srgb')
color(srgb 1 1 1 / 1)
>>> Color('srgb', [1.49, 1, 0.5]).convert('hsl').convert('srgb')
color(srgb 1.49 1 0.5 / 1)

Negative saturation is how HSL deals with colors with a lightness that exceeds 100%. Large saturation is how it generally deals with colors saturated beyond its gamut. Now, none of this is by a design as it is only designed for in gamut colors, but more incidentally.

The example you've given is actually outside the visible gamut. I don't think such a value is a real world concern. If you are talking about a gamut such as Rec. 2020, I believe it should handle all colors in the gamut fine, but the shape will be far from a cylinder 🙂.

newplot

romainmenke commented 1 year ago

Yes, 1.5 is a more extreme example :)

It was this test that triggered a failure on my end : https://github.com/web-platform-tests/wpt/blob/00e27884bfe6426de9c3e7c33e32db76bc1faed8/css/css-color/parsing/relative-color-out-of-gamut.html#L52

hsl(from lab(100 104.3 -50.9) h s l)

This eventually becomes rgb(255, 246, 244) and to me this seemed to be caused by the negative saturation returned by the sample algorithm for hsl.

When clamping the saturation to 0, I get the correct result (rgb(255, 255, 255))

facelessuser commented 1 year ago

Ah, I haven't looked as closely at the sequencing of steps for relative color syntax, so I can't speak to whether the tests accurately represent the spec. I'm not sure exactly when things get clamped and/or gamut mapped during the process.

romainmenke commented 1 year ago

Previously we did a gamut mapping step when converting lab(100 104.3 -50.9) to hsl. But after https://github.com/w3c/csswg-drafts/issues/8444 we removed this step.

(maybe my understanding of that resolution was wrong :) )

Gamut mapping helped here because it ensured that any input color would have sane value ranges for a given color model/space.

facelessuser commented 1 year ago

Gamut mapping helped here because it ensured that any input color would have sane value ranges for a given color model/space.

Well, define sane. They were restrained to sRGB. I don't know if the tests are doing what they should or not, but I know that lab(100 104.3 -50.9) perfectly round trips through HSL. It doesn't appear that the negative saturation is having an adverse effect and altering the color incorrectly.

>>> hsl = Color('lab(100 104.3 -50.9)').convert('hsl')
>>> hsl
color(--hsl 311.21 -5.5486 1.0906 / 1)
>>> hsl.convert('lab')
color(--lab 100 104.3 -50.9 / 1)

I also know if you gamut map in OkLCh referencing the color as HSL vs sRGB that you get different results. I'm not exactly sure what CSS does here. Does it use sRGB when gamut mapping HSL or does it gamut map HSL colors keeping them as HSL? I assume HSL.

>>> hsl = Color('lab(100 104.3 -50.9)').convert('hsl')
>>> hsl.clone().fit('hsl', fit='oklch-chroma').convert('srgb').to_string()
'rgb(255 255 255)'
>>> hsl.clone().fit('srgb', fit='oklch-chroma').convert('srgb').to_string()
'rgb(255 253.18 255)'

In neither case did I get rgb(255, 246, 244), but Colorjs.io matches mine as well for what it's worth.

> new Color('lab(100 104.3 -50.9)').to('srgb').toGamut({method: 'oklch.chroma'})
Color { coords: [ 1, 1, 1 ], alpha: 1 }
> new Color('lab(100 104.3 -50.9)').to('srgb').toGamut('srgb', {method: 'oklch.chroma'})
Color { coords: [ 1, 0.9951746276359744, 1 ], alpha: 1 }

It is possible there is something about RCS that I don't understand, some step(s) that I'm not executing. I haven't really given CSS color level 5 the same critical eye that I've given level 4.

romainmenke commented 1 year ago

🤔 After a bit of poking I think it's something completely different. @facelessuser thank you for those results 🙇


Most color functions describe clipping of values outside certain ranges.

https://www.w3.org/TR/css-color-3/#hsl-color

If saturation is less than 0%, implementations must clip it to 0%. If the resulting value is outside the device gamut, implementations must clip it to the device gamut. This clipping should preserve the hue when possible, but is otherwise undefined. (In other words, the clipping is different from applying the rules for clipping of RGB colors after applying the algorithm below for converting HSL to RGB.)

But this should be done only during parsing.

In our implementation we always do this step, but after https://github.com/w3c/csswg-drafts/issues/8444 I think it becomes clear that we must only do it when parsing, and not when computing.


Are these the same or different colors?

hsl(from lab(100 104.3 -50.9) h s l)
hsl(from lab(100 104.3 -50.9) 311.21deg -5.5486% 1.0906%)
hsl(from white 311.21deg -5.5486% 1.0906%)
hsl(311.21deg -5.5486% 1.0906%)

Closing and filing a new issue to avoid confusion :)

romainmenke commented 1 year ago

see : https://github.com/w3c/csswg-drafts/issues/9259

svgeesus commented 11 months ago

Re-opening because clipping on parsing negative saturation in hsl() is:

facelessuser commented 11 months ago

It may be possible that the HSL conversion algorithm can be altered to ensure that negative saturation is never returned and still have completely accurate results. Then negative saturation can be clamped at zero with no ceiling. When negative saturation occurs, it just rotates the hue by 180. So if you rotate the hue by 180, you can set the saturation to a positive value during conversion. Then there is no need to allow parsing negative saturation.

Going with this earlier example, we can correct the saturation and hue. Then when we convert back, we get the same value we had before.

>>> c1 = Color('lab(100 104.3 -50.9)').convert('hsl')
>>> c1
color(--hsl 311.21 -5.5486 1.0906 / 1)
>>> c1.set('s', lambda x: abs(x)).set('h', lambda x: x + 180).convert('lab')
color(--lab 100 104.3 -50.9 / 1)

I suspect this is generally true, but more experiments would need to be performed to ensure this is the case. I assume if there are outliers, the reasons would need to be explored as HSL has hard limits. Once the limits are exceeded, the results will never round trip back. But if you are using such extreme values, which are well outside the visible spectrum, with HSL, you get what you get 🙂.

>>> Color('color(srgb 1.5 1 0.5)').convert('hsl').convert('srgb')
color(srgb 1 1 1 / 1)
>>> Color('color(srgb 1.5 1 0.5)').convert('xyz-d65')
color(xyz-d65 1.4425 1.2701 0.37169 / 1)
facelessuser commented 11 months ago

As a note, I used the rec2100-pq HDR color space to generate 1,030,301 colors which I then converted to HSL. 363,630 returned negative saturation, every single one was able to successfully round trip to the original color after correcting the negative saturation and adjusting the hue.

So it seems HSL algorithm can be altered successfully to correct the negative saturation without introducing issues if negative saturation is a problem.

svgeesus commented 10 months ago

So it seems HSL algorithm can be altered successfully to correct the negative saturation without introducing issues if negative saturation is a problem.

We now have:

/**
 * @param {number} red - Red component 0..1
 * @param {number} green - Green component 0..1
 * @param {number} blue - Blue component 0..1
 * @return {number[]} Array of HSL values: Hue as degrees 0..360, Saturation and Lightness in reference range [0,100]
 */
function rgbToHsl (red, green, blue) {
    let max = Math.max(red, green, blue);
    let min = Math.min(red, green, blue);
    let [hue, sat, light] = [NaN, 0, (min + max)/2];
    let d = max - min;

    if (d !== 0) {
        sat = (light === 0 || light === 1)
            ? 0
            : (max - light) / Math.min(light, 1 - light);

        switch (max) {
            case red:   hue = (green - blue) / d + (green < blue ? 6 : 0); break;
            case green: hue = (blue - red) / d + 2; break;
            case blue:  hue = (red - green) / d + 4;
        }

        hue = hue * 60;
    }

    // Very out of gamut colors can produce negative saturation
    // If so, just rotate the hue by 180 and use a positive saturation
    // see https://github.com/w3c/csswg-drafts/issues/9222
    if (sat < 0) {
        hue += 180;
        sat = Math.abs(sat);
    }

    if (hue >= 360) {
        hue -= 360;
    }

    return [hue, sat * 100, light * 100];
}
svgeesus commented 10 months ago

So we no longer return negative saturation.

I'm wondering whether it is better to

  1. Leave hslToRgb as-is, because we no longer generate negative saturation
  2. Correct negative saturation in hslToRgb as well, in case some other code produces it.

Tending towards the belt-and-braces option 2, opinions welcome

romainmenke commented 10 months ago

Thank you for the update 🙇 The change above works well on my end.

Option 2 seems fine to me, I can't think of any case where you would need the uncorrected saturation. But I don't have a strong opinion on this :)

facelessuser commented 10 months ago

Keep in mind this approach works for most cylindrical color spaces, OkLCh and LCh included. We could, theoretically, better handle OkLCh and LCh negative chroma when converting back to Oklab and Lab without having to hard clamp to zero.


As I side note, I did mention most cylindrical spaces. Depending on the model, this may not always be true, as a non CSS example CAM16 does not work this way due to the complex way in which chroma is calculated for a given lightness and hue. The only way to resolve such saturation is to convert it back to the XYZ base and then convert it forward to CAM16 again, though once you are out of the visible spectrum, you can still get negative chroma no matter what you do. The algorithm just doesn't handle colors so far out. This is true for some other spaces like HSV as well. It is better to convert HSV back to sRGB and then back to HSV, and if the colors happen to resolve, great, if not 🤷🏻.

I mainly mention this for anyone thinking this works for every cylindrical space, without exception.

facelessuser commented 10 months ago

I will note that since OkLCh and LCh never return negative chroma, the only way this could occur is through manually specifying it or maybe calc()? I don't recall the current rules on how that is handled.

svgeesus commented 10 months ago

In general the approach now is to do some sanitizing on input, and try not to do any on intermediate values during color conversion and interpolation; and we leave "make this make sense as a color" to display gamut mapping.

facelessuser commented 10 months ago

Okay, then if CSS wanted to not clamp chroma in a manually input chroma value of oklch(0.5 -0.1 270), it could instead just accept it as oklch(0.5 0.1 90). But one could argue people shouldn't be using negative chroma inputs and leave it at that :). I'm not arguing for it, more just saying it could be handled sanely if it was desirable.

svgeesus commented 10 months ago

I can see merit in both approaches; either treat a specified negative chroma as an error (and clamp it to zero) or fix up the hue and absolutize chroma.

But as you say, unlike HSL, the conversion code is not producing negative values so I feel the best thing is to not fix up the hue.

svgeesus commented 10 months ago

CSS Color 3 said to clamp negative saturation at parse time (and was silent about generated values, since it was sRGB-only). This is tested in and browsers are interoperable.

Looks like that parse-time check should be added back to CSSColor 4, for interoperability.

svgeesus commented 9 months ago

@dbaron @tabatkins looking for reviewers of WPT update that tests negative saturation clamping with modern HSL syntax (legacy was already tested).

svgeesus commented 9 months ago

It looks like, in modern syntax, Firefox clamps negative saturation while Chrome does not (test). Both clamp, per CSS Color 3, in legacy syntax.

romainmenke commented 9 months ago

I think there is a recent regression in Chrome. Chrome 121 shipped support for mixed numbers and percentages.

Chrome 120 with hsl(120 -100% 25.1%)

Chrome 120 renders a grey square

Chrome 121 with hsl(120 -100% 25.1%)

Chrome 121 renders a purple rectangle

I also checked a bunch of older versions and they all consistently have a full grey square when not mixing numbers and percentages.

svgeesus commented 9 months ago

It looks like, in modern syntax, Firefox clamps negative saturation while Chrome does not (test). Both clamp, per CSS Color 3, in legacy syntax.

So, what do we want the spec to say? I lean slightly towards not clamping in modern syntax, which has no back-compat worries, and we can't change the clamp in legacy stntax.

Also, this is only at parse time; intermediate results from color conversion will not be clamped.

@emilio @dbaron @weinig

Once we resolve, I can either continue the WPT PR that requires clamping in modern syntax or close it, as appropriate

svgeesus commented 9 months ago

(Title updated to cover the one remaining issue)

svgeesus commented 9 months ago

Oh, my. In CSS Color 3, for hsl() it says:

If saturation is less than 0%, implementations must clip it to 0%. If the resulting value is outside the device gamut, implementations must clip it to the device gamut. This clipping should preserve the hue when possible, but is otherwise undefined. (In other words, the clipping is different from applying the rules for clipping of RGB colors after applying the algorithm below for converting HSL to RGB.)

(No mention of clipping lightness). And for hsla() it says:

Implementations must clip the hue, saturation, and lightness components of HSLA color values to the device gamut according to the rules for the HSL color value composed of those components.

The wording in both is somewhat confusing, as it mixes up parse-time clipping and used-value mapping to the display gamut.

css-meeting-bot commented 8 months ago

The CSS Working Group just discussed [css-color-4] Parse-time clip of HSL negative saturation for modern syntax?, and agreed to the following:

The full IRC log of that discussion <fantasai> chris: Long thread because originally about something else, but discovered a problem so about that
<fantasai> chris: CSS Color L3 required that negative saturation be clipped
<fantasai> chris: all implementations do that, tested in css-color-3 tests
<fantasai> chris: We have since decided that we want to round-trip through HSL
<fantasai> chris: which requires representing out-of-gamut colors
<fantasai> chris: and HSL bakes sRGB
<fantasai> chris: Because color-4 said don't clip, and color-3 said clip
<fantasai> chris: UAs interpreted, clip for old syntaxes and don't clip for new syntaxes
<fantasai> chris: There are pros and cons to various approaches
<fantasai> chris: My preference is it's better to not clip in modern syntax, but we need to agree on that
<fantasai> chris: also this is a parse-time clip, values from the stylesheet. If you happen to wander into HSL because of color conversion, then we don't clip
<emilio> q+
<emilio> fantasai: is there a web compat constraint for not clipping in the old syntax?
<emilio> ... could we not clip always?
<fantasai> chris: It's been baked in for so long, it might break some sites ... I haven't checked, but it would be a change
<emilio> chris: it's been there for a while and interoperable
<emilio> fantasai: it's fairly rare to type negative values
<emilio> ... it seems it might be possible to just not clip
<emilio> ... maybe we should try to pursue
<astearns> ack fantasai
<emilio> chris: it's not non-sensical, it just means that you've gone past the axis
<fantasai> astearns: We could resolve on modern syntax, and then check if old syntax could be changed
<astearns> ack emilio
<fantasai> emilio: Can you remind me, what status is on colors serializing to rgb?
<fantasai> emilio: IIRC at least some of the HSL syntax serializes to rgb()
<fantasai> emilio: if it serializes to rgb() then it should be consistent and clip
<fantasai> chris: Correct, and that's why for color-mix() when you go through HSL you come out in color() rather than hsl()
<fantasai> emilio: I suggest either do Firefox behavior (always clip) or make modern HSL syntax not serialize to rgb()
<fantasai> emilio: but that does seem more risky
<chris> s/rather than hsl()/rather than rgb()
<fantasai> emilio: because otherwise the color can't round-tirp
<fantasai> s/tirp/trip
<fantasai> chris: Worried about Web-compat for not serializing to rgb()
<fantasai> emilio: I think ideally the color should round-trip.
<fantasai> emilio: div.style.color = div.style.color shouldn't change the color
<fantasai> chris: Would Chrome change to always clip?
<fantasai> [none of the Chrome engineers step up to answer]
<fantasai> chris: Based on legacy syntax tests, we have interop on it
<fantasai> chris: Was going to commit tests for new syntax, and dbaron was unsure when reviewing so we have this issue
<fantasai> matthieud: for color-mix() we still [missed] so we clip
<matthieud> for color-mix, we still output rgb syntax and not color(in srgb...)
<fantasai> emilio: since legacy syntax (and modern) syntax convert to rgb(), more consistent to clip always
<fantasai> chris: I would be OK with that. If you use HSL, you brought this on yourself.
<fantasai> astearns: given no Blink opinion, shall we resolve to clip all HSL syntaxes?
<fantasai> fantasai: All HSL or all HSL except color() ?
<fantasai> chris: color() doesn't affect HSL, this is hsl() only
<fantasai> PROPOSED: All hsl() clip to non-negative numbers
<fantasai> for saturation
<fantasai> RESOLVED: All hsl() clip to non-negative numbers for saturation
svgeesus commented 8 months ago

The current specification already conforms to this resolution (it does not distinguish legacy and modern syntax):

For historical reasons, if the saturation is less than 0% it is clamped to 0% at parsed-value time, before being converted to an sRGB color.

So this does not need spec edits, but does need review of my WPR PR which tests this in modern syntax.

svgeesus commented 8 months ago

WPT updated, thanks @dbaron for swift re-review

cdoublev commented 8 months ago

Should it be considered a bug if saturation is also capped at 100% before conversion to RGB?

to8Bit(hslToRgb(0, 150, 40)) // [255,-51,-51]
to8Bit(hslToRgb(0, 100, 40)) // [204,0,0]
element.style.color = 'hsl(0%, 150%, 40%)'
element.style.color // rgb(204, 0, 0) in Chrome and FF

But I cannot find where clamping values outside of [0,255] after conversion to RGB, is defined.

svgeesus commented 8 months ago

Should it be considered a bug if saturation is also capped at 100% before conversion to RGB?

Yes, and that should be observable via color-mix() when output to color(srgb ...). I mean clamping will happen during conversion to rgb() but not before conversion.

But I cannot find where clamping values outside of [0,255] after conversion to RGB, is defined.

It is in 5.1 The RGB functions: rgb() and rgba():

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

and, hmm,

14.1. Resolving sRGB values

Also for historical reasons, when calc() is simplified down to a single value, the color values are clamped to [0.0, 255.0].

This clamping also takes care of values such as Infinity, -Infiinity, and NaN which will clamp at 255, 0 and 0 respectively.

I agree that it doesn't say explicitly about after conversion. In particular,

11. Converting Colors says

Colors may be converted from one color space to another and, provided that there is no gamut mapping and that each color space can represent out of gamut colors, (for RGB spaces, this means that the transfer function is defined over the extended range) then (subject to numerical precision and round-off error) the two colors will look the same and represent the same color sensation.

Which is true, but does not cover the case where the color space (in this case, sRGB) is defined over the extended range but a serialization form (rgb()) does not allow extended range values.

cdoublev commented 8 months ago

I would definitely expect channel values to not be clamped before mixing colors (unless otherwise specified at parse time).

The definition of hslToRgb() says:

It returns an array of three numbers [...] normalized to the range [0, 1].

And its saturation/lightness argument should be in reference range [0,100].

Now, hslToRgb(0, 150, 40) returns [1, -0.2, -0.2]. It would serialize as rgb(255, 0, 0) assuming values outside of [0,255] are clamped, which is only specified to apply at parse time for rgb().

It returns [0.8, 0, 0] if saturation is clamped before, which would serialize as rgb(204, 0, 0).

And the specified/computed value of hsl(0, 150%, 40%) is currently rgb(204, 0, 0), not rgb(255, 0, 0). The following examples shows some differences between legacy/modern syntax, between Chrome and FF.

element.style.color = 'hsl(0, 150%, 40%)'
getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0)
element.style.color = 'hsl(0 150% 40%)'
getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0)

element.style.color = 'color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))'
getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0)
element.style.color = 'color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))'
getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)

element.style.color = 'rgb(from hsl(0, 150%, 40%) r g b)'
getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0)
element.style.color = 'rgb(from hsl(0 150% 40%) r g b)'
getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)

Unfortunately, I cannot find any test on WPT with a saturation greater than 100% in hsl().

svgeesus commented 8 months ago

And the specified/computed value of hsl(0, 150%, 40%) is currently rgb(204, 0, 0), not rgb(255, 0, 0). The following examples shows some differences between legacy/modern syntax, between Chrome and FF.

element.style.color = 'hsl(0, 150%, 40%)'
getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0)
element.style.color = 'hsl(0 150% 40%)'
getComputedStyle(element).color; // Chrome-FF -> rgb(204, 0, 0)

element.style.color = 'color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))'
getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0)
element.style.color = 'color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))'
getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)

element.style.color = 'rgb(from hsl(0, 150%, 40%) r g b)'
getComputedStyle(element).color; // Chrome-FF -> color(srgb 0.8 0 0)
element.style.color = 'rgb(from hsl(0 150% 40%) r g b)'
getComputedStyle(element).color; // Chrome -> color(srgb 1 -0.2 -0.2) ; FF -> color(srgb 0.8 0 0)

Confirmed with Chrome Canary Version 123.0.6312.0 (Official Build) canary (64-bit)

$0.style.color = 'hsl(0, 150%, 40%)' 
'hsl(0, 150%, 40%)'
getComputedStyle($0).color
'rgb(204, 0, 0)'
$0.style.color ='color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))' 
'color-mix(in srgb, hsl(0, 150%, 40%), hsl(0, 150%, 40%))'
getComputedStyle($0).color
'color(srgb 0.8 0 0)'
$0.style.color ='color-mix(in srgb, hsl(0 150% 40%), hsl(0, 150%, 40%))' 
'color-mix(in srgb, hsl(0 150% 40%), hsl(0, 150%, 40%))'
getComputedStyle($0).color
'color(srgb 0.9 -0.1 -0.1)'
$0.style.color ='color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))' 
'color-mix(in srgb, hsl(0 150% 40%), hsl(0 150% 40%))'
getComputedStyle($0).color
'color(srgb 1 -0.2 -0.2)'

Unfortunately, I cannot find any test on WPT with a saturation greater than 100% in hsl().

I will add some to css/css-color/parsing/color-computed-hsl.html and css/css-color/parsing/color-mix-out-of-gamut.html

svgeesus commented 8 months ago

"normalized to the range [0, 1]" used to be true but now needs to be restated. "Colors in the sRGB gamut will be in the range [0, 1]" perhaps.

cdoublev commented 5 months ago

Sorry, I am still confused... so what should be the serialized value of hsl(0 150% 40%)?

  1. rgb(204, 0, 0) (current browser output)
  2. rgb(255, 0, 0)
  3. rgb(255, -51, -51)

Answer 3 is eliminated because it does not round-trip (rgb channel values must be clamped at parse time).

Should it be considered a bug if saturation is also capped at 100% before conversion to RGB?

Yes, and that should be observable via color-mix() when output to color(srgb ...). I mean clamping will happen during conversion to rgb() but not before conversion.

I conclude that answer 1 is eliminated because the saturation is capped before conversion... unless I am missing how it happens during conversion to rgb() but the conversion algorithm does not clamp the saturation or the resulting rgb channel values: the result of hslToRgb(0, 150, 40) is currently [1, -0.2, -0.2], which corresponds to [255, -51, -51].

Assuming answer 2 is expected:

But I cannot find where clamping values outside of [0,255] after conversion to RGB, is defined.

It is in 5.1 The RGB functions: rgb() and rgba():

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

The only way I see for this to apply would be to convert hsl() at parse time (unless it is nested in another color function), which is unspecified, and to consider the resulting rgb() as if it was present in the input and clamp its channel values at parse time, as specified.