From 1d6709860dd242e6d677ed3f8d73f6546c0591d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:35:26 +0000 Subject: [PATCH 1/3] css: parse calc( * ) in relative colors In relative color syntax a channel keyword is a , so calc(r * 50%) is a percentage of the channel. The percentage pass of RelativeComponentParser only knew the keyword as a Percentage value, which made that a percentage times a percentage; the color failed to parse, and rgb()/hsl() with such an alpha were emitted as rgb(255 0 0/calc(alpha*50%)), which browsers reject. The pass now evaluates the expression with the keywords as the s of the function's range first, and only then retries it with the keywords standing for a percentage of their range, which keeps the calc(l + 15%) form bun has always accepted working. Percentage::from_calc returned NaN instead of failing when a was added to a . The first pass relies on that being an error to fall through, and it also stops opacity: calc(50% + 1) from minifying to 0; the declaration is left as written like Angle and Time already do. --- src/css/values/calc.rs | 4 +- src/css/values/color.rs | 31 ++++++++--- test/js/bun/css/color.test.ts | 97 +++++++++++++++++++++++++++++++++++ test/js/bun/css/css.test.ts | 65 +++++++++++++++++++++++ 4 files changed, 187 insertions(+), 10 deletions(-) diff --git a/src/css/values/calc.rs b/src/css/values/calc.rs index a429d0d7381e..a9eb8a714647 100644 --- a/src/css/values/calc.rs +++ b/src/css/values/calc.rs @@ -1771,10 +1771,10 @@ impl CalcValue for Percentage { fn into_calc(self) -> Calc { Calc::Value(Box::new(self)) } - fn from_calc(c: Calc, _input: &mut css::Parser) -> CssResult { + fn from_calc(c: Calc, input: &mut css::Parser) -> CssResult { match c { Calc::Value(v) => Ok(*v), - _ => Ok(Percentage { v: f32::NAN }), + _ => Err(input.new_custom_error(css::ParserError::invalid_value)), } } #[inline] diff --git a/src/css/values/color.rs b/src/css/values/color.rs index a89c51e0b668..94368e91a323 100644 --- a/src/css/values/color.rs +++ b/src/css/values/color.rs @@ -2185,17 +2185,32 @@ impl RelativeComponentParser { } /// A math function the `` pass could not fold, evaluated as a - /// `` and returned as a unit value. A keyword is its channel as - /// a percentage of the channel's range here, which is what folds - /// `min(r, g)` (`reduce_args` only compares values) and what gives - /// `calc(l + 10%)` its pre-existing meaning. Callers try `parse_number` - /// first, so a math function over plain numbers gets the CSS Color 5 - /// meaning, in which the keywords are ``s. + /// `` and returned as a unit value. The keywords are ``s + /// first, per CSS Color 5 (`calc(r * 50%)`). That fails for the forms bun has + /// always accepted with the keyword standing for a percentage of its range + /// (`calc(l + 10%)`, and `min(r, g)`, which `reduce_args` only folds over + /// values), so those are retried that way. fn parse_percentage(input: &mut css::Parser, this: &RelativeComponentParser) -> CssResult { - match Calc::::parse_with(input, this, |ctx, ident| { + if let Ok(unit_value) = input.try_parse(|i| { + RelativeComponentParser::parse_percentage_calc(i, this, |ctx, ident| { + Some(Calc::Number(ctx.get_number(ident, ChannelType::NUMBER)?)) + }) + }) { + return Ok(unit_value); + } + + RelativeComponentParser::parse_percentage_calc(input, this, |ctx, ident| { let (unit_value, _) = ctx.get_ident(ident, ChannelType::NUMBER)?; Some(Calc::Value(Box::new(Percentage { v: unit_value }))) - }) { + }) + } + + fn parse_percentage_calc( + input: &mut css::Parser, + this: &RelativeComponentParser, + keyword: impl Fn(&RelativeComponentParser, &[u8]) -> Option> + Copy, + ) -> CssResult { + match Calc::::parse_with(input, this, keyword) { Ok(Calc::Value(v)) => Ok(v.v), _ => Err(input.new_custom_error(css::ParserError::invalid_value)), } diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 921bb9d17f8e..409ef69bf12f 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -550,6 +550,103 @@ describe("css string output parses back to the same color", () => { }); }); +// https://drafts.csswg.org/css-color-5/#relative-colors +// Inside a math function a channel keyword is a in the function's +// range, so multiplying it by a gives a of the +// channel. Every value in the first three groups is lightningcss 1.30 output. +describe("relative color calc() with percentages", () => { + const origin = "color(srgb .8 .4 .2)"; + + test.each([ + ["calc(r * 50%)", ".4"], + ["calc(50% * r)", ".4"], + ["calc(r * -50%)", "-.4"], + ["calc(r * 50% + 10%)", ".5"], + ["calc(50% - r * 25%)", ".3"], + ["calc(r / 2 * 100%)", ".4"], + ["calc(r * g * 50%)", ".16"], + ["calc((r + g) * 50%)", ".6"], + ["min(r * 50%, 30%)", ".3"], + ["max(r * 50%, 30%)", ".4"], + ["round(r * 100%, 30%)", ".9"], + ["mod(r * 100%, 30%)", ".2"], + ["hypot(r * 100%, 30%)", ".8544"], + ])("%s is a percentage of the channel", (expr, expected) => { + expect(color(`color(from ${origin} srgb ${expr} g b)`, "css")).toBe(`color(srgb ${expected} .4 .2)`); + }); + + const spaces: [space: string, channels: string, printed?: string][] = [ + ["srgb", "g b"], + ["srgb-linear", "g b"], + ["display-p3", "g b"], + ["prophoto-rgb", "g b"], + ["rec2020", "g b"], + ["xyz-d50", "y z"], + ["xyz-d65", "y z", "xyz"], + ["xyz", "y z"], + ]; + test.each(spaces)("color(from ... %s calc( * ))", (space, channels, printed = space) => { + const [b, c] = channels.split(" "); + expect(color(`color(from color(${space} .8 .4 .2) ${space} ${b} calc(${b} * 50%) calc(50% * ${c}))`, "css")).toBe( + `color(${printed} .4 .2 .1)`, + ); + }); + + // The keyword is the channel's in the function's own range (r is 200 + // in rgb(), s is 50 in hsl(), l is 50 in lab() and .5 in oklab()), not the + // unit value the color is stored as. + test.each([ + ["rgb(from rgb(200 100 50) calc(r * 50%) g b)", "#ff6432"], + ["rgb(from rgb(200 100 50) calc(r * 0.1%) g b)", "#336432"], + ["rgb(from rgb(200 100 50) r g b / calc(r * 0.1%))", "#c8643233"], + ["rgb(from rgb(200 100 50 / .5) r g b / calc(alpha * 50%))", "#c8643240"], + ["hsl(from hsl(120 50% 40%) h calc(s * 50%) l)", "#0c0"], + ["hsl(from hsl(120 50% 40%) h calc(s * 1%) l)", "#393"], + ["hsl(from hsl(120 50% 40%) h s calc(l * 1.5%))", "#6c6"], + ["hsl(from hsl(120 50% 40%) h s l / calc(s * 0.5%))", "#33993340"], + ["hwb(from hwb(120 20% 30%) h calc(w * 2%) b)", "#66b366"], + ["hwb(from hwb(120 20% 30% / .5) h w b / calc(alpha * 50%))", "#33b33340"], + ["lab(from lab(50% 20 30) calc(l * 1.2%) a b)", "lab(60% 20 30)"], + ["lab(from lab(50% 20 30) l a b / calc(l * 1%))", "lab(50% 20 30 / .5)"], + ["lch(from lch(50% 30 120) calc(l * 1%) c h)", "lch(50% 30 120)"], + ["lch(from lch(50% 30 120 / .5) l c h / calc(alpha * 50%))", "lch(50% 30 120 / .25)"], + ["oklab(from oklab(50% .1 .1) calc(l * 50%) a b)", "oklab(25% .1 .1)"], + ["oklab(from oklab(50% .1 .1 / .5) l a b / calc(50% * alpha))", "oklab(50% .1 .1 / .25)"], + ["oklch(from oklch(50% .1 120) calc(l * 50%) c h)", "oklch(25% .1 120)"], + ["oklch(from oklch(50% .1 120 / .5) l c h / calc(alpha * 50%))", "oklch(50% .1 120 / .25)"], + ["color(from color(srgb .8 .4 .2 / .5) srgb r g b / calc(alpha * 50%))", "color(srgb .8 .4 .2 / .25)"], + ])("%s", (input, expected) => { + expect(color(input, "css")).toBe(expected); + }); + + // Forms bun accepted before, with the keyword standing for a percentage of its + // range (the documented `calc(l + 15%)`), still mean what they did. + test.each([ + ["lab(from lab(50% 20 30) calc(l + 15%) a b)", "lab(65% 20 30)"], + ["hsl(from hsl(120 50% 40%) h calc(s - 10%) l)", "#3d8f3d"], + ["rgb(from rgb(200 100 50 / .5) r g b / calc(alpha - 20%))", "#c864324d"], + [`color(from ${origin} srgb calc(r + 10%) g b)`, "color(srgb .9 .4 .2)"], + [`color(from ${origin} srgb min(r, 50%) g b)`, "color(srgb .5 .4 .2)"], + [`color(from ${origin} srgb min(r, g) g b)`, "color(srgb .4 .4 .2)"], + [`color(from ${origin} srgb calc(r * .5) g b)`, "color(srgb .4 .4 .2)"], + ])("%s still parses", (input, expected) => { + expect(color(input, "css")).toBe(expected); + }); + + test.each([ + `color(from ${origin} srgb calc(r * 50% + .1) g b)`, + `color(from ${origin} srgb calc(r * 50% - g) g b)`, + `color(from ${origin} srgb calc(r * 50% * 10%) g b)`, + // Without relative color syntax there is no keyword to fall back on; these + // used to evaluate to NaN and parse as a `none` channel. + "color(srgb calc(1 + 50%) 0 0)", + "color(srgb calc(50% + 1) 0 0)", + "hsl(120 calc(50% + 1) 40%)", + ])("%s mixes a number and a percentage and does not parse", input => { + expect(color(input, "css")).toBeNull(); + }); +}); + describe("input forms", () => { test.each([ ["a named color", "red"], diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index c40880b9dd5f..1e43a227b4da 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -180,6 +180,71 @@ describe("css tests", () => { minify_test(`a { opacity: calc(NaN) }`, `a{opacity:0}`); minify_test(`a { rotate: calc(NaN * 1deg) }`, `a{rotate:0deg}`); minify_test(`a { transition-duration: calc(NaN * 1s) }`, `a{transition-duration:0s}`); + + // A and a cannot be added: the declaration is invalid and + // is left as written, not evaluated to NaN and serialized as 0. + minify_test(`a { opacity: calc(50% + 1) }`, `a{opacity:calc(50% + 1)}`); + minify_test(`a { opacity: calc(1 - 50%) }`, `a{opacity:calc(1 - 50%)}`); + minify_test(`a { opacity: calc(50% + 25%) }`, `a{opacity:.75}`); + minify_test(`a { opacity: calc(50% * 1.5) }`, `a{opacity:.75}`); + }); + + describe("relative color calc() with percentages", () => { + // https://drafts.csswg.org/css-color-5/#relative-colors: a channel keyword is + // a in the function's range, so `calc(r * 50%)` is a of + // the channel. Expected values are lightningcss output. + minify_test( + ".foo { color: color(from color(srgb .8 .4 .2) srgb calc(r * 50%) calc(50% * g) b) }", + ".foo{color:color(srgb .4 .2 .2)}", + ); + minify_test( + ".foo { color: color(from color(display-p3 .8 .4 .2) display-p3 r calc(g * 50% + 10%) calc(50% - b * 50%)) }", + ".foo{color:color(display-p3 .8 .3 .4)}", + ); + minify_test( + ".foo { color: color(from color(srgb .8 .4 .2) srgb min(r * 50%, 30%) max(g * 50%, 30%) b) }", + ".foo{color:color(srgb .3 .3 .2)}", + ); + minify_test( + ".foo { color: color(from color(srgb .8 .4 .2) srgb round(r * 100%, 30%) g b) }", + ".foo{color:color(srgb .9 .4 .2)}", + ); + minify_test( + ".foo { color: color(from color(srgb .8 .4 .2 / .5) srgb r g b / calc(alpha * 50%)) }", + ".foo{color:color(srgb .8 .4 .2/.25)}", + ); + minify_test(".foo { color: rgb(from rgb(200 100 50) calc(r * 50%) g b) }", ".foo{color:#ff6432}"); + minify_test(".foo { color: rgb(from rgb(200 100 50) calc(r * 0.1%) g b) }", ".foo{color:#336432}"); + minify_test(".foo { color: rgb(from rgb(200 100 50) r g b / calc(r * 0.1%)) }", ".foo{color:#c8643233}"); + minify_test(".foo { color: rgb(from rgb(200 100 50 / .5) r g b / calc(alpha * 50%)) }", ".foo{color:#c8643240}"); + // Not `rgb(255 0 0/calc(alpha*50%))`, which no browser accepts. + minify_test(".foo { color: rgb(from red r g b / calc(alpha * 50%)) }", ".foo{color:#ff000080}"); + minify_test(".foo { color: hsl(from hsl(120 50% 40%) h calc(s * 50%) l) }", ".foo{color:#0c0}"); + minify_test(".foo { color: hsl(from hsl(120 50% 40%) h s calc(l * 1.5%)) }", ".foo{color:#6c6}"); + minify_test(".foo { color: hsl(from hsl(120 50% 40%) h s l / calc(s * 0.5%)) }", ".foo{color:#33993340}"); + minify_test(".foo { color: hwb(from hwb(120 20% 30%) h calc(w * 2%) b) }", ".foo{color:#66b366}"); + minify_test(".foo { color: lab(from lab(50% 20 30) calc(l * 1.2%) a b) }", ".foo{color:lab(60% 20 30)}"); + minify_test(".foo { color: lab(from lab(50% 20 30) l a b / calc(l * 1%)) }", ".foo{color:lab(50% 20 30/.5)}"); + minify_test(".foo { color: lch(from lch(50% 30 120) calc(l * 1%) c h) }", ".foo{color:lch(50% 30 120)}"); + minify_test(".foo { color: oklab(from oklab(50% .1 .1) calc(l * 50%) a b) }", ".foo{color:oklab(25% .1 .1)}"); + minify_test( + ".foo { color: oklch(from oklch(50% .1 120 / .5) l c h / calc(alpha * 50%)) }", + ".foo{color:oklch(50% .1 120/.25)}", + ); + + // The keyword-as-percentage forms bun accepted before keep their meaning. + minify_test(".foo { color: lab(from lab(50% 20 30) calc(l + 15%) a b) }", ".foo{color:lab(65% 20 30)}"); + minify_test(".foo { color: rgb(from rgb(200 100 50 / .5) r g b / calc(alpha - 20%)) }", ".foo{color:#c864324d}"); + minify_test( + ".foo { color: color(from color(srgb .8 .4 .2) srgb min(r, 50%) calc(g + 10%) b) }", + ".foo{color:color(srgb .5 .5 .2)}", + ); + + minify_test( + ".foo { color: color(from red srgb calc(r * 50% + .1) g b) }", + ".foo{color:color(from red srgb calc(r*50% + .1)g b)}", + ); + minify_test(".foo { color: color(srgb calc(1 + 50%) 0 0) }", ".foo{color:color(srgb calc(1 + 50%)0 0)}"); }); describe("calc stack overflow", () => { // https://github.com/oven-sh/bun/issues/20128 From 5c593d01760e08d659d63ecb107cb1e6c69cb73a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:39:47 +0000 Subject: [PATCH 2/3] css: shorten the parse_percentage doc comment --- src/css/values/color.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/css/values/color.rs b/src/css/values/color.rs index 94368e91a323..2b75dceecbdd 100644 --- a/src/css/values/color.rs +++ b/src/css/values/color.rs @@ -2184,12 +2184,8 @@ impl RelativeComponentParser { Err(input.new_error_for_next_token()) } - /// A math function the `` pass could not fold, evaluated as a - /// `` and returned as a unit value. The keywords are ``s - /// first, per CSS Color 5 (`calc(r * 50%)`). That fails for the forms bun has - /// always accepted with the keyword standing for a percentage of its range - /// (`calc(l + 10%)`, and `min(r, g)`, which `reduce_args` only folds over - /// values), so those are retried that way. + /// A `` math function, as a unit value. The keywords are ``s + /// (`calc(r * 50%)`), else percentages of their range as before (`calc(l + 10%)`). fn parse_percentage(input: &mut css::Parser, this: &RelativeComponentParser) -> CssResult { if let Ok(unit_value) = input.try_parse(|i| { RelativeComponentParser::parse_percentage_calc(i, this, |ctx, ident| { From 4d2cc7c0da4a2c70d99199a86ff4f1bbbf9a51b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:44:46 +0000 Subject: [PATCH 3/3] css: one-line comments on the relative percentage passes --- src/css/values/color.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/css/values/color.rs b/src/css/values/color.rs index 2b75dceecbdd..48c6a5b1dc95 100644 --- a/src/css/values/color.rs +++ b/src/css/values/color.rs @@ -2184,8 +2184,7 @@ impl RelativeComponentParser { Err(input.new_error_for_next_token()) } - /// A `` math function, as a unit value. The keywords are ``s - /// (`calc(r * 50%)`), else percentages of their range as before (`calc(l + 10%)`). + /// A `` math function over the channel keywords, as a unit value. fn parse_percentage(input: &mut css::Parser, this: &RelativeComponentParser) -> CssResult { if let Ok(unit_value) = input.try_parse(|i| { RelativeComponentParser::parse_percentage_calc(i, this, |ctx, ident| { @@ -2195,6 +2194,7 @@ impl RelativeComponentParser { return Ok(unit_value); } + // Keyword as a percentage of its range: `calc(l + 10%)`, `min(r, g)`. RelativeComponentParser::parse_percentage_calc(input, this, |ctx, ident| { let (unit_value, _) = ctx.get_ident(ident, ChannelType::NUMBER)?; Some(Calc::Value(Box::new(Percentage { v: unit_value })))