diff --git a/src/css/values/color.rs b/src/css/values/color.rs index 0bb221f293cc..70b5406b033f 100644 --- a/src/css/values/color.rs +++ b/src/css/values/color.rs @@ -1396,6 +1396,12 @@ pub(crate) fn parse_hsl_hwb< }) } +/// The `` that stands for `100%` in the saturation/lightness of hsl() and the +/// whiteness/blackness of hwb(): `hsl(120 50 40)` is `hsl(120 50% 40%)`. +/// https://www.w3.org/TR/css-color-4/#the-hsl-notation +/// https://www.w3.org/TR/css-color-4/#the-hwb-notation +const HSL_PERCENT_BASIS: f32 = 100.0; + pub(crate) fn parse_hsl_hwb_components( input: &mut css::Parser, parser: &mut ComponentParser, @@ -1408,13 +1414,22 @@ pub(crate) fn parse_hsl_hwb_components( && !h.is_nan() && input.try_parse(|i| i.expect_comma()).is_ok(); - let a = parser.parse_percentage(input)?.clamp(0.0, 1.0); + // Only the legacy comma syntax is limited to ``. + let parse_channel = |input: &mut css::Parser, parser: &ComponentParser| { + if is_legacy_syntax { + parser.parse_percentage(input) + } else { + parser.parse_unit_channel(input, HSL_PERCENT_BASIS) + } + }; + + let a = parse_channel(input, parser)?.clamp(0.0, 1.0); if is_legacy_syntax { input.expect_comma()?; } - let b = parser.parse_percentage(input)?.clamp(0.0, 1.0); + let b = parse_channel(input, parser)?.clamp(0.0, 1.0); if is_legacy_syntax && (a.is_nan() || b.is_nan()) { return Err(input.new_custom_error(css::ParserError::invalid_value)); @@ -1994,6 +2009,16 @@ impl ComponentParser { } } + /// A channel written as ` | | none` and stored as a unit value, + /// `percent_basis` being the `` that stands for `100%`. + fn parse_unit_channel(&self, input: &mut css::Parser, percent_basis: f32) -> CssResult { + if let Ok(number) = input.try_parse(CSSNumberFns::parse) { + return Ok(number / percent_basis); + } + + self.parse_percentage(input) + } + fn parse_percentage(&self, input: &mut css::Parser) -> CssResult { if let Some(from) = &self.from { if let Ok(res) = input.try_parse(|i| RelativeComponentParser::parse_percentage(i, from)) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 567d7e71632b..8c44cf4c2092 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -764,3 +764,94 @@ describe("conversions between color spaces", () => { ); }); }); + +// In the modern (space separated) syntax, the saturation and lightness of hsl() +// and the whiteness and blackness of hwb() are | | none, +// and the number is the percentage without its sign: hsl(120 50 40) is +// hsl(120 50% 40%). The legacy comma syntax takes only percentages. +// https://www.w3.org/TR/css-color-4/#the-hsl-notation +// https://www.w3.org/TR/css-color-4/#the-hwb-notation +describe("hsl() and hwb() number channels", () => { + // Every expected value is what the same color spelled with percentages gives, + // and what lightningcss 1.30 emits for the number spelling. + test.each([ + ["hsl(120 50 40)", "#393"], + ["hsl(120 50 40%)", "#393"], + ["hsl(120 50% 40)", "#393"], + ["hsl(120deg 50 40)", "#393"], + ["hsla(120 50 40)", "#393"], + ["hsl(120 50 40 / 0.5)", "#33993380"], + ["hsl(120 50 40 / 50%)", "#33993380"], + ["hsla(120 50 40 / 0.5)", "#33993380"], + ["hsl(120 none 40)", "#666"], + ["hsl(120 50 none)", "#000"], + ["hsl(none 50 40)", "#933"], + ["hsl(120.5 50.5 40.5)", "#339b34"], + ["hsl(120 .5 40)", "#656765"], + ["hsl(120 1e1 40)", "#5c705c"], + ["hsl(120 calc(25 * 2) 40)", "#393"], + ["hsl(120 calc(25% * 2) 40)", "#393"], + ["hsl(120 50 calc(20 * 2))", "#393"], + ["hwb(120 20 30)", "#33b333"], + ["hwb(120 20 30%)", "#33b333"], + ["hwb(120 20% 30)", "#33b333"], + ["hwb(120deg 20 30)", "#33b333"], + ["hwb(120 20 30 / 0.5)", "#33b33380"], + ["hwb(120 none 30)", "#00b300"], + ["hwb(120 20 none)", "#3f3"], + ["hwb(120 0 0)", "#0f0"], + ["hwb(120 100 0)", "#fff"], + ["hwb(120 0 100)", "#000"], + ["hwb(120 calc(10 * 2) 30)", "#33b333"], + // Relative color syntax is the modern syntax, so it takes numbers too, as + // do the origin colors and the operands of color-mix(). + ["hsl(from red h 50 l)", "#bf4040"], + ["hwb(from red h 20 b)", "#f33"], + ["hsl(from hsl(120 50 40) h s l)", "#393"], + ["hwb(from hwb(120 20 30) h w b)", "#33b333"], + ["rgb(from hsl(120 50 40) r g b)", "#393"], + ["color-mix(in hsl, hsl(120 50 40), hwb(120 20 30))", "#33a633"], + ])("%s", (input, expected) => { + expect(color(input, "css")).toBe(expected); + }); + + // Out-of-range numbers clamp exactly like the corresponding percentages: + // hsl() to 0..100, hwb() to 0..100 and then w + b is normalized to 100. + test.each([ + ["hsl(120 150 40)", "hsl(120 150% 40%)", "#00cc00"], + ["hsl(120 -50 40)", "hsl(120 -50% 40%)", "#666666"], + ["hsl(120 50 140)", "hsl(120 50% 140%)", "#ffffff"], + ["hsl(120 50 -40)", "hsl(120 50% -40%)", "#000000"], + ["hwb(120 60 60)", "hwb(120 60% 60%)", "#808080"], + ["hwb(120 -20 30)", "hwb(120 -20% 30%)", "#00b300"], + ["hwb(120 20 130)", "hwb(120 20% 130%)", "#2a2a2a"], + ])("%s is %s", (numbers, percentages, expected) => { + expect(color(numbers, "hex")).toBe(expected); + expect(color(percentages, "hex")).toBe(expected); + }); + + test.each([ + "hsl(120, 50, 40)", + "hsl(120, 50%, 40)", + "hsl(120, 50, 40%)", + "hsla(120, 50, 40, 0.5)", + // hwb() has no legacy syntax at all. + "hwb(120, 20, 30)", + "hwb(120, 20%, 30%)", + "hsl(120 50, 40)", + "hsl(120 50 40 60)", + // The legacy rgb() percentage form shares the percentage parser and still + // does not take numbers. + "rgb(10%, 20, 30)", + ])("%s is invalid", input => { + expect(color(input, "css")).toBeNull(); + }); + + test.each([ + ["hsl(120, 50%, 40%)", "#393"], + ["hsla(120, 50%, 40%, 0.5)", "#33993380"], + ["rgb(10%, 20%, 30%)", "#1a334d"], + ])("%s still parses", (input, expected) => { + expect(color(input, "css")).toBe(expected); + }); +}); diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 6eba357f68e4..db4784897b80 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -7666,6 +7666,96 @@ describe("css tests", () => { minify_test(".foo{grid-template-areas:none}", ".foo{grid-template-areas:none}"); }); + describe("hsl() and hwb() number channels", () => { + // In the modern syntax the saturation/lightness and whiteness/blackness are + // | | none, and the number is the percentage without + // its sign; only the legacy comma syntax is limited to percentages. + // https://www.w3.org/TR/css-color-4/#the-hsl-notation + // https://www.w3.org/TR/css-color-4/#the-hwb-notation + // Every output below is what the percentage spelling already produced and + // what lightningcss 1.30 emits for the number spelling. + minify_test(".foo { color: hsl(120 50 40) }", ".foo{color:#393}"); + minify_test(".foo { color: hsl(120 50 40%) }", ".foo{color:#393}"); + minify_test(".foo { color: hsl(120 50% 40) }", ".foo{color:#393}"); + minify_test(".foo { color: hsl(120deg 50 40) }", ".foo{color:#393}"); + minify_test(".foo { color: hsla(120 50 40) }", ".foo{color:#393}"); + minify_test(".foo { color: hsl(120 50 40 / 0.5) }", ".foo{color:#33993380}"); + minify_test(".foo { color: hsla(120 50 40 / 50%) }", ".foo{color:#33993380}"); + minify_test(".foo { color: hsl(120 none 40) }", ".foo{color:#666}"); + minify_test(".foo { color: hsl(120 50 none) }", ".foo{color:#000}"); + minify_test(".foo { color: hsl(120 150 40) }", ".foo{color:#0c0}"); + minify_test(".foo { color: hsl(120 -50 40) }", ".foo{color:#666}"); + minify_test(".foo { color: hsl(120 50 140) }", ".foo{color:#fff}"); + minify_test(".foo { color: hsl(120.5 50.5 40.5) }", ".foo{color:#339b34}"); + minify_test(".foo { color: hsl(120 .5 40) }", ".foo{color:#656765}"); + minify_test(".foo { color: hsl(120 calc(25 * 2) 40) }", ".foo{color:#393}"); + minify_test(".foo { color: hsl(120 calc(25% * 2) 40) }", ".foo{color:#393}"); + minify_test(".foo { color: hwb(120 20 30) }", ".foo{color:#33b333}"); + minify_test(".foo { color: hwb(120 20 30%) }", ".foo{color:#33b333}"); + minify_test(".foo { color: hwb(120 20% 30) }", ".foo{color:#33b333}"); + minify_test(".foo { color: hwb(120deg 20 30) }", ".foo{color:#33b333}"); + minify_test(".foo { color: hwb(120 20 30 / 0.5) }", ".foo{color:#33b33380}"); + minify_test(".foo { color: hwb(120 none 30) }", ".foo{color:#00b300}"); + minify_test(".foo { color: hwb(120 20 none) }", ".foo{color:#3f3}"); + minify_test(".foo { color: hwb(120 60 60) }", ".foo{color:gray}"); + minify_test(".foo { color: hwb(120 -20 30) }", ".foo{color:#00b300}"); + minify_test(".foo { color: hwb(120 20 130) }", ".foo{color:#2a2a2a}"); + minify_test(".foo { color: hwb(120 calc(10 * 2) 30) }", ".foo{color:#33b333}"); + + // Relative color syntax is the modern syntax; the origin of a relative + // color, the operands of color-mix() and colors nested in other values go + // through the same parser. + minify_test(".foo { color: hsl(from red h 50 l) }", ".foo{color:#bf4040}"); + minify_test(".foo { color: hwb(from red h 20 b) }", ".foo{color:#f33}"); + minify_test(".foo { color: rgb(from hsl(120 50 40) r g b) }", ".foo{color:#393}"); + minify_test(".foo { color: color-mix(in hsl, hsl(120 50 40), hwb(120 20 30)) }", ".foo{color:#33a633}"); + minify_test( + ".foo { background: linear-gradient(hsl(120 50 40), hwb(120 20 30)) }", + ".foo{background:linear-gradient(#393,#33b333)}", + ); + // Colors inside token lists (custom properties, var() fallbacks). + minify_test(".foo { --c: hsl(120 50 40) }", ".foo{--c:#393}"); + minify_test(".foo { --c: hwb(120 20 30) }", ".foo{--c:#33b333}"); + minify_test(".foo { color: var(--c, hsl(120 50 40)) }", ".foo{color:var(--c,#393)}"); + // An hsl() whose alpha cannot be resolved keeps its channels, which are + // always written as percentages. + minify_test(".foo { color: hsl(120 50 40 / var(--a)) }", ".foo{color:hsl(120 50% 40%/var(--a))}"); + minify_test(".foo { color: hsl(120 50 40% / var(--a)) }", ".foo{color:hsl(120 50% 40%/var(--a))}"); + + // Downleveling for targets without the space separated syntax works from + // the parsed color, so it is the same as for the percentage spelling. + prefix_test( + `.foo { + color: hsl(120 50 40 / 0.5); + border-color: hsl(120 50 40 / var(--a)); + background: hwb(120 20 30 / 0.5); + }`, + indoc` + .foo { + color: rgba(51, 153, 51, .5); + border-color: hsla(120, 50%, 40%, var(--a)); + background: rgba(51, 179, 51, .5); + } + `, + { chrome: Some(60 << 16) }, + ); + + // The legacy comma syntax takes only percentages, and hwb() has no legacy + // syntax, so these are left for the browser to reject, as before. + minify_test(".foo { color: hsl(120, 50, 40) }", ".foo{color:hsl(120,50,40)}"); + minify_test(".foo { color: hsl(120, 50%, 40) }", ".foo{color:hsl(120,50%,40)}"); + minify_test(".foo { color: hsl(120, 50, 40%) }", ".foo{color:hsl(120,50,40%)}"); + minify_test(".foo { color: hsla(120, 50, 40, 0.5) }", ".foo{color:hsla(120,50,40,.5)}"); + minify_test(".foo { color: hwb(120, 20, 30) }", ".foo{color:hwb(120,20,30)}"); + minify_test(".foo { color: hsl(120 50, 40) }", ".foo{color:hsl(120 50,40)}"); + minify_test(".foo { color: hsl(120 50 40 60) }", ".foo{color:hsl(120 50 40 60)}"); + minify_test(".foo { --c: hsl(120, 50, 40) }", ".foo{--c:hsl(120,50,40)}"); + // The legacy rgb() percentage form shares the percentage parser. + minify_test(".foo { color: rgb(10%, 20, 30) }", ".foo{color:rgb(10%,20,30)}"); + minify_test(".foo { color: hsl(120, 50%, 40%) }", ".foo{color:#393}"); + minify_test(".foo { color: rgb(10%, 20%, 30%) }", ".foo{color:#1a334d}"); + }); + describe("edge cases", () => { describe("invalid gradient", () => { cssTest(