8000 fix: hsl parsing not being spec-compliant by kleinfreund · Pull Request #650 · color-js/color.js · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

fix: hsl parsing not being spec-compliant #650

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

kleinfreund
Copy link
Contributor
@kleinfreund kleinfreund commented Jun 9, 2025

Changes

Add the <number> type to the saturation and lightness coordinates (i.e. changing them from <percentage> to <percentage> | <number>) for the HSL color space to match the specification (see https://www.w3.org/TR/css-color-4/#funcdef-hsl).

Make parsing independent from the format.alpha configuration of color spaces and instead rely on the parser to identify whether there is a "last alpha" element in a color string using functional notation. This way, colors that are being parsed with a format that sets format.alpha to true (for serialization purposes) can still omit the alpha value which is valid in both legacy and modern syntax for hsl/hsla colors.

Closes #428, #648.

Notes

  • Important: Currently (both before and with this PR's changes), HSL colors in the legacy functional format parse fine with <number> values for saturation/lightness. Two skipped tests are added for this.

  • This pull request contains a significant change to the parsing logic that needs careful review. Specifically, there's one assumption I'm making about the structure of color strings in functional notation: I assume that all color strings in functional notation except color() that have four "elements" contain the alpha channel in the last element. Now I know this is certainly not true for relative CSS colors (e.g. lch(from blue calc(l + 20) c h)), but I'm not sure there's ever a chance for the current parsing logic to handle such colors. In other words, I assume that the parsing logic I changed deals exclusively with <color-function> excluding specifically color().

  • The exclusion of color() is possibly something that can be addressed, but I wanted to keep the scope of this PR clear. From a quick look at https://www.w3.org/TR/css-color-4/#predefined, a similar assumption as above could be made for color(): Perhaps one can assume that all color() strings that have five "elements" contain the alpha channel in the last element.

  • I used the currently failing examples from the issues Ensure HSL is spec compliant #428 and parse.js throws unnecessary error for hls() #648 in the tests, feel free to suggest more cases that are useful to test.

  • Only related on a meta level: I found working with the test suite a little tricky because logging expressions was difficult. For one, logging synchronously would often lead to nothing being logged. I eventually resorted to using the following timeout-based technique which isn't ideal:

    let messages = [JSON.stringify({ lastAlpha: env.parsed.lastAlpha, args: env.parsed.args })]
    global.setTimeout(() => console.log(messages.join(':')), 15)

    Furthermore, I found myself commenting out a lot of the tests to reduce the noise while logging. htest now has support for skip which is useful, but while working on code, I regularly use only flags for only running tests within a test suite flagged as such without the need to disable them otherwise.

Copy link
netlify bot commented Jun 9, 2025

Deploy Preview for colorjs ready!

Name Link
🔨 Latest commit 085a113
🔍 Latest deploy log https://app.netlify.com/projects/colorjs/deploys/6846c81561326a00085d8123
😎 Deploy Preview https://deploy-preview-650--colorjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@kleinfreund kleinfreund marked this pull request as ready for review June 9, 2025 09:30
@kleinfreund kleinfreund force-pushed the fix/hsl-parsing-not-being-spec-compliant branch 2 times, most recently from 41617f7 to 3c04af8 Compare June 9, 2025 11:32
Add the `<number>` type to the saturation and lightness coordinates (i.e. changing them from `<percentage>` to `<percentage> | <number>`) for the HSL color space to match the specification (see https://www.w3.org/TR/css-color-4/#funcdef-hsl).

Make parsing independent from the `format.alpha` configuration of color spaces and instead rely on the parser to identify whether there is a "last alpha" element in a color string using functional notation. This way, colors that are being parsed with a format that sets `format.alpha` to `true` (for serialization purposes) can still omit the alpha value which is valid in both legacy and modern syntax for hsl/hsla colors.

Closes color-js#428, color-js#648.
@kleinfreund kleinfreund force-pushed the fix/hsl-parsing-not-being-spec-compliant branch from 3c04af8 to 085a113 Compare June 9, 2025 11:40
Copy link
Member
@LeaVerou LeaVerou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a few reservations on the logic in line 257, but damn this is a great PR if I've ever seen one. Well-commented, with tests, with good code, with TODOs and skipped tests for things not perfect yet. 🤩 Thank you so much!

@LeaVerou
Copy link
Member

@svgeesus @facelessuser @lloydk @MysteryBlokHed Any objections to merging this? Hearing none, I’ve approved it and will merge in the next couple days.

// If there's a slash here, it's modern syntax
$0.startsWith("/")
// If there's still elements to process after there's already 3 in `args` (and the we're not dealing with "color()"), it's likely to be a legacy color like "hsl(0, 0%, 0%, 0.5)"
|| (name !== "color" && args.length === 3)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would note that if you ever implement a color space with more than 3 color channels, this will be a problem. It is true that most color spaces are usually 3 channels, and it may be that there is no intention of implementing color spaces with more than 3 channels, but I feel I need to at least point it out.

Copy link
Member
@LeaVerou LeaVerou Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that was my reservation too, but I figured we can cross that bridge when we get to it. Though nothing prevents folks from having custom color spaces with more coordinates, so now I wonder if it may be better to localize this to the few formats it affects (I believe only hsl(), hsla(), rgb(), rgba()?). E.g. what if alpha in the format could also be a function instead of a static boolean? Then we could pass the current parsing state, instead of encoding the logic here.

Or, alternatively, we can expose a higher level property specifically for this (e.g. legacy: true).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If moving forward in the current state is preferred, I would consider at least going ahead and generating a technical debt issue describing the issue to be solved in the future. I agree though that it might not be an immediate concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's an obvious flaw in my approach. It's an assumption that currently holds true, but that can break with time.

The first thing I thought was to store the number of possible channels on the relevant format. This also opens up the possibility for users to hook into individual parsing rules per channel for custom formats should that be something you're interested in.

Since in parseFunction, we don't have knowledge of the Format instance just yet, that would be a bit more heavy-handed to change. I can create a ticket for this as it would at least be good to have an answer for the project's intention with this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Draft for such a follow up issue should this get merged:

Remove parser dependance on all CSS colors in functional notation (except color()) having 4 elements (3 color coordinates; 1 alpha)

Currently, the colorjs.io parsing logic has a dependance on color strings in functional notation (except color()) having 4 elements: 3 color coordinates and 1 alpha. This was introduced in #650 and discussed in #650 (comment).

This dependance would lead to errors if support for color strings in functional notation with a different number of elements is introduced.

One such case is color() which has 5 elements (https://www.w3.org/TR/css-color-4/#predefined). This also illustrates that it's plausible that this becomes a problem as the elements in between the parentheses aren't constrained to be just color coordinates and an alpha value. For color(), the first element is the color space parameter and effectively shifts the color and alpha elements "to the right".

To remove this dependance, each color space format could hold a definition of each element in the corresponding functional notation. The introduction of a new format and/or color space would then have to only take care of providing an accurate definition and not need to mess with the general parsing logic.

Copy link
Member
@LeaVerou LeaVerou Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to note is that even though the official color spaces don't use more than 3 channels yet, an author-defined color space can use any number of channels.

I’m not sure storing the number of channels on the format is the right way to go, since it could be variable.
It definitely seems like the right API shape is not obvious, so in the interest of progress, I'm still leaning towards merging this and filing the issue above.

Perhaps you could contain the scope of the change somewhat by adding a check for commas.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of places in the types where we assume only 3 coordinates, the most notable being the Coords type.

export type Coords = [number | null, number | null, number | null];

So we'll need to make a number of changes to the types to support an arbitrary number of coordinates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not sure storing the number of channels on the format is the right way to go, since it could be variable.

I was thinking more along the lines of capturing the same information as the specs show for the syntax of colors: https://www.w3.org/TR/css-color-4/#funcdef-hsl

This would allow you to accurately describe where which kind of element can be expected and also whether its optional. In any case, that'll be part of the discussion in the follow up issue.

Perhaps you could contain the scope of the change somewhat by adding a check for commas.

Would you mind clarifying that for me? Checking parts[2] for commas like name !== "color" && args.length === 3 && parts[2].includes(",")?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened the technical debt tracking issue for the dependance on number of channels here: #651

@LeaVerou
Copy link
Member

Only related on a meta level: I found working with the test suite a little tricky because logging expressions was difficult. For one, logging synchronously would often lead to nothing being logged.

Just saw this. Typically, expressions are logged in the collapsed tree of the test, to avoid noise (i.e. you need to expand them). Is that the case or are they not logged in at all? I’m thinking that perhaps this should have been opt-in rather than just wrapping all console methods. 🤔

cc @DmitrySharabin (not urgent)

@kleinfreund
Copy link
Contributor Author

Just saw this. Typically, expressions are logged in the collapsed tree of the test, to avoid noise (i.e. you need to expand them). Is that the case or are they not logged in at all? I’m thinking that perhaps this should have been opt-in rather than just wrapping all console methods. 🤔

Having now tried this on a macOS computer, I can confirm that logs happen. I originally worked on this using Windows where I'm fairly sure I tried this and it didn't work. I think what didn't work was expanding the leave nodes in the interactive report. So the timeout-based logging allowed me to "break" out of the reported nodes, so to speak. For what it's worth, I didn't thoroughly look into htest's options for whether perhaps either the "console log wrapping" or interactive reporter can be disabled. I reckon either would've "revealed" the logs to me.

@DmitrySharabin
Copy link
Member

Only related on a meta level: I found working with the test suite a little tricky because logging expressions was difficult. For one, logging synchronously would often lead to nothing being logged.

Just saw this. Typically, expressions are logged in the collapsed tree of the test, to avoid noise (i.e. you need to expand them). Is that the case or are they not logged in at all? I’m thinking that perhaps this should have been opt-in rather than just wrapping all console methods. 🤔

cc @DmitrySharabin (not urgent)

Oh, that is possibly an issue we are aware of and have already addressed, but it has not been released yet. It's been quite a while since our last release, and some good changes are still pending. It's probably about time to release them, @LeaVerou?

// If there's a slash here, it's modern syntax
$0.startsWith("/")
// If there's still elements to process after there's already 3 in `args` (and the we're not dealing with "color()"), it's likely to be a legacy color like "hsl(0, 0%, 0%, 0.5)"
|| (name !== "color" && args.length === 3)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah not super keen on that assumption, but the nature of legacy functional syntax makes it hard to avoid.
Having read the discussion I am in favor of merging it and logging the issue, as suggested.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracking how to improve this in #651

@@ -565,6 +565,60 @@ const tests = {
args: "hsl(230.6 179.7% 37.56% / 1)",
expect: { spaceId: "hsl", coords: [230.6, 179.7, 37.56], alpha: 1 },
},
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks both for the tests and the notes on missing/invalid tests!

@svgeesus
Copy link
Member

This may also break if we ever pick up

although note that RCS is only valid on modern syntax, so perhaps not.

@svgeesus svgeesus merged commit 2311b8d into color-js:main Jun 12, 2025
5 checks passed
@kleinfreund kleinfreund deleted the fix/hsl-parsing-not-being-spec-compliant branch June 12, 2025 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Ensure HSL is spec compliant
6 participants
0