Repository navigation
css: parse calc(<channel> * <percentage>) in relative colors #38513
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <number> in the function's | ||
| // range, so multiplying it by a <percentage> gives a <percentage> 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"], | ||
| ]; | ||
|
Comment on lines
+578
to
+587
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Extended reasoning...What the gap isThe Why this is a findingREVIEW.md → Tests reviewers reject → "Cover the variant matrix, not just the repro. Every sibling entry point receiving the same fix". The PR description explicitly claims coverage of " Why nothing else covers itThe other new test blocks in this PR use specific spaces ( ImpactLow. All predefined Step-by-step proof of the omission
How to fixAdd one line to the const spaces: [space: string, channels: string, printed?: string][] = [
["srgb", "g b"],
["srgb-linear", "g b"],
["display-p3", "g b"],
["a98-rgb", "g b"],
["prophoto-rgb", "g b"],
... |
||
| test.each(spaces)("color(from ... %s calc(<channel> * <percentage>))", (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 <number> 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"], | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 This fallback wraps each keyword's stored component as
Percentage{v: unit_value}and discards its range, so inlab()/lch()— wherelhas range 100 buta/b/chave range 1 —lab(from lab(50% 20 30) min(l, a) a b)compares 0.5 vs 20 and folds tolab(50% 20 30)instead of the spec-correctlab(20% 20 30); before this PRawasCT_NUM, didn't matchChannelType::PERCENTAGE, and the color was left unparsed for the browser to compute correctly. Separately, the description'sreduce_argsbullet (Number↔Number folding,min(r, .5),line-height: min(1.5, 1.2),min(NaN, .5),min(10px, 1.5)) and its tests are not in the diff — restoring that arm would foldmin(l, a)correctly in theCalc<f32>pass and make this fallback unreachable for keyword-onlymin/max; otherwise this pass should compare range-scaled values (or reject when the arguments' ranges differ) and the description/table should be updated.Extended reasoning...
What the bug is
The second-pass fallback of
RelativeComponentParser::parse_percentage(color.rs:2202-2205) resolves each channel keyword viaget_ident, takes the stored component (unit_value), discards the returned range, and wraps it asCalc::Value(Percentage{v: unit_value}). This is what letsmin(r, g)fold whenreduce_argscannot compareCalc::Numbers, and it is only correct when every keyword in the expression has the same range. This PR introduces per-channel ranges, and inlab()/lch()lightness has range 100 whilea/b/c(andalpha) have range 1 — somin(l, a)compares 0.5 against 20 and picks the wrong argument.Code path
For
lab(from lab(50% 20 30) min(l, a) a b), the lightness position callsparse_unit_channel(i, 100).parse_labsetsfrom.ranges = (100, 1, 1); the origin storescomponents = (0.5, 20, 30, 1.0).self.parse_number→parse_calc<f32>(color.rs:2234-2246):l→get_number→ 0.5×100 =Number(50);a→ 20×1 =Number(20).reduce_args(calc.rs:1090) only matchesif let Calc::Value(val) = &*arg, so bothNumbers survive →Calc::Function(Min)→ rejected (onlyCalc::Value/Calc::Numberaccepted at 2241-2246).parse_percentagepass 1 (color.rs:2196-2200, keyword→Calc::Number(get_number(...))): identical[Number(50), Number(20)]→Function→parse_percentage_calc(2214) only acceptsCalc::Value→ rejected.parse_percentagepass 2 (color.rs:2202-2205):l→get_ident→(0.5, 100), discards 100, wraps 0.5;a→get_ident→(20, 1), discards 1, wraps 20.reduce_argscomparesPercentage{0.5}vsPercentage{20}: pushes 0.5, thenpartial_cmp(20, 0.5) = Greater ≠ Less→ drops 20. ReturnsPercentage{0.5}→Ok(0.5).parse_unit_channelreturns 0.5 →lab(50% 20 30).Why nothing else catches it
The
Calc<f32>pass computes the correct numbers (50, 20) but cannot fold them becausereduce_argshas noCalc::Numberarm. The firstCalc<Percentage>pass has the same problem. Only the second pass folds, and it does so on the wrong axis. Every existingmin/max-of-keywords test in the PR (min(r, g)incolor()/rgb(),min(s, l)inhsl(),max(w, b)inhwb()) uses keywords whose ranges are equal, so stored-value comparison happens to give the same answer as range-scaled comparison.Regression
Before this PR,
LABhadtypes = (CT_PCT, CT_NUM, CT_NUM)and the oldparse_percentagelooked up keywords withChannelType::PERCENTAGE, soa(CT_NUM) returnedNonefromget_ident, theCalc<Percentage>pass errored, and the whole color fell back to unparsed tokens — the browser then computedmin(50, 20) = 20→lab(20% 20 30). After this PR, all channels areCT_NUMand the lookup usesChannelType::NUMBER, soaresolves and bun emits a wrong constant — strictly worse than the pre-PR passthrough, and exactly the failure mode the PR description warns against forrgb()alpha. The same applies tolch(from ... min(l, c) c h). (min(l, alpha)was already wrong pre-PR since alpha matched PERCENTAGE;min(l, a)/min(l, b)/min(l, c)are newly broken.)The stale
reduce_argsclaimThe PR description's Fix section says "
reduce_args:min()/max()also reduceCalc::Numberarguments against each other …min(r, .5)folds too …line-height: min(1.5, 1.2)minifies to1.2", and the Verified section listsmin(r, .5),min(NaN, .5),min(10px, 1.5)as pinned tests. None of that is in the diff: the calc.rs hunk only touchesPercentage::from_calc;reduce_argsat ~1090 still has noCalc::Numberarm; grep oftest/js/bun/css/finds none of those four cases; and the doc comment on the newparse_percentage("min(r, g), whichreduce_argsonly folds over values") confirms the author knowsreduce_argswas not changed. Somin(r, .5)does not fold (both passes end with mixed[Number, Number]or[Value, Number]args),line-height: min(1.5, 1.2)staysmin(1.5,1.2), and the before/after table's rows for both are wrong. The review timeline (claude[bot] at 13:59 references "the newreduce_argsarms", then a fallback-pass redesign landed at 19:35) suggests thereduce_argschange and its tests were dropped during the rework and the description was never updated (CLAUDE.md #11: "NEVER overstate what you got done").Step-by-step proof
Input:
lab(from lab(50% 20 30) min(l, a) a b).Per CSS Color 5 the channel keywords are
<number>s in the function's own range:l = 50,a = 20.min(50, 20) = 20. In the lightness position 20 meansl = 20/100 = 0.2stored →lab(20% 20 30). Chrome/Firefox and lightningcss (which leaves it unparsed) agree.Bun after this PR: pass 1 →
[Number(50), Number(20)],reduce_argspushes both →Function(Min)→ rejected. Pass-2Calc<Percentage>(Number keyword): same → rejected. Pass-2 fallback (Value keyword):[Value(Pct{0.5}), Value(Pct{20})];reduce_argspushes 0.5, then compares 20 vs 0.5 →Greater, order isLess, so 20 is dropped. Reduced to[Value(0.5)]→ returns 0.5 →lab(50% 20 30). Wrong.How to fix
Two independently sufficient options:
reduce_argschange (aCalc::Number↔Calc::Numbercomparison arm, plus the four claimed tests). Then theCalc<f32>pass foldsmin(l, a)toNumber(20),parse_calcaccepts it,parse_unit_channelreturns20/100 = 0.2→lab(20% 20 30), and the buggy fallback is never reached for keyword-onlymin/max. This also delivers what the description already claims (min(r, .5),line-height: min(1.5, 1.2)).reduce_argsas-is, correct the description/table, and make this fallback compare on the range-scaled axis — e.g. wrapget_number(ident, ChannelType::NUMBER)? / <range of the position being parsed>as thePercentagevalue so all keywords land on the same scale — or simply reject (returnNone) when the arguments' ranges differ so the color falls back to unparsed tokens as before.Either way, add
lab(from lab(50% 20 30) min(l, a) a b)(and thelchanalogue) to the test matrix.