diff --git a/docs/bundler/css.mdx b/docs/bundler/css.mdx index 809081234796..7be736f31693 100644 --- a/docs/bundler/css.mdx +++ b/docs/bundler/css.mdx @@ -644,15 +644,15 @@ Media query range syntax expresses breakpoints with comparison operators (`<`, ` } } -/* Exclusive range using < and > */ -@media (width > 320px) and (width < 768px) { +/* Exclusive bound using < */ +@media (width < 768px) { .mobile-only { display: block; } } ``` -Bun's CSS bundler converts range queries to traditional media query syntax for compatibility with all browsers: +When a target browser has no range syntax (Chrome before 104, Safari before 16.4), Bun's CSS bundler converts range queries to traditional media query syntax: ```css title="styles.css" icon="file-code" /* Converted to traditional min/max syntax */ @@ -668,13 +668,22 @@ Bun's CSS bundler converts range queries to traditional media query syntax for c } } -@media (min-width: 321px) and (max-width: 767px) { +/* Exclusive bound: "not at least 768px" */ +@media not all and (min-width: 768px) { .mobile-only { display: block; } } ``` +The `min-` and `max-` prefixes are inclusive. When a query is one exclusive bound (`<` or `>`), Bun writes it as the negation of the opposite inclusive bound. The bound stays exclusive, and Bun adds nothing to its value. Two kinds of bound have another form. An integer bound moves to the next integer: `(color > 2)` becomes `(min-color: 3)`. A `resolution` bound moves by a small step. + +Browsers without range syntax negate only a whole query. When a query combines an exclusive bound with a media type or another condition, Bun moves a length by `.02px` instead. For example, `screen and (width < 768px)` becomes `screen and (max-width: 767.98px)`. This form is an approximation: + +- A viewport width between `767.98px` and `768px` is on the wrong side of the bound. +- Safari 14 reads a media length as an integer, so `screen and (width > 768px)` also applies at `768px` there. +- A length of `10000px` or more and an `env()` value get no step. + ### Shorthands CSS has introduced several shorthand properties that combine multiple longhand properties. diff --git a/src/css/media_query.rs b/src/css/media_query.rs index 1258998f178e..075d6689047e 100644 --- a/src/css/media_query.rs +++ b/src/css/media_query.rs @@ -678,6 +678,17 @@ impl MediaList { } dest.write_comma_separated(&self.media_queries, |d, q| q.to_css(d)) } + + /// `to_css` with `MediaQuery::to_css_as_written` for each query. + pub(crate) fn to_css_as_written( + &self, + dest: &mut Printer, + ) -> core::result::Result<(), PrintErr> { + if self.media_queries.is_empty() { + return dest.write_str("not all"); + } + dest.write_comma_separated(&self.media_queries, |d, q| q.to_css_as_written(d)) + } } impl MediaQuery { @@ -696,6 +707,16 @@ impl MediaQuery { } pub fn to_css(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> { + if let Some(lone) = self.lone_feature() + && lone.to_css_without_range_syntax(dest)? + { + return Ok(()); + } + self.to_css_as_written(dest) + } + + /// `to_css` without `LoneFeature::to_css_without_range_syntax`. + fn to_css_as_written(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> { if let Some(qual) = self.qualifier { qual.to_css(dest)?; dest.write_char(b' ')?; @@ -1074,23 +1095,26 @@ impl MediaFeatureValue { } } - /// Adjust by `other` for strict-inequality → min/max - /// boundary lowering. Consumes `self`. - fn add_f32(self, other: f32) -> MediaFeatureValue { + /// Steps past `self` for a strict bound in a `min-`/`max-` feature: `sign` is 1.0 for `>`, -1.0 for `<`. + fn step(self, sign: f32) -> MediaFeatureValue { + let other = sign * 0.001; match self { - MediaFeatureValue::Length(len) => MediaFeatureValue::Length(len.add(Length::px(other))), + // Blink's `CompareDoubleValue` gives a `min-`/`max-` length 1/64px of slack, so a smaller step does nothing. + MediaFeatureValue::Length(len) => { + MediaFeatureValue::Length(len.add(Length::px(sign * 0.02))) + } MediaFeatureValue::Number(num) => MediaFeatureValue::Number(num + other), MediaFeatureValue::Integer(num) => { - MediaFeatureValue::Integer(num + if other.is_sign_positive() { 1 } else { -1 }) + MediaFeatureValue::Integer(num.saturating_add(sign as CSSInteger)) } MediaFeatureValue::Boolean(v) => MediaFeatureValue::Boolean(v), MediaFeatureValue::Resolution(res) => MediaFeatureValue::Resolution(res.add_f32(other)), - MediaFeatureValue::Ratio(ratio) => MediaFeatureValue::Ratio(ratio.add_f32(other)), + MediaFeatureValue::Ratio(ratio) => MediaFeatureValue::Ratio(ratio.step(sign)), MediaFeatureValue::Ident(id) => MediaFeatureValue::Ident(id), // env() values pass through unchanged — the calc-lattice add // (wrapping the env() reference in a calc() to apply the // strict-inequality boundary adjustment) is not implemented, so - // range bounds on env() values lose the +/-0.001 nudge. + // range bounds on env() values lose the step. MediaFeatureValue::Env(env) => MediaFeatureValue::Env(env), } } @@ -1221,20 +1245,134 @@ fn write_min_max( // `MediaFeatureValue: Clone`, so clone-by-value before adjusting. let adjusted: Option = match operator { - MediaFeatureComparison::GreaterThan => Some(value.clone().add_f32(0.001)), - MediaFeatureComparison::LessThan => Some(value.clone().add_f32(-0.001)), + MediaFeatureComparison::GreaterThan => Some(value.clone().step(1.0)), + MediaFeatureComparison::LessThan => Some(value.clone().step(-1.0)), _ => None, }; - if let Some(val) = adjusted { - val.to_css(dest)?; - } else { - value.to_css(dest)?; + match adjusted.as_ref().unwrap_or(value) { + // Chrome before 110 and Safari before 16.4 read a ratio only as two integers. + MediaFeatureValue::Ratio(ratio) => ratio.to_css_both_terms(dest)?, + value => value.to_css(dest)?, } dest.write_char(b')') } +/// A query that is one feature of media type `all`, with or without a `not`: `not all and (width < V)`. +struct LoneFeature<'a> { + /// The `MediaCondition::Feature` of the query. + condition: &'a MediaCondition, + /// The query starts with `not all and`. + not_all: bool, + /// The feature is inside `not (..)`, which needs the same browsers as range syntax. + written_not: bool, +} + +impl MediaQuery { + /// `None` for a query that `LoneFeature::to_css_without_range_syntax` leaves as it is. + #[inline] + fn lone_feature(&self) -> Option> { + if !matches!(self.media_type, MediaType::All) { + return None; + } + // `only` changes nothing in a browser that reads media queries. + let not_all = matches!(self.qualifier, Some(Qualifier::Not)); + let (condition, written_not) = match &self.condition { + Some(MediaCondition::Not(inner)) if !not_all => (&**inner, true), + Some(condition) => (condition, false), + None => return None, + }; + let MediaCondition::Feature(feature) = condition else { + return None; + }; + // `(min-width: V)` parses as `width >= V`, so most queries stop here. + if !written_not && feature.strict_bound().is_none() { + return None; + } + Some(LoneFeature { + condition, + not_all, + written_not, + }) + } +} + +impl MediaFeature { + /// The inclusive comparison that a strict bound negates: `(width < V)` is "not `(width >= V)`". + #[inline] + fn strict_bound( + &self, + ) -> Option<( + MediaFeatureComparison, + &MediaFeatureName, + &MediaFeatureValue, + )> { + let QueryFeature::Range { + name, + operator, + value, + } = self + else { + return None; + }; + let negates = match operator { + MediaFeatureComparison::LessThan => MediaFeatureComparison::GreaterThanEqual, + MediaFeatureComparison::GreaterThan => MediaFeatureComparison::LessThanEqual, + _ => return None, + }; + // An integer has an inclusive neighbour: `(color > 2)` is `(min-color: 3)`. + if matches!(value, MediaFeatureValue::Integer(_)) { + return None; + } + Some((negates, name, value)) + } +} + +impl LoneFeature<'_> { + /// For targets without range syntax, writes the query with its `not` on the whole query. Returns whether it did. + #[cold] + fn to_css_without_range_syntax( + &self, + dest: &mut Printer, + ) -> core::result::Result { + if !dest + .targets + .should_compile_same(css::compat::Feature::MediaRangeSyntax) + { + return Ok(false); + } + let MediaCondition::Feature(feature) = self.condition else { + return Ok(false); + }; + let strict = feature.strict_bound(); + // `not (width < V)` is `(min-width: V)`. + let not = (self.not_all || self.written_not) != strict.is_some(); + let (QueryFeature::Plain { name, .. } + | QueryFeature::Boolean { name } + | QueryFeature::Range { name, .. } + | QueryFeature::Interval { name, .. }) = &**feature; + // Safari before 16 never matches a `resolution` feature, so adding or removing `not all and` flips the result there. + if not != self.not_all + && matches!(name, MediaFeatureName::Standard(MediaFeatureId::Resolution)) + { + return Ok(false); + } + if not { + // Not lightningcss's `not (..)`: browsers without range syntax reject it (parcel-bundler/lightningcss#1361). + dest.write_str("not all and ")?; + } + let Some((negates, name, value)) = strict else { + // Through the condition: a second caller of `QueryFeature::to_css` takes it out of line for every query. + self.condition.to_css(dest)?; + return Ok(true); + }; + dest.write_char(b'(')?; + write_min_max(negates, name, value, dest)?; + Ok(true) + } +} + // ───────────────────────── deep_clone ───────────────────────── // Arena-aware `deep_clone`. // diff --git a/src/css/rules/custom_media.rs b/src/css/rules/custom_media.rs index b88c99bac137..8e5c285d9eb7 100644 --- a/src/css/rules/custom_media.rs +++ b/src/css/rules/custom_media.rs @@ -29,7 +29,8 @@ impl CustomMediaRule { dest.write_str("@custom-media ")?; DashedIdentFns::to_css(&self.name, dest)?; dest.write_char(b' ')?; - self.query.to_css(dest)?; + // `(--name)` puts this query inside other queries, where a `not all and` would negate those too. + self.query.to_css_as_written(dest)?; dest.write_char(b';')?; Ok(()) } diff --git a/src/css/values/ratio.rs b/src/css/values/ratio.rs index 7dbc5e734843..5c8957275d5c 100644 --- a/src/css/values/ratio.rs +++ b/src/css/values/ratio.rs @@ -36,18 +36,46 @@ impl Ratio { } pub(crate) fn to_css(self, dest: &mut Printer) -> core::result::Result<(), PrintErr> { - CSSNumberFns::to_css(self.numerator, dest)?; if self.denominator != 1.0 { - dest.delim(b'/', true)?; - CSSNumberFns::to_css(self.denominator, dest)?; + return self.to_css_both_terms(dest); } - Ok(()) + CSSNumberFns::to_css(self.numerator, dest) + } + + /// Writes `numerator / denominator`, also when the denominator is 1. + pub(crate) fn to_css_both_terms( + self, + dest: &mut Printer, + ) -> core::result::Result<(), PrintErr> { + CSSNumberFns::to_css(self.numerator, dest)?; + dest.delim(b'/', true)?; + CSSNumberFns::to_css(self.denominator, dest) } - pub(crate) fn add_f32(self, other: f32) -> Ratio { + /// Steps past the ratio for a strict bound. Integer terms stay integers: `16 / 9` steps up to `16001 / 9000`. + pub(crate) fn step(self, sign: f32) -> Ratio { + let Ratio { + numerator, + denominator, + } = self; + let integers = numerator >= 1.0 + && denominator >= 1.0 + && numerator.fract() == 0.0 + && denominator.fract() == 0.0; + if !integers { + return Ratio { + numerator: numerator + sign * 0.001, + denominator, + }; + } + // At most 5 digits: Safari before 16.4 multiplies a term by the viewport size as a 32-bit float. + let mut scale = 1.0; + while numerator.max(denominator) * scale * 10.0 < 100_000.0 { + scale *= 10.0; + } Ratio { - numerator: self.numerator + other, - denominator: self.denominator, + numerator: numerator * scale + sign, + denominator: denominator * scale, } } } diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 0679a6644722..ddf64ce15e1c 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -1,4 +1,4 @@ -import { describe } from "bun:test"; +import { describe, expect } from "bun:test"; import { join } from "node:path"; import { itBundled } from "../expectBundled"; @@ -2144,6 +2144,61 @@ c { outfile: "/out.css", }); + // The default browser targets have no range syntax (Chrome 80, Safari 14). A query + // that is one strict bound prints as a negation with the value as written, so + // `(width < 1024px)` and `(width >= 1024px)` never both match at 1024px. A strict + // bound beside another part steps .02px past its value. + for (const backend of ["cli", "api"] as const) { + itBundled(`css/MediaRangeStrictBound/${backend}`, { + backend, + files: { + "/entry.css": /* css */ ` + @import "https://example.com/narrow.css" (width < 1024px); + @import "https://example.com/screen.css" screen and (width < 1024px); + @import "./tablet.css" (width < 1024px); + @import "./chain.css" (width > 640px); + @media (width < 1024px) { .nav { display: none } } + @media (width >= 1024px) { .burger { display: none } } + @media (width > 1280px) { .wide { color: red } } + @media (width < 64rem) { .rem { color: red } } + @media screen and (width < 768px) { .screen { color: red } } + .card { color: blue; @media (width < 768px) { color: red } } + `, + "/tablet.css": /* css */ `.tablet { color: red }`, + "/chain.css": /* css */ `@import "https://example.com/short.css" (height < 500px);`, + }, + outfile: "/out.css", + onAfterBundle(api) { + const rule = (prelude: string, selector: string, declaration: string) => + [`@media ${prelude} {`, ` ${selector} {`, ` ${declaration};`, " }", "}", ""].join("\n"); + expect(api.readFile("/out.css")).toBe( + [ + '@import "https://example.com/narrow.css" not all and (min-width: 1024px);', + '@import "https://example.com/screen.css" screen and (max-width: 1023.98px);', + '@import "data:text/css,@import \\"https://example.com/short.css\\" not all and (min-height: 500px);" not all and (max-width: 640px);', + "", + "/* tablet.css */", + rule("not all and (min-width: 1024px)", ".tablet", "color: red"), + "/* chain.css */", + "", + "", + "/* entry.css */", + rule("not all and (min-width: 1024px)", ".nav", "display: none"), + rule("(min-width: 1024px)", ".burger", "display: none"), + rule("not all and (max-width: 1280px)", ".wide", "color: red"), + rule("not all and (min-width: 64rem)", ".rem", "color: red"), + rule("screen and (max-width: 767.98px)", ".screen", "color: red"), + ".card {", + " color: #00f;", + "}", + "", + rule("not all and (min-width: 768px)", ".card", "color: red"), + ].join("\n"), + ); + }, + }); + } + // This test mainly just makes sure that this scenario doesn't crash itBundled("css/CSSAndJavaScriptCodeSplittingESBuildIssue1064", { files: { diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 890ffa4dcd95..0f1bdfd0f67e 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -2,10 +2,12 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { cssInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { join } from "path"; import { + type Browsers, cssTest, cssTestTodo, indoc, @@ -17,6 +19,8 @@ import { prefix_test, } from "./util"; +const { minifyTest, prefixTest } = cssInternals; + function Some(n: number): number { return n; } @@ -6131,7 +6135,7 @@ describe("css tests", () => { } `, ` - @media (min-width: 240.001px) { + @media not all and (max-width: 240px) { .foo { color: #7fff00; } @@ -6191,7 +6195,7 @@ describe("css tests", () => { } `, ` - @media (max-width: 239.999px) { + @media not all and (min-width: 240px) { .foo { color: #7fff00; } @@ -6291,7 +6295,7 @@ describe("css tests", () => { } `, ` - @media (min-width: 100.001px) and (max-width: 199.999px) { + @media (min-width: 100.02px) and (max-width: 199.98px) { .foo { color: #7fff00; } @@ -6322,6 +6326,262 @@ describe("css tests", () => { }, ); + // A `min-`/`max-` feature is inclusive. A query that is one strict bound prints as the + // negation of the opposite feature, with nothing added to its value. Chrome 104 and + // Safari 16.4 are the first with range syntax, and also the first with `not (..)` + // inside a condition. Before them `not` negates only a whole query, so a strict bound + // beside a media type or another condition steps past its value. + describe("strict range bounds", () => { + const safari14: Browsers = { safari: 14 << 16 }; + const rule = (prelude: string) => `@media ${prelude} {\n a {\n color: red;\n }\n}\n`; + const lower = (query: string) => prefixTest(`@media ${query} { a { color: red } }`, "", safari14); + + // The syntax in a printed query list that a browser without range syntax skips. + const needsMediaQueries4 = (prelude: string) => + ( + [ + [/[<>=]/, "a range"], + [/\bnot\s*\(/, "`not (..)`"], + [/\)\s+or\s+\(/, "`or`"], + ] as const + ) + .filter(([syntax]) => syntax.test(prelude)) + .map(([, name]) => name); + + // `query` prints as `expected` for Safari 14, and Safari 14 has the syntax for it. + const expectLowered = (query: string, expected: string) => { + expect(lower(query)).toBe(rule(expected)); + expect(needsMediaQueries4(expected)).toEqual([]); + }; + + describe("a query that is one bound", () => { + test.each([ + ...[768, 1024, 1280].flatMap(px => [ + [`(width < ${px}px)`, `not all and (min-width: ${px}px)`], + [`(width > ${px}px)`, `not all and (max-width: ${px}px)`], + [`(width <= ${px}px)`, `(max-width: ${px}px)`], + [`(width >= ${px}px)`, `(min-width: ${px}px)`], + ]), + ["(240px > width)", "not all and (min-width: 240px)"], + ["(height > 1080px)", "not all and (max-height: 1080px)"], + ["(width < 100000px)", "not all and (min-width: 100000px)"], + ["(width < 64rem)", "not all and (min-width: 64rem)"], + ["(width < 50vw)", "not all and (min-width: 50vw)"], + ["(width < calc(1000px + 2em))", "not all and (min-width: calc(1000px + 2em))"], + ["(width < env(safe-area-inset-left))", "not all and (min-width: env(safe-area-inset-left))"], + ["(aspect-ratio > 1920/1080)", "not all and (max-aspect-ratio: 1920 / 1080)"], + ["(aspect-ratio < 1)", "not all and (min-aspect-ratio: 1 / 1)"], + ["(-webkit-device-pixel-ratio > 2)", "not all and (-webkit-max-device-pixel-ratio: 2)"], + // `only` changes nothing in a browser that reads media queries. + ["only all and (width < 768px)", "not all and (min-width: 768px)"], + ["all and (width < 768px)", "not all and (min-width: 768px)"], + // An integer has an inclusive neighbour. + ["(color > 2)", "(min-color: 3)"], + ["(color < 2)", "(max-color: 1)"], + ])("%s", expectLowered); + + // The step past the last integer used to overflow, which aborts a debug build, so + // this runs in a child process. + test("an integer at the end of its range", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { prefixTest } = require("bun:internal-for-testing").cssInternals; +for (const query of ["(color > 2147483647)", "(color < -2147483648)"]) + console.log(prefixTest("@media " + query + " { a { color: red } }", "", { safari: 14 << 16 }).split("\\n")[0]);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "@media (min-color: 2147483647) {\n@media (max-color: -2147483648) {\n", + stderr: "", + exitCode: 0, + }); + }); + }); + + // `not (width < V)` is `(width >= V)`. `not (..)` itself needs the same browsers as + // range syntax, so `not (hover)` is `not all and (hover)`. + describe("a query that is `not` of one feature", () => { + test.each([ + ["not (width < 1024px)", "(min-width: 1024px)"], + ["not (width > 1024px)", "(max-width: 1024px)"], + ["not all and (width < 1024px)", "(min-width: 1024px)"], + ["only all and not (width < 1024px)", "(min-width: 1024px)"], + ["not (width >= 48rem)", "not all and (min-width: 48rem)"], + ["not (width <= 48rem)", "not all and (max-width: 48rem)"], + ["not (min-width: 48rem)", "not all and (min-width: 48rem)"], + ["not (width = 600px)", "not all and (width: 600px)"], + ["not (color > 2)", "not all and (min-color: 3)"], + ["not (aspect-ratio >= 1)", "not all and (min-aspect-ratio: 1 / 1)"], + ["not (hover)", "not all and (hover)"], + ["not (hover: hover)", "not all and (hover: hover)"], + ["not (prefers-reduced-motion: reduce)", "not all and (prefers-reduced-motion: reduce)"], + ["not (100px <= width <= 200px)", "not all and (min-width: 100px) and (max-width: 200px)"], + ["not (100px < width < 200px)", "not all and (min-width: 100.02px) and (max-width: 199.98px)"], + ])("%s", expectLowered); + }); + + test.each([ + ["(width < 600px), (width > 1200px)", "not all and (min-width: 600px), not all and (max-width: 1200px)"], + ["print, (width < 600px)", "print, not all and (min-width: 600px)"], + [ + "screen and (width <= 1024px), (width < 600px)", + "screen and (max-width: 1024px), not all and (min-width: 600px)", + ], + ["not (width >= 48rem), (hover)", "not all and (min-width: 48rem), (hover)"], + ["print, screen and (width < 1024px)", "print, screen and (max-width: 1023.98px)"], + ])("each query of a list: %s", expectLowered); + + // No whole query to negate. Blink gives a `min-`/`max-` length 1/64px of slack, so + // the step is .02px: `(max-width: 1023.999px)` holds at 1024px there. + describe("a strict bound beside another part steps past its value", () => { + test.each([ + ...[768, 1024, 1280].flatMap(px => [ + [`screen and (width < ${px}px)`, `screen and (max-width: ${px - 1}.98px)`], + [`screen and (width > ${px}px)`, `screen and (min-width: ${px}.02px)`], + ]), + ["only screen and (width > 1024px)", "only screen and (min-width: 1024.02px)"], + ["not screen and (width < 1024px)", "not screen and (max-width: 1023.98px)"], + ["(width < 1024px) and (hover)", "(max-width: 1023.98px) and (hover)"], + ["(width >= 640px) and (width < 1024px)", "(min-width: 640px) and (max-width: 1023.98px)"], + ["(640px <= width < 1024px)", "(min-width: 640px) and (max-width: 1023.98px)"], + ["(100px < width < 200px)", "(min-width: 100.02px) and (max-width: 199.98px)"], + ["(1 < color < 8) and (width < 1024px)", "(min-color: 2) and (max-color: 7) and (max-width: 1023.98px)"], + // Chromium before 104 rejects a `calc()` with two units. + ["screen and (width < 64rem)", "screen and (max-width: calc(64rem - .02px))"], + ["screen and (height > 50vh)", "screen and (min-height: calc(50vh + .02px))"], + // The printer keeps 6 digits of a number. + ["screen and (width < 10000px)", "screen and (max-width: 9999.98px)"], + ["screen and (width < 10001px)", "screen and (max-width: 10001px)"], + ["screen and (resolution > 2dppx)", "screen and (min-resolution: 2.001dppx)"], + ])("%s", expectLowered); + }); + + // Chrome before 110 and Safari before 16.4 read a ratio only as two integers. + describe("a ratio in a `min-`/`max-` feature has two integer terms", () => { + test.each([ + ["(min-aspect-ratio: 2/1)", "(min-aspect-ratio: 2 / 1)"], + ["(aspect-ratio >= 1)", "(min-aspect-ratio: 1 / 1)"], + ["(1/1 <= aspect-ratio <= 16/9)", "(min-aspect-ratio: 1 / 1) and (max-aspect-ratio: 16 / 9)"], + ["screen and (aspect-ratio > 16/9)", "screen and (min-aspect-ratio: 16001 / 9000)"], + ["screen and (aspect-ratio < 16/9)", "screen and (max-aspect-ratio: 15999 / 9000)"], + ["(4/3 <= aspect-ratio < 16/9)", "(min-aspect-ratio: 4 / 3) and (max-aspect-ratio: 15999 / 9000)"], + ["screen and (aspect-ratio > 1)", "screen and (min-aspect-ratio: 10001 / 10000)"], + ["screen and (aspect-ratio > 1920/1080)", "screen and (min-aspect-ratio: 19201 / 10800)"], + ["screen and (aspect-ratio > 192000/108000)", "screen and (min-aspect-ratio: 192001 / 108000)"], + ])("%s", expectLowered); + + test("a term that is not an integer keeps the .001 step", () => { + expect(lower("screen and (aspect-ratio > 1.5/1)")).toBe(rule("screen and (min-aspect-ratio: 1.501 / 1)")); + }); + }); + + // Safari before 16 reads a `resolution` feature and never matches it. With `not all + // and` added or taken away, such a query goes from never to always there, or back. + test.each([ + ["(resolution > 2dppx)", "(min-resolution: 2.001dppx)"], + ["(resolution < 2dppx)", "(max-resolution: 1.999dppx)"], + ["not all and (resolution > 2dppx)", "not all and (min-resolution: 2.001dppx)"], + ["not all and (resolution < 2dppx)", "not all and (max-resolution: 1.999dppx)"], + ["not all and (min-resolution: .001dpcm)", "not all and (min-resolution: .001dpcm)"], + ])("a `resolution` query keeps its `not all and`, or its lack of one: %s", expectLowered); + + test("`not (..)` of a strict `resolution` bound has no `not` to write", () => { + expectLowered("not (resolution > 2dppx)", "(max-resolution: 2dppx)"); + }); + + // One old-syntax query cannot state these, or this lowering does not try to. They + // print with Media Queries 4 syntax, and a browser without range syntax skips them. + test.each([ + ["(hover) or (width < 1024px)", "(hover) or (max-width: 1023.98px)", ["`or`"]], + ["not ((width < 1024px) and (hover))", "not ((max-width: 1023.98px) and (hover))", ["`not (..)`"]], + ["not ((hover: hover) and (pointer: fine))", "not ((hover: hover) and (pointer: fine))", ["`not (..)`"]], + ["screen and (not (width > 1024px))", "screen and not (min-width: 1024.02px)", ["`not (..)`"]], + ["screen and not (hover)", "screen and not (hover)", ["`not (..)`"]], + ["not all and not (hover)", "not all and not (hover)", ["`not (..)`"]], + ["not (resolution >= 2dppx)", "not (min-resolution: 2dppx)", ["`not (..)`"]], + ["not (resolution: 2dppx)", "not (resolution: 2dppx)", ["`not (..)`"]], + ] as [string, string, string[]][])("a query that still needs Media Queries 4: %s", (query, expected, syntax) => { + expect(lower(query)).toBe(rule(expected)); + expect(needsMediaQueries4(expected)).toEqual(syntax); + }); + + describe("any other query prints as before", () => { + test.each([ + ["(100px <= width <= 200px)", "(min-width: 100px) and (max-width: 200px)"], + [ + "screen and (min-width: 100px) and (max-width: 200px)", + "screen and (min-width: 100px) and (max-width: 200px)", + ], + ["not all and (min-width: 100px)", "not all and (min-width: 100px)"], + ["not screen and (min-width: 100px)", "not screen and (min-width: 100px)"], + ["(width = 1024px)", "(width: 1024px)"], + ["not all and (hover)", "not all and (hover)"], + ["(aspect-ratio: 16/9)", "(aspect-ratio: 16 / 9)"], + ])("%s", expectLowered); + }); + + describe("targets with range syntax", () => { + test.each([ + ["(width < 1024px)", { chrome: 104 << 16 }, "(width < 1024px)"], + ["(width < 1024px)", { safari: (16 << 16) | (4 << 8) }, "(width < 1024px)"], + ["(width < 1024px)", { firefox: 85 << 16 }, "(width < 1024px)"], + ["(width < 1024px)", undefined, "(width < 1024px)"], + ["screen and (width < 1024px)", { chrome: 104 << 16 }, "screen and (width < 1024px)"], + ["not (width < 1024px)", { chrome: 104 << 16 }, "not (width < 1024px)"], + ["not (width >= 48rem)", { chrome: 104 << 16 }, "not (width >= 48rem)"], + ["not (hover)", { chrome: 104 << 16 }, "not (hover)"], + ["(aspect-ratio >= 1)", { chrome: 104 << 16 }, "(aspect-ratio >= 1)"], + ] as [string, Browsers | undefined, string][])("%s %j", (query, targets, expected) => { + expect(prefixTest(`@media ${query} { a { color: red } }`, "", targets)).toBe(rule(expected)); + }); + }); + + test("a rule nested in a style rule, in @supports and in @layer", () => { + const source = [ + ".a { color: blue; @media (width < 1024px) { color: red } }", + "@supports (display: grid) { @media (width > 1280px) { b { color: red } } }", + "@layer base { @media not (width >= 48rem) { c { color: red } } }", + ].join("\n"); + expect(minifyTest(source, "", safari14)).toBe( + [ + ".a{color:#00f}@media not all and (min-width:1024px){.a{color:red}}", + "@supports (display: grid){@media not all and (max-width:1280px){b{color:red}}}", + "@layer base{@media not all and (min-width:48rem){c{color:red}}}", + ].join(""), + ); + }); + + // `(--name)` puts a `@custom-media` query inside other queries, where a + // `not all and` would negate those too. + test.each([ + ['@import "foo.css" (width < 1024px);', '@import "foo.css" not all and (min-width: 1024px);'], + ['@import "foo.css" print, (width > 1280px);', '@import "foo.css" print, not all and (max-width: 1280px);'], + [ + '@import "foo.css" layer(x) supports(display: grid) (width < 1024px);', + '@import "foo.css" layer(x) supports(display: grid) not all and (min-width: 1024px);', + ], + ['@import "foo.css" not (width >= 48rem);', '@import "foo.css" not all and (min-width: 48rem);'], + ['@import "foo.css" screen and (width < 1024px);', '@import "foo.css" screen and (max-width: 1023.98px);'], + ["@custom-media --narrow (width < 600px);", "@custom-media --narrow (max-width: 599.98px);"], + ["@custom-media --narrow not (width >= 600px);", "@custom-media --narrow not (min-width: 600px);"], + ["@custom-media --no-hover not (hover);", "@custom-media --no-hover not (hover);"], + ["@custom-media --wide (width >= 600px);", "@custom-media --wide (min-width: 600px);"], + [ + "@container (width < 400px) {\n a {\n color: red;\n }\n}", + "@container (width < 400px) {\n a {\n color: red;\n }\n}", + ], + ])("%s", (source, expected) => { + expect(prefixTest(source, "", safari14).trim()).toBe(expected); + }); + }); + cssTest( ` @media not all { @@ -6342,7 +6602,7 @@ describe("css tests", () => { } `, ` - @media (min-width: calc(1.001px + 1rem)) { + @media not all and (max-width: calc(1px + 1rem)) { .foo { color: #ff0; } @@ -6359,7 +6619,7 @@ describe("css tests", () => { } `, ` - @media (min-width: calc(max(10px, 1rem) + .001px)) { + @media not all and (max-width: max(10px, 1rem)) { .foo { color: #ff0; } @@ -6376,7 +6636,7 @@ describe("css tests", () => { } `, ` - @media (min-width: .001px) { + @media not all and (max-width: 0) { .foo { color: #ff0; }