Skip to content

[css-color-4] Use 0 chroma when hue is powerless during conversion #14133

Closed

Description

@romainmenke

https://drafts.csswg.org/css-color-4/#powerless-color-component

When performing color space conversion to a cylindrical polar color space, user agents shall treat a hue component as powerless if the chroma (or other measure of colorfulness, such as saturation in hsl) is less than the epsilon (ε) specified for that color space. For example, a gray color converted into oklch() may, due to numerical errors, have an extremely small chroma rather than precisely 0%; as a result, the hue component is powerless.

https://drafts.csswg.org/css-color-4/#specifying-oklab-oklch

Powerless hue ε C <= 0.000004

So hue is powerless when chroma is smaller than 0.000004 but chroma itself is not set to 0.
And any missing component is treated as 0 for the actual conversion math.

So color(from oklch(0.5 0.000004 180) display-p3 r g b) which should have a powerless hue still ends up as: color(display-p3 0.38857936 0.38856994 0.38857244)

Converting that to hsl for example results in color(srgb 0.38858148 0.38856954 0.3885725).


Shouldn't chroma/saturation also be set to 0 when it matches the powerless hue condition? Otherwise the outcome is a color with whatever hue 0deg is for a given color space. This hue might itself be powerless, but when converting to polar spaces it will still surface.

@svgeesus

Activity

  1. romainmenke commented on Jul 4, 2026

    @romainmenke
    MemberAuthor

    It isn't obvious what to do about hwb where powerlessness results from the combined value of w and b.

    Currently I am using:

    if ((Math.max(0, (Number.isNaN(out[1]) ? 0 : out[1])) + Math.max(0, (Number.isNaN(out[2]) ? 0 : out[2]))) >= 99.999) {
    	out[0] = Number.NaN;
    
    	if ((Math.max(0, (Number.isNaN(out[1]) ? 0 : out[1])) + Math.max(0, (Number.isNaN(out[2]) ? 0 : out[2]))) < 100) {
    		if (!Number.isNaN(out[1]) && !Number.isNaN(out[2])) {
    			out[2] = 100 - out[1];
    		} else if (!Number.isNaN(out[1])) {
    			out[1] = 100;
    		} else if (!Number.isNaN(out[2])) {
    			out[2] = 100;
    		}
    	}
    }
    1. if w + b is greater than 99.999
    2. if w + b is less than 100
    3. if w and b are not missing
      3.1. set b to 100 - w
    4. else if w is not missing
      4.1. set w to 100
    5. else if b is not missing
      5.1. set b to 100
  2. facelessuser commented on Jul 6, 2026

    @facelessuser
    Contributor

    I would think when w + b is very close or greater than 1 (or I should say 100 in CSS), you could assume the color is achromatic (hue is powerless). At least, that is what I do in ColorAide: https://github.com/facelessuser/coloraide/blob/main/coloraide/spaces/hwb/__init__.py#L51

    A value of 100 is achromatic; anything greater, I think, is beyond maximum whiteness and blackness, so achromatic as well. You can count powerlessness very close to achromatic or make 100 a hard limit. Either seems like it would work.

    Shouldn't chroma/saturation also be set to 0 when it matches the powerless hue condition? Otherwise the outcome is a color with whatever hue 0deg is for a given color space. This hue might itself be powerless, but when converting to polar spaces it will still surface.

    I'm not sure the hue matters in this case, because it is...well...powerless. If chroma is very small, the actual chroma shouldn't matter as the resultant hue is powerless regardless. If you force chroma to zero, then you just get whatever random hue your floating-point noise resolves to. Is that better or worse? Some spaces may always return 0 for the hue, but some may return something else.

    I've turned off achromatic hues from being normalized to undefined.

    >>> c = Color('lch(50% 0.0000004 230)')
    >>> c.clone().convert('oklch', norm=False)
    color(--oklch 0.56897 0 226.09deg / 1)
    >>> c.clone().set('c', 0).convert('oklch', norm=False)
    color(--oklch 0.56897 0 170.54deg / 1)

    You just get a different hue that doesn't matter, and affects the round-trip conversion some.

    The actual zero percent chroma hue is dictated by whatever noise is in your calculation:

    >>> Color('white').convert('lch', norm=False)
    color(--lch 100 0 0deg / 1)
    >>> Color('white').convert('oklch', norm=False)
    color(--oklch 1 0 167.47deg / 1)
  3. romainmenke commented on Jul 6, 2026

    @romainmenke
    MemberAuthor

    If chroma is very small, the actual chroma shouldn't matter as the resultant hue is powerless regardless.

    The hue isn't really powerless (as in, it does affect the outcome) when converting to another colorspace and that is kinda the point :)
    It will always be set to 0 to make the maths work.

    Only when the result also has a powerless hue (destination is polar and chroma/sat fits the epsilon).

    Otherwise the result can be a color that is barely but still measurably chromatic.
    While setting the chroma to 0 does seem to produce very good results:

    csstools/postcss-plugins@af47719#diff-53ef003573657bbe0a2e4a2aba162759c10625269703a2a600e43d8187fa3938R16

  4. romainmenke commented on Jul 6, 2026

    @romainmenke
    MemberAuthor

    This is a cleaner diff of what the effect is of setting chroma/sat to 0: csstools/postcss-plugins@4472cc7#diff-53ef003573657bbe0a2e4a2aba162759c10625269703a2a600e43d8187fa3938

  5. facelessuser commented on Jul 6, 2026

    @facelessuser
    Contributor

    The hue isn't really powerless when converting to another colorspace and that is kinda the point :)
    It will always be set to 0 to make the maths work.

    Yes, if the hue is undefined, then it must be set to something, and 0 is fine, but it could be anything.

    Here we normalize hue such that it is undefined and we avoid setting chroma to zero and also set chroma to zero. We get whatever hue floating-point noise gives us:

    >>> c.normalize().convert('oklch', norm=False)
    color(--oklch 0.56897 0 359.51deg / 1)
    >>> c.normalize().set('c', 0).convert('oklch', norm=False)
    color(--oklch 0.56897 0 170.54deg / 1)

    Otherwise the result can be a color that is barely but still measurably chromatic.
    While setting the chroma to 0 does seem to produce very good results:

    Yeah, but none of that is visually significant. What this means is that maybe the achromatic threshold for chroma is too large. But, yes, you could force chroma to zero to make it clamp the value to a true achromatic value.

  6. romainmenke commented on Jul 6, 2026

    @romainmenke
    MemberAuthor

    It is true that none of this is visually significant after a single conversion that doesn't do anything else.

    But when doing calc expressions in relative color syntax you might suddenly get unexpected results when between zero and the epsilon.

    I am thinking of cases where the chroma/saturation of the input color is animated slowly while there is a calc expression that amplifies the chroma/saturation of the output color.

    Such a transition would go from:

    1. true achromatic
    2. a hue of 0 but with a significant chroma making that color visible
    3. the actual hue of the input color

    With a hard transition between 2 and 3.

    While with a forced chroma of 0 it would stay a true achromatic for a bit longer and then go immediately to the expected hue. Although also with a hard transition. There is no way to avoid the discontinuity in such cases, but we can try to make it less surprising.

  7. facelessuser commented on Jul 7, 2026

    @facelessuser
    Contributor

    Yeah, it's possible I can't visualize the case, but I can see colors with chroma on the edge of a threshold teetering back and forth. Normally, if you are operating with a specific color space, this wouldn't be a problem (at least outside of CSS). Maybe there are some peculiarities here with how numbers are converted and being forced to convert back. I guess I would have to see a case to better understand.

  8. romainmenke commented on Jul 7, 2026

    @romainmenke
    MemberAuthor

    Firefox nightly seems to show what I mean with this demo: https://codepen.io/romainmenke/pen/dPNJgaa

    Screen.Recording.2026-07-07.at.17.25.06.mov
  9. svgeesus commented on Jul 31, 2026

    @svgeesus
    Collaborator

    I agree that explicitly setting chroma to 0 (rather than leaving it at some very small value) will give greater consistency and avoid amplifying floating-point noise.

    @romainmenke would you mind submitting a pull request to explicitly state this in the spec? I agree with the Agenda+ F2F, having concrete text to discuss would be helpful. It looks like color issues are on Thursday morning

  10. romainmenke commented on Jul 31, 2026

    @romainmenke
    MemberAuthor

    Proposed text:

    	When changing a powerless hue component to a [=missing component=],
    	the chroma (or other measure of colorfulness, such as saturation in ''hsl''; see [=analogous components=]) is set to zero
    	to avoid amplifying floating-point noise.
    	For ''hwb'' follow these steps instead:
    		<pre>
    		1. if W + B is greater than 99.999 and W + B is less than 100
    		2. if W and B are not missing
    			2.1. set B to 100 - W
    		3. else if W is not missing
    			3.1. set W to 100
    		4. else if B is not missing
    			4.1. set B to 100
    		</pre>
  11. moved this from Agenda+ FTF to Thursday Morning in CSSWG summer 2026 meeting agendaon Aug 1, 2026
  12. css-meeting-bot commented on Aug 6, 2026

    @css-meeting-bot
    Member

    The CSS Working Group just discussed [css-color-4] Use `0` chroma when hue is powerless during conversion, and agreed to the following:

    • RESOLVED: Accept text in issue
    The full IRC log of that discussion <TabAtkins> romain: when converting powerless components, powerless hue, to 0
    <TabAtkins> romain: this can happen when saturation is 0, or very near
    <TabAtkins> romain: when it's converted to another polar space with a significant chroma component, on the other end it's not powerless
    <TabAtkins> romain: so proposal is to go ahead and force it to zero
    <ChrisL> q+
    <TabAtkins> romain: with a special carve out for black because it's not a single channel making it achromatic
    <astearns> ack ChrisL
    <TabAtkins> ChrisL: I agree, this gets rid of some floating point noise. we have proposed text in the issue
    <TabAtkins> JohnJansen: this number doesn't show up in divisions?
    <TabAtkins> ChrisL: no
    <TabAtkins> TabAtkins: and if it does, 0 chroma happens naturally anyway
    <TabAtkins> proposed: Accept text in issue
    <TabAtkins> RESOLVED: Accept text in issue
  13. svgeesus commented on Aug 25, 2026

    @svgeesus
    Collaborator

    Closed by #14244

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions