From 39656f7d9aeb722f3bd480b0ed6b196faaa848c1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 1 Jul 2026 21:25:26 +0200 Subject: [PATCH 01/60] Add `--poll` option to `@tailwindcss/cli` (#20297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR re-adds the `--poll` option to the `@tailwindcss/cli` that we had in Tailwind CSS v3, but didn't in Tailwind CSS v4. In Tailwind CSS v4, we started using `@parcel/watcher` instead of chokidar for our watcher in the CLI. However, this currently doesn't support a `--poll` option. This PR implements our own `--poll` option such that you can use it in environments where fs events don't work properly (e.g. Docker). Polling can be enabled by using `--watch --poll`, in this case we will poll every `250ms` (I'm open for a different default value). You can also pick your own interval by using `--watch --poll 500` which is defined in milliseconds. The `--poll` option will be less efficient than a normal `--watch`. But if you are in a situation where you can't use `--watch` on its own then this is a good fallback. One thing you can do today is run the build command manually. If you do have some tooling that _does_ work on your machine (such as [`watchexec`](https://github.com/watchexec/watchexec)) then you can automatically perform a full build. The biggest downside of this approach is that you are doing a full build every time, instead of an incremental build. With this PR, we try to fix that by still allowing incremental builds. This should result in the same behavior as the normal `--watch` function: 1. First run, will trigger a full build 1. When any of the source files changes: - If all classes were already known, then it will be a no-op, but you will see a log in the terminal about it. - If a new class is detected, then the CSS will be updated, but it will be much more efficient than a full rebuild 1. When the input CSS file changes, or any of its dependencies, then a full rebuild will be triggered (such that your new `@utility` are available, and `@theme` values are updated). The implementation is a little bit more complex just because I didn't want to spam the terminal output even if we are polling every `250ms`. In the Oxide scanner we do track the modified times of each file. Every `250ms` we traverse the file system and skip the files that we know didn't change (since the mtime is the same). If the file was touched, then we will parse it again to extract possible Tailwind CSS classes. We will also track which files were scanned such that we can know whether we have to trigger a full-rebuild or not (in case the input.css file or any of its dependencies was changed). Fixes: #18109 Fixes: #18540 Fixes: #15750 ## Test plan 1. Existing tests pass 2. An integration test has been added for the `--poll` option 3. Tested it on the tailwindcss.com codebase: image Annotated: ``` ≈ tailwindcss v4.3.2 Done in 105ms Initial build Done in 3ms Saved a file that resulted in a no-op Done in 2ms Saved a file that resulted in a no-op Done in 3ms Saved a file that resulted in a no-op Done in 53ms Saved a file with a new class Done in 2ms Saved a file that resulted in a no-op Done in 2ms Saved a file that resulted in a no-op Done in 3ms Saved a file that resulted in a no-op Done in 88ms Saved a the input.css file Done in 3ms Saved a file that resulted in a no-op Polling for changes… ``` We check the file system every `250ms` by default, but we won't log to prevent spamming the terminal. --- CHANGELOG.md | 4 +- crates/node/src/lib.rs | 5 + crates/oxide/src/scanner/mod.rs | 63 ++-- crates/oxide/tests/scanner.rs | 59 ++++ integrations/cli/index.test.ts | 34 +++ .../src/commands/build/index.ts | 274 ++++++++++++++++-- packages/@tailwindcss-cli/src/index.ts | 2 +- .../@tailwindcss-cli/src/utils/args.test.ts | 31 ++ 8 files changed, 418 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe55aa6c..aa9356494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297)) ## [4.3.2] - 2026-06-26 diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 11ff25b0a..fb32a818c 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -165,6 +165,11 @@ impl Scanner { self.scanner.get_files() } + #[napi(getter)] + pub fn scanned_files(&self) -> Vec { + self.scanner.get_scanned_files() + } + #[napi(getter)] pub fn globs(&mut self) -> Vec { self diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 02830555d..3d89fbaf6 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -78,11 +78,12 @@ pub struct Scanner { /// Track unique set of candidates candidates: FxHashSet, - /// Track mtimes for files so re-scans can skip unchanged files. - /// Only populated after the first scan completes (to avoid unnecessary - /// metadata calls on initial build). + /// Track mtimes for files so incremental scans can skip unchanged files. mtimes: FxHashMap, + /// Files that were scanned during the last `scan()` call. + scanned_files: Vec, + /// Whether we've completed at least one full scan. When false, we skip /// mtime tracking entirely so the initial build stays fast. has_scanned_once: bool, @@ -122,9 +123,10 @@ impl Scanner { pub fn scan(&mut self) -> Vec { self.sources_scanned = false; - let (scanned_blobs, css_files) = self.discover_sources(); + let (scanned_blobs, css_files, files) = self.discover_sources(); self.extract_candidates(scanned_blobs, css_files); + self.scanned_files = files; // Return all candidates sorted let mut result = self.candidates.iter().cloned().collect::>(); @@ -256,6 +258,11 @@ impl Scanner { .collect() } + #[tracing::instrument(skip_all)] + pub fn get_scanned_files(&self) -> Vec { + self.scanned_files.clone() + } + #[tracing::instrument(skip_all)] pub fn get_globs(&mut self) -> Vec { if let Some(globs) = &self.globs { @@ -353,14 +360,14 @@ impl Scanner { } #[tracing::instrument(skip_all)] - fn discover_sources(&mut self) -> (Vec>, Vec) { + fn discover_sources(&mut self) -> (Vec>, Vec, Vec) { if self.sources_scanned { - return (vec![], vec![]); + return (vec![], vec![], vec![]); } self.sources_scanned = true; let Some(walker) = &mut self.walker else { - return (vec![], vec![]); + return (vec![], vec![], vec![]); }; // Use synchronous walk for the initial build (lower overhead) and parallel @@ -372,7 +379,8 @@ impl Scanner { }; let mut css_files: Vec = vec![]; - let mut content_paths: Vec<(PathBuf, String)> = Vec::new(); + let mut content_paths: Vec<(PathBuf, String)> = vec![]; + let mut changed_files = vec![]; // Fresh state self.files.clear(); @@ -380,7 +388,7 @@ impl Scanner { self.extensions.clear(); self.globs = None; - for (path, is_dir, extension) in all_entries { + for (path, is_dir, extension, mtime) in all_entries { if is_dir { self.dirs.insert(path); } else { @@ -390,13 +398,10 @@ impl Scanner { } self.extensions.insert(extension.clone()); - // On re-scans, check mtime to skip unchanged files. - // On the first scan we skip this entirely to avoid extra - // metadata syscalls. + // On incremental scans, check mtime to skip unchanged files. + // On the first scan, track mtimes while still scanning every file. let changed = if self.has_scanned_once { - let current_mtime = path.metadata().ok().and_then(|m| m.modified().ok()); - - match current_mtime { + match mtime { Some(mtime) => { let prev = self.mtimes.insert(path.clone(), mtime); prev.is_none_or(|prev| prev != mtime) @@ -404,6 +409,10 @@ impl Scanner { None => true, } } else { + if let Some(mtime) = mtime { + self.mtimes.insert(path.clone(), mtime); + } + true }; @@ -411,6 +420,10 @@ impl Scanner { continue; } + if let Ok(file) = path.clone().into_os_string().into_string() { + changed_files.push(file); + } + match extension.as_str() { // Special handing for CSS files, we don't want to extract candidates from // these files, but we do want to extract used CSS variables. @@ -442,7 +455,9 @@ impl Scanner { self.has_scanned_once = true; } - (scanned_blobs, css_files) + changed_files.par_sort_unstable(); + + (scanned_blobs, css_files, changed_files) } } @@ -547,27 +562,28 @@ where .collect() } -type WalkEntry = (PathBuf, bool, String); +type WalkEntry = (PathBuf, bool, String, Option); /// Walk the file system synchronously. Used for the initial build where the overhead of spawning /// parallel walker threads is not worth it. #[tracing::instrument(skip_all)] fn walk_synchronous(walker: &mut WalkBuilder) -> Vec { - let mut entries = Vec::new(); + let mut entries = vec![]; for entry in walker.build().filter_map(Result::ok) { let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); let path = entry.into_path(); if is_dir { - entries.push((path, true, String::new())); + entries.push((path, true, String::new(), None)); } else { let ext = path .extension() .and_then(|x| x.to_str()) .unwrap_or_default() .to_owned(); - entries.push((path, false, ext)); + let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); + entries.push((path, false, ext, mtime)); } } @@ -591,7 +607,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { } } - let collected: Arc>> = Arc::new(Mutex::new(Vec::new())); + let collected: Arc>> = Arc::new(Mutex::new(vec![])); walker.build_parallel().run(|| { let mut buf = FlushOnDrop { @@ -608,14 +624,15 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { let path = entry.into_path(); if is_dir { - buf.local.push((path, true, String::new())); + buf.local.push((path, true, String::new(), None)); } else { let ext = path .extension() .and_then(|x| x.to_str()) .unwrap_or_default() .to_owned(); - buf.local.push((path, false, ext)); + let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); + buf.local.push((path, false, ext, mtime)); } if buf.local.len() >= 256 { diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 1139196e7..726ce5921 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -106,6 +106,18 @@ mod scanner { globs } + fn normalize_files(files: Vec, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut files = files + .iter() + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + files.sort(); + files + } + fn scan_with_globs( paths_with_content: &[(&str, &str)], source_directives: Vec<&str>, @@ -1045,6 +1057,53 @@ mod scanner { assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*"))); } + #[test] + fn it_should_track_files_scanned_by_the_last_scan() { + let dir = tempdir().unwrap().into_path(); + + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep.html", "content-['src/keep.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + assert_eq!( + scanner.scan(), + vec!["content-['src/index.html']", "content-['src/keep.html']"] + ); + + assert_eq!( + scanner.scan(), + vec!["content-['src/index.html']", "content-['src/keep.html']"] + ); + assert_eq!(scanner.get_scanned_files(), Vec::::new()); + + sleep(Duration::from_millis(10)); + fs::write(dir.join("src/index.html"), "content-['src/changed.html']").unwrap(); + + assert_eq!( + scanner.scan(), + vec![ + "content-['src/changed.html']", + "content-['src/index.html']", + "content-['src/keep.html']", + ] + ); + assert_eq!( + normalize_files(scanner.get_scanned_files(), &dir), + vec!["src/index.html"] + ); + } + #[test] fn it_should_ignore_negated_custom_sources() { let ScanResult { diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index ba11d546a..e742662be 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -375,6 +375,40 @@ describe.each([ }, ) + test( + 'watch mode with polling', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'src/index.css': css`@import 'tailwindcss/utilities';`, + 'src/index.html': html` +
+ `, + }, + }, + async ({ fs, spawn }) => { + let process = await spawn( + `${command} --input src/index.css --output dist/out.css --watch --poll=50`, + ) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [candidate`underline`]) + + await fs.write('src/index.html', html` +
+ `) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + }, + ) + test( "watch mode with unknown @source paths shouldn't crash on Windows", { diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 8ea5448a8..c06d02c50 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -26,6 +26,7 @@ import { drainStdin, outputFile } from './utils' const css = String.raw const DEBUG = env.DEBUG +const DEFAULT_POLL_INTERVAL_MS = 250 export function options() { return { @@ -47,6 +48,12 @@ export function options() { alias: '-w', values: ['always'], }, + '--poll': { + type: 'boolean | number', + description: 'Use polling instead of filesystem events when watching', + default: false, + values: ['ms'], + }, '--minify': { type: 'boolean', description: 'Optimize and minify the output', @@ -129,7 +136,17 @@ export async function handle(args: Result>) { // If the user passes `{bin} build --map -` then this likely means they want to output the map inline // this is the default behavior of `{bin build} --map` to inform the user of that if (args['--map'] === '-') { - eprintln(`Use --map without a value to inline the source map`) + eprintln(`Use --map without a value to inline the source map.`) + process.exit(1) + } + + if (args['--poll'] === undefined) { + eprintln(`Use --poll with a non-zero value in milliseconds.`) + process.exit(1) + } + let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll'] + if (pollInterval !== false && pollInterval <= 0) { + eprintln(`Specified polling interval must be a positive number.`) process.exit(1) } @@ -152,6 +169,7 @@ export async function handle(args: Result>) { let previous = { css: '', optimizedCss: '', + output: null as string | null, } async function write( @@ -208,13 +226,18 @@ export async function handle(args: Result>) { } } + let didChange = output !== previous.output + DEBUG && I.start('Write output') if (args['--output'] && args['--output'] !== '-') { await outputFile(args['--output'], output) - } else { + } else if (didChange) { println(output) } DEBUG && I.end('Write output') + + previous.output = output + return didChange } let inputFilePath = @@ -275,10 +298,10 @@ export async function handle(args: Result>) { } let [compiler, scanner] = await handleError(() => createCompiler(input, I)) + let cleanupWatchers: (() => Promise)[] = [] // Watch for changes - if (args['--watch']) { - let cleanupWatchers: (() => Promise)[] = [] + if (args['--watch'] && pollInterval === false) { cleanupWatchers.push( await createWatchers(await watchDirectories(scanner), async function handle(files) { try { @@ -292,36 +315,15 @@ export async function handle(args: Result>) { // Re-compile the input let start = process.hrtime.bigint() - let changedFiles: ChangedContent[] = [] - let rebuildStrategy: 'incremental' | 'full' = 'incremental' - let resolvedFullRebuildPaths = fullRebuildPaths - - for (let file of files) { - // If one of the changed files is related to the input CSS or JS - // config/plugin files, then we need to do a full rebuild because - // the theme might have changed. - if (resolvedFullRebuildPaths.includes(file)) { - rebuildStrategy = 'full' - - // No need to check the rest of the events, because we already know we - // need to do a full rebuild. - break - } - - // Track new and updated files for incremental rebuilds. - changedFiles.push({ - file, - extension: path.extname(file).slice(1), - } satisfies ChangedContent) - } + let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths) // Track the compiled CSS let compiledCss = '' let compiledMap: SourceMap | null = null // Scan the entire `base` directory for full rebuilds. - if (rebuildStrategy === 'full') { + if (rebuildStrategy.kind === 'full') { // Read the new `input`. let input = args['--input'] ? args['--input'] === '-' @@ -378,9 +380,9 @@ export async function handle(args: Result>) { } // Scan changed files only for incremental rebuilds. - else if (rebuildStrategy === 'incremental') { + else if (rebuildStrategy.kind === 'incremental') { DEBUG && I.start('Scan for candidates') - let newCandidates = scanner.scanFiles(changedFiles) + let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles) DEBUG && I.end('Scan for candidates') // No new candidates found which means we don't need to write to @@ -490,6 +492,170 @@ export async function handle(args: Result>) { let end = process.hrtime.bigint() if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) + + // Watch for changes, using polling + if (args['--watch'] && pollInterval !== false) { + let cleanupPollingIndicator = () => {} + + function logPollingMessage(message: string) { + if (!args['--silent']) { + process.stderr.write('\r\x1B[2K') + } + + eprintln(message) + + if (!args['--silent']) { + process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`) + } + } + + async function fullRebuild(I: Instrumentation) { + clearRequireCache(fullRebuildPaths) + backupRebuildPaths = fullRebuildPaths.slice() + fullRebuildPaths = inputFilePath ? [inputFilePath] : [] + + let input = args['--input'] + ? args['--input'] === '-' + ? await drainStdin() + : await fs.readFile(args['--input'], 'utf-8') + : css` + @import 'tailwindcss'; + ` + + ;[compiler, scanner] = await createCompiler(input, I) + backupRebuildPaths = fullRebuildPaths.slice() + + DEBUG && I.start('Scan for candidates') + let candidates = scanner.scan() + DEBUG && I.end('Scan for candidates') + + DEBUG && I.start('Build CSS') + let output = compiler.build(candidates) + DEBUG && I.end('Build CSS') + + let map: SourceMap | null = null + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + map = toSourceMap(compiler.buildSourceMap()) + DEBUG && I.end('Build Source Map') + } + + await write(output, map, args, I) + } + + if (!args['--silent']) { + let restored = false + + function restoreCursor() { + if (restored) return + restored = true + process.stderr.write('\r\x1B[2K\x1B[?25h') + process.off('exit', restoreCursor) + process.off('SIGINT', onSigint) + process.off('SIGTERM', onSigterm) + } + + function onSigint() { + restoreCursor() + process.exit(130) + } + + function onSigterm() { + restoreCursor() + process.exit(143) + } + + process.stderr.write('\x1B[?25l') + process.on('exit', restoreCursor) + process.on('SIGINT', onSigint) + process.on('SIGTERM', onSigterm) + + cleanupPollingIndicator = restoreCursor + cleanupWatchers.push(async () => cleanupPollingIndicator()) + } + + cleanupWatchers.push( + createPollingWatcher(async () => { + using I = new Instrumentation() + + DEBUG && I.start('Scan for candidates') + let candidates = scanner.scan() + DEBUG && I.end('Scan for candidates') + + let files = scanner.scannedFiles.filter( + (file) => file !== args['--output'] && file !== args['--map'], + ) + + if (files.length <= 0) return + + let start = process.hrtime.bigint() + + let strategy = getRebuildStrategy(files, fullRebuildPaths) + + if (strategy.kind === 'full') { + try { + await fullRebuild(I) + } catch (err) { + fullRebuildPaths = backupRebuildPaths + + let message = [red('Error:'), dim('\u250C')] + .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`)) + .concat(dim('\u2514')) + .join('\n') + + logPollingMessage(message) + } + + if (!args['--silent']) { + logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) + } + return + } + + // No candidates found which means we don't need to write to disk, and + // can return early. + if (candidates.length <= 0) { + if (!args['--silent']) { + logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) + } + return + } + + DEBUG && I.start('Build CSS') + let output = compiler.build(candidates) + DEBUG && I.end('Build CSS') + + let map: SourceMap | null = null + + if (args['--map']) { + DEBUG && I.start('Build Source Map') + map = toSourceMap(compiler.buildSourceMap()) + DEBUG && I.end('Build Source Map') + } + + await write(output, map, args, I) + + if (!args['--silent']) { + logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`) + } + }, pollInterval), + ) + + if (args['--watch'] !== 'always') { + process.stdin.on('end', () => { + Promise.all(cleanupWatchers.map((fn) => fn())).then( + () => process.exit(0), + () => process.exit(1), + ) + }) + } + + process.stdin.resume() + if (!args['--silent']) { + process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`) + } + } } async function createWatchers(dirs: string[], cb: (files: string[]) => void) { @@ -594,6 +760,56 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) { } } +function getRebuildStrategy( + files: string[], + fullRebuildPaths: string[], +): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } { + let changedFiles: ChangedContent[] = [] + + for (let file of files) { + // If one of the changed files is related to the input CSS or JS + // config/plugin files, then we need to do a full rebuild because + // the theme might have changed. + if (fullRebuildPaths.includes(file)) { + return { kind: 'full' } + } + + // Track new and updated files for incremental rebuilds. + changedFiles.push({ + file, + extension: path.extname(file).slice(1), + } satisfies ChangedContent) + } + + return { kind: 'incremental', changedFiles } +} + +function createPollingWatcher(cb: () => Promise, pollInterval: number) { + let disposed = false + let timer: ReturnType | null = null + + async function poll() { + if (disposed) return + + try { + await cb() + } catch (err) { + console.error(err) + } finally { + if (!disposed) { + timer = setTimeout(poll, pollInterval) + } + } + } + + timer = setTimeout(poll, pollInterval) + + return async () => { + disposed = true + if (timer) clearTimeout(timer) + } +} + async function watchDirectories(scanner: Scanner) { let directories = ( await Promise.all( diff --git a/packages/@tailwindcss-cli/src/index.ts b/packages/@tailwindcss-cli/src/index.ts index 4b308e694..1543f7d13 100644 --- a/packages/@tailwindcss-cli/src/index.ts +++ b/packages/@tailwindcss-cli/src/index.ts @@ -11,7 +11,7 @@ const sharedOptions = { } satisfies Arg function buildUsage(command = 'tailwindcss') { - return `${command} [--input input.css] [--output output.css] [--watch] [options…]` + return `${command} [--input input.css] [--output output.css] [--watch] [--poll=ms] [options…]` } function rootHelp({ invalid }: { invalid?: string } = {}) { diff --git a/packages/@tailwindcss-cli/src/utils/args.test.ts b/packages/@tailwindcss-cli/src/utils/args.test.ts index 4ac2e8778..249fb23eb 100644 --- a/packages/@tailwindcss-cli/src/utils/args.test.ts +++ b/packages/@tailwindcss-cli/src/utils/args.test.ts @@ -153,3 +153,34 @@ it('should be possible to provide multiple types, and convert the value to that } `) }) + +it('should be possible to provide boolean or number arguments', () => { + let options = { + '--poll': { + type: 'boolean | number', + description: 'Use polling instead of filesystem events when watching', + default: false, + }, + } satisfies Arg + + expect(args(options, ['--poll'])).toMatchInlineSnapshot(` + { + "--poll": true, + "_": [], + } + `) + + expect(args(options, ['--poll=50'])).toMatchInlineSnapshot(` + { + "--poll": 50, + "_": [], + } + `) + + expect(args(options, ['--poll=foo'])).toMatchInlineSnapshot(` + { + "--poll": undefined, + "_": [], + } + `) +}) From e46b3d74eeae122008b2888afa498999e0f5a97a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 2 Jul 2026 00:04:44 +0200 Subject: [PATCH 02/60] Canonicalization: make hex colors case insensitive (#20298) This PR fixes an issue where hex-based colors in arbitrary properties and values were considered case-sensitive even though they are case-insensitive in CSS. If you look at the linked issue, there is this input CSS: ```css @theme { --color-brand-purple: #3f3cbb; } ``` We expect that both `bg-[#3f3cbb]` and `bg-[#3F3CBB]` get canonicalized to `color-brand-purple` but before this pr, only the first one would get canonicalized that way (since it's a perfect match). Technically a bunch more values are case-insensitive but a lot of them _are_ sensitive so to get this 100% correct, a lot more parsing needs to happen. I think we can start with this and expand the logic when needed. Fixes: #20295 ## Test plan 1. Added a regression test based on the linked issue 2. Added tests for arbitrary properties (`[color:#fff]` vs `[color:#FFF]`), and tests for arbitrary properties (`bg-[#fff]` vs `bg-[#FFF]`) --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 38 +++++++++++++++++++ .../src/canonicalize-candidates.ts | 18 +++++++++ 3 files changed, 57 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9356494..a8a0cbc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297)) +- Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298)) ## [4.3.2] - 2026-06-26 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index c01ee78e1..03229f450 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -316,6 +316,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['[color:var(--color-red-500)]', 'text-red-500'], ['[background-color:var(--color-red-500)]', 'bg-red-500'], + // Arbitrary property to named utility (case insensitive) + ['[color:#fff]', 'text-white'], + ['[color:#FFF]', 'text-white'], + // Arbitrary property with modifier to named functional utility with modifier ['[color:var(--color-red-500)]/25', 'text-red-500/25'], @@ -354,6 +358,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', --*: initial; --spacing: 0.25rem; --color-red-500: red; + --color-white: #fff; /* Equivalent of blue-500/50 */ --color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent); @@ -394,6 +399,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['bg-[theme(colors.red.500/75%)]', 'bg-red-500/75'], ['bg-[theme(colors.red.500/12.34%)]', 'bg-red-500/[12.34%]'], + // Arbitrary value with different casing + ['bg-[#fff]', 'bg-white'], + ['bg-[#FFF]', 'bg-white'], + // Values that don't contain only `theme(…)` calls should not be converted to // use a modifier since the color is not the whole value. [ @@ -478,6 +487,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ])(testName, { timeout }, async (candidate, expected) => { let input = css` @import 'tailwindcss'; + @theme { + --color-white: #fff; + } ` await expectCanonicalization(input, candidate, expected) @@ -1508,4 +1520,30 @@ describe('regressions', () => { expect(designSystem.canonicalizeCandidates(['px-[calc(1rem+0px)]'], options)).toEqual(['px-4']) }) + + // https://github.com/tailwindlabs/tailwindcss/issues/20295 + test('canonicalizations work when casing of arbitrary values is different', async () => { + let designSystem = await designSystems.get(__dirname).get(css` + @import 'tailwindcss'; + @theme { + --color-brand-purple: #3f3cbb; + } + `) + + let options: CanonicalizeOptions = { + collapse: true, + logicalToPhysical: true, + rem: 16, + } + + expect(designSystem.canonicalizeCandidates(['bg-[#3F3cbb]'], options)).toEqual([ + 'bg-brand-purple', + ]) + expect(designSystem.canonicalizeCandidates(['bg-[#3F3Cbb]'], options)).toEqual([ + 'bg-brand-purple', + ]) + expect(designSystem.canonicalizeCandidates(['bg-[#3F3CBB]'], options)).toEqual([ + 'bg-brand-purple', + ]) + }) }) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 6faf346ed..eb9d8697c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -2425,6 +2425,12 @@ function createUtilitySignatureCache( }) } +// #RGB +// #RGBA +// #RRGGBB +// #RRGGBBAA +const HEX_REGEX = /#(?:[a-f0-9]{8}|[a-f0-9]{6}|[a-f0-9]{4}|[a-f0-9]{3})/gi + // Optimize the CSS AST to make it suitable for signature comparison. We want to // expand declarations, ignore comments, sort declarations etc... function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: SignatureOptions) { @@ -2536,6 +2542,18 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si return a.property.localeCompare(b.property) }) } + + // + else if (node.kind === 'declaration' && node.value) { + // Leave CSS variables alone + if (node.property[0] === '-' && node.property[1] === '-') return + + // Ensure hex colors are always lowercased + { + HEX_REGEX.lastIndex = 0 + node.value = node.value.replace(HEX_REGEX, (color) => color.toLowerCase()) + } + } }, }) From ef79119d4e2b8b0f37710e736605bd0b3419afb7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 2 Jul 2026 11:45:49 +0200 Subject: [PATCH 03/60] Bump dependencies (#20300) This PR bumps most of the dependencies to the latest version. This also marked some dependencies using a range such that you get updates for free during the installation: ```diff catalog: - enhanced-resolve: 5.21.6 + enhanced-resolve: ^5.24.1 - vite: 8.0.14 + vite: ^8.1.2 - webpack: 5.107.0 + webpack: ^5.108.3 ``` Fixes: #20291 ## Test plan 1. All tests on all OSes still pass [ci-all] --- package.json | 4 +- .../@tailwindcss-standalone/src/types.d.ts | 3 +- .../src/constant-fold-declaration.ts | 2 +- pnpm-lock.yaml | 611 +++++++++--------- pnpm-workspace.yaml | 12 +- 5 files changed, 324 insertions(+), 308 deletions(-) diff --git a/package.json b/package.json index 658743f9f..f9430bdbe 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.61.1", "@types/node": "catalog:", "postcss": "catalog:", "postcss-import": "^16.1.1", @@ -58,7 +58,7 @@ "tsup": "^8.5.1", "turbo": "^2.9.18", "typescript": "^5.9.3", - "vitest": "^4.1.7" + "vitest": "^4.1.9" }, "packageManager": "pnpm@11.9.0" } diff --git a/packages/@tailwindcss-standalone/src/types.d.ts b/packages/@tailwindcss-standalone/src/types.d.ts index 3212ac5b6..0b5deb6ea 100644 --- a/packages/@tailwindcss-standalone/src/types.d.ts +++ b/packages/@tailwindcss-standalone/src/types.d.ts @@ -6,6 +6,5 @@ declare module '*.css' { declare var __tw_version: string | undefined declare var __tw_resolve: undefined | ((id: string, base?: string) => string | false) declare var __tw_readFile: - | undefined - | ((path: string, encoding: BufferEncoding) => Promise) + undefined | ((path: string, encoding: BufferEncoding) => Promise) declare var __tw_load: undefined | ((path: string) => Promise) diff --git a/packages/tailwindcss/src/constant-fold-declaration.ts b/packages/tailwindcss/src/constant-fold-declaration.ts index 34a746d8e..c15420150 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.ts @@ -190,7 +190,7 @@ export function constantFoldDeclarationAst( if ( operator === '+' && !( - (constant[1] === known[1]) // Only same unit is allowed + constant[1] === known[1] // Only same unit is allowed ) ) { return diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3059bf744..b5b2d1c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,14 +7,14 @@ settings: catalogs: default: '@types/node': - specifier: 22.19.19 - version: 22.19.19 + specifier: 22.19.21 + version: 22.19.21 dedent: specifier: 1.7.2 version: 1.7.2 enhanced-resolve: - specifier: 5.21.6 - version: 5.21.6 + specifier: ^5.24.1 + version: 5.24.1 lightningcss: specifier: 1.32.0 version: 1.32.0 @@ -40,17 +40,17 @@ catalogs: specifier: 1.32.0 version: 1.32.0 postcss: - specifier: ^8.5.15 - version: 8.5.15 + specifier: ^8.5.16 + version: 8.5.16 prettier: - specifier: 3.8.3 - version: 3.8.3 + specifier: 3.9.4 + version: 3.9.4 vite: - specifier: 8.0.14 - version: 8.0.14 + specifier: ^8.1.2 + version: 8.1.2 webpack: - specifier: 5.107.0 - version: 5.107.0 + specifier: ^5.108.3 + version: 5.108.3 patchedDependencies: '@parcel/watcher@2.5.1': c22241764997c5af4980d3be22550ae47858f14849dca486726653fa127eb69c @@ -61,29 +61,29 @@ importers: .: devDependencies: '@playwright/test': - specifier: ^1.60.0 - version: 1.60.0 + specifier: ^1.61.1 + version: 1.61.1 '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 postcss: specifier: 'catalog:' - version: 8.5.15 + version: 8.5.16 postcss-import: specifier: ^16.1.1 - version: 16.1.1(postcss@8.5.15) + version: 16.1.1(postcss@8.5.16) prettier: specifier: 'catalog:' - version: 3.8.3 + version: 3.9.4 prettier-plugin-embed: specifier: ^0.5.1 version: 0.5.1 prettier-plugin-organize-imports: specifier: ^4.3.0 - version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) + version: 4.3.0(prettier@3.9.4)(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) turbo: specifier: ^2.9.18 version: 2.9.18 @@ -91,8 +91,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@22.19.19)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.19.21)(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) crates/node: devDependencies: @@ -228,7 +228,7 @@ importers: version: link:../../crates/node enhanced-resolve: specifier: 'catalog:' - version: 5.21.6 + version: 5.24.1 mri: specifier: ^1.2.0 version: 1.2.0 @@ -246,7 +246,7 @@ importers: version: 2.3.5 enhanced-resolve: specifier: 'catalog:' - version: 5.21.6 + version: 5.24.1 jiti: specifier: ^2.7.0 version: 2.7.0 @@ -276,14 +276,14 @@ importers: version: link:../../crates/node postcss: specifier: 'catalog:' - version: 8.5.15 + version: 8.5.16 tailwindcss: specifier: workspace:* version: link:../tailwindcss devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 '@types/postcss-import': specifier: 14.0.3 version: 14.0.3 @@ -295,7 +295,7 @@ importers: version: link:../internal-example-plugin postcss-import: specifier: ^16.1.1 - version: 16.1.1(postcss@8.5.15) + version: 16.1.1(postcss@8.5.16) packages/@tailwindcss-standalone: dependencies: @@ -316,7 +316,7 @@ importers: version: 1.0.3 enhanced-resolve: specifier: 'catalog:' - version: 5.21.6 + version: 5.24.1 tailwindcss: specifier: workspace:* version: link:../tailwindcss @@ -383,7 +383,7 @@ importers: version: 1.7.2 enhanced-resolve: specifier: 'catalog:' - version: 5.21.6 + version: 5.24.1 globby: specifier: ^16.2.0 version: 16.2.0 @@ -398,16 +398,16 @@ importers: version: 1.1.1 postcss: specifier: 'catalog:' - version: 8.5.15 + version: 8.5.16 postcss-import: specifier: ^16.1.1 - version: 16.1.1(postcss@8.5.15) + version: 16.1.1(postcss@8.5.16) postcss-selector-parser: specifier: ^7.1.4 version: 7.1.4 prettier: specifier: 'catalog:' - version: 3.8.3 + version: 3.9.4 semver: specifier: ^7.8.4 version: 7.8.4 @@ -423,7 +423,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 '@types/postcss-import': specifier: ^14.0.3 version: 14.0.3 @@ -445,10 +445,10 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 vite: specifier: 'catalog:' - version: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + version: 8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) packages/@tailwindcss-webpack: dependencies: @@ -470,10 +470,10 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 webpack: specifier: 'catalog:' - version: 5.107.0(esbuild@0.27.7)(postcss@8.5.15) + version: 5.108.3(esbuild@0.27.7)(postcss@8.5.16) packages/internal-example-plugin: {} @@ -487,7 +487,7 @@ importers: version: link:../../crates/node '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 dedent: specifier: 'catalog:' version: 1.7.2 @@ -511,7 +511,7 @@ importers: version: 3.3.3 next: specifier: 16.2.7 - version: 16.2.7(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -524,7 +524,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 '@types/react': specifier: 19.2.15 version: 19.2.15 @@ -539,7 +539,7 @@ importers: dependencies: next: specifier: 16.2.7 - version: 16.2.7(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -552,7 +552,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.19 + version: 22.19.21 '@types/react': specifier: 19.2.15 version: 19.2.15 @@ -561,7 +561,7 @@ importers: version: 19.2.3(@types/react@19.2.15) autoprefixer: specifier: ^10.5.0 - version: 10.5.0(postcss@8.5.15) + version: 10.5.0(postcss@8.5.16) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -573,7 +573,7 @@ importers: version: link:../../packages/@tailwindcss-vite '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 6.0.2(vite@8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) react: specifier: ^19.2.6 version: 19.2.6 @@ -595,7 +595,7 @@ importers: version: 1.3.14 vite: specifier: 'catalog:' - version: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + version: 8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) packages: @@ -1357,6 +1357,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': resolution: {integrity: sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==} engines: {node: '>= 10'} @@ -1641,8 +1647,8 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.132.0': - resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} @@ -1822,102 +1828,102 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - '@playwright/test@1.60.0': - resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} hasBin: true - '@rolldown/binding-android-arm64@1.0.2': - resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.2': - resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.2': - resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.2': - resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.2': - resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.2': - resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.2': - resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.2': - resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.2': - resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.2': - resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.2': - resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.2': - resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.2': - resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.2': - resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.2': - resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2189,6 +2195,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/bun@1.3.14': resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} @@ -2207,8 +2216,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@22.19.19': - resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/node@22.19.21': + resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -2240,11 +2249,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.7': - resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.7': - resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2254,20 +2263,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.7': - resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.7': - resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.7': - resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.7': - resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.7': - resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2553,12 +2562,8 @@ packages: node-addon-api: optional: true - enhanced-resolve@5.21.6: - resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} - engines: {node: '>=10.13.0'} - - enhanced-resolve@5.22.0: - resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + enhanced-resolve@5.24.1: + resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} engines: {node: '>=10.13.0'} es-errors@1.3.0: @@ -2686,9 +2691,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - globby@16.2.0: resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} engines: {node: '>=20'} @@ -2886,6 +2888,49 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + minimizer-webpack-plugin@5.6.1: + resolution: {integrity: sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -3004,13 +3049,13 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.60.0: - resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} engines: {node: '>=18'} hasBin: true @@ -3079,6 +3124,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + prettier-plugin-embed@0.5.1: resolution: {integrity: sha512-2Ege8gIlLNTvHElUeU5XcFsD7/dbDXkQA6H9TczHSJAGxB58nNjjifk/dlRMS5E29eqQx/z+ToA4ZVMWzjME/A==} @@ -3092,8 +3141,8 @@ packages: vue-tsc: optional: true - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} engines: {node: '>=14'} hasBin: true @@ -3149,8 +3198,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.2: - resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3257,49 +3306,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - terser-webpack-plugin@5.6.0: - resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@minify-html/node': '*' - '@swc/core': '*' - '@swc/css': '*' - '@swc/html': '*' - clean-css: '*' - cssnano: '*' - csso: '*' - esbuild: '*' - html-minifier-terser: '*' - lightningcss: '*' - postcss: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@minify-html/node': - optional: true - '@swc/core': - optional: true - '@swc/css': - optional: true - '@swc/html': - optional: true - clean-css: - optional: true - cssnano: - optional: true - csso: - optional: true - esbuild: - optional: true - html-minifier-terser: - optional: true - lightningcss: - optional: true - postcss: - optional: true - uglify-js: - optional: true - terser@5.48.0: resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} @@ -3333,6 +3339,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -3445,13 +3455,13 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@8.0.14: - resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + vite@8.1.2: + resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 + '@vitejs/devtools': ^0.3.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -3488,20 +3498,20 @@ packages: yaml: optional: true - vitest@4.1.7: - resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.7 - '@vitest/browser-preview': 4.1.7 - '@vitest/browser-webdriverio': 4.1.7 - '@vitest/coverage-istanbul': 4.1.7 - '@vitest/coverage-v8': 4.1.7 - '@vitest/ui': 4.1.7 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3529,16 +3539,16 @@ packages: jsdom: optional: true - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} webpack-sources@3.5.0: resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} engines: {node: '>=10.13.0'} - webpack@5.107.0: - resolution: {integrity: sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==} + webpack@5.108.3: + resolution: {integrity: sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -4117,19 +4127,19 @@ snapshots: '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.2 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.2 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': optional: true @@ -4343,7 +4353,7 @@ snapshots: '@oven/bun-windows-x64@1.3.14': optional: true - '@oxc-project/types@0.132.0': {} + '@oxc-project/types@0.137.0': {} '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -4463,57 +4473,57 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 - '@playwright/test@1.60.0': + '@playwright/test@1.61.1': dependencies: - playwright: 1.60.0 + playwright: 1.61.1 - '@rolldown/binding-android-arm64@1.0.2': + '@rolldown/binding-android-arm64@1.1.3': optional: true - '@rolldown/binding-darwin-arm64@1.0.2': + '@rolldown/binding-darwin-arm64@1.1.3': optional: true - '@rolldown/binding-darwin-x64@1.0.2': + '@rolldown/binding-darwin-x64@1.1.3': optional: true - '@rolldown/binding-freebsd-x64@1.0.2': + '@rolldown/binding-freebsd-x64@1.1.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.2': + '@rolldown/binding-linux-arm64-gnu@1.1.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.2': + '@rolldown/binding-linux-arm64-musl@1.1.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.2': + '@rolldown/binding-linux-ppc64-gnu@1.1.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.2': + '@rolldown/binding-linux-s390x-gnu@1.1.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.2': + '@rolldown/binding-linux-x64-gnu@1.1.3': optional: true - '@rolldown/binding-linux-x64-musl@1.0.2': + '@rolldown/binding-linux-x64-musl@1.1.3': optional: true - '@rolldown/binding-openharmony-arm64@1.0.2': + '@rolldown/binding-openharmony-arm64@1.1.3': optional: true - '@rolldown/binding-wasm32-wasi@1.0.2': + '@rolldown/binding-wasm32-wasi@1.1.3': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.2': + '@rolldown/binding-win32-arm64-msvc@1.1.3': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.2': + '@rolldown/binding-win32-x64-msvc@1.1.3': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -4690,6 +4700,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/bun@1.3.14': dependencies: bun-types: 1.3.14 @@ -4707,7 +4722,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@22.19.19': + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 @@ -4717,7 +4732,7 @@ snapshots: '@types/postcss-import@14.0.3': dependencies: - postcss: 8.5.15 + postcss: 8.5.16 '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: @@ -4729,49 +4744,49 @@ snapshots: '@types/semver@7.7.1': {} - '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(vite@8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/expect@4.1.7': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.7 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.7': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.7': + '@vitest/runner@4.1.9': dependencies: - '@vitest/utils': 4.1.7 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.7': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.7': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.7': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.7 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -4890,13 +4905,13 @@ snapshots: assertion-error@2.0.1: {} - autoprefixer@10.5.0(postcss@8.5.15): + autoprefixer@10.5.0(postcss@8.5.16): dependencies: browserslist: 4.28.2 caniuse-lite: 1.0.30001793 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.15 + postcss: 8.5.16 postcss-value-parser: 4.2.0 baseline-browser-mapping@2.10.31: {} @@ -5039,12 +5054,7 @@ snapshots: optionalDependencies: node-addon-api: 8.7.0 - enhanced-resolve@5.21.6: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - enhanced-resolve@5.22.0: + enhanced-resolve@5.24.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -5175,8 +5185,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - globby@16.2.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -5236,7 +5244,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.19.19 + '@types/node': 25.9.1 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -5349,6 +5357,17 @@ snapshots: mini-svg-data-uri@1.4.4: {} + minimizer-webpack-plugin@5.6.1(esbuild@0.27.7)(postcss@8.5.16)(webpack@5.108.3(esbuild@0.27.7)(postcss@8.5.16)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.108.3(esbuild@0.27.7)(postcss@8.5.16) + optionalDependencies: + esbuild: 0.27.7 + postcss: 8.5.16 + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -5372,7 +5391,7 @@ snapshots: neo-async@2.6.2: {} - next@16.2.7(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.7 '@swc/helpers': 0.5.15 @@ -5391,7 +5410,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.7 '@next/swc-win32-arm64-msvc': 16.2.7 '@next/swc-win32-x64-msvc': 16.2.7 - '@playwright/test': 1.60.0 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5443,11 +5462,11 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - playwright-core@1.60.0: {} + playwright-core@1.61.1: {} - playwright@1.60.0: + playwright@1.61.1: dependencies: - playwright-core: 1.60.0 + playwright-core: 1.61.1 optionalDependencies: fsevents: 2.3.2 @@ -5458,9 +5477,9 @@ snapshots: read-cache: 1.0.0 resolve: 1.22.12 - postcss-import@16.1.1(postcss@8.5.15): + postcss-import@16.1.1(postcss@8.5.16): dependencies: - postcss: 8.5.15 + postcss: 8.5.16 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 @@ -5478,12 +5497,12 @@ snapshots: postcss: 8.5.15 yaml: 2.9.0 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.16)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.15 + postcss: 8.5.16 yaml: 2.9.0 postcss-nested@6.2.0(postcss@8.5.15): @@ -5520,6 +5539,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.16: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prettier-plugin-embed@0.5.1: dependencies: '@types/estree': 1.0.9 @@ -5531,12 +5556,12 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - prettier-plugin-organize-imports@4.3.0(prettier@3.8.3)(typescript@5.9.3): + prettier-plugin-organize-imports@4.3.0(prettier@3.9.4)(typescript@5.9.3): dependencies: - prettier: 3.8.3 + prettier: 3.9.4 typescript: 5.9.3 - prettier@3.8.3: {} + prettier@3.9.4: {} queue-microtask@1.2.3: {} @@ -5579,26 +5604,26 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.2: + rolldown@1.1.3: dependencies: - '@oxc-project/types': 0.132.0 + '@oxc-project/types': 0.137.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.2 - '@rolldown/binding-darwin-arm64': 1.0.2 - '@rolldown/binding-darwin-x64': 1.0.2 - '@rolldown/binding-freebsd-x64': 1.0.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 - '@rolldown/binding-linux-arm64-gnu': 1.0.2 - '@rolldown/binding-linux-arm64-musl': 1.0.2 - '@rolldown/binding-linux-ppc64-gnu': 1.0.2 - '@rolldown/binding-linux-s390x-gnu': 1.0.2 - '@rolldown/binding-linux-x64-gnu': 1.0.2 - '@rolldown/binding-linux-x64-musl': 1.0.2 - '@rolldown/binding-openharmony-arm64': 1.0.2 - '@rolldown/binding-wasm32-wasi': 1.0.2 - '@rolldown/binding-win32-arm64-msvc': 1.0.2 - '@rolldown/binding-win32-x64-msvc': 1.0.2 + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 rollup@4.60.4: dependencies: @@ -5756,17 +5781,6 @@ snapshots: tapable@2.3.3: {} - terser-webpack-plugin@5.6.0(esbuild@0.27.7)(postcss@8.5.15)(webpack@5.107.0(esbuild@0.27.7)(postcss@8.5.15)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - terser: 5.48.0 - webpack: 5.107.0(esbuild@0.27.7)(postcss@8.5.15) - optionalDependencies: - esbuild: 0.27.7 - postcss: 8.5.15 - terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -5797,6 +5811,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: @@ -5829,7 +5848,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -5840,7 +5859,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.16)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.60.4 source-map: 0.7.6 @@ -5849,7 +5868,7 @@ snapshots: tinyglobby: 0.2.16 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.16 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -5904,28 +5923,28 @@ snapshots: util-deprecate@1.0.2: {} - vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): + vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.2 - tinyglobby: 0.2.16 + postcss: 8.5.16 + rolldown: 1.1.3 + tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 22.19.19 + '@types/node': 22.19.21 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 yaml: 2.9.0 - vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): + vite@8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.2 - tinyglobby: 0.2.16 + postcss: 8.5.16 + rolldown: 1.1.3 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.1 esbuild: 0.27.7 @@ -5934,15 +5953,15 @@ snapshots: terser: 5.48.0 yaml: 2.9.0 - vitest@4.1.7(@types/node@22.19.19)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@22.19.21)(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.7 - '@vitest/runner': 4.1.7 - '@vitest/snapshot': 4.1.7 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -5954,21 +5973,20 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.19.19 + '@types/node': 22.19.21 transitivePeerDependencies: - msw - watchpack@2.5.1: + watchpack@2.5.2: dependencies: - glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 webpack-sources@3.5.0: {} - webpack@5.107.0(esbuild@0.27.7)(postcss@8.5.15): + webpack@5.108.3(esbuild@0.27.7)(postcss@8.5.16): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -5979,19 +5997,18 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.22.0 + enhanced-resolve: 5.24.1 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 - glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 loader-runner: 4.3.2 mime-db: 1.54.0 + minimizer-webpack-plugin: 5.6.1(esbuild@0.27.7)(postcss@8.5.16)(webpack@5.108.3(esbuild@0.27.7)(postcss@8.5.16)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.0(esbuild@0.27.7)(postcss@8.5.15)(webpack@5.107.0(esbuild@0.27.7)(postcss@8.5.15)) - watchpack: 2.5.1 + watchpack: 2.5.2 webpack-sources: 3.5.0 transitivePeerDependencies: - '@minify-html/node' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a537b6aba..f3c46a980 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,9 +10,9 @@ patchedDependencies: lightningcss@1.32.0: patches/lightningcss@1.32.0.patch catalog: - '@types/node': 22.19.19 + '@types/node': 22.19.21 dedent: 1.7.2 - enhanced-resolve: 5.21.6 + enhanced-resolve: ^5.24.1 lightningcss-darwin-arm64: 1.32.0 lightningcss-darwin-x64: 1.32.0 lightningcss-linux-arm64-gnu: 1.32.0 @@ -21,10 +21,10 @@ catalog: lightningcss-linux-x64-musl: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 lightningcss: 1.32.0 - postcss: ^8.5.15 - prettier: 3.8.3 - vite: 8.0.14 - webpack: 5.107.0 + postcss: ^8.5.16 + prettier: 3.9.4 + vite: ^8.1.2 + webpack: ^5.108.3 onlyBuiltDependencies: - '@parcel/watcher' From b53fa096c92d830fc64b7cf69581496242583446 Mon Sep 17 00:00:00 2001 From: highoncomputers Date: Thu, 2 Jul 2026 16:09:28 +0530 Subject: [PATCH 04/60] fix: exclude iframes from focus-visible auto outline in Preflight (#20292) ## Description Firefox's UA stylesheet already sets `iframe:focus-visible { outline-style: none; }`, so Preflight's `:-moz-focusring { outline: auto; }` rule overrides that and applies an unwanted auto outline to focused iframes. Adding `:where(:not(iframe))` to the selector preserves the improved focus ring behavior for all other elements while respecting Firefox's native iframe focus styling. Fixes #19795 Edit by @RobinMalfait ## Test plan Before: file-f833142116d36e1e620290b97455f001 After: file-400d9214fedf5b43693e10580a4869de --------- Co-authored-by: root Co-authored-by: Kirk Loretz Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + integrations/cli/index.test.ts | 2 +- .../@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap | 2 +- packages/tailwindcss/preflight.css | 2 +- packages/tailwindcss/src/__snapshots__/index.test.ts.snap | 2 +- packages/tailwindcss/src/source-maps/source-map.test.ts | 4 ++-- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a0cbc1a..e6866fb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297)) - Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298)) +- Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292)) ## [4.3.2] - 2026-06-26 diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index e742662be..a2b2ee4ab 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2903,7 +2903,7 @@ test( border-color: inherit; border-collapse: collapse; } - :-moz-focusring { + :-moz-focusring:where(:not(iframe)) { outline: auto; } progress { diff --git a/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap b/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap index d1f4c64ba..6d4823219 100644 --- a/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap +++ b/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap @@ -110,7 +110,7 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` border-collapse: collapse; } - :-moz-focusring { + :-moz-focusring:where(:not(iframe)) { outline: auto; } diff --git a/packages/tailwindcss/preflight.css b/packages/tailwindcss/preflight.css index 753e79ef0..e7dd8ec4e 100644 --- a/packages/tailwindcss/preflight.css +++ b/packages/tailwindcss/preflight.css @@ -170,7 +170,7 @@ table { Use the modern Firefox focus style for all focusable elements. */ -:-moz-focusring { +:-moz-focusring:where(:not(iframe)) { outline: auto; } diff --git a/packages/tailwindcss/src/__snapshots__/index.test.ts.snap b/packages/tailwindcss/src/__snapshots__/index.test.ts.snap index 04d331115..4a43ae7f9 100644 --- a/packages/tailwindcss/src/__snapshots__/index.test.ts.snap +++ b/packages/tailwindcss/src/__snapshots__/index.test.ts.snap @@ -226,7 +226,7 @@ exports[`compiling CSS > prefix all CSS variables inside preflight 1`] = ` border-collapse: collapse; } - :-moz-focusring { + :-moz-focusring:where(:not(iframe)) { outline: auto; } diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 25399e843..9238f495f 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -338,8 +338,8 @@ test('source maps trace back to @import location', async ({ expect }) => { ^^^^^^^^^^^^^^^^^^^^^^^^^ CS @ 73:4-29 | ^^^^^^^^^^^^^^^^^^^^^^^^^ CS @ 166:2-27 | 167 } 74 } | - 75 :-moz-focusring { | 173 :-moz-focusring { - ^^^^^^^^^^^^^^^^ CT @ 75:2-18 | ^^^^^^^^^^^^^^^^ CT @ 173:0-16 + 75 :-moz-focusring:where(:not(iframe)) { | 173 :-moz-focusring:where(:not(iframe)) { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CT @ 75:2-38 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CT @ 173:0-36 76 outline: auto; | 174 outline: auto; ^^^^^^^^^^^^^ CU @ 76:4-17 | ^^^^^^^^^^^^^ CU @ 174:2-15 | 175 } From 04588b1e8f3dbc856d8a3fad97c460bee689dbf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B5=AD?= Date: Thu, 2 Jul 2026 22:20:14 +0900 Subject: [PATCH 05/60] Fix theme() in JS plugins returning unresolved object instead of DEFAULT value (#20299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When a CSS theme key defined via `@theme` (or a JS config's `theme` object) shares a dash-separated prefix with a sibling key — e.g. `--color-foo` and `--color-foo-bar` — calling `theme('colors.foo')` from inside a JS plugin (`addUtilities`, `addComponents`, etc.) does not resolve to the `foo` value. Instead it returns an internal disambiguation object shaped like `{ DEFAULT: 'red', bar: 'blue', __CSS_VALUES__: {...} }`, because there's no way to tell from CSS custom property names alone whether `foo-bar` is a sibling key or a nested sub-key of `foo`. This same ambiguity was already fixed for the CSS-embedded `theme()` function in #19097 (which unwraps to the `DEFAULT` key when present), and the changelog entry for that PR states it fixes this "in JS configs **and plugins**" — but the fix only touched `apply-compat-hooks.ts`'s `resolveThemeValue`, not `createThemeFn`'s `theme` function that's exposed directly to plugins in `plugin-functions.ts`. This PR closes that gap by applying the same DEFAULT-unwrapping there. Without this fix, passing the raw object into `addUtilities` (a very natural thing to do, since a plugin author expects a string) produces broken CSS — the reserved `DEFAULT` key gets mangled into a garbage property name (`-d-e-f-a-u-l-t`) by the kebab-case conversion, and the internal `__CSS_VALUES__` bookkeeping leaks into the generated stylesheet. ### Minimal reproduction ```js // tailwind.config.js (registered via @config, or any @plugin-registered plugin) const plugin = require('tailwindcss/plugin') module.exports = { plugins: [ plugin(function ({ addUtilities, theme }) { addUtilities({ '.example-foo': { color: theme('colors.foo') }, }) }), ], } ``` ```css @import "tailwindcss"; @config "./tailwind.config.js"; @theme { --color-foo: red; --color-foo-bar: blue; } ``` **Before:** ```css .example-foo color { -d-e-f-a-u-l-t: red; bar: blue; } .example-foo color __CSS_VALUES__ { -d-e-f-a-u-l-t: 0; bar: 0; } ``` **After:** ```css .example-foo { color: red; } ``` ## Test plan - Added a regression test in `packages/tailwindcss/src/compat/plugin-api.test.ts` ("theme() resolves the DEFAULT value when a bare CSS theme key shares a prefix with a sibling key") - Verified via `pnpm --filter tailwindcss exec vitest run` that this test fails with the exact broken output shown above when the fix is reverted, and passes once it's applied - Ran the full `tailwindcss` package test suite (`pnpm --filter tailwindcss exec vitest run`) — 4684 tests passing, no regressions - Verified formatting on the changed files with `npx prettier --check` --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + .../tailwindcss/src/compat/plugin-api.test.ts | 40 +++++++++++++++++++ .../src/compat/plugin-functions.ts | 17 ++++++++ 3 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6866fb88..b4058a772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297)) - Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298)) - Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292)) +- Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) ## [4.3.2] - 2026-06-26 diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index e3ea3fb07..2ecdbe9a3 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -635,6 +635,46 @@ describe('theme', async () => { }) }) + test('theme() resolves the DEFAULT value when a bare CSS theme key shares a prefix with a sibling key', async () => { + expect( + await run( + ['example-foo', 'example-foo-bar'], + css` + @tailwind utilities; + @theme { + --color-foo: red; + --color-foo-bar: blue; + } + @plugin "my-plugin"; + `, + { + loadModule: async (_id, base) => { + return { + path: '', + base, + module: plugin(function ({ addUtilities, theme }) { + addUtilities({ + '.example-foo': { color: theme('colors.foo') }, + '.example-foo-bar': { color: theme('colors.foo-bar') }, + }) + }), + } + }, + }, + ), + ).toMatchInlineSnapshot(` + " + .example-foo { + color: red; + } + + .example-foo-bar { + color: #00f; + } + " + `) + }) + test('all necessary theme keys support bare values', async () => { expect( await run( diff --git a/packages/tailwindcss/src/compat/plugin-functions.ts b/packages/tailwindcss/src/compat/plugin-functions.ts index 40b8c93e8..ee795c40f 100644 --- a/packages/tailwindcss/src/compat/plugin-functions.ts +++ b/packages/tailwindcss/src/compat/plugin-functions.ts @@ -100,6 +100,23 @@ export function createThemeFn( return [base, extra] } + // If `--color-foo` and `--color-foo-bar` are both defined, `colors.foo` + // produces a synthetic object: + // + // ```ts + // { DEFAULT: 'red', bar: 'blue', __CSS_VALUES__: { DEFAULT: 0, bar: 0 } } + // ``` + // + // Prefer `DEFAULT` instead of exposing the object to plugin code. + if ( + cssValue !== null && + typeof cssValue === 'object' && + !Array.isArray(cssValue) && + 'DEFAULT' in cssValue + ) { + return cssValue.DEFAULT + } + // Values from CSS take precedence over values from the config return cssValue ?? configValue })() From 2683903b86d695f9c2647a06eb58bd96ade1eaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B5=AD?= Date: Fri, 3 Jul 2026 21:34:02 +0900 Subject: [PATCH 06/60] Support fractional opacity modifiers for named shadow sizes (#20302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `shadow-*`, `text-shadow-*`, `drop-shadow-*`, and `inset-shadow-*` accept a bare (non-arbitrary) opacity modifier like `/50`, but the named-size branch of all four utilities validates it with `isPositiveInteger(candidate.modifier.value)` instead of `isValidOpacityValue(candidate.modifier.value)` — the helper every other opacity/alpha modifier in the codebase uses (`asColor`, used by `bg-*`, `text-*`, `border-*`, `ring-*`, `fill-*`, `stroke-*`, `decoration-*`, `accent-*`, `caret-*`, `outline-*`, `placeholder-*`, `divide-*`). This means a fractional modifier like `/12.5` is silently ignored for a *named size* (`shadow-sm/12.5` behaves exactly like `shadow-sm`, dropping the modifier), while the exact same `/12.5` modifier works correctly on the *color* variant of the same utility (`shadow-red-500/12.5` → `color-mix(in oklab, var(--color-red-500) 12.5%, transparent)`), since that path already goes through `asColor`. `drop-shadow-*` is worse: its named-size branch has an extra guard (`if (candidate.modifier && !alpha) return`) that bails out of the *entire* utility when a modifier is present but couldn't be resolved to an alpha — so `drop-shadow-sm/12.5` produces no CSS at all. This isn't a case of fractional percentages being unsupported by design — the CHANGELOG entry that introduced `shadow-*/` explicitly describes it as controlling shadow **opacity**, and the color branch of these same utilities already supports fractional values (`shadow-red-500/2.25`, `/2.5`, `/2.75` are covered by existing tests). The named-size branch just never got the same treatment. **Repro** (verified with `pnpm --filter tailwindcss exec vitest run`): - `shadow-red-500/12.5` → `color-mix(in oklab, var(--color-red-500) 12.5%, transparent)` (correct) - `shadow-sm/12.5` → identical output to plain `shadow-sm` (modifier silently dropped) - `drop-shadow-sm/12.5` → **no CSS generated at all** - `shadow-sm/50` (integer, control) → works correctly ## Fix Replaced `isPositiveInteger(candidate.modifier.value)` with `isValidOpacityValue(candidate.modifier.value)` in the four affected utility definitions in `packages/tailwindcss/src/utilities.ts` (`shadow`, `text-shadow`, `drop-shadow`, `inset-shadow`). No other changes were needed — once `alpha` resolves correctly, `drop-shadow`'s existing `if (candidate.modifier && !alpha) return` guard naturally stops bailing out, since `alpha` is no longer `undefined` for valid fractional modifiers. ## Test plan - Added a regression test in `packages/tailwindcss/src/utilities.test.ts` covering `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5`. - Verified via `pnpm --filter tailwindcss exec vitest run` that this test fails (modifier dropped / empty output) with the fix reverted, and passes with it applied. - Ran the full `tailwindcss` package test suite (`pnpm --filter tailwindcss exec vitest run`) — 4685 tests passing, no regressions. - Verified formatting on the changed files with `npx prettier --check`. --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + packages/tailwindcss/src/utilities.test.ts | 28 ++++++++++++++++++++++ packages/tailwindcss/src/utilities.ts | 8 +++---- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4058a772..4b4256ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298)) - Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292)) - Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) +- Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) ## [4.3.2] - 2026-06-26 diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index e404a0a90..5201878f2 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -24053,6 +24053,7 @@ test('filter', async () => { 'invert-[var(--value)]', 'drop-shadow', 'drop-shadow/25', + 'drop-shadow/12.5', 'drop-shadow-xl', 'drop-shadow-multi', 'drop-shadow-[0_0_red]', @@ -24150,6 +24151,13 @@ test('filter', async () => { filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); } + .drop-shadow\\/12\\.5 { + --tw-drop-shadow-alpha: 12.5%; + --tw-drop-shadow-size: drop-shadow(0 1px 1px var(--tw-drop-shadow-color, oklab(0% 0 0 / .125))); + --tw-drop-shadow: drop-shadow(var(--drop-shadow)); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + .drop-shadow\\/25 { --tw-drop-shadow-alpha: 25%; --tw-drop-shadow-size: drop-shadow(0 1px 1px var(--tw-drop-shadow-color, oklab(0% 0 0 / .25))); @@ -26901,6 +26909,7 @@ test('text-shadow', async () => { 'text-shadow-[shadow:var(--value)]', 'text-shadow-sm/25', + 'text-shadow-sm/12.5', 'text-shadow-[12px_12px_#0088cc]/25', 'text-shadow-[12px_12px_var(--value)]/25', 'text-shadow-[10px_10px]/25', @@ -26979,6 +26988,11 @@ test('text-shadow', async () => { text-shadow: 12px 12px var(--tw-text-shadow-color, oklab(59.9824% -.067 -.124 / .25)); } + .text-shadow-sm\\/12\\.5 { + --tw-text-shadow-alpha: 12.5%; + text-shadow: 0px 1px 2px var(--tw-text-shadow-color, oklab(0% 0 0 / .125)), 0px 2px 2px var(--tw-text-shadow-color, oklab(0% 0 0 / .125)); + } + .text-shadow-sm\\/25 { --tw-text-shadow-alpha: 25%; text-shadow: 0px 1px 2px var(--tw-text-shadow-color, oklab(0% 0 0 / .25)), 0px 2px 2px var(--tw-text-shadow-color, oklab(0% 0 0 / .25)); @@ -27266,6 +27280,7 @@ test('shadow', async () => { 'shadow-[shadow:var(--value)]', 'shadow-sm/25', + 'shadow-sm/12.5', 'shadow-[12px_12px_#0088cc]/25', 'shadow-[12px_12px_var(--value)]/25', 'shadow-[10px_10px]/25', @@ -27368,6 +27383,12 @@ test('shadow', async () => { box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-sm\\/12\\.5 { + --tw-shadow-alpha: 12.5%; + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, oklab(0% 0 0 / .125)), 0 1px 2px -1px var(--tw-shadow-color, oklab(0% 0 0 / .125)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm\\/25 { --tw-shadow-alpha: 25%; --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, oklab(0% 0 0 / .25)), 0 1px 2px -1px var(--tw-shadow-color, oklab(0% 0 0 / .25)); @@ -27742,6 +27763,7 @@ test('inset-shadow', async () => { 'inset-shadow-[12px_12px_#0088cc,12px_12px_var(--value,#0088cc)]', 'inset-shadow-sm/25', + 'inset-shadow-sm/12.5', 'inset-shadow-[12px_12px_#0088cc]/25', 'inset-shadow-[12px_12px_var(--value)]/25', 'inset-shadow-[10px_10px]/25', @@ -27857,6 +27879,12 @@ test('inset-shadow', async () => { box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .inset-shadow-sm\\/12\\.5 { + --tw-inset-shadow-alpha: 12.5%; + --tw-inset-shadow: inset 0 1px 1px var(--tw-inset-shadow-color, oklab(0% 0 0 / .125)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .inset-shadow-sm\\/25 { --tw-inset-shadow-alpha: 25%; --tw-inset-shadow: inset 0 1px 1px var(--tw-inset-shadow-color, oklab(0% 0 0 / .25)); diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index c9fb0cc43..5da9e63e1 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4618,7 +4618,7 @@ export function createUtilities(theme: Theme) { if (candidate.modifier.kind === 'arbitrary') { alpha = candidate.modifier.value } else { - if (isPositiveInteger(candidate.modifier.value)) { + if (isValidOpacityValue(candidate.modifier.value)) { alpha = `${candidate.modifier.value}%` } } @@ -5427,7 +5427,7 @@ export function createUtilities(theme: Theme) { if (candidate.modifier.kind === 'arbitrary') { alpha = candidate.modifier.value } else { - if (isPositiveInteger(candidate.modifier.value)) { + if (isValidOpacityValue(candidate.modifier.value)) { alpha = `${candidate.modifier.value}%` } } @@ -5573,7 +5573,7 @@ export function createUtilities(theme: Theme) { if (candidate.modifier.kind === 'arbitrary') { alpha = candidate.modifier.value } else { - if (isPositiveInteger(candidate.modifier.value)) { + if (isValidOpacityValue(candidate.modifier.value)) { alpha = `${candidate.modifier.value}%` } } @@ -5699,7 +5699,7 @@ export function createUtilities(theme: Theme) { if (candidate.modifier.kind === 'arbitrary') { alpha = candidate.modifier.value } else { - if (isPositiveInteger(candidate.modifier.value)) { + if (isValidOpacityValue(candidate.modifier.value)) { alpha = `${candidate.modifier.value}%` } } From 67c745efde4c6b36879e812b4c3d9250f7e0b31e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 3 Jul 2026 21:13:13 +0200 Subject: [PATCH 07/60] Fix bug in attribute selector parsing (#20303) This PR fixes a bug in the selector parser where an attribute selector followed by a type selector inside a compound selector resulted in the wrong result. Given you have this CSS: ```css [data-foo]div {} ``` Then parsing it before this PR, would result in: ```ts { kind: 'compound', nodes: [ { kind: 'selector', value: '[data-foo]div' }, ], }, ``` But with this PR, it's properly split: ```ts { kind: 'compound', nodes: [ { kind: 'selector', value: '[data-foo]' }, { kind: 'selector', value: 'div' }, ], }, ``` I also tweaked some of the comments in the parser that are unrelated, but I was there already. ## Test plan 1. Added a regression test 2. All other tests should pass --- CHANGELOG.md | 1 + .../tailwindcss/src/selector-parser.test.ts | 38 +++++++++- packages/tailwindcss/src/selector-parser.ts | 74 ++++++++++--------- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4256ce9..d1e7cdf28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292)) - Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) - Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) +- Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) ## [4.3.2] - 2026-06-26 diff --git a/packages/tailwindcss/src/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts index e66abd90b..1cf5838f7 100644 --- a/packages/tailwindcss/src/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -445,7 +445,7 @@ describe('parse', () => { ]) }) - it('parses universal selector after an attribute selector', () => { + it('parses the universal selector after an attribute selector', () => { expect(parse('[data-foo]*')).toEqual([ { kind: 'compound', @@ -457,6 +457,42 @@ describe('parse', () => { ]) }) + it('parses another attribute selector after an attribute selector', () => { + expect(parse('[data-foo][data-bar]')).toEqual([ + { + kind: 'compound', + nodes: [ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: '[data-bar]' }, + ], + }, + ]) + }) + + it('parses a type selector before an attribute selector', () => { + expect(parse('div[data-foo]')).toEqual([ + { + kind: 'compound', + nodes: [ + { kind: 'selector', value: 'div' }, + { kind: 'selector', value: '[data-foo]' }, + ], + }, + ]) + }) + + it('parses a type selector after an attribute selector', () => { + expect(parse('[data-foo]div')).toEqual([ + { + kind: 'compound', + nodes: [ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: 'div' }, + ], + }, + ]) + }) + it('should parse selector lists as real selectors', () => { expect(parse('.foo[attr], .bar#id, .baz + .qux')).toEqual([ { diff --git a/packages/tailwindcss/src/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts index fcc78b2e9..b7632834a 100644 --- a/packages/tailwindcss/src/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -196,20 +196,14 @@ export function parse(input: string) { let currentChar = input.charCodeAt(i) switch (currentChar) { - // E.g.: + // Handle selector lists // // ```css - // .foo .bar + // .foo, .bar {} // ^ - // - // .foo > .bar - // ^^^ // ``` case COMMA: { - // Flush remaining buffer, mark it as a selector - // - // Combinators are handled separately, and functions end with `)` which - // means that the `buffer` will be empty at that point. + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' @@ -244,19 +238,30 @@ export function parse(input: string) { break } + // Handle combinators + // + // E.g.: + // + // ```css + // .foo .bar + // ^ + // + // .foo > .bar + // ^^^ + // ``` case GREATER_THAN: case NEWLINE: case SPACE: case PLUS: case TAB: case TILDE: { - // 1. Handle everything before the combinator as a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' } - // 2. Look ahead and find the end of the combinator + // Look ahead and find the end of the combinator let start = i let end = i + 1 for (; end < input.length; end++) { @@ -288,7 +293,7 @@ export function parse(input: string) { break } - // Start of a function call. + // Start of a function call // // E.g.: // @@ -312,7 +317,7 @@ export function parse(input: string) { let start = i + 1 let nesting = 0 - // Find the closing bracket. + // Find the closing bracket for (let j = i + 1; j < input.length; j++) { peekChar = input.charCodeAt(j) if (peekChar === OPEN_PAREN) { @@ -347,7 +352,7 @@ export function parse(input: string) { break } - // End of a function call. + // End of a function call // // E.g.: // @@ -356,7 +361,7 @@ export function parse(input: string) { // ^ // ``` case CLOSE_PAREN: { - // Handle everything before the closing paren a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' @@ -376,7 +381,7 @@ export function parse(input: string) { break } - // Split compound selectors. + // Split compound selectors // // E.g.: // @@ -392,8 +397,8 @@ export function parse(input: string) { break } - // Handle everything before the combinator as a selector and - // start a new selector + // Handle everything before the combinator as a selector and start a new + // selector if (buffer.length > 0) { append(selector(buffer)) } @@ -401,7 +406,7 @@ export function parse(input: string) { break } - // Start of an attribute selector. + // Start of an attribute selector // // NOTE: Right now we don't care about the individual parts of the // attribute selector, we just want to find the matching closing bracket. @@ -410,16 +415,16 @@ export function parse(input: string) { // future, then we can use the `AttributeSelectorParser` here (and even // inline it if needed) case OPEN_BRACKET: { - // Handle everything before the combinator as a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) + buffer = '' } - buffer = '' let start = i let nesting = 0 - // Find the closing bracket. + // Find the closing bracket for (let j = i + 1; j < input.length; j++) { peekChar = input.charCodeAt(j) if (peekChar === OPEN_BRACKET) { @@ -435,12 +440,11 @@ export function parse(input: string) { } } - // Adjust `buffer` to include the string. - buffer += input.slice(start, i + 1) + append(selector(input.slice(start, i + 1))) break } - // Start of a string. + // Start of a string case SINGLE_QUOTE: case DOUBLE_QUOTE: { let start = i @@ -452,43 +456,43 @@ export function parse(input: string) { // // ```css // "This is a string with a 'quote' in it" - // ^ ^ -> These are not the end of the string. + // ^ ^ -> These are not the end of the string // ``` for (let j = i + 1; j < input.length; j++) { peekChar = input.charCodeAt(j) - // Current character is a `\` therefore the next character is escaped. + // Current character is a `\` therefore the next character is escaped if (peekChar === BACKSLASH) { j += 1 } - // End of the string. + // End of the string else if (peekChar === currentChar) { i = j break } } - // Adjust `buffer` to include the string. + // Adjust `buffer` to include the string buffer += input.slice(start, i + 1) break } - // Nesting `&` is always a new selector. - // Universal `*` is always a new selector. + // Nesting `&` is always a new selector + // Universal `*` is always a new selector case AMPERSAND: case ASTERISK: { - // 1. Handle everything before the combinator as a selector + // Flush remaining buffer as a selector if (buffer.length > 0) { append(selector(buffer)) buffer = '' } - // 2. Handle the `&` or `*` as a selector on its own + // Handle the `&` or `*` as a selector on its own append(selector(input[i])) break } - // Escaped characters. + // Escaped characters case BACKSLASH: { buffer += input[i] + input[i + 1] i += 1 @@ -502,7 +506,7 @@ export function parse(input: string) { } } - // Collect the remainder as a word + // Collect the remainder as a selector if (buffer.length > 0) { append(selector(buffer)) } From 9b0e8af25861ad5b8f5af420ad3ee0b188665027 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Mon, 6 Jul 2026 16:32:20 +0500 Subject: [PATCH 08/60] Ensure `@tailwindcss/postcss` rebuilds when the input CSS changes but its mtime is unchanged (#20310) ## Summary `@tailwindcss/postcss` chooses between an incremental and a full rebuild by comparing the mtimes of the entry file and its resolved `@import`/`@config`/`@plugin` graph. It never looks at the input CSS itself, so when that CSS is produced by an upstream tool (e.g. Sass) and passed to the plugin via `process()`, it can change while the `from` file's mtime stays the same. The plugin then re-emits its previously cached output and silently drops the change. This stores the input CSS per cache entry and takes the existing full rebuild path when it differs from the previous compile, mirroring the fix the CLI watcher already has for changed input files. Fixes #20307 ## Test plan - Added a regression test in `packages/@tailwindcss-postcss/src/index.test.ts` that compiles two different inputs for the same on-disk `from` file (unchanged mtime) and asserts the second compile reflects the new CSS. - Confirmed it fails on `main` (the second compile returns the stale first output) and passes with the fix. - Ran the `@tailwindcss/postcss` package tests (all green) and checked formatting with Prettier. --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + .../@tailwindcss-postcss/src/index.test.ts | 63 +++++++++++++++---- packages/@tailwindcss-postcss/src/index.ts | 12 ++++ 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e7cdf28..f20f7cd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent `theme('colors.foo')` in JS plugins from returning an internal disambiguation object when a CSS theme key shares a prefix with a sibling key like `--color-foo-bar` ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299)) - Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) - Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) +- Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) ## [4.3.2] - 2026-06-26 diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index 14acdec50..b393cc382 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -20,6 +20,16 @@ function inputCssFilePath() { const css = dedent +async function run(plugin: any, from: string, input: string): Promise { + let ast = postcss.parse(input) + for (let runner of plugin.plugins) { + if (runner.Once) { + await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } }) + } + } + return ast.toString() +} + test("`@import 'tailwindcss'` is replaced with the generated CSS", async () => { let processor = postcss([ tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), @@ -422,17 +432,7 @@ describe('concurrent builds', () => { let plugin = tailwindcss({ optimize: { minify: false } }) - async function run(input: string): Promise { - let ast = postcss.parse(input) - for (let runner of (plugin as any).plugins) { - if (runner.Once) { - await runner.Once(ast, { postcss, result: { opts: { from }, messages: [] } }) - } - } - return ast.toString() - } - - let result = await run(input) + let result = await run(plugin, from, input) expect(result).toContain('.underline') @@ -448,14 +448,51 @@ describe('concurrent builds', () => { `, ) - let promise1 = run(input) - let promise2 = run(input) + let promise1 = run(plugin, from, input) + let promise2 = run(plugin, from, input) expect(await promise1).toContain('.red') expect(await promise2).toContain('.red') }) }) +test('rebuilds when the input CSS changes even if the `from` file on disk did not', async () => { + let dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss')) + // The `from` file exists on disk with a stable mtime. The input CSS is handed + // to the plugin directly (as a preprocessor like Sass would produce it), so it + // can change without `from`'s mtime ever changing. + await writeFile(path.join(dir, 'index.css'), '') + + let from = path.join(dir, 'index.css') + let plugin = tailwindcss({ base: dir, optimize: { minify: false } }) + + let first = await run( + plugin, + from, + css` + @tailwind utilities; + .first { + @apply underline; + } + `, + ) + let second = await run( + plugin, + from, + css` + @tailwind utilities; + .second { + @apply line-through; + } + `, + ) + + expect(first).toContain('.first') + expect(second).toContain('.second') + + await rm(dir, { recursive: true, force: true }) +}) + test('does not register the input file as a dependency, even if it is passed in as relative path', async () => { let processor = postcss([ tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }), diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index d0706045a..a4a8d9326 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -20,6 +20,7 @@ const DEBUG = env.DEBUG interface CacheEntry { mtimes: Map + inputCss: string compiler: null | ReturnType scanner: null | Scanner tailwindCssAst: AstNode[] @@ -34,6 +35,7 @@ function getContextFromCache(postcss: Postcss, inputFile: string, opts: PluginOp if (cache.has(key)) return cache.get(key)! let entry = { mtimes: new Map(), + inputCss: '', compiler: null, scanner: null, @@ -194,6 +196,16 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { rebuildStrategy = 'full' context.mtimes.set(file, changedTime) } + + // The mtimes above only track files on disk. When the input CSS is + // generated by another tool (e.g. Sass) and passed to `process()`, + // it can change without any tracked file's mtime changing, so also + // rebuild when the input CSS itself differs from the last build. + let inputCss = root.source?.input.css ?? root.toString() + if (context.inputCss !== inputCss) { + rebuildStrategy = 'full' + context.inputCss = inputCss + } } DEBUG && I.end('Register full rebuild paths') From 5835691d210b1c530160c844a562c36fa5fcbdb3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jul 2026 18:28:41 +0200 Subject: [PATCH 09/60] Handle CSS nesting natively (#20124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a new feature where we will be handling the CSS nesting ourselves. We currently still rely on Lightning CSS in most places. But there are situations where we don't use Lightning CSS out of the box: 1. During development, typically optimization/minification isn't setup 2. In places where it isn't as easy to run Lightning CSS such as in `@tailwindcss/browser` or in Tailwind Play. We handle CSS nesting in a single pass over the AST by tracking some information as we go. It's not the most complex code, but there are some tricky parts to make this happen in an efficient way, especially for the few additional optimizations we handle. While going over the AST, we will only emit CSS the moment we see declarations or comments. This also means that this has a fun side effect of removing CSS that ends up with empty nodes automatically. (Caveat: there are exceptions for body-less rules such as `@layer foo;` or `@charset "UTF-8";) This also allowed us to do some cleanup in `optimizeAst` that tried to do this as well, but now this will be handled by the code that handles nesting automatically. Which is preferred because the version in `optimizeAst` mutated the AST. This also contains some optimizations where we merge adjacent at-rules (with the same name / params), and adjacent rules with the same selector, and get rid of declarations that are duplicated in a node. (Caveat: there are exceptions, in case of `@font-family { … }` where we don't want to merge them) ~~To ensure that this implementation is correct, I also added an oracle implementation in the tests. This implementation does multiple passes over the AST, because it does each step one by one, with minimal code. Each step contains comments with examples to see what's happening in that step. We then test the optimized version against this.~~ Once the implementation was in place, and all the tests were passing, then I deleted the oracle implementation. That way we don't have to keep it in sync all the time. While handling the nesting, we have to make sure that `&` exists and if we replace it with a parent selector that we do use `:is(…)` semantics. This means that: ```css .foo { &:hover { color: red; } } ``` Becomes: ```css :is(.foo):hover { color: red; } ``` We then also make sure that we optimize the selector by removing the unnecessary `:is(…)` wrappers, but only if they were introduced by the nesting logic. If _you_ wrote `:is(…)` in your CSS, we won't touch it. If you look at the commits, the first thing we did is remove the optimization step from Lightning CSS in the tests. Then we enabled our CSS nesting handling code. This allows us to see the effect of the changes we are making. At the end, we re-enabled Lightning CSS. For now, this PR will be a step that happens before Lightning CSS is executed, while still using Lightning CSS. But now this step will also always happen in places where we don't use Lightning CSS at all. This should not result in any breaking changes. It could result in changed CSS output in environments where Lightning CSS isn't used. In environments where it is being used, then there could be some differences related to some selectors but they should result in the same behavior with the same specificity. While testing things, I noticed that there are some missed opportunities for performance related to how we extract variables from declaration values. I want to tackle `optimizeAst` in future PRs to make it simpler, more performant, and maybe even merge it with the CSS nesting handling. As part of testing this, I tested it against the tailwindcss.com codebase which contains a lot of CSS (807.67 KB, 18 174 AST nodes) because almost every utility is being used in examples. The oracle implementation is rather slow: ``` [131.59ms] ↳ oracle (step by step) [129.23ms] ↳ handleNesting(…) [ 2.32ms] ↳ toCss(…) ``` But the final code is much faster (`<15ms`): ``` [ 10.11ms] ↳ hand written (single pass) [ 8.40ms] ↳ handleNesting(…) [ 1.67ms] ↳ toCss(…) ``` In contrast, Lightning CSS takes: `[ 37.48ms] Optimized by Lightning CSS` One interesting thing to notice is that in big projects, this could add `10ms` to the build, but Lightning CSS would then take less time to process, which results in a no-op with better output. One thing to keep in mind here is that Lightning CSS does more things, such as normalizing values, handling vendor prefixes, CSS nesting, etc.
Some notes on how the algorithm works: ### The basic idea When you have CSS that looks this: ```css .foo { .bar { color: red; } } ``` Then the AST looks like this: ``` [ { kind: 'rule', selector: '.foo', nodes: [ { kind: 'rule', selector: '.bar', nodes: [ { kind: 'declaration', property: 'color', value: 'red', important: false } ] } ] } ] ``` When we walk this tree, and we encounter a `rule`, then we will track the selector on a stack. When we are done walking over the rule, then we will pop the selector from the stack. This means that the top-most selector on the stack will always be the parent selector. ```ts let selectorStack = [] walk(ast, { enter(node) { selectorStack.push(node.selector) }, exit(node) { selectorStack.pop() }, }) ``` The moment we encounter a `rule`, and if a previous rule was seen, then we push the `selector` of the rule onto the stack, but in a way that the `&` is already replaced by the selector. This way, a sibling rule will also get the already-prepared parent selector. The simple version looks like this: ```ts walk(ast, { enter(node) { // In the real code we properly handle `&` replacement, and make sure that // parent selector is prepended if there is no `&` used in the selector of the // node. let selector = selectorStack.length > 0 // At this point, we don't optimize anything related to the selector yet ? node.selector.replaceAll('&', `:is(${selectorStack.at(-1)})`) : node.selector selectorStack.push(selector) }, exit(node) { selectorStack.pop() }, }) ``` So far we aren't doing much yet, but the interesting part is when we encounter a `declaration` (or a `comment`). The moment we see any of those, then will we emit a node with the information from the `selectorStack`. We then also track the last node's `nodes` we created such that we can push more declarations into it as a shortcut. ```ts let result: AstNode[] = [] let nodes: AstNode[] | null = null walk(ast, { enter(node) { if (node.kind === 'declaration') { // `nodes` is available, nothing special to do if (nodes) { nodes.push(node) return } // Track new nodes let nodes = [node] // Create a new node with a reference to `nodes` for future declarations let newNode = rule(selectorStack.at(-1), nodes) result.push(newNode) } }, }) ``` The last important part is that whenever we see a new `rule`, then we have to reset that `nodes` tracking variable such that we can create a fresh node the next time we see a declaration. For the `at-rules`, something similar happens but they are tracked in a similar but separate stack. The idea there is that we can then wrap those `at-rules` around the `newNode` we create. That way the at-rules naturally float to the top. I can keep going here, but I think if you're interested in this, then you could go over the commits in this PR, or you can look at the `ast.ts` implementation directly to see what's going on.
## Test plan 1. Existing tests should pass 2. New tests have been added to test the flattening of nested CSS --- CHANGELOG.md | 1 + integrations/cli/plugins.test.ts | 12 +- integrations/vite/index.test.ts | 42 +- integrations/vite/resolvers.test.ts | 4 +- packages/tailwindcss/src/ast.test.ts | 1315 ++++++++++++----- packages/tailwindcss/src/ast.ts | 714 ++++++++- .../src/attribute-selector-parser.test.ts | 5 + .../src/attribute-selector-parser.ts | 3 + .../tailwindcss/src/compat/config.test.ts | 52 +- .../tailwindcss/src/compat/plugin-api.test.ts | 37 +- packages/tailwindcss/src/compat/plugin-api.ts | 9 +- packages/tailwindcss/src/intellisense.test.ts | 88 +- .../tailwindcss/src/selector-parser.test.ts | 33 +- packages/tailwindcss/src/selector-parser.ts | 122 +- .../src/source-maps/source-map.test.ts | 32 +- packages/tailwindcss/src/utilities.test.ts | 96 +- packages/tailwindcss/src/utils/variables.ts | 7 +- packages/tailwindcss/src/variants.test.ts | 9 + packages/tailwindcss/src/variants.ts | 27 +- 19 files changed, 2037 insertions(+), 571 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f20f7cd14..baad68046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302)) - Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) - Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) +- Ensure CSS nesting is handled even when Lightning CSS isn't run, like in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124)) ## [4.3.2] - 2026-06-26 diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts index 4cda7955a..0b810c538 100644 --- a/integrations/cli/plugins.test.ts +++ b/integrations/cli/plugins.test.ts @@ -109,13 +109,11 @@ test( expect(await fs.dumpFiles('dist/out.css')).toMatchInlineSnapshot(` " --- dist/out.css --- - .prose-custom { - :where(hr):not(:where([class~="not-prose"], [class~="not-prose"] *)) { - --x: 1; - @media (width >= 640px) { - &.custom { - --x: 2; - } + .prose-custom :where(hr):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + --x: 1; + @media (width >= 640px) { + &.custom { + --x: 2; } } } diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index bb237e6cd..34940c74e 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -1145,7 +1145,7 @@ test( -
Hello, world!
+
Hello, world!
`, 'src/index.css': css` @@ -1157,16 +1157,16 @@ test( async ({ exec, expect, fs }) => { await exec('pnpm vite build') - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - // Should not be minified when optimize is disabled - let content = await fs.read(filename) - expect(content).toContain('.hover\\:flex {') - expect(content).toContain('&:hover {') - expect(content).toContain('@media (hover: hover) {') - expect(content).toContain('display: flex;') + expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css')) + .toMatchInlineSnapshot(` + " + --- dist/assets/index-.css --- + .focus\\:text-\\[black\\]:focus { + color: black; + } + " + `) }, ) @@ -1200,7 +1200,7 @@ test( -
Hello, world!
+
Hello, world!
`, 'src/index.css': css` @@ -1212,15 +1212,17 @@ test( async ({ exec, expect, fs }) => { await exec('pnpm vite build') - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - // Should be optimized but not minified - let content = await fs.read(filename) - expect(content).toContain('@media (hover: hover) {') - expect(content).toContain('.hover\\:flex:hover {') - expect(content).toContain('display: flex;') + expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css')) + .toMatchInlineSnapshot(` + " + --- dist/assets/index-.css --- + @media (hover: hover) { + .hover\\:text-\\[black\\]:hover { + color: #000; + } + } + " + `) }, ) diff --git a/integrations/vite/resolvers.test.ts b/integrations/vite/resolvers.test.ts index a96b26ed1..fc9aca5c7 100644 --- a/integrations/vite/resolvers.test.ts +++ b/integrations/vite/resolvers.test.ts @@ -572,7 +572,7 @@ test( await exec('pnpm vite build') expect( - (await fs.dumpFiles('./dist/**/*.css')).replace(/-([_a-zA-Z0-9]*?)\.css/g, '-.css'), + (await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css'), ).toMatchInlineSnapshot(` " --- ./dist/assets/index-.css --- @@ -643,7 +643,7 @@ test( await exec('pnpm vite build') expect( - (await fs.dumpFiles('./dist/**/*.css')).replace(/-([_a-zA-Z0-9]*?)\.css/g, '-.css'), + (await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css'), ).toMatchInlineSnapshot(` " --- ./dist/assets/index-.css --- diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 24f31f7bb..f3499b350 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -1,26 +1,28 @@ -import { expect, it } from 'vitest' +import { describe, expect, it, test } from 'vitest' import { - atRule, context, cssContext, decl, + handleNesting, optimizeAst, + rule, styleRule, toCss, type AstNode, } from './ast' import * as CSS from './css-parser' import { buildDesignSystem } from './design-system' +import { pretty } from './test-utils/run' import { Theme } from './theme' -import { walk, WalkAction } from './walk' +import { walk } from './walk' const css = String.raw const defaultDesignSystem = buildDesignSystem(new Theme()) it('should pretty print an AST', () => { - expect(toCss(optimizeAst(CSS.parse('.foo{color:red;&:hover{color:blue;}}'), defaultDesignSystem))) - .toMatchInlineSnapshot(` - ".foo { + expect(pretty(toCss(CSS.parse('.foo{color:red;&:hover{color:blue;}}')))).toMatchInlineSnapshot(` + " + .foo { color: red; &:hover { color: blue; @@ -68,8 +70,9 @@ it('allows the placement of context nodes', () => { expect(blueContext).toEqual({ context: 'a' }) expect(greenContext).toEqual({ context: 'b' }) - expect(toCss(optimizeAst(ast, defaultDesignSystem))).toMatchInlineSnapshot(` - ".foo { + expect(pretty(toCss(optimizeAst(ast, defaultDesignSystem)))).toMatchInlineSnapshot(` + " + .foo { color: red; } .bar { @@ -82,36 +85,6 @@ it('allows the placement of context nodes', () => { `) }) -it('should stop walking when returning `WalkAction.Stop`', () => { - let ast = [ - styleRule('.foo', [styleRule('.nested', [styleRule('.bail', [decl('color', 'red')])])]), - styleRule('.bar'), - styleRule('.baz'), - styleRule('.qux'), - ] - - let seen = new Set() - - walk(ast, (node) => { - if (node.kind === 'rule') { - seen.add(node.selector) - } - - if (node.kind === 'rule' && node.selector === '.bail') { - return WalkAction.Stop - } - }) - - // We do not want to see `.bar`, `.baz`, or `.qux` because we bailed early - expect(seen).toMatchInlineSnapshot(` - Set { - ".foo", - ".nested", - ".bail", - } - `) -}) - it('should not emit empty rules once optimized', () => { let ast = CSS.parse(css` /* Empty rule */ @@ -154,13 +127,15 @@ it('should not emit empty rules once optimized', () => { /* Exceptions: */ @charset "UTF-8"; @layer foo, bar, baz; + @layer foo, bar, baz; /* Will be deduped */ @custom-media --modern (color), (hover); @namespace 'http://www.w3.org/1999/xhtml'; @import url('https://fonts.googleapis.com/css2?family=Cedarville+Cursive&display=swap'); `) - expect(toCss(ast)).toMatchInlineSnapshot(` - ".foo { + expect(pretty(toCss(ast))).toMatchInlineSnapshot(` + " + .foo { } .foo { .bar { @@ -187,14 +162,16 @@ it('should not emit empty rules once optimized', () => { } @charset "UTF-8"; @layer foo, bar, baz; + @layer foo, bar, baz; @custom-media --modern (color), (hover); @namespace 'http://www.w3.org/1999/xhtml'; @import url('https://fonts.googleapis.com/css2?family=Cedarville+Cursive&display=swap'); " `) - expect(toCss(optimizeAst(ast, defaultDesignSystem))).toMatchInlineSnapshot(` - "@charset "UTF-8"; + expect(pretty(toCss(optimizeAst(ast, defaultDesignSystem)))).toMatchInlineSnapshot(` + " + @charset "UTF-8"; @layer foo, bar, baz; @custom-media --modern (color), (hover); @namespace 'http://www.w3.org/1999/xhtml'; @@ -235,8 +212,9 @@ it('should not emit exact duplicate declarations in the same rule', () => { } `) - expect(toCss(ast)).toMatchInlineSnapshot(` - ".foo { + expect(pretty(toCss(ast))).toMatchInlineSnapshot(` + " + .foo { color: red; .bar { color: green; @@ -267,20 +245,27 @@ it('should not emit exact duplicate declarations in the same rule', () => { " `) - expect(toCss(optimizeAst(ast, defaultDesignSystem))).toMatchInlineSnapshot(` - ".foo { + expect(pretty(toCss(optimizeAst(ast, defaultDesignSystem)))).toMatchInlineSnapshot(` + " + .foo { .bar { + color: green; color: blue; color: green; } color: red; - } - .foo { - color: green; - color: blue; - color: red; + & { + color: green; + & { + color: red; + color: green; + color: blue; + } + color: red; + } background: blue; .bar { + color: green; color: blue; color: green; } @@ -307,8 +292,9 @@ it('should not emit color-mix() fallbacks inside @keyframes', () => { let design = buildDesignSystem(theme) - expect(toCss(optimizeAst(ast, design))).toMatchInlineSnapshot(` - "@keyframes my-animation { + expect(pretty(toCss(optimizeAst(ast, design)))).toMatchInlineSnapshot(` + " + @keyframes my-animation { 0% { color: color-mix(in oklab, var(--color-emerald-600) 0%, transparent); } @@ -320,303 +306,936 @@ it('should not emit color-mix() fallbacks inside @keyframes', () => { `) }) -it('should only visit children once when calling `replaceWith` with single element array', () => { - let visited = new Set() +describe('optimization', () => { + function optimize(input: string) { + return pretty(toCss(handleNesting(CSS.parse(input)))) + } - let ast: AstNode[] = [ - atRule('@media', '', [styleRule('.foo', [decl('color', 'blue')])]), - styleRule('.bar', [decl('color', 'blue')]), - ] + // See: https://drafts.csswg.org/css-nesting-1/ + describe('CSS Nesting Module Level 1', () => { + it('uses the descendant combinator by default', async () => { + expect( + optimize(css` + .a { + element { + --x: 1; + } + .class { + --x: 2; + } + #id { + --x: 3; + } + :pseudo-class { + --x: 4; + } + ::pseudo-element { + --x: 5; + } + [attribute] { + --x: 6; + } + * { + --x: 7; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a element { + --x: 1; + } + .a .class { + --x: 2; + } + .a #id { + --x: 3; + } + .a :pseudo-class { + --x: 4; + } + .a ::pseudo-element { + --x: 5; + } + .a [attribute] { + --x: 6; + } + .a * { + --x: 7; + } + " + `) + }) - walk(ast, (node) => { - if (visited.has(node)) { - throw new Error('Visited node twice') - } - visited.add(node) + it('should be possible to change the combinator', async () => { + expect( + optimize(css` + .a { + + .b { + --x: 1; + } + > .c { + --x: 2; + } + ~ .d { + --x: 3; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a + .b { + --x: 1; + } + .a > .c { + --x: 2; + } + .a ~ .d { + --x: 3; + } + " + `) + }) - if (node.kind === 'at-rule') return WalkAction.Replace(node.nodes) + it('should replace the first rule, that contains `&` with `:scope`', async () => { + expect( + optimize(css` + /* Standalone */ + & { + --x: 1; + } + + /* In an at-rule */ + @supports (--y: 1) { + & { + --x: 2; + } + } + + /* With :is(…) */ + :is(&) { + --x: 3; + } + + /* In an at-rule, with :is(…) */ + @supports (--y: 2) { + :is(&) { + --x: 4; + } + } + + /* With multiple selectors */ + &, + .a { + --x: 5; + } + + /* With multiple selectors + :is(…) */ + :is(&), + .b { + --x: 6; + } + + /* With multiple selectors in an at-rule */ + @supports (--y: 3) { + &, + .c { + --x: 7; + } + } + + /* With multiple selectors in an at-rule + :is(…) */ + @supports (--y: 4) { + :is(&), + .d { + --x: 8; + } + } + `), + ).toMatchInlineSnapshot(` + " + :scope { + --x: 1; + } + @supports (--y: 1) { + :scope { + --x: 2; + } + } + :is(:scope) { + --x: 3; + } + @supports (--y: 2) { + :is(:scope) { + --x: 4; + } + } + :scope, .a { + --x: 5; + } + :is(:scope), .b { + --x: 6; + } + @supports (--y: 3) { + :scope, .c { + --x: 7; + } + } + @supports (--y: 4) { + :is(:scope), .d { + --x: 8; + } + } + " + `) + }) + + it('should be possible to use `&` to explicitly match the parent', async () => { + expect( + optimize(css` + .a { + & + .b { + --x: 1; + } + & > .c { + --x: 2; + } + & ~ .d { + --x: 3; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a + .b { + --x: 1; + } + .a > .c { + --x: 2; + } + .a ~ .d { + --x: 3; + } + " + `) + }) + + it('should be possible to use `&` in a different location', async () => { + expect( + optimize(css` + .a { + .b & { + --x: 1; + } + .c + & { + --x: 2; + } + .d > & { + --x: 3; + } + .e ~ & { + --x: 4; + } + } + `), + ).toMatchInlineSnapshot(` + " + .b .a { + --x: 1; + } + .c + .a { + --x: 2; + } + .d > .a { + --x: 3; + } + .e ~ .a { + --x: 4; + } + " + `) + }) + + it('should be possible to use `&` on its own', async () => { + expect( + optimize(css` + .a { + & { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a { + --x: 1; + } + " + `) + }) + + it('should be possible to use `&` nested in `:is(…)`', async () => { + expect( + optimize(css` + .a { + :is(&) { + --x: 1; + } + :is(:is(:is(&))) { + --x: 2; + } + } + `), + ).toMatchInlineSnapshot(` + " + :is(.a) { + --x: 1; + } + :is(:is(:is(.a))) { + --x: 2; + } + " + `) + }) + + it('should be possible to handle nesting with a parent selector list', async () => { + expect( + optimize(css` + .a, + .b { + .c, + .d & { + --x: 1; + &:hover { + --x: 2; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + :is(.a, .b) .c, .d :is(.a, .b) { + --x: 1; + &:hover { + --x: 2; + } + } + " + `) + }) + + it('should not replace `\&`', () => { + expect( + optimize(css` + .a { + .b-\& { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a .b-\\& { + --x: 1; + } + " + `) + }) + + it('should not replace `&` as part of a string', () => { + expect( + optimize(css` + .a { + [data-b='c&d'] { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a [data-b='c&d'] { + --x: 1; + } + " + `) + }) + + it.each([ + ['element', '&element', ':is(element)element'], // Invalid CSS + ['element', 'element&', 'element:is(element)'], + ['element', '&.class', 'element.class'], + ['element', '.class&', '.class:is(element)'], // Optimization: element.class + ['element', '&#id', 'element#id'], + ['element', '#id&', '#id:is(element)'], // Optimization: element#id + ['element', '&:hover', 'element:hover'], + ['element', ':hover&', ':hover:is(element)'], + ['element', '&::before', 'element::before'], + ['element', '::before&', '::before:is(element)'], // Invalid CSS + ['element', '&:not(.x)', 'element:not(.x)'], + ['element', ':not(.x)&', ':not(.x):is(element)'], // Optimization: element:not(.x) + ['element', '&[attribute]', 'element[attribute]'], + ['element', '[attribute]&', '[attribute]:is(element)'], // Optimization: element[attribute] + ['element', '&*', ':is(element)*'], // Invalid CSS + ['element', '*&', 'element'], + + ['.class', '&element', ':is(.class)element'], // Invalid CSS + ['.class', 'element&', 'element.class'], + ['.class', '&.class', '.class.class'], + ['.class', '.class&', '.class.class'], + ['.class', '&#id', '.class#id'], + ['.class', '#id&', '#id.class'], + ['.class', '&:hover', '.class:hover'], + ['.class', ':hover&', ':hover.class'], + ['.class', '&::before', '.class::before'], + ['.class', '::before&', '::before.class'], // Invalid CSS + ['.class', '&:not(.x)', '.class:not(.x)'], + ['.class', ':not(.x)&', ':not(.x).class'], + ['.class', '&[attribute]', '.class[attribute]'], + ['.class', '[attribute]&', '[attribute].class'], + ['.class', '&*', ':is(.class)*'], // Invalid CSS + ['.class', '*&', '.class'], + + ['#id', '&element', ':is(#id)element'], // Invalid CSS + ['#id', 'element&', 'element#id'], + ['#id', '&.class', '#id.class'], + ['#id', '.class&', '.class#id'], + ['#id', '&#id', '#id#id'], + ['#id', '#id&', '#id#id'], + ['#id', '&:hover', '#id:hover'], + ['#id', ':hover&', ':hover#id'], + ['#id', '&::before', '#id::before'], + ['#id', '::before&', '::before#id'], // Invalid CSS + ['#id', '&:not(.x)', '#id:not(.x)'], + ['#id', ':not(.x)&', ':not(.x)#id'], + ['#id', '&[attribute]', '#id[attribute]'], + ['#id', '[attribute]&', '[attribute]#id'], + ['#id', '&*', ':is(#id)*'], // Invalid CSS + ['#id', '*&', '#id'], + + [':hover', '&element', ':is(:hover)element'], // Invalid CSS + [':hover', 'element&', 'element:hover'], + [':hover', '&.class', ':hover.class'], + [':hover', '.class&', '.class:hover'], + [':hover', '&#id', ':hover#id'], + [':hover', '#id&', '#id:hover'], + [':hover', '&:hover', ':hover:hover'], + [':hover', ':hover&', ':hover:hover'], + [':hover', '&::before', ':hover::before'], + [':hover', '::before&', '::before:hover'], + [':hover', '&:not(.x)', ':hover:not(.x)'], + [':hover', ':not(.x)&', ':not(.x):hover'], + [':hover', '&[attribute]', ':hover[attribute]'], + [':hover', '[attribute]&', '[attribute]:hover'], + [':hover', '&*', ':is(:hover)*'], // Invalid CSS + [':hover', '*&', ':hover'], + + ['::before', '&element', ':is(::before)element'], // Invalid CSS + ['::before', 'element&', 'element::before'], + ['::before', '&.class', '::before.class'], + ['::before', '.class&', '.class::before'], + ['::before', '&#id', '::before#id'], + ['::before', '#id&', '#id::before'], + ['::before', '&:hover', '::before:hover'], + ['::before', ':hover&', ':hover::before'], + ['::before', '&::before', '::before::before'], + ['::before', '::before&', '::before::before'], + ['::before', '&:not(.x)', '::before:not(.x)'], + ['::before', ':not(.x)&', ':not(.x)::before'], + ['::before', '&[attribute]', '::before[attribute]'], + ['::before', '[attribute]&', '[attribute]::before'], + ['::before', '&*', ':is(::before)*'], // Invalid CSS + ['::before', '*&', '::before'], + + [':not(.x)', '&element', ':is(:not(.x))element'], // Invalid CSS + [':not(.x)', 'element&', 'element:not(.x)'], + [':not(.x)', '&.class', ':not(.x).class'], + [':not(.x)', '.class&', '.class:not(.x)'], + [':not(.x)', '&#id', ':not(.x)#id'], + [':not(.x)', '#id&', '#id:not(.x)'], + [':not(.x)', '&:hover', ':not(.x):hover'], + [':not(.x)', ':hover&', ':hover:not(.x)'], + [':not(.x)', '&::before', ':not(.x)::before'], + [':not(.x)', '::before&', '::before:not(.x)'], + [':not(.x)', '&:not(.x)', ':not(.x):not(.x)'], + [':not(.x)', ':not(.x)&', ':not(.x):not(.x)'], + [':not(.x)', '&[attribute]', ':not(.x)[attribute]'], + [':not(.x)', '[attribute]&', '[attribute]:not(.x)'], + [':not(.x)', '&*', ':is(:not(.x))*'], // Invalid CSS + [':not(.x)', '*&', ':not(.x)'], + + ['[attribute]', '&element', ':is([attribute])element'], // Invalid CSS + ['[attribute]', 'element&', 'element[attribute]'], + ['[attribute]', '&.class', '[attribute].class'], + ['[attribute]', '.class&', '.class[attribute]'], + ['[attribute]', '&#id', '[attribute]#id'], + ['[attribute]', '#id&', '#id[attribute]'], + ['[attribute]', '&:hover', '[attribute]:hover'], + ['[attribute]', ':hover&', ':hover[attribute]'], + ['[attribute]', '&::before', '[attribute]::before'], + ['[attribute]', '::before&', '::before[attribute]'], + ['[attribute]', '&:not(.x)', '[attribute]:not(.x)'], + ['[attribute]', ':not(.x)&', ':not(.x)[attribute]'], + ['[attribute]', '&[attribute]', '[attribute][attribute]'], + ['[attribute]', '[attribute]&', '[attribute][attribute]'], + ['[attribute]', '&*', ':is([attribute])*'], // Invalid CSS + ['[attribute]', '*&', '[attribute]'], + + ['*', '&element', ':is(*)element'], // Invalid CSS + ['*', 'element&', 'element:is(*)'], + ['*', '&.class', '.class'], + ['*', '.class&', '.class:is(*)'], // Optimization: *.class → .class + ['*', '&#id', '#id'], + ['*', '#id&', '#id:is(*)'], // Optimization: *#id → #id + ['*', '&:hover', ':hover'], + ['*', ':hover&', ':hover:is(*)'], + ['*', '&::before', '::before'], + ['*', '::before&', '::before:is(*)'], // Invalid CSS + ['*', '&:not(.x)', ':not(.x)'], + ['*', ':not(.x)&', ':not(.x):is(*)'], // Optimization: *:not(.x) → :not(.x) + ['*', '&[attribute]', '[attribute]'], + ['*', '[attribute]&', '[attribute]:is(*)'], // Optimization: *[attribute] → [attribute] + ['*', '&*', ':is(*)*'], // Invalid CSS + ['*', '*&', '*'], + + ['&', '&element', ':is(:scope)element'], // Invalid CSS + ['&', 'element&', 'element:scope'], + ['&', '&.class', ':scope.class'], + ['&', '.class&', '.class:scope'], + ['&', '&#id', ':scope#id'], + ['&', '#id&', '#id:scope'], + ['&', '&:hover', ':scope:hover'], + ['&', ':hover&', ':hover:scope'], + ['&', '&::before', ':scope::before'], + ['&', '::before&', '::before:scope'], + ['&', '&:not(.x)', ':scope:not(.x)'], + ['&', ':not(.x)&', ':not(.x):scope'], + ['&', '&[attribute]', ':scope[attribute]'], + ['&', '[attribute]&', '[attribute]:scope'], + ['&', '&*', ':is(:scope)*'], // Invalid CSS + ['&', '*&', ':scope'], + ])(`'%s { %s }' → '%s' (%#)`, async (root, nested, expected) => { + let optimized = optimize(toCss([rule(root, [rule(nested, [decl('--x', '0')])])])) + let ast = CSS.parse(optimized) + + let count = 0 + walk(ast, () => void count++) + + // 1 rule, 1 declaration + expect(count).toBe(2) + + if (ast[0].kind !== 'rule') throw new Error('expected a rule') + expect(ast[0].selector).toEqual(expected) + }) + + it('should not remove the `*` namespace from namespaced selectors', () => { + expect( + optimize(css` + .a { + & *|div { + color: red; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a *|div { + color: red; + } + " + `) + }) + + test('multiple selectors in the list are relative to the parent', async () => { + expect( + optimize(css` + .a, + .b { + --x: 1; + + .c { + --x: 2; + } + &.d { + --x: 3; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a, .b { + --x: 1; + + .c { + --x: 2; + } + &.d { + --x: 3; + } + } + " + `) + }) + + it('should be possible to use `&` multiple times', async () => { + expect( + optimize(css` + .a { + & .b & .c & .d { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a .b .a .c .a .d { + --x: 1; + } + " + `) + }) + + it('should be possible to use `&` multiple times in a row', async () => { + expect( + optimize(css` + .a { + &&& { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + .a.a.a { + --x: 1; + } + " + `) + }) + + it('should be possible to use `&` inside a selector', async () => { + expect( + optimize(css` + .a { + :not(&) { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + :not(.a) { + --x: 1; + } + " + `) + }) + + it('should be possible to use deeply nested CSS', async () => { + expect( + optimize(css` + .a, + .b { + --x: 1; + + .c & { + --x: 2; + + &:hover, + &:focus { + --x: 3; + .d { + --x: 4; + } + } + } + } + `), + ).toMatchInlineSnapshot(` + " + .a, .b { + --x: 1; + .c & { + --x: 2; + &:hover, &:focus { + --x: 3; + .d { + --x: 4; + } + } + } + } + " + `) + }) + + it('should properly split rules to guarantee specificity', async () => { + expect( + optimize(css` + .a { + --before: 1; + &:hover { + --inside: 1; + } + --after: 1; + } + `), + ).toMatchInlineSnapshot(` + " + .a { + --before: 1; + &:hover { + --inside: 1; + } + --after: 1; + } + " + `) + }) + + it.each([ + ['div', '&', 'div'], + ['div', '[before]&', '[before]:is(div)'], + ['div', '&[after]', 'div[after]'], + ['div', '[before]&[after]', '[before]:is(div)[after]'], + ['div', '[before] &', '[before] div'], + ['div', '& [after]', 'div [after]'], + ['div', '[before] & [after]', '[before] div [after]'], + + ['.parent', '&', '.parent'], + ['.parent', '[before]&', '[before].parent'], + ['.parent', '&[after]', '.parent[after]'], + ['.parent', '[before]&[after]', '[before].parent[after]'], + ['.parent', '[before] &', '[before] .parent'], + ['.parent', '& [after]', '.parent [after]'], + ['.parent', '[before] & [after]', '[before] .parent [after]'], + + ['.a > .b', '&', '.a > .b'], + ['.a > .b', '[before]&', '[before]:is(.a > .b)'], + ['.a > .b', '&[after]', '.a > .b[after]'], + ['.a > .b', '[before]&[after]', '[before]:is(.a > .b)[after]'], + ['.a > .b', '[before] &', '[before] :is(.a > .b)'], + ['.a > .b', '& [after]', '.a > .b [after]'], + ['.a > .b', '[before] & [after]', '[before] :is(.a > .b) [after]'], + ])(`should optimize '%s { %s }' → '%s' (%#)`, async (root, nested, expected) => { + let optimized = optimize(toCss([rule(root, [rule(nested, [decl('--x', '0')])])])) + let ast = CSS.parse(optimized) + + let count = 0 + walk(ast, () => void count++) + + // 1 rule, 1 declaration + expect(count).toBe(2) + + if (ast[0].kind !== 'rule') throw new Error('expected a rule') + expect(ast[0].selector).toEqual(expected) + }) + + it('should hoist at-rules', async () => { + expect( + optimize(css` + @layer utilities { + .a, + .b { + @media (print) { + --x: 1; + .c { + @media (min-width: 123px) { + --x: 2; + } + } + } + } + .d { + @media (print) { + @media (min-width: 123px) { + --x: 3; + } + } + } + } + @property --foo { + syntax: '*'; + } + @layer utilities { + .e { + @media (print) { + --x: 4; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @media (print) { + .a, .b { + --x: 1; + } + @media (min-width: 123px) { + :is(.a, .b) .c { + --x: 2; + } + .d { + --x: 3; + } + } + } + } + @property --foo { + syntax: '*'; + } + @layer utilities { + @media (print) { + .e { + --x: 4; + } + } + } + " + `) + }) + + it('should leave `@property` and `@apply` alone', async () => { + expect( + optimize(css` + .foo { + .bar { + @apply text-red-500 hover:text-red-600; + } + } + + .baz { + @property --tw-content { + syntax: '*'; + initial-value: ''; + inherits: false; + } + + @property --tw-border-spacing-x { + syntax: ''; + inherits: false; + initial-value: 0; + } + } + `), + ).toMatchInlineSnapshot(` + " + .foo .bar { + @apply text-red-500 hover:text-red-600; + } + .baz { + @property --tw-content { + syntax: '*'; + initial-value: ''; + inherits: false; + } + @property --tw-border-spacing-x { + syntax: ''; + inherits: false; + initial-value: 0; + } + } + " + `) + }) + + it('should merge a body-less @layer with an @layer with the same name that has a body', async () => { + expect( + optimize(css` + @layer a; + + @layer a { + @layer b { + .x { + color: red; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @layer a { + @layer b { + .x { + color: red; + } + } + } + " + `) + }) + + it('should not get rid of `:is(…)` when the compound selector is part of a complex selector', async () => { + expect( + optimize(css` + .foo .bar { + .system &:focus { + --x: 1; + } + } + .foo:hover { + .system &:focus { + --x: 2; + } + } + `), + ).toMatchInlineSnapshot(` + " + .system :is(.foo .bar):focus { + --x: 1; + } + .system .foo:hover:focus { + --x: 2; + } + " + `) + }) + + it('should not dedupe adjacent at-rules with the same prelude but different bodies', async () => { + expect( + optimize(css` + @font-face { + font-family: 'A'; + src: url('/fonts/a.woff2'); + } + + @font-face { + font-family: 'B'; + src: url('/fonts/b.woff2'); + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + @keyframes spin { + to { + transform: rotate(-360deg); + } + } + `), + ).toMatchInlineSnapshot(` + " + @font-face { + font-family: 'A'; + src: url('/fonts/a.woff2'); + } + @font-face { + font-family: 'B'; + src: url('/fonts/b.woff2'); + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @keyframes spin { + to { + transform: rotate(-360deg); + } + } + " + `) + }) }) }) - -it('should only visit children once when calling `replaceWith` with multi-element array', () => { - let visited = new Set() - - let ast: AstNode[] = [ - atRule('@media', '', [ - context({}, [ - styleRule('.foo', [decl('color', 'red')]), - styleRule('.baz', [decl('color', 'blue')]), - ]), - ]), - styleRule('.bar', [decl('color', 'green')]), - ] - - walk(ast, (node) => { - let key = id(node) - if (visited.has(key)) { - throw new Error('Visited node twice') - } - visited.add(key) - - if (node.kind === 'at-rule') return WalkAction.Replace(node.nodes) - }) - - expect(visited).toMatchInlineSnapshot(` - Set { - "@media ", - "", - ".foo", - "color: red", - ".baz", - "color: blue", - ".bar", - "color: green", - } - `) -}) - -it('should never visit children when calling `replaceWith` with `WalkAction.Skip`', () => { - let visited = new Set() - - let inner = styleRule('.foo', [decl('color', 'blue')]) - - let ast: AstNode[] = [atRule('@media', '', [inner]), styleRule('.bar', [decl('color', 'blue')])] - - walk(ast, (node) => { - visited.add(node) - - if (node.kind === 'at-rule') { - return WalkAction.ReplaceSkip(node.nodes) - } - }) - - expect(visited).not.toContain(inner) - expect(visited).toMatchInlineSnapshot(` - Set { - { - "kind": "at-rule", - "name": "@media", - "nodes": [ - { - "kind": "rule", - "nodes": [ - { - "important": false, - "kind": "declaration", - "property": "color", - "value": "blue", - }, - ], - "selector": ".foo", - }, - ], - "params": "", - }, - { - "kind": "rule", - "nodes": [ - { - "important": false, - "kind": "declaration", - "property": "color", - "value": "blue", - }, - ], - "selector": ".bar", - }, - { - "important": false, - "kind": "declaration", - "property": "color", - "value": "blue", - }, - } - `) -}) - -it('should skip the correct number of children based on the replaced children nodes', () => { - { - let ast = [ - decl('--index', '0'), - decl('--index', '1'), - decl('--index', '2'), - decl('--index', '3'), - decl('--index', '4'), - ] - let visited: string[] = [] - walk(ast, (node) => { - visited.push(id(node)) - if (node.kind === 'declaration' && node.value === '2') { - return WalkAction.ReplaceSkip([]) - } - }) - - expect(visited).toMatchInlineSnapshot(` - [ - "--index: 0", - "--index: 1", - "--index: 2", - "--index: 3", - "--index: 4", - ] - `) - } - - { - let ast = [ - decl('--index', '0'), - decl('--index', '1'), - decl('--index', '2'), - decl('--index', '3'), - decl('--index', '4'), - ] - let visited: string[] = [] - walk(ast, (node) => { - visited.push(id(node)) - if (node.kind === 'declaration' && node.value === '2') { - return WalkAction.Replace([]) - } - }) - - expect(visited).toMatchInlineSnapshot(` - [ - "--index: 0", - "--index: 1", - "--index: 2", - "--index: 3", - "--index: 4", - ] - `) - } - - { - let ast = [ - decl('--index', '0'), - decl('--index', '1'), - decl('--index', '2'), - decl('--index', '3'), - decl('--index', '4'), - ] - let visited: string[] = [] - walk(ast, (node) => { - visited.push(id(node)) - if (node.kind === 'declaration' && node.value === '2') { - return WalkAction.ReplaceSkip([decl('--index', '2.1')]) - } - }) - - expect(visited).toMatchInlineSnapshot(` - [ - "--index: 0", - "--index: 1", - "--index: 2", - "--index: 3", - "--index: 4", - ] - `) - } - - { - let ast = [ - decl('--index', '0'), - decl('--index', '1'), - decl('--index', '2'), - decl('--index', '3'), - decl('--index', '4'), - ] - let visited: string[] = [] - walk(ast, (node) => { - visited.push(id(node)) - if (node.kind === 'declaration' && node.value === '2') { - return WalkAction.Replace([decl('--index', '2.1')]) - } - }) - - expect(visited).toMatchInlineSnapshot(` - [ - "--index: 0", - "--index: 1", - "--index: 2", - "--index: 2.1", - "--index: 3", - "--index: 4", - ] - `) - } - - { - let ast = [ - decl('--index', '0'), - decl('--index', '1'), - decl('--index', '2'), - decl('--index', '3'), - decl('--index', '4'), - ] - let visited: string[] = [] - walk(ast, (node) => { - visited.push(id(node)) - if (node.kind === 'declaration' && node.value === '2') { - return WalkAction.ReplaceSkip([decl('--index', '2.1'), decl('--index', '2.2')]) - } - }) - - expect(visited).toMatchInlineSnapshot(` - [ - "--index: 0", - "--index: 1", - "--index: 2", - "--index: 3", - "--index: 4", - ] - `) - } - - { - let ast = [ - decl('--index', '0'), - decl('--index', '1'), - decl('--index', '2'), - decl('--index', '3'), - decl('--index', '4'), - ] - let visited: string[] = [] - walk(ast, (node) => { - visited.push(id(node)) - if (node.kind === 'declaration' && node.value === '2') { - return WalkAction.Replace([decl('--index', '2.1'), decl('--index', '2.2')]) - } - }) - - expect(visited).toMatchInlineSnapshot(` - [ - "--index: 0", - "--index: 1", - "--index: 2", - "--index: 2.1", - "--index: 2.2", - "--index: 3", - "--index: 4", - ] - `) - } -}) - -function id(node: AstNode) { - switch (node.kind) { - case 'at-rule': - return `${node.name} ${node.params}` - case 'rule': - return node.selector - case 'context': - return '' - case 'at-root': - return '' - case 'declaration': - return `${node.property}: ${node.value}` - case 'comment': - return `// ${node.value}` - default: - node satisfies never - throw new Error('Unknown node kind') - } -} diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index ce841f377..e22c13e83 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -1,14 +1,17 @@ import { Polyfills } from '.' +import * as SelectorParser from '../src/selector-parser' import { parseAtRule } from './css-parser' import type { DesignSystem } from './design-system' import type { Source, SourceLocation } from './source-maps/source' import { Theme, ThemeOptions } from './theme' import { DefaultMap } from './utils/default-map' +import { segment } from './utils/segment' import { extractUsedVariables } from './utils/variables' import * as ValueParser from './value-parser' import { walk, WalkAction, type VisitContext } from './walk' const AT_SIGN = 0x40 +const PIPE = 0x7c export type StyleRule = { kind: 'rule' @@ -286,9 +289,9 @@ export function optimizeAst( // found in the theme config. if ( polyfills & Polyfills.ColorMix && - node.value.includes('color-mix(') && !context.supportsColorMix && - !context.keyframes + !context.keyframes && + node.value.includes('color-mix(') ) { colorMixDeclarations.get(parent).add(node) } @@ -304,37 +307,7 @@ export function optimizeAst( transform(child, nodes, context, depth + 1) } - // Keep the last decl when there are exact duplicates. Keeping the *first* one might - // not be correct when given nested rules where a rule sits between declarations. - let seen: Record = {} - let toRemove = new Set() - - // Keep track of all nodes that produce a given declaration - for (let child of nodes) { - if (child.kind !== 'declaration') continue - - let key = `${child.property}:${child.value}:${child.important}` - seen[key] ??= [] - seen[key].push(child) - } - - // And remove all but the last of each - for (let key in seen) { - for (let i = 0; i < seen[key].length - 1; ++i) { - toRemove.add(seen[key][i]) - } - } - - if (toRemove.size > 0) { - nodes = nodes.filter((node) => !toRemove.has(node)) - } - - if (nodes.length === 0) return - - // Rules with `&` as the selector should be flattened - if (node.selector === '&') { - parent.push(...nodes) - } else { + if (nodes.length > 0) { parent.push({ ...node, nodes }) } } @@ -679,9 +652,682 @@ export function optimizeAst( } } - return newAst + return handleNesting(newAst) } +export function handleNesting(ast: AstNode[]): AstNode[] { + let parseSelectorCache = new DefaultMap(SelectorParser.parse) + + // Track `rule` selectors as we go + let selectorStack: [ + selector: string, + src: SourceLocation | undefined, + dst: SourceLocation | undefined, + ][] = [] + + // Track `at-rule` information as we go. Tracking this separately from the + // selector stack for rules such that we can hoist this above all the rules. + let atRuleStack: [ + name: string, + params: string, + src: SourceLocation | undefined, + dst: SourceLocation | undefined, + ][] = [] + + // The current "nodes" we can push to + let nodes = null as AstNode[] | null + + // Optimization: Track the declaration properties we've seen in the current + // nodes. + let seenDeclarationProperties = new Set() + + // Track nodes lists where we want to dedupe declarations + let dedupeDeclarationsInNodes = new Set() + + // The final, new AST + let result: AstNode[] = [] + + // Track whether we should skip a node in the `exit` phase + let skipExit = new Set() + + walk(ast, { + enter(node) { + switch (node.kind) { + case 'rule': { + nodes = null // Start a new level + + // First time we see a rule + if (selectorStack.length === 0) { + // A rule with a selector containing `&` should replace the `&` with + // `:scope` if there is no parent rule. + // + // Note: there could be false positives when the `&` is escaped or + // part of a string inside an attribute selector. But the + // SelectorParser will take care of that. + if (node.selector.includes('&')) { + let ast = SelectorParser.parse(node.selector) + let changed = false + + walk(ast, (node) => { + if (node.kind === 'selector' && node.value === '&') { + changed = true + node.value = ':scope' + } + }) + + if (changed) { + selectorStack.push([SelectorParser.toCss(ast), node.src, node.dst]) + } else { + selectorStack.push([node.selector, node.src, node.dst]) + } + } + + // No nesting markers, track as-is + else { + selectorStack.push([node.selector, node.src, node.dst]) + } + } + + // Nested rule, ensure `&` is present in each selector. Then track the + // selector. + else { + // A rule with just `&` can be replaced by its children. Let's + // ignore this node and keep walking its children. + if (node.selector === '&') { + skipExit.add(node) + return + } + + let lastSelector = selectorStack[selectorStack.length - 1][0] + let selector = segment(node.selector, ',') + .map((selector) => { + // Fast path: we know there isn't an `&` so we can prepend the + // parent selector immediately. + if (!selector.includes('&')) { + let lastAst = parseSelectorCache.get(lastSelector) + return `${lastAst.length === 1 && lastAst[0].kind === 'list' ? `:is(${lastSelector})` : lastSelector} ${selector}` + } + + // Slow path: we need to replace the `&` with the parent + // selector. A simple `replaceAll(…)` won't work because a `&` + // could be escaped, or could be part of an attribute selector. + // + // Much safer to parse the selector and replace the `&` that way + { + let ast = SelectorParser.parse(selector) + let changed = false + walk(ast, { + enter(node, ctx) { + if (node.kind !== 'selector' || node.value !== '&') return + + changed = true + + // Safest option: use `is(…)` semantics when + // substituting `&` for the parent selector. + node.value = `:is(${lastSelector})` + + // We should always have a parent, so this shouldn't happen + if (ctx.parent === null) return + + // Optimizations: + let parentAst = parseSelectorCache.get(lastSelector) + + // 1. If we're dealing with multiple selectors, then we + // know that the `:is(…)` needs to stay. Nothing to optimize. + if (parentAst.length === 1 && parentAst[0].kind === 'list') { + return // Keep `:is(…)` semantics + } + + // 2. We know that `&` is standalone when it's inside of + // a complex selector. E.g. `[before] & [after]` + if (ctx.parent.kind === 'complex') { + // `& [after]` + // + // `:is(…)` semantics are not required, because these + // are equivalent: + // + // - `:is(div) [after]` → `div [after]` + // - `:is(.x) [after]` → `.x [after]` + // - `:is([before]) [after]` → `[before] [after]` + // - `:is(.a > .b) [after]` → `.a > .b [after]` + if (ctx.index === 0) { + node.value = lastSelector + return + } + + // `[before] &` + // + // `:is(…)` semantics are required if we're dealing + // with a complex parent selector. Otherwise the + // meaning of the selector could change: + // + // - `[before] :is(div)` → `[before] div` + // - `[before] :is(.x)` → `[before] .x` + // - `[before] :is([after])` → `[before] [after]` + // - `[before] :is(.a > .b)` → `[before] :is(.a > .b)` (!) + else if (ctx.index === ctx.siblings.length - 1) { + if (parentAst[0].kind === 'complex') { + return // Keep `:is(…)` semantics + } + + node.value = lastSelector + return + } + + // `[before] & [after]` + // + // `:is(…)` semantics are required if we're dealing + // with a complex parent selector. Otherwise the + // meaning of the selector could change: + // + // - `[before] :is(div) [after]` → `[before] div [after]` + // - `[before] :is(.x) [after]` → `[before] .x [after]` + // - `[before] :is(.a > .b) [after]` → `[before] :is(.a > .b) [after]` (!) + else { + if (parentAst[0].kind === 'complex') { + return // Keep `:is(…)` semantics + } + + node.value = lastSelector + return + } + } + + // 3. We know that `&` is attached to some other + // selector when it's inside of a compound selector. + // + // E.g. `[before]&[after]` + // + // We have to be careful that our parent, when it's + // part of a complex selector, that the same rules apply + // + // E.g.: `[before] &[after]` + // ^ current node + // ^^^^^^^^ compound selector + // ^^^^^^^^^^^^^^^^^ complex selector + // + else if (ctx.parent.kind === 'compound') { + if (parentAst[0].kind === 'complex') { + let path = ctx.path() + let grandParent = path[path.length - 2] + + // When our compound parent is part of a complex + // selector, and it's not the very first node, then we + // can't safely get rid of the `:is(…)` if the last + // selector is a complex selector as well, unless the + // `&` maps to a single selector or compound selector. + // + // ```css + // .foo .bar { /* Complex selector */ + // .system &:focus { /* Complex selector + compound selecto*/ + // --x: 1; + // } + // } + // .foo:hover { /* Compound selector */ + // .system &:focus { /* Complex selector + compound selector */ + // --x: 2; + // } + // } + // ``` + // + // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ + // + // ```css + // .system :is(.foo .bar):focus { /* Cannot drop the `:is(…)`, otherwise `.system` and `.foo` can be swapped in the DOM */ + // --x: 1; + // } + // .system .foo:hover:focus { + // --x: 2; + // } + // ``` + if ( + grandParent && + grandParent.kind === 'complex' && + grandParent.nodes[0] !== ctx.parent + ) { + return // Keep `:is(…)` semantics + } + } + + // `&*` and `&div` are invalid CSS so these should stay + // invalid. They should be written as `*&` and `div&` instead. + if ( + ctx.siblings + .slice(ctx.index + 1) + .some( + (sibling) => + SelectorParser.isUniversalSelector(sibling) || + SelectorParser.isTypeSelector(sibling), + ) + ) { + return // Keep `:is(…)` semantics + } + + // `&[after]` + // + // `:is(…)` semantics are not required, because these + // are equivalent: + // + // - `:is(div)[after]` → `div[after]` + // - `:is(.x)[after]` → `.x[after]` + // - `:is([before])[after]` → `[before][after]` + // - `:is(.a > .b)[after]` → `.a > .b[after]` + if (ctx.index === 0) { + node.value = lastSelector + return + } + + // `[before]&` + // + // `:is(…)` semantics are required if we're dealing with a + // + // - complex parent selector, to prevent changing the meaning of the selector + // - a universal selector, because `*` needs to be first + // - a type selector, because `div` needs to be first + // + // - `[before]:is(div)` → `[before]:is(div)` (!) + // - `[before]:is(*)` → `[before]:is(*)` (!) + // - `[before]:is(.a > .b)` → `[before]:is(.a > .b)` (!) + // - `[before]:is(.x)` → `[before].x` + else if (ctx.index === ctx.siblings.length - 1) { + if ( + parentAst[0].kind === 'complex' || + SelectorParser.isUniversalSelector(parentAst[0]) || + SelectorParser.isTypeSelector(parentAst[0]) + ) { + return // Keep `:is(…)` semantics + } + + node.value = lastSelector + return + } + + // `[before]&[after]` + // + // `:is(…)` semantics are required if we're dealing with a + // + // - complex parent selector, to prevent changing the meaning of the selector + // - a universal selector, because `*` needs to be first + // - a type selector, because `div` needs to be first + // + // - `[before]:is(div)[after]` → `[before]:is(div)[after]` (!) + // - `[before]:is(*)[after]` → `[before]:is(*)[after]` (!) + // - `[before]:is(.a > .b)[after]` → `[before]:is(.a > .b)[after]` (!) + // - `[before]:is(.x)[after]` → `[before].x[after]` + else { + if ( + parentAst[0].kind === 'complex' || + SelectorParser.isUniversalSelector(parentAst[0]) || + SelectorParser.isTypeSelector(parentAst[0]) + ) { + return // Keep `:is(…)` semantics + } + + node.value = lastSelector + return + } + } + + // 4. When the current node is a function argument (e.g. + // `:not(&))`, then we can drop the `:is(…)` entirely. + // + // The only exception is when the parent has multiple + // selectors because then we would introduce multiple + // arguments. Multiple selectors are already handled. + else if (ctx.parent.kind === 'function') { + node.value = lastSelector + return + } + }, + exit(node, ctx) { + // Optimization: We can remove the universal selector `*` if + // they are part of a compound selector. E.g.: + // + // - `*:hover` → `:hover` + // - `*[attribute]` → `[attribute]` + // + // Except when the `*` is a namespace, e.g.: `*|div`, + // because `*|div` (any namespace) and `|div` (no + // namespace) have different meanings. + if ( + ctx.index === 0 && + ctx.siblings.length > 1 && + ctx.parent?.kind === 'compound' && + SelectorParser.isUniversalSelector(node) + ) { + let next = ctx.siblings[1] + if (next.kind === 'selector' && next.value.charCodeAt(0) === PIPE) { + return + } + return WalkAction.ReplaceSkip([]) + } + }, + }) + + if (changed) { + return SelectorParser.toCss(ast) + } + + // It could be that `&` was not found as an actual selector, + // in that case we still have to prepend the parent selector. + let lastAst = parseSelectorCache.get(lastSelector) + return `${lastAst.length === 1 && lastAst[0].kind === 'list' ? `:is(${lastSelector})` : lastSelector} ${selector}` + } + }) + .join(', ') + selectorStack.push([selector, node.src, node.dst]) + } + + // Once we hit a rule that has at least one declaration, then we can + // stop handling the nested selectors. + // + // This ensures that browser devtools can at least show _something_ + // for a given rule. This also means that we can leverage CSS nesting + // which is better for gzip results due to increased repetition. + // + // E.g.: + // + // ```css + // .a { + // &[b] { + // color: red; + // &:hover { + // color: blue; + // } + // } + // } + // ``` + // + // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ + // + // ```css + // .a[b] { /* ← flattened */ + // color: red; /* ← saw a declaration, therefore */ + // &:hover { /* ← we keep this nested syntax */ + // color: blue; + // } + // } + // ``` + if (node.nodes.some((child) => child.kind === 'declaration')) { + // Emitting each child instead of the node itself because we do want + // to flatten the current node and its selector that is already + // pushed to the stack. + for (let child of node.nodes) emit(child) + return WalkAction.Skip + } + break + } + + case 'at-rule': { + nodes = null // Start a new level + + // `@layer` is hoistable, but when it's empty then we have to make + // sure that we still emit it because this might influence the layer + // order. We can't just get rid of it. + if (node.nodes.length === 0 && !DROPPABLE_IF_EMPTY_AT_RULES.has(node.name)) { + emit(node) + skipExit.add(node) + return WalkAction.Skip + } + + // Hoist at-rules + else if (HOISTABLE_AT_RULES.has(node.name)) { + atRuleStack.push([node.name, node.params, node.src, node.dst]) + } + + // If we can't hoist them, emit them immediately as-is + else { + emit(node) + skipExit.add(node) + return WalkAction.Skip + } + break + } + + case 'declaration': + case 'comment': { + emit(node) + break + } + + case 'context': + case 'at-root': + break + + default: + node satisfies never + break + } + }, + exit(node) { + if (skipExit.delete(node)) return + + switch (node.kind) { + case 'rule': { + nodes = null + selectorStack.pop() + break + } + + case 'at-rule': { + nodes = null + atRuleStack.pop() + break + } + + case 'declaration': + case 'comment': + case 'context': + case 'at-root': + break + + default: + node satisfies never + break + } + }, + }) + + // Dedupe declarations that we've already seen before if they match the + // `property`, `value` and `important` information. + { + for (let nodes of dedupeDeclarationsInNodes) { + let seen = new Set() + for (let i = nodes.length - 1; i >= 0; --i) { + let node = nodes[i] + if (node.kind !== 'declaration') continue + + let id = `${node.property}\0${node.value}\0${node.important}` + + if (seen.has(id)) nodes.splice(i, 1) + else seen.add(id) + } + } + } + + return result + + function emit(node: AstNode) { + // Existing nodes are available, emit into those nodes + if (nodes) { + // Optimization: track used declarations in the current node. + if (node.kind === 'declaration') { + if (seenDeclarationProperties.has(node.property)) { + dedupeDeclarationsInNodes.add(nodes) + } else { + seenDeclarationProperties.add(node.property) + } + } + + nodes.push(node) + return + } + + // Nothing available, setup a fresh node + { + // There are no parent rules or at-rules available, which means that we + // can emit the node as-is. + if (selectorStack.length === 0 && atRuleStack.length === 0) { + let target = result + let lastNode = target[target.length - 1] + + // Optimization: when the current and last node are the same, ignore the + // new node entirely otherwise we will get unnecessary duplicate + // results. + // + // We only care about at-rules with no body because some of them (such + // as `@charset` or `@layer`) need to be emitted. A normal rule that's + // empty doesn't need to be emitted. At-rules _with_ a body (such as + // `@font-face` or `@keyframes`) can share the same prelude while + // containing different bodies, so they must all be emitted. + if ( + lastNode && + lastNode.kind === 'at-rule' && + node.kind === 'at-rule' && + lastNode.nodes.length === 0 && + node.nodes.length === 0 && + lastNode.name === node.name && + lastNode.params === node.params + ) { + return + } + + result.push(node) + return + } + + // Track the new "parent" nodes + { + nodes = [node] + + // Clear out seen declarations from the previous work in progress nodes + seenDeclarationProperties.clear() + + // Track new declaration + if (node.kind === 'declaration') { + seenDeclarationProperties.add(node.property) + } + } + + // Track the new root node that we build up to store in the final AST + let root = null as AstNode | null + + let target = result + let atRuleOffset = 0 + + // Optimization: merge adjacent at-rules + // + // Figure out whether we can push our new node into a previous node that + // was already emitted. + // + // We have to make sure that the order stays the same, so therefore we + // only ever have to look at the last node that was emitted. + { + let lastNode = target[target.length - 1] + if (lastNode && lastNode.kind === 'at-rule') { + for (let i = 0; i < atRuleStack.length; i++) { + let atRule = atRuleStack[i] + if (!lastNode) break + if (lastNode.kind !== 'at-rule') break + if (lastNode.name !== atRule[0]) break + if (lastNode.params !== atRule[1]) break + + atRuleOffset++ + target = lastNode.nodes + lastNode = lastNode.nodes[lastNode.nodes.length - 1] + } + } + } + + // Build up the rule + if (selectorStack.length > 0) { + let [selector, src, dst] = selectorStack[selectorStack.length - 1] + + // Optimization: merge adjacent rules with the same selector + // + // Figure out whether we can push into an existing rule. + // + // If we have some at-rules that we have to keep into account, then we + // definitely can't. + if (atRuleStack.length - atRuleOffset <= 0) { + let lastNode = target[target.length - 1] + if (lastNode && lastNode.kind === 'rule' && lastNode.selector === selector) { + lastNode.nodes.push(...nodes) + + // Ensure that our current nodes points to the nodes of the + // `lastNode`, otherwise we will lose information. + nodes = lastNode.nodes + + // We appended a group that could contain declarations already in the + // existing rule, so let the final dedupe pass handle it once. + // + // Note: we could loop over _all_ previous nodes to figure out if we + // really want to dedupe this. But this could result in a bunch of + // duplicate work if we have `n` nodes that we want to merge + // together. + dedupeDeclarationsInNodes.add(nodes) + + // We know that we don't have to handle any more at-rules, so we can + // bail early since we just merged the nodes with the same selector. + return + } + } + + // Can't push into existing node, create a new node + root = rule(selector, nodes) + if (src || dst) Object.assign(root, { src, dst }) + } + + // Wrap in at-rules, if we can push into an existing node then we can + // ignore `offset` amount of nodes since the `root`/`nodes` will already + // point to a nested node. + for (let i = atRuleStack.length - 1; i >= atRuleOffset; --i) { + let [name, params, src, dst] = atRuleStack[i] + + root = atRule(name, params, root ? [root] : nodes) + if (src || dst) Object.assign(root, { src, dst }) + } + + // Track the root node in the AST + if (root) { + target.push(root) + } + + // We didn't build up any new root, so we can move our node directly into + // the target. This can happen when we emit a node that is not a + // declaration or a comment. + else { + target.push(...nodes) + } + } + } +} + +// A set of at-rules that can be hoisted to the top without any repercussions. +// Typically at-rules that rely on the environment, not parent information and +// contain other rules/declarations. +const HOISTABLE_AT_RULES = new Set([ + '@container', + '@layer', + '@media', + '@page', + '@starting-style', + '@supports', + '@view-transition', +]) + +// A set of at-rules that can be dropped if they don't contain any nodes. We +// don't have the distinction between an at-rule with no body, or an at-rule +// with a body that is empty right now. +const DROPPABLE_IF_EMPTY_AT_RULES = new Set([ + '@container', + '@media', + '@page', + '@starting-style', + '@supports', + '@view-transition', +]) + export function toCss(ast: AstNode[], track?: boolean) { let pos = 0 diff --git a/packages/tailwindcss/src/attribute-selector-parser.test.ts b/packages/tailwindcss/src/attribute-selector-parser.test.ts index 140336ab0..87f5f3013 100644 --- a/packages/tailwindcss/src/attribute-selector-parser.test.ts +++ b/packages/tailwindcss/src/attribute-selector-parser.test.ts @@ -28,6 +28,11 @@ describe('parse', () => { '[ data-foo ]', { attribute: 'data-foo', operator: null, quote: null, value: null, sensitivity: null }, ], + [ + // Non-ASCII code points are valid in a CSS ident + '[data-état]', + { attribute: 'data-état', operator: null, quote: null, value: null, sensitivity: null }, + ], [ '[data-state=expanded]', { attribute: 'data-state', operator: '=', quote: null, value: 'expanded', sensitivity: null }, diff --git a/packages/tailwindcss/src/attribute-selector-parser.ts b/packages/tailwindcss/src/attribute-selector-parser.ts index e732b2979..624043d59 100644 --- a/packages/tailwindcss/src/attribute-selector-parser.ts +++ b/packages/tailwindcss/src/attribute-selector-parser.ts @@ -23,6 +23,7 @@ const ZERO = 48 const NINE = 57 const DASH = 45 const UNDERSCORE = 95 +const NON_ASCII = 128 interface AttributeSelector { attribute: string @@ -61,6 +62,8 @@ export function parse(input: string): AttributeSelector | null { if (currentChar >= LOWER_A && currentChar <= LOWER_Z) continue if (currentChar >= ZERO && currentChar <= NINE) continue if (currentChar === DASH || currentChar === UNDERSCORE) continue + // Non-ASCII code points are valid in a CSS ident, e.g.: `[data-état]` + if (currentChar >= NON_ASCII) continue break } diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index aaba93c4e..296848010 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -1297,22 +1297,22 @@ test('utilities must be prefixed', async () => { // Prefixed utilities are generated expect(await run(['tw:underline', 'tw:hover:line-through', 'tw:custom'], input, options)) .toMatchInlineSnapshot(` - " - .tw\\:custom { - color: red; - } - - .tw\\:underline { - text-decoration-line: underline; - } - - @media (hover: hover) { - .tw\\:hover\\:line-through:hover { - text-decoration-line: line-through; + " + .tw\\:custom { + color: red; } - } - " - `) + + .tw\\:underline { + text-decoration-line: underline; + } + + @media (hover: hover) { + .tw\\:hover\\:line-through:hover { + text-decoration-line: line-through; + } + } + " + `) // Non-prefixed utilities are ignored expect(await run(['underline', 'hover:line-through', 'custom'], input, options)).toEqual('') @@ -1534,18 +1534,18 @@ test('blocklisted candidates are not generated', async () => { // underline will as will md:bg-white expect(await run(['underline', 'bg-white', 'md:bg-white'], input, options)) .toMatchInlineSnapshot(` - " - .underline { - text-decoration-line: underline; - } - - @media (min-width: 48rem) { - .md\\:bg-white { - background-color: var(--color-white, #fff); + " + .underline { + text-decoration-line: underline; } - } - " - `) + + @media (min-width: 48rem) { + .md\\:bg-white { + background-color: var(--color-white, #fff); + } + } + " + `) }) test('blocklisted candidates cannot be used with `@apply`', async () => { diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 2ecdbe9a3..bf2265fa6 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -3328,7 +3328,42 @@ describe('addUtilities()', () => { ).toMatchInlineSnapshot(` " @layer utilities { - .j.j, .j.j, .a .b:hover .c, .a .b:hover .c, .a .b:hover .c, .d > *, .e .bar:not(.f):has(.g), .e .bar:not(.f):has(.g), .h ~ .i, .h ~ .i { + .j.j, .a .b:hover .c, .d > *, .e .bar:not(.f):has(.g), .h ~ .i { + color: red; + } + } + " + `) + }) + + test('does not register class names inside `:nth-child(… of …)` as utilities', async () => { + expect( + await run( + ['foo', 'mark'], + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + async loadModule(_id, base) { + return { + path: '', + base, + module: ({ addUtilities }: PluginAPI) => { + addUtilities({ + '.foo:nth-child(2 of .mark)': { color: 'red' }, + }) + }, + } + }, + }, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + .foo:nth-child(2 of .mark) { color: red; } } diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index af6e98d77..4ea62f864 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -310,7 +310,14 @@ export function buildPluginApi({ return } - if (node.kind === 'function' && node.value === ':not') { + if ( + node.kind === 'function' && + (node.value === ':not' || + // A class inside `:nth-child(… of )` is part of the + // condition, not a utility being defined. + node.value === ':nth-child' || + node.value === ':nth-last-child') + ) { return WalkAction.Skip } }) diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index 17e11f4fe..a31ec5d74 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -213,11 +213,9 @@ test('Utilities do not show wrapping selector in intellisense', async () => { text-decoration-line: underline; } ", - ".hover\\:line-through { - &:hover { - @media (hover: hover) { - text-decoration-line: line-through; - } + "@media (hover: hover) { + .hover\\:line-through:hover { + text-decoration-line: line-through; } } ", @@ -244,11 +242,9 @@ test('Utilities, when marked as important, show as important in intellisense', a text-decoration-line: underline !important; } ", - ".hover\\:line-through { - &:hover { - @media (hover: hover) { - text-decoration-line: line-through !important; - } + "@media (hover: hover) { + .hover\\:line-through:hover { + text-decoration-line: line-through !important; } } ", @@ -361,48 +357,48 @@ test('Functional utilities from plugins are listed in hovers and completions', a expect(design.candidatesToCss(['custom-1-red', 'custom-1-green', 'custom-1-unknown'])) .toMatchInlineSnapshot(` - [ - ".custom-1-red { - color: #ff0000; - } - ", - ".custom-1-green { - color: #ff0000; - } - ", - null, - ] - `) + [ + ".custom-1-red { + color: #ff0000; + } + ", + ".custom-1-green { + color: #ff0000; + } + ", + null, + ] + `) expect(design.candidatesToCss(['custom-2-red', 'custom-2-green', 'custom-2-unknown'])) .toMatchInlineSnapshot(` - [ - ".custom-2-red { - color: #ff0000 / 0%; - } - ", - ".custom-2-green { - color: #ff0000 / 0%; - } - ", - null, - ] - `) + [ + ".custom-2-red { + color: #ff0000 / 0%; + } + ", + ".custom-2-green { + color: #ff0000 / 0%; + } + ", + null, + ] + `) expect(design.candidatesToCss(['custom-2-red/50', 'custom-2-red/75', 'custom-2-red/unknown'])) .toMatchInlineSnapshot(` - [ - ".custom-2-red\\/50 { - color: #ff0000 / 50%; - } - ", - ".custom-2-red\\/75 { - color: #ff0000 / 75%; - } - ", - null, - ] - `) + [ + ".custom-2-red\\/50 { + color: #ff0000 / 50%; + } + ", + ".custom-2-red\\/75 { + color: #ff0000 / 75%; + } + ", + null, + ] + `) let classMap = new Map(design.getClassList()) let classNames = Array.from(classMap.keys()) diff --git a/packages/tailwindcss/src/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts index 1cf5838f7..fa38dc578 100644 --- a/packages/tailwindcss/src/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -326,14 +326,39 @@ describe('parse', () => { }) it('parses :nth-child()', () => { + // The `An+B` part is not a selector, it stays an opaque value expect(parse(':nth-child(n+1)')).toEqual([ + { + kind: 'function', + value: ':nth-child', + nodes: [{ kind: 'value', value: 'n+1' }], + }, + ]) + + // The selector list after `of` is parsed as a selector + expect(parse(':nth-child(2 of &)')).toEqual([ { kind: 'function', value: ':nth-child', nodes: [ + { kind: 'value', value: '2 of ' }, + { kind: 'selector', value: '&' }, + ], + }, + ]) + + expect(parse(':nth-child(2n + 1 of .foo, .bar)')).toEqual([ + { + kind: 'function', + value: ':nth-child', + nodes: [ + { kind: 'value', value: '2n + 1 of ' }, { - kind: 'value', - value: 'n+1', + kind: 'list', + nodes: [ + { kind: 'selector', value: '.foo' }, + { kind: 'selector', value: '.bar' }, + ], }, ], }, @@ -565,7 +590,11 @@ describe('toCss', () => { }) it('should print :nth-child()', () => { + // The `An+B` part is printed verbatim, whitespace in the selector list + // after `of` is normalized expect(toCss(parse(':nth-child(n+1)'))).toBe(':nth-child(n+1)') + expect(toCss(parse(':nth-child(+2)'))).toBe(':nth-child(+2)') + expect(toCss(parse(':nth-child(2n + 1 of .foo,.bar)'))).toBe(':nth-child(2n + 1 of .foo, .bar)') }) it('should pretty print a complex selector', () => { diff --git a/packages/tailwindcss/src/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts index b7632834a..2a435f687 100644 --- a/packages/tailwindcss/src/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -70,7 +70,7 @@ function compound(nodes: SelectorAstNode[]): SelectorCompoundNode { } } -function fun(value: string, nodes: SelectorAstNode[]): SelectorFunctionNode { +export function fun(value: string, nodes: SelectorAstNode[]): SelectorFunctionNode { return { kind: 'function', value, @@ -85,7 +85,7 @@ function list(nodes: SelectorAstNode[]): SelectorListNode { } } -function selector(value: string): SelectorNode { +export function selector(value: string): SelectorNode { return { kind: 'selector', value, @@ -99,6 +99,80 @@ function value(value: string): SelectorValueNode { } } +export function isUniversalSelector(node: SelectorAstNode): boolean { + return node.kind === 'selector' && node.value.charCodeAt(0) === ASTERISK +} + +export function isNestingSelector(node: SelectorAstNode): boolean { + return node.kind === 'selector' && node.value.charCodeAt(0) === AMPERSAND +} + +export function isClassSelector(node: SelectorAstNode): boolean { + return node.kind === 'selector' && node.value.charCodeAt(0) === DOT +} + +export function isIdSelector(node: SelectorAstNode): boolean { + return node.kind === 'selector' && node.value.charCodeAt(0) === HASH +} + +export function isPseudoSelector(node: SelectorAstNode): boolean { + return node.kind === 'selector' && node.value.charCodeAt(0) === COLON +} + +export function isAttributeSelector(node: SelectorAstNode): boolean { + return node.kind === 'selector' && node.value.charCodeAt(0) === OPEN_BRACKET +} + +export function isTypeSelector(node: SelectorAstNode): boolean { + if (node.kind !== 'selector') return false + + switch (node.value.charCodeAt(0)) { + case ASTERISK: // Universal selector + case AMPERSAND: // Nesting selector + case DOT: // Class selector + case HASH: // ID selector + case COLON: // Pseudo selector + case OPEN_BRACKET: // Attribute selector + return false + + // We don't fully verify whether this is actually a proper type selector, + // but we assume it is one if it's not any of the other ones. + default: + return true + } +} + +export function cloneAstNode(node: T): T { + switch (node.kind) { + case 'combinator': + case 'selector': + case 'value': + return { + kind: node.kind, + value: node.value, + } as T + + case 'complex': + case 'compound': + case 'list': + return { + kind: node.kind, + nodes: node.nodes.map(cloneAstNode), + } as T + + case 'function': + return { + kind: node.kind, + value: node.value, + nodes: node.nodes.map(cloneAstNode), + } satisfies SelectorFunctionNode as T + + default: + node satisfies never + throw new Error(`Unknown node kind: ${(node as any).kind}`) + } +} + export function toCss(ast: SelectorAstNode[], minify = false) { let css = '' for (let node of ast) { @@ -141,10 +215,10 @@ const CLOSE_PAREN = 0x29 const COLON = 0x3a const COMMA = 0x2c const DOUBLE_QUOTE = 0x22 -const FULL_STOP = 0x2e +const DOT = 0x2e const GREATER_THAN = 0x3e const NEWLINE = 0x0a -const NUMBER_SIGN = 0x23 +const HASH = 0x23 const OPEN_BRACKET = 0x5b const OPEN_PAREN = 0x28 const PLUS = 0x2b @@ -305,7 +379,7 @@ export function parse(input: string) { let node = fun(buffer, []) buffer = '' - // If the function is not one of the following, we combine all it's + // If the function is not one of the following, we combine all its // contents into a single value node if ( node.value !== ':not' && @@ -334,7 +408,39 @@ export function parse(input: string) { } let end = i - node.nodes.push(value(input.slice(start, end))) + let contents = input.slice(start, end) + + // `:nth-child(…)` and `:nth-last-child(…)` can contain an + // `of ` clause. The selector list must be + // parsed (e.g. to be able to substitute `&`), but the `An+B` part + // is not a selector so it stays an opaque value node. E.g.: + // + // ```css + // :nth-child(2n + 1 of .foo, .bar) + // ^^^^^^^^^^ value + // ^^^^^^^^^^ selector list + // ``` + if (node.value === ':nth-child' || node.value === ':nth-last-child') { + let idx = contents.indexOf('of ') + if (idx !== -1) { + node.nodes.push( + value( + contents.slice(0, idx + 3), // value `2n + 1 of ` + ), + ...parse( + contents.slice(idx + 3), // `.foo, .bar` + ), + ) + buffer = '' + i = end + + append(node) + + break + } + } + + node.nodes.push(value(contents)) buffer = '' i = end @@ -389,9 +495,9 @@ export function parse(input: string) { // .foo.bar // ^ // ``` - case FULL_STOP: + case DOT: case COLON: - case NUMBER_SIGN: { + case HASH: { if (currentChar === COLON && buffer === ':') { buffer += input[i] break diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 9238f495f..62edc0f46 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -865,25 +865,19 @@ test('Source locations for `addBase` point to the `@plugin` that generated them' expect(annotations).toMatchInlineSnapshot(` " - output.css | input.css - | - 1 @layer base { | 1 @plugin "./plugin.js"; - ^^^^^^^^^^^^ A @ 1:0-12 | ^^^^^^^^^^^^^^^^^^^^^ A @ 1:0-21 - 2 body { | - ^^^^^ A @ 2:2-7 | - 3 color: red; | - ^^^^^^^^^^ A @ 3:4-14 | - 4 } | - 5 } | - 6 @layer base { | 2 @config "./config.js"; - ^^^^^^^^^^^^ B @ 6:0-12 | ^^^^^^^^^^^^^^^^^^^^^ B @ 2:0-21 - 7 body { | - ^^^^^ B @ 7:2-7 | - 8 color: green; | - ^^^^^^^^^^^^ B @ 8:4-16 | - 9 } | - 10 } | - 11 | + output.css | input.css + | + 1 @layer base { | 1 @plugin "./plugin.js"; + ^^^^^^^^^^^^ A @ 1:0-12 | ^^^^^^^^^^^^^^^^^^^^^ A @ 1:0-21 + 2 body { | + ^^^^^ A @ 2:2-7 | + 3 color: red; | + ^^^^^^^^^^ A @ 3:4-14 | + 4 color: green; | 2 @config "./config.js"; + ^^^^^^^^^^^^ B @ 4:4-16 | ^^^^^^^^^^^^^^^^^^^^^ B @ 2:0-21 + 5 } | + 6 } | + 7 | " `) }) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 5201878f2..a9d97d0dd 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -11581,20 +11581,20 @@ test('scrollbar-width', async () => { test('scrollbar-gutter', async () => { expect(await run(['scrollbar-gutter-auto', 'scrollbar-gutter-stable', 'scrollbar-gutter-both'])) .toMatchInlineSnapshot(` - " - .scrollbar-gutter-auto { - scrollbar-gutter: auto; - } + " + .scrollbar-gutter-auto { + scrollbar-gutter: auto; + } - .scrollbar-gutter-both { - scrollbar-gutter: stable both-edges; - } + .scrollbar-gutter-both { + scrollbar-gutter: stable both-edges; + } - .scrollbar-gutter-stable { - scrollbar-gutter: stable; - } - " - `) + .scrollbar-gutter-stable { + scrollbar-gutter: stable; + } + " + `) expect( await run([ 'scrollbar-gutter', @@ -30204,20 +30204,20 @@ describe('custom utilities', () => { ` expect(await run(['border--0', 'border--1', 'border--2'], input)).toMatchInlineSnapshot(` - " - .border--0 { - border-color: var(--color-border-0, #e5e7eb); - } + " + .border--0 { + border-color: var(--color-border-0, #e5e7eb); + } - .border--1 { - border-color: var(--color-border-1, #d1d5db); - } + .border--1 { + border-color: var(--color-border-1, #d1d5db); + } - .border--2 { - border-color: var(--color-border-2, #9ca3af); - } - " - `) + .border--2 { + border-color: var(--color-border-2, #9ca3af); + } + " + `) expect(await run(['border--3'], input)).toEqual('') }) @@ -30313,20 +30313,20 @@ describe('custom utilities', () => { ` expect(await run(['example-1', 'example-76', 'example-971'], input)).toMatchInlineSnapshot(` - " - .example-1 { - --resolved-value: 1; - } + " + .example-1 { + --resolved-value: 1; + } - .example-76 { - --resolved-value: 76; - } + .example-76 { + --resolved-value: 76; + } - .example-971 { - --resolved-value: 971; - } - " - `) + .example-971 { + --resolved-value: 971; + } + " + `) expect(await run(['example-foo'], input)).toEqual('') }) @@ -30650,20 +30650,20 @@ describe('custom utilities', () => { ` expect(await run(['example-a', 'example-76', 'example-[123]'], input)).toMatchInlineSnapshot(` - " - .example-76 { - --resolved-value: 76; - } + " + .example-76 { + --resolved-value: 76; + } - .example-\\[123\\] { - --resolved-value: 123; - } + .example-\\[123\\] { + --resolved-value: 123; + } - .example-a { - --resolved-value: var(--example-a, 8); - } - " - `) + .example-a { + --resolved-value: var(--example-a, 8); + } + " + `) expect(await run(['example-[#0088cc]', 'example-[1px]'], input)).toEqual('') }) diff --git a/packages/tailwindcss/src/utils/variables.ts b/packages/tailwindcss/src/utils/variables.ts index 57ac4c3a5..b72987565 100644 --- a/packages/tailwindcss/src/utils/variables.ts +++ b/packages/tailwindcss/src/utils/variables.ts @@ -1,7 +1,8 @@ import * as ValueParser from '../value-parser' import { walk, WalkAction } from '../walk' +import { DefaultMap } from './default-map' -export function extractUsedVariables(raw: string): string[] { +let extractUsedVariablesCache = new DefaultMap((raw) => { let variables: string[] = [] walk(ValueParser.parse(raw), (node) => { if (node.kind !== 'function' || node.value !== 'var') return @@ -15,4 +16,8 @@ export function extractUsedVariables(raw: string): string[] { return WalkAction.Skip }) return variables +}) + +export function extractUsedVariables(raw: string): string[] { + return extractUsedVariablesCache.get(raw) } diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 305859d5b..56b069b31 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -2098,6 +2098,15 @@ test('data', async () => { 'data-[potato=salad]/foo:flex', ]), ).toEqual('') + + // Non-ASCII code points are valid in attribute names + expect(await run(['data-[état]:flex', 'aria-[état]:flex'])).toMatchInlineSnapshot(` + " + .aria-\\[état\\]\\:flex[aria-état], .data-\\[état\\]\\:flex[data-état] { + display: flex; + } + " + `) }) test('portrait', async () => { diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 4d4637d8f..d62f13c8f 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -11,6 +11,7 @@ import { type Rule, type StyleRule, } from './ast' +import * as AttributeSelectorParser from './attribute-selector-parser' import { type Variant } from './candidate' import { applyVariant } from './compile' import type { DesignSystem } from './design-system' @@ -845,11 +846,15 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null if (variant.value.kind === 'arbitrary') { - ruleNode.nodes = [ - styleRule(`&[aria-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes), - ] + let selector = `[aria-${quoteAttributeValue(variant.value.value)}]` + let parsed = AttributeSelectorParser.parse(selector) + if (parsed === null) return null + ruleNode.nodes = [styleRule(`&${selector}`, ruleNode.nodes)] } else { - ruleNode.nodes = [styleRule(`&[aria-${variant.value.value}="true"]`, ruleNode.nodes)] + let selector = `[aria-${variant.value.value}="true"]` + let parsed = AttributeSelectorParser.parse(selector) + if (parsed === null) return null + ruleNode.nodes = [styleRule(`&${selector}`, ruleNode.nodes)] } }) @@ -868,9 +873,11 @@ export function createVariants(theme: Theme): Variants { variants.functional('data', (ruleNode, variant) => { if (!variant.value || variant.modifier) return null - ruleNode.nodes = [ - styleRule(`&[data-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes), - ] + let selector = `[data-${quoteAttributeValue(variant.value.value)}]` + let parsed = AttributeSelectorParser.parse(selector) + if (parsed === null) return null + + ruleNode.nodes = [styleRule(`&${selector}`, ruleNode.nodes)] }) variants.functional('nth', (ruleNode, variant) => { @@ -1295,7 +1302,11 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): } } - nodes.push(node) + if (node.selector === '&') { + nodes.push(...node.nodes) + } else { + nodes.push(node) + } } // Update the variant at-rule node, to be the `&` rule node From 4af47fbe942245fd05ad8cd4776c2dbd27237bee Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 9 Jul 2026 13:36:34 +0200 Subject: [PATCH 10/60] Fix hues in achromatic theme colors to be `none` (#20314) --- CHANGELOG.md | 1 + integrations/upgrade/js-config.test.ts | 22 +++++++++---------- packages/tailwindcss/src/compat/colors.ts | 26 +++++++++++------------ packages/tailwindcss/theme.css | 26 +++++++++++------------ 4 files changed, 38 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baad68046..64f11e57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) - Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) - Ensure CSS nesting is handled even when Lightning CSS isn't run, like in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124)) +- Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#19830](https://github.com/tailwindlabs/tailwindcss/issues/19830)) ## [4.3.2] - 2026-06-26 diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 1a037b764..cab0d3d62 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -594,17 +594,17 @@ test( @import 'tailwindcss'; @theme { - --color-gray-50: oklch(98.5% 0 0); - --color-gray-100: oklch(97% 0 0); - --color-gray-200: oklch(92.2% 0 0); - --color-gray-300: oklch(87% 0 0); - --color-gray-400: oklch(70.8% 0 0); - --color-gray-500: oklch(55.6% 0 0); - --color-gray-600: oklch(43.9% 0 0); - --color-gray-700: oklch(37.1% 0 0); - --color-gray-800: oklch(26.9% 0 0); - --color-gray-900: oklch(20.5% 0 0); - --color-gray-950: oklch(14.5% 0 0); + --color-gray-50: oklch(98.5% 0 none); + --color-gray-100: oklch(97% 0 none); + --color-gray-200: oklch(92.2% 0 none); + --color-gray-300: oklch(87% 0 none); + --color-gray-400: oklch(70.8% 0 none); + --color-gray-500: oklch(55.6% 0 none); + --color-gray-600: oklch(43.9% 0 none); + --color-gray-700: oklch(37.1% 0 none); + --color-gray-800: oklch(26.9% 0 none); + --color-gray-900: oklch(20.5% 0 none); + --color-gray-950: oklch(14.5% 0 none); } /* diff --git a/packages/tailwindcss/src/compat/colors.ts b/packages/tailwindcss/src/compat/colors.ts index a1d162c54..64e3bc9c7 100644 --- a/packages/tailwindcss/src/compat/colors.ts +++ b/packages/tailwindcss/src/compat/colors.ts @@ -31,7 +31,7 @@ export default { '950': 'oklch(13% 0.028 261.692)', }, zinc: { - '50': 'oklch(98.5% 0 0)', + '50': 'oklch(98.5% 0 none)', '100': 'oklch(96.7% 0.001 286.375)', '200': 'oklch(92% 0.004 286.32)', '300': 'oklch(87.1% 0.006 286.286)', @@ -44,17 +44,17 @@ export default { '950': 'oklch(14.1% 0.005 285.823)', }, neutral: { - '50': 'oklch(98.5% 0 0)', - '100': 'oklch(97% 0 0)', - '200': 'oklch(92.2% 0 0)', - '300': 'oklch(87% 0 0)', - '400': 'oklch(70.8% 0 0)', - '500': 'oklch(55.6% 0 0)', - '600': 'oklch(43.9% 0 0)', - '700': 'oklch(37.1% 0 0)', - '800': 'oklch(26.9% 0 0)', - '900': 'oklch(20.5% 0 0)', - '950': 'oklch(14.5% 0 0)', + '50': 'oklch(98.5% 0 none)', + '100': 'oklch(97% 0 none)', + '200': 'oklch(92.2% 0 none)', + '300': 'oklch(87% 0 none)', + '400': 'oklch(70.8% 0 none)', + '500': 'oklch(55.6% 0 none)', + '600': 'oklch(43.9% 0 none)', + '700': 'oklch(37.1% 0 none)', + '800': 'oklch(26.9% 0 none)', + '900': 'oklch(20.5% 0 none)', + '950': 'oklch(14.5% 0 none)', }, stone: { '50': 'oklch(98.5% 0.001 106.423)', @@ -70,7 +70,7 @@ export default { '950': 'oklch(14.7% 0.004 49.25)', }, mauve: { - '50': 'oklch(98.5% 0 0)', + '50': 'oklch(98.5% 0 none)', '100': 'oklch(96% 0.003 325.6)', '200': 'oklch(92.2% 0.005 325.62)', '300': 'oklch(86.5% 0.012 325.68)', diff --git a/packages/tailwindcss/theme.css b/packages/tailwindcss/theme.css index 502f5c752..78b0fc588 100644 --- a/packages/tailwindcss/theme.css +++ b/packages/tailwindcss/theme.css @@ -235,7 +235,7 @@ --color-gray-900: oklch(21% 0.034 264.665); --color-gray-950: oklch(13% 0.028 261.692); - --color-zinc-50: oklch(98.5% 0 0); + --color-zinc-50: oklch(98.5% 0 none); --color-zinc-100: oklch(96.7% 0.001 286.375); --color-zinc-200: oklch(92% 0.004 286.32); --color-zinc-300: oklch(87.1% 0.006 286.286); @@ -247,17 +247,17 @@ --color-zinc-900: oklch(21% 0.006 285.885); --color-zinc-950: oklch(14.1% 0.005 285.823); - --color-neutral-50: oklch(98.5% 0 0); - --color-neutral-100: oklch(97% 0 0); - --color-neutral-200: oklch(92.2% 0 0); - --color-neutral-300: oklch(87% 0 0); - --color-neutral-400: oklch(70.8% 0 0); - --color-neutral-500: oklch(55.6% 0 0); - --color-neutral-600: oklch(43.9% 0 0); - --color-neutral-700: oklch(37.1% 0 0); - --color-neutral-800: oklch(26.9% 0 0); - --color-neutral-900: oklch(20.5% 0 0); - --color-neutral-950: oklch(14.5% 0 0); + --color-neutral-50: oklch(98.5% 0 none); + --color-neutral-100: oklch(97% 0 none); + --color-neutral-200: oklch(92.2% 0 none); + --color-neutral-300: oklch(87% 0 none); + --color-neutral-400: oklch(70.8% 0 none); + --color-neutral-500: oklch(55.6% 0 none); + --color-neutral-600: oklch(43.9% 0 none); + --color-neutral-700: oklch(37.1% 0 none); + --color-neutral-800: oklch(26.9% 0 none); + --color-neutral-900: oklch(20.5% 0 none); + --color-neutral-950: oklch(14.5% 0 none); --color-stone-50: oklch(98.5% 0.001 106.423); --color-stone-100: oklch(97% 0.001 106.424); @@ -271,7 +271,7 @@ --color-stone-900: oklch(21.6% 0.006 56.043); --color-stone-950: oklch(14.7% 0.004 49.25); - --color-mauve-50: oklch(98.5% 0 0); + --color-mauve-50: oklch(98.5% 0 none); --color-mauve-100: oklch(96% 0.003 325.6); --color-mauve-200: oklch(92.2% 0.005 325.62); --color-mauve-300: oklch(86.5% 0.012 325.68); From 35a3e9c5159bea77af0d48f0c8849279211cc7e9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 9 Jul 2026 18:34:27 +0200 Subject: [PATCH 11/60] Always produce `` value when optimizing `--spacing(0)` (#20319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes a type issue when using `--spacing(0)`. This construction doesn't really make sense, but if you use it, it produces the value `0` instead of `calc(var(--spacing) * 0)` which is fine if you use it in a spot where a `` data type can be used, then the `0` is interpreted as a ``. E.g.: `padding: 0` and `padding: 0px` are equivalent. However, if you use it in a CSS variable, then the `0` will turn in a `` if you don't have an `@property` definition for that CSS variable. This on its own isn't the issue, but if you later use it as part of a `calc(…)` then the `` instead of `` type is being used. As seen in #20315. We could remove the optimization, and use `calc(var(--spacing) * 0)` again, but this is a bit silly since it only makes your CSS file larger. We could try to be smart, and only do it _if_ we're assigning to a CSS variable (which #20317 is doing). But if your CSS variable _does_ use a `` then it's a non-issue. We would run into the same issue if you use `width: calc(100% --spacing(0))`. This value is a bit silly anyway, but it would originally resolve to `calc(100% + calc(var(--spacing) * 0))`, the optimization of `calc(100% - 0)` would make it invalid, so the fix in #20317 would not be enough. Instead I opted for an inbetween solution, by always using `0px`. In most cases we can use `0`, but in the places we can't the `0px` would at least ensure that we are dealing with `` data types. This way we don't have to try to be smart to analyze where we use the value, and `0px` is still better than the long `calc(var(--spacing) * 0)` value. Fixes: #20315 Closes: #20317 ## Test plan 1. Manually tested that now `--spacing(0)` does produce `0px` which has a length type --- CHANGELOG.md | 1 + .../tailwindcss/src/css-functions.test.ts | 26 +++++++---- packages/tailwindcss/src/css-functions.ts | 6 ++- packages/tailwindcss/src/utilities.test.ts | 44 +++++++++---------- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f11e57d..e50fcd529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) - Ensure CSS nesting is handled even when Lightning CSS isn't run, like in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124)) - Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#19830](https://github.com/tailwindlabs/tailwindcss/issues/19830)) +- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319)) ## [4.3.2] - 2026-06-26 diff --git a/packages/tailwindcss/src/css-functions.test.ts b/packages/tailwindcss/src/css-functions.test.ts index 0e4146fdf..646201cc0 100644 --- a/packages/tailwindcss/src/css-functions.test.ts +++ b/packages/tailwindcss/src/css-functions.test.ts @@ -1,8 +1,9 @@ import fs from 'node:fs/promises' import path from 'node:path' import { describe, expect, test } from 'vitest' +import { compile } from '.' import plugin from './plugin' -import { compileCss, run } from './test-utils/run' +import { compileCss, pretty, run } from './test-utils/run' const css = String.raw @@ -106,6 +107,14 @@ describe('--spacing(…)', () => { }) describe('optimizations', () => { + // Use custom `compileCss` such that we can use a version without Lightning + // CSS. This way we test _our_ optimizations instead of Lightning's + // optimizations. + async function compileCss(css: string, options: Parameters[1] = {}) { + let { build } = await compile(css, options) + return pretty(build([])) + } + test('--spacing(…) optimizes the output when the input is `0`', async () => { expect( await compileCss(css` @@ -121,8 +130,8 @@ describe('--spacing(…)', () => { ).toMatchInlineSnapshot(` " .foo { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; } " `) @@ -143,8 +152,8 @@ describe('--spacing(…)', () => { ).toMatchInlineSnapshot(` " .foo { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; } " `) @@ -165,9 +174,8 @@ describe('--spacing(…)', () => { ).toMatchInlineSnapshot(` " :root, :host { - --spacing: .25rem; + --spacing: 0.25rem; } - .foo { margin: var(--spacing); padding: var(--spacing); @@ -191,8 +199,8 @@ describe('--spacing(…)', () => { ).toMatchInlineSnapshot(` " .foo { - margin: .25rem; - padding: .25rem; + margin: 0.25rem; + padding: 0.25rem; } " `) diff --git a/packages/tailwindcss/src/css-functions.ts b/packages/tailwindcss/src/css-functions.ts index 66b8b6728..32fba455f 100644 --- a/packages/tailwindcss/src/css-functions.ts +++ b/packages/tailwindcss/src/css-functions.ts @@ -70,11 +70,13 @@ function spacing( // - We can assume that the `--spacing` value is not set to a `0`-like value. // Otherwise `p-` would calculate as `0` which wouldn't make sense. // - // - That means that a value of `0` can be replaced by `0` + // - That means that a value of `0` can be replaced by `0px`. It's important + // to keep the unit so the value stays a ``, e.g. when assigned to + // a custom property and later used inside `calc(…)`. // - That means that a value of `1` can be replaced by `multiplier` let valueDimension = dimensions.get(value) if (valueDimension) { - if (valueDimension[0] === 0) return '0' + if (valueDimension[0] === 0) return '0px' if (valueDimension[0] === 1) return multiplier } diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index a9d97d0dd..60d5daf59 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -16145,7 +16145,7 @@ test('mask-t-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-top: linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position)); - --tw-mask-top-from-position: 0; + --tw-mask-top-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -16405,7 +16405,7 @@ test('mask-t-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-top: linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position)); - --tw-mask-top-to-position: 0; + --tw-mask-top-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -16666,7 +16666,7 @@ test('mask-r-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-right: linear-gradient(to right, var(--tw-mask-right-from-color) var(--tw-mask-right-from-position), var(--tw-mask-right-to-color) var(--tw-mask-right-to-position)); - --tw-mask-right-from-position: 0; + --tw-mask-right-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -16927,7 +16927,7 @@ test('mask-r-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-right: linear-gradient(to right, var(--tw-mask-right-from-color) var(--tw-mask-right-from-position), var(--tw-mask-right-to-color) var(--tw-mask-right-to-position)); - --tw-mask-right-to-position: 0; + --tw-mask-right-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -17188,7 +17188,7 @@ test('mask-b-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-bottom: linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position)); - --tw-mask-bottom-from-position: 0; + --tw-mask-bottom-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -17449,7 +17449,7 @@ test('mask-b-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-bottom: linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position)); - --tw-mask-bottom-to-position: 0; + --tw-mask-bottom-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -17710,7 +17710,7 @@ test('mask-l-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-left: linear-gradient(to left, var(--tw-mask-left-from-color) var(--tw-mask-left-from-position), var(--tw-mask-left-to-color) var(--tw-mask-left-to-position)); - --tw-mask-left-from-position: 0; + --tw-mask-left-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -17971,7 +17971,7 @@ test('mask-l-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-left: linear-gradient(to left, var(--tw-mask-left-from-color) var(--tw-mask-left-from-position), var(--tw-mask-left-to-color) var(--tw-mask-left-to-position)); - --tw-mask-left-to-position: 0; + --tw-mask-left-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -18240,9 +18240,9 @@ test('mask-x-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-right: linear-gradient(to right, var(--tw-mask-right-from-color) var(--tw-mask-right-from-position), var(--tw-mask-right-to-color) var(--tw-mask-right-to-position)); - --tw-mask-right-from-position: 0; + --tw-mask-right-from-position: 0px; --tw-mask-left: linear-gradient(to left, var(--tw-mask-left-from-color) var(--tw-mask-left-from-position), var(--tw-mask-left-to-color) var(--tw-mask-left-to-position)); - --tw-mask-left-from-position: 0; + --tw-mask-left-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -18547,9 +18547,9 @@ test('mask-x-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-right: linear-gradient(to right, var(--tw-mask-right-from-color) var(--tw-mask-right-from-position), var(--tw-mask-right-to-color) var(--tw-mask-right-to-position)); - --tw-mask-right-to-position: 0; + --tw-mask-right-to-position: 0px; --tw-mask-left: linear-gradient(to left, var(--tw-mask-left-from-color) var(--tw-mask-left-from-position), var(--tw-mask-left-to-color) var(--tw-mask-left-to-position)); - --tw-mask-left-to-position: 0; + --tw-mask-left-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -18854,9 +18854,9 @@ test('mask-y-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-top: linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position)); - --tw-mask-top-from-position: 0; + --tw-mask-top-from-position: 0px; --tw-mask-bottom: linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position)); - --tw-mask-bottom-from-position: 0; + --tw-mask-bottom-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -19161,9 +19161,9 @@ test('mask-y-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear: var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top); --tw-mask-top: linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position)); - --tw-mask-top-to-position: 0; + --tw-mask-top-to-position: 0px; --tw-mask-bottom: linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position)); - --tw-mask-bottom-to-position: 0; + --tw-mask-bottom-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -19618,7 +19618,7 @@ test('mask-linear-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear-stops: var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position); --tw-mask-linear: linear-gradient(var(--tw-mask-linear-stops)); - --tw-mask-linear-from-position: 0; + --tw-mask-linear-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -19858,7 +19858,7 @@ test('mask-linear-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-linear-stops: var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position); --tw-mask-linear: linear-gradient(var(--tw-mask-linear-stops)); - --tw-mask-linear-to-position: 0; + --tw-mask-linear-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -20347,7 +20347,7 @@ test('mask-radial-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-radial-stops: var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position); --tw-mask-radial: radial-gradient(var(--tw-mask-radial-stops)); - --tw-mask-radial-from-position: 0; + --tw-mask-radial-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -20601,7 +20601,7 @@ test('mask-radial-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-radial-stops: var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position); --tw-mask-radial: radial-gradient(var(--tw-mask-radial-stops)); - --tw-mask-radial-to-position: 0; + --tw-mask-radial-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -21014,7 +21014,7 @@ test('mask-conic-from', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-conic-stops: from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position); --tw-mask-conic: conic-gradient(var(--tw-mask-conic-stops)); - --tw-mask-conic-from-position: 0; + --tw-mask-conic-from-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; @@ -21254,7 +21254,7 @@ test('mask-conic-to', async () => { mask-image: var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic); --tw-mask-conic-stops: from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position); --tw-mask-conic: conic-gradient(var(--tw-mask-conic-stops)); - --tw-mask-conic-to-position: 0; + --tw-mask-conic-to-position: 0px; -webkit-mask-composite: source-in; -webkit-mask-composite: source-in; mask-composite: intersect; From b03e5e77316feb81e9696c7cf2a4be3c21eafbb9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 13 Jul 2026 15:53:42 +0200 Subject: [PATCH 12/60] Lazy load `@parcel/watcher` (#20325) This PR ensure that we lazy load the `@parcel/watcher` in the `@tailwindcss/cli`. This means that we don't have to load it at all when using a normal build or when using `--watch --poll` combination. The bigger reason is that on some platforms `@parcel/watcher` might not work, and therefore the build will fail even if you don't use `--watch` at all. This PR fixes that by lazy loading it. Then, if we can't load it when using `--watch`, a useful workaround is shown by using `--watch --poll` instead. This PR also improves showing errors such that the `error.cause` property can be rendered as well. Maybe in the future we can make use of deferred imports (https://github.com/tc39/proposal-defer-import-eval) Fixes: #20322 ## Test plan 1. All tests still pass [ci-all] 2. Fabricated a fake error locally to prove that we can still use a normal build and `--watch --poll` as a workaround file-2f91d99cfb9eb0e24a196c2dd358c1f3 --- CHANGELOG.md | 3 +- .../src/commands/build/index.ts | 77 ++++++++++++++----- .../@tailwindcss-cli/src/utils/renderer.ts | 4 +- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e50fcd529..d856ff54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303)) - Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310)) - Ensure CSS nesting is handled even when Lightning CSS isn't run, like in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124)) -- Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#19830](https://github.com/tailwindlabs/tailwindcss/issues/19830)) +- Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/issues/20314)) - Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319)) +- Lazily load `@parcel/watcher` when using the `--watch` flag in `@tailwindcss/cli`, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/issues/20325)) ## [4.3.2] - 2026-06-26 diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index c06d02c50..c85eda28c 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -1,4 +1,3 @@ -import watcher from '@parcel/watcher' import { compile, env, @@ -21,6 +20,7 @@ import { highlight, println, relative, + wordWrap, } from '../../utils/renderer' import { drainStdin, outputFile } from './utils' @@ -80,17 +80,44 @@ export function options() { } satisfies Arg } +function formatError(error: unknown): string { + let seen = new Set() // Track seen errors to prevent circular errors + function render(err: unknown, depth: number): string[] { + let indent = ' '.repeat(depth) + let width = (process.stderr.columns ?? Infinity) - 2 * (depth + 1) + + let output = [ + `${indent}${red(depth === 0 ? 'Error:' : 'Caused by:')}`, + `${indent}${dim('\u250C')}`, + ] + + for (let line of `${err}`.split('\n')) { + let wrapped = wordWrap(line, width) + if (wrapped.length === 0) wrapped = [''] + for (let chunk of wrapped) { + output.push(`${indent}${dim('\u2502')} ${chunk}`) + } + } + + output.push(`${indent}${dim('\u2514')}`) + + if (typeof err === 'object' && err !== null && 'cause' in err && err.cause != null) { + if (!seen.add(err).has(err.cause)) { + output.push(...render(err.cause, depth + 1)) + } + } + + return output + } + + return render(error, 0).join('\n') +} + async function handleError(fn: () => T): Promise { try { return await fn() } catch (err) { - eprintln( - [red('Error:'), dim('\u250C')] - .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`)) - .concat(dim('\u2514')) - .join('\n'), - ) - + eprintln(formatError(err)) process.exit(1) } } @@ -302,6 +329,10 @@ export async function handle(args: Result>) { // Watch for changes if (args['--watch'] && pollInterval === false) { + // Ensure the file watcher can be loaded before setting up any watchers, + // such that we can present a helpful error message if needed. + await handleError(() => loadWatcher()) + cleanupWatchers.push( await createWatchers(await watchDirectories(scanner), async function handle(files) { try { @@ -445,12 +476,7 @@ export async function handle(args: Result>) { // Catch any errors and print them to stderr, but don't exit the process // and keep watching. - eprintln( - [red('Error:'), dim('\u250C')] - .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`)) - .concat(dim('\u2514')) - .join('\n'), - ) + eprintln(formatError(err)) let end = process.hrtime.bigint() if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) @@ -599,12 +625,7 @@ export async function handle(args: Result>) { } catch (err) { fullRebuildPaths = backupRebuildPaths - let message = [red('Error:'), dim('\u250C')] - .concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`)) - .concat(dim('\u2514')) - .join('\n') - - logPollingMessage(message) + logPollingMessage(formatError(err)) } if (!args['--silent']) { @@ -658,7 +679,23 @@ export async function handle(args: Result>) { } } +// Load `@parcel/watcher` lazily so a missing or broken native binding only +// affects `--watch` (without `--poll`), instead of crashing one-off builds and +// polling mode as well. +async function loadWatcher(): Promise { + try { + return (await import('@parcel/watcher')).default + } catch (err) { + throw new Error( + `Failed to load the file watcher. Your platform may not be supported by \`@parcel/watcher\`. As a workaround, you can use polling instead by passing the \`--watch --poll\` flags.`, + { cause: err }, + ) + } +} + async function createWatchers(dirs: string[], cb: (files: string[]) => void) { + let watcher = await loadWatcher() + // Remove any directories that are children of an already watched directory. // If we don't we may not get notified of certain filesystem events regardless // of whether or not they are for the directory that is duplicated. diff --git a/packages/@tailwindcss-cli/src/utils/renderer.ts b/packages/@tailwindcss-cli/src/utils/renderer.ts index 6b0098586..da817dda7 100644 --- a/packages/@tailwindcss-cli/src/utils/renderer.ts +++ b/packages/@tailwindcss-cli/src/utils/renderer.ts @@ -49,7 +49,9 @@ export function wordWrap(text: string, width: number) { for (let word of words) { let wordLength = stripVTControlCharacters(word).length - if (lineLength + wordLength + 1 > width) { + // A word longer than `width` is kept on its own line rather than + // introducing an empty line before it. + if (lineLength > 0 && lineLength + wordLength + 1 > width) { lines.push(line) line = '' lineLength = 0 From e48c5e80473c25fe5a27fe267fbaf4f6512424a3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 14 Jul 2026 17:04:20 +0200 Subject: [PATCH 13/60] Fix weird character rendering on Windows with Japanese locale (#20318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes an issue where some characters are incorrectly rendered on Windows with the Japanese locale. This is arguably a bug in the font that's loaded by Windows when it encounters `system-ui`. But waiting for fixes there might ... take a while. Another option is to not change the defaults in Tailwind CSS and instead let the users that support different locales implement a fallback by overriding the `--font-sans` variable. The biggest reason for me to _not_ change it in Tailwind CSS is that it requires us to know what the (proper) fallback fonts need to be on a per OS basis. But the main reason why I did want to make the change is that MDN says this about the `system-ui` font: > Glyphs are taken from the default user interface font on a given platform. Because typographic traditions vary widely across the world, this generic is provided for typefaces that don't map cleanly into the other generics. > > **Note:** As the name implies, `system-ui` is intended to make UI elements look like native apps, and not for typesetting large paragraphs of text. It may cause the displayed typeface to be undesirable for some users—for example, the default Windows CJK font may render Latin scripts poorly, and the `lang` attribute may not affect the displayed font. Some operating systems do not allow customizing `system-ui`, while browsers generally allow customizing the `sans-serif` font family. For large paragraphs, use `sans-serif` or some other non-UI font family instead. > > — https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-family#system-ui There are PRs in other big projects that made this kind of change as well. E.g.: - https://github.com/withastro/starlight/pull/3729 - https://github.com/vuejs/vitepress/pull/4988 The reasoning for getting rid of `ui-sans-serif` is twofold: 1. Because the starlight PR seems very well tested, and they got rid of it 2. In the event that the browser decided to load the broken font when it encounters `ui-sans-serif`, then we will run into the same issue again. Fixes: #19767 Fixes: #19768 ## Test plan 1. `system-ui` is not used anymore, so the bug doesn't happen 3. Everything still looks the same for the places I checked, but it's hard to know if this created _other_ issues on other OS + Locale combinations... --- CHANGELOG.md | 1 + integrations/cli/index.test.ts | 6 +- .../src/__snapshots__/index.test.ts.snap | 6 +- packages/tailwindcss/preflight.css | 9 +- .../src/__snapshots__/index.test.ts.snap | 6 +- .../tailwindcss/src/compat/default-theme.ts | 9 +- .../tailwindcss/src/css-functions.test.ts | 11 +- .../src/source-maps/source-map.test.ts | 678 +++++++++--------- packages/tailwindcss/theme.css | 4 +- 9 files changed, 376 insertions(+), 354 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d856ff54e..39b9c3d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/issues/20314)) - Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319)) - Lazily load `@parcel/watcher` when using the `--watch` flag in `@tailwindcss/cli`, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/issues/20325)) +- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#19767](https://github.com/tailwindlabs/tailwindcss/issues/19767), [#19768](https://github.com/tailwindlabs/tailwindcss/issues/19768)) ## [4.3.2] - 2026-06-26 diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index a2b2ee4ab..310086802 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2831,8 +2831,8 @@ test( @layer theme, base, components, utilities; @layer theme { :root, :host { - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --color-red-500: oklch(63.7% 0.237 25.331); @@ -2851,7 +2851,7 @@ test( line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; - font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; diff --git a/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap b/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap index 6d4823219..05087fa39 100644 --- a/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap +++ b/packages/@tailwindcss-postcss/src/__snapshots__/index.test.ts.snap @@ -12,8 +12,8 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` @layer theme { :root, :host { - --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --color-black: #000; @@ -44,7 +44,7 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = ` -webkit-text-size-adjust: 100%; tab-size: 4; line-height: 1.5; - font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; diff --git a/packages/tailwindcss/preflight.css b/packages/tailwindcss/preflight.css index e7dd8ec4e..04e87905c 100644 --- a/packages/tailwindcss/preflight.css +++ b/packages/tailwindcss/preflight.css @@ -32,8 +32,13 @@ html, tab-size: 4; /* 3 */ font-family: --theme( --default-font-family, - ui-sans-serif, - system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + 'Noto Sans', + Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', diff --git a/packages/tailwindcss/src/__snapshots__/index.test.ts.snap b/packages/tailwindcss/src/__snapshots__/index.test.ts.snap index 4a43ae7f9..9921709d7 100644 --- a/packages/tailwindcss/src/__snapshots__/index.test.ts.snap +++ b/packages/tailwindcss/src/__snapshots__/index.test.ts.snap @@ -132,8 +132,8 @@ exports[`compiling CSS > prefix all CSS variables inside preflight 1`] = ` " @layer theme { :root, :host { - --tw-font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + --tw-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --tw-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --tw-default-font-family: var(--tw-font-sans); @@ -160,7 +160,7 @@ exports[`compiling CSS > prefix all CSS variables inside preflight 1`] = ` -webkit-text-size-adjust: 100%; tab-size: 4; line-height: 1.5; - font-family: var(--tw-default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-family: var(--tw-default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); font-feature-settings: var(--tw-default-font-feature-settings, normal); font-variation-settings: var(--tw-default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; diff --git a/packages/tailwindcss/src/compat/default-theme.ts b/packages/tailwindcss/src/compat/default-theme.ts index f540e63a2..90e699c1c 100644 --- a/packages/tailwindcss/src/compat/default-theme.ts +++ b/packages/tailwindcss/src/compat/default-theme.ts @@ -353,8 +353,13 @@ export default { }, fontFamily: { sans: [ - 'ui-sans-serif', - 'system-ui', + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + '"Noto Sans"', + 'Arial', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', diff --git a/packages/tailwindcss/src/css-functions.test.ts b/packages/tailwindcss/src/css-functions.test.ts index 646201cc0..8cab2949f 100644 --- a/packages/tailwindcss/src/css-functions.test.ts +++ b/packages/tailwindcss/src/css-functions.test.ts @@ -912,8 +912,9 @@ describe('theme(…)', () => { await compileCss(css` @theme default reference { --font-family-sans: - ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, - Segoe UI Symbol, Noto Color Emoji; + -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Noto Sans, + Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, + Noto Color Emoji; } .fam { font-family: theme(fontFamily.sans); @@ -922,7 +923,7 @@ describe('theme(…)', () => { ).toMatchInlineSnapshot(` " .fam { - font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; } " `) @@ -942,7 +943,7 @@ describe('theme(…)', () => { ).toMatchInlineSnapshot(` " .fam { - font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; } " `) @@ -1162,7 +1163,7 @@ describe('theme(…)', () => { test.each([ [ 'fontFamily.sans', - 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', ], ['maxWidth.xs', '20rem'], ['transitionTimingFunction.in-out', 'cubic-bezier(.4, 0, .2, 1)'], diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 62edc0f46..2bc7b0962 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -117,14 +117,14 @@ test('source maps trace back to @import location', async ({ expect }) => { | --- theme.css --- 3 :root, :host { | 1 @theme default { ^^^^^^^^^^^^^ C @ 3:2-15 | ^^^^^^^^^^^^^^^ C @ 1:0-15 - 4 --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'... | 2 --font-sans: + 4 --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'N... | 2 --font-sans: ^ D @ 4:4 | ^ D @ 2:2 - 4 --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'... | 3 ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI S... + 4 --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'N... | 3 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Ar... ^ E @ 4:4 | ^ E @ 3:0 - 4 --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'... | 4 'Noto Color Emoji'; + 4 --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'N... | 4 sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... F @ 4:4-5:0 | ^ F @ 4:0 - 5 'Noto Color Emoji'; | 4 'Noto Color Emoji'; - ^ G @ 5:22-6:0 | ^ G @ 4:22 + 5 sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; | 4 sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + ... G @ 5:92-6:0 | ... G @ 4:92 | 5 --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; 6 --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', '... | 6 --font-mono: ^ H @ 6:4 | ^ H @ 6:2 @@ -176,398 +176,408 @@ test('source maps trace back to @import location', async ({ expect }) => { ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AA @ 21:4-34 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AA @ 31:2-32 22 tab-size: 4; | 32 tab-size: 4; /* 3 */ ^^^^^^^^^^^ AB @ 22:4-15 | ^^^^^^^^^^^ AB @ 32:2-13 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 33 font-family: --theme( + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 33 font-family: --theme( ^ AC @ 23:4 | ^ AC @ 33:2 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 34 --default-font-family, + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 34 --default-font-family, ^ AD @ 23:4 | ^ AD @ 34:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 35 ui-sans-serif, + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 35 -apple-system, ^ AE @ 23:4 | ^ AE @ 35:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 36 system-ui, + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 36 BlinkMacSystemFont, ^ AF @ 23:4 | ^ AF @ 36:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 37 sans-serif, + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 37 'Segoe UI', ^ AG @ 23:4 | ^ AG @ 37:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 38 'Apple Color Emoji', + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 38 Roboto, ^ AH @ 23:4 | ^ AH @ 38:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 39 'Segoe UI Emoji', + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 39 'Helvetica Neue', ^ AI @ 23:4 | ^ AI @ 39:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 40 'Segoe UI Symbol', + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 40 'Noto Sans', ^ AJ @ 23:4 | ^ AJ @ 40:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 41 'Noto Color Emoji' + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 41 Arial, ^ AK @ 23:4 | ^ AK @ 41:0 - 23 font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Col... | 42 ); /* 4 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... AL @ 23:4-159 | ^^^ AL @ 42:0-3 - 24 font-feature-settings: var(--default-font-feature-settings, normal); | 43 font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AM @ 24:4-71 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AM @ 43:2-73 - 25 font-variation-settings: var(--default-font-variation-settings, normal); | 44 font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AN @ 25:4-75 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AN @ 44:2-77 - 26 -webkit-tap-highlight-color: transparent; | 45 -webkit-tap-highlight-color: transparent; /* 7 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AO @ 26:4-44 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AO @ 45:2-42 - | 46 } + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 42 sans-serif, + ^ AL @ 23:4 | ^ AL @ 42:0 + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 43 'Apple Color Emoji', + ^ AM @ 23:4 | ^ AM @ 43:0 + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 44 'Segoe UI Emoji', + ^ AN @ 23:4 | ^ AN @ 44:0 + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 45 'Segoe UI Symbol', + ^ AO @ 23:4 | ^ AO @ 45:0 + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 46 'Noto Color Emoji' + ^ AP @ 23:4 | ^ AP @ 46:0 + 23 font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', R... | 47 ); /* 4 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... AQ @ 23:4-226 | ^^^ AQ @ 47:0-3 + 24 font-feature-settings: var(--default-font-feature-settings, normal); | 48 font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AR @ 24:4-71 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AR @ 48:2-73 + 25 font-variation-settings: var(--default-font-variation-settings, normal); | 49 font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AS @ 25:4-75 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AS @ 49:2-77 + 26 -webkit-tap-highlight-color: transparent; | 50 -webkit-tap-highlight-color: transparent; /* 7 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AT @ 26:4-44 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AT @ 50:2-42 + | 51 } 27 } | - 28 hr { | 54 hr { - ^^^ AP @ 28:2-5 | ^^^ AP @ 54:0-3 - 29 height: 0; | 55 height: 0; /* 1 */ - ^^^^^^^^^ AQ @ 29:4-13 | ^^^^^^^^^ AQ @ 55:2-11 - 30 color: inherit; | 56 color: inherit; /* 2 */ - ^^^^^^^^^^^^^^ AR @ 30:4-18 | ^^^^^^^^^^^^^^ AR @ 56:2-16 - 31 border-top-width: 1px; | 57 border-top-width: 1px; /* 3 */ - ^^^^^^^^^^^^^^^^^^^^^ AS @ 31:4-25 | ^^^^^^^^^^^^^^^^^^^^^ AS @ 57:2-23 - | 58 } + 28 hr { | 59 hr { + ^^^ AU @ 28:2-5 | ^^^ AU @ 59:0-3 + 29 height: 0; | 60 height: 0; /* 1 */ + ^^^^^^^^^ AV @ 29:4-13 | ^^^^^^^^^ AV @ 60:2-11 + 30 color: inherit; | 61 color: inherit; /* 2 */ + ^^^^^^^^^^^^^^ AW @ 30:4-18 | ^^^^^^^^^^^^^^ AW @ 61:2-16 + 31 border-top-width: 1px; | 62 border-top-width: 1px; /* 3 */ + ^^^^^^^^^^^^^^^^^^^^^ AX @ 31:4-25 | ^^^^^^^^^^^^^^^^^^^^^ AX @ 62:2-23 + | 63 } 32 } | - 33 abbr:where([title]) { | 64 abbr:where([title]) { - ^^^^^^^^^^^^^^^^^^^^ AT @ 33:2-22 | ^^^^^^^^^^^^^^^^^^^^ AT @ 64:0-20 - 34 -webkit-text-decoration: underline dotted; | 65 -webkit-text-decoration: underline dotted; - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AU @ 34:4-45 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AU @ 65:2-43 - 35 text-decoration: underline dotted; | 66 text-decoration: underline dotted; - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AV @ 35:4-37 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AV @ 66:2-35 - | 67 } + 33 abbr:where([title]) { | 69 abbr:where([title]) { + ^^^^^^^^^^^^^^^^^^^^ AY @ 33:2-22 | ^^^^^^^^^^^^^^^^^^^^ AY @ 69:0-20 + 34 -webkit-text-decoration: underline dotted; | 70 -webkit-text-decoration: underline dotted; + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AZ @ 34:4-45 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AZ @ 70:2-43 + 35 text-decoration: underline dotted; | 71 text-decoration: underline dotted; + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ BA @ 35:4-37 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ BA @ 71:2-35 + | 72 } 36 } | - 37 h1, h2, h3, h4, h5, h6 { | 73 h1, - ^ AW @ 37:2 | ^ AW @ 73:0 - 37 h1, h2, h3, h4, h5, h6 { | 74 h2, - ^ AX @ 37:2 | ^ AX @ 74:0 - 37 h1, h2, h3, h4, h5, h6 { | 75 h3, - ^ AY @ 37:2 | ^ AY @ 75:0 - 37 h1, h2, h3, h4, h5, h6 { | 76 h4, - ^ AZ @ 37:2 | ^ AZ @ 76:0 - 37 h1, h2, h3, h4, h5, h6 { | 77 h5, - ^ BA @ 37:2 | ^ BA @ 77:0 - 37 h1, h2, h3, h4, h5, h6 { | 78 h6 { - ^^^^^^^^^^^^^^^^^^^^^^^ BB @ 37:2-25 | ^^^ BB @ 78:0-3 - 38 font-size: inherit; | 79 font-size: inherit; - ^^^^^^^^^^^^^^^^^^ BC @ 38:4-22 | ^^^^^^^^^^^^^^^^^^ BC @ 79:2-20 - 39 font-weight: inherit; | 80 font-weight: inherit; - ^^^^^^^^^^^^^^^^^^^^ BD @ 39:4-24 | ^^^^^^^^^^^^^^^^^^^^ BD @ 80:2-22 - | 81 } + 37 h1, h2, h3, h4, h5, h6 { | 78 h1, + ^ BB @ 37:2 | ^ BB @ 78:0 + 37 h1, h2, h3, h4, h5, h6 { | 79 h2, + ^ BC @ 37:2 | ^ BC @ 79:0 + 37 h1, h2, h3, h4, h5, h6 { | 80 h3, + ^ BD @ 37:2 | ^ BD @ 80:0 + 37 h1, h2, h3, h4, h5, h6 { | 81 h4, + ^ BE @ 37:2 | ^ BE @ 81:0 + 37 h1, h2, h3, h4, h5, h6 { | 82 h5, + ^ BF @ 37:2 | ^ BF @ 82:0 + 37 h1, h2, h3, h4, h5, h6 { | 83 h6 { + ^^^^^^^^^^^^^^^^^^^^^^^ BG @ 37:2-25 | ^^^ BG @ 83:0-3 + 38 font-size: inherit; | 84 font-size: inherit; + ^^^^^^^^^^^^^^^^^^ BH @ 38:4-22 | ^^^^^^^^^^^^^^^^^^ BH @ 84:2-20 + 39 font-weight: inherit; | 85 font-weight: inherit; + ^^^^^^^^^^^^^^^^^^^^ BI @ 39:4-24 | ^^^^^^^^^^^^^^^^^^^^ BI @ 85:2-22 + | 86 } 40 } | - 41 a { | 87 a { - ^^ BE @ 41:2-4 | ^^ BE @ 87:0-2 - 42 color: inherit; | 88 color: inherit; - ^^^^^^^^^^^^^^ BF @ 42:4-18 | ^^^^^^^^^^^^^^ BF @ 88:2-16 - 43 -webkit-text-decoration: inherit; | 89 -webkit-text-decoration: inherit; - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ BG @ 43:4-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ BG @ 89:2-34 - 44 text-decoration: inherit; | 90 text-decoration: inherit; - ^^^^^^^^^^^^^^^^^^^^^^^^ BH @ 44:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ BH @ 90:2-26 - | 91 } + 41 a { | 92 a { + ^^ BJ @ 41:2-4 | ^^ BJ @ 92:0-2 + 42 color: inherit; | 93 color: inherit; + ^^^^^^^^^^^^^^ BK @ 42:4-18 | ^^^^^^^^^^^^^^ BK @ 93:2-16 + 43 -webkit-text-decoration: inherit; | 94 -webkit-text-decoration: inherit; + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ BL @ 43:4-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ BL @ 94:2-34 + 44 text-decoration: inherit; | 95 text-decoration: inherit; + ^^^^^^^^^^^^^^^^^^^^^^^^ BM @ 44:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ BM @ 95:2-26 + | 96 } 45 } | - 46 b, strong { | 97 b, - ^ BI @ 46:2 | ^ BI @ 97:0 - 46 b, strong { | 98 strong { - ^^^^^^^^^^ BJ @ 46:2-12 | ^^^^^^^ BJ @ 98:0-7 - 47 font-weight: bolder; | 99 font-weight: bolder; - ^^^^^^^^^^^^^^^^^^^ BK @ 47:4-23 | ^^^^^^^^^^^^^^^^^^^ BK @ 99:2-21 - | 100 } + 46 b, strong { | 102 b, + ^ BN @ 46:2 | ^ BN @ 102:0 + 46 b, strong { | 103 strong { + ^^^^^^^^^^ BO @ 46:2-12 | ^^^^^^^ BO @ 103:0-7 + 47 font-weight: bolder; | 104 font-weight: bolder; + ^^^^^^^^^^^^^^^^^^^ BP @ 47:4-23 | ^^^^^^^^^^^^^^^^^^^ BP @ 104:2-21 + | 105 } 48 } | - 49 code, kbd, samp, pre { | 109 code, - ^ BL @ 49:2 | ^ BL @ 109:0 - 49 code, kbd, samp, pre { | 110 kbd, - ^ BM @ 49:2 | ^ BM @ 110:0 - 49 code, kbd, samp, pre { | 111 samp, - ^ BN @ 49:2 | ^ BN @ 111:0 - 49 code, kbd, samp, pre { | 112 pre { - ^^^^^^^^^^^^^^^^^^^^^ BO @ 49:2-23 | ^^^^ BO @ 112:0-4 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 113 font-family: --theme( - ^ BP @ 50:4 | ^ BP @ 113:2 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 114 --default-mono-font-family, - ^ BQ @ 50:4 | ^ BQ @ 114:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 115 ui-monospace, - ^ BR @ 50:4 | ^ BR @ 115:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 116 SFMono-Regular, - ^ BS @ 50:4 | ^ BS @ 116:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 117 Menlo, - ^ BT @ 50:4 | ^ BT @ 117:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 118 Monaco, - ^ BU @ 50:4 | ^ BU @ 118:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 119 Consolas, + 49 code, kbd, samp, pre { | 114 code, + ^ BQ @ 49:2 | ^ BQ @ 114:0 + 49 code, kbd, samp, pre { | 115 kbd, + ^ BR @ 49:2 | ^ BR @ 115:0 + 49 code, kbd, samp, pre { | 116 samp, + ^ BS @ 49:2 | ^ BS @ 116:0 + 49 code, kbd, samp, pre { | 117 pre { + ^^^^^^^^^^^^^^^^^^^^^ BT @ 49:2-23 | ^^^^ BT @ 117:0-4 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 118 font-family: --theme( + ^ BU @ 50:4 | ^ BU @ 118:2 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 119 --default-mono-font-family, ^ BV @ 50:4 | ^ BV @ 119:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 120 'Liberation Mono', + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 120 ui-monospace, ^ BW @ 50:4 | ^ BW @ 120:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 121 'Courier New', + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 121 SFMono-Regular, ^ BX @ 50:4 | ^ BX @ 121:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 122 monospace + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 122 Menlo, ^ BY @ 50:4 | ^ BY @ 122:0 - 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 123 ); /* 1 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... BZ @ 50:4-148 | ^^^ BZ @ 123:0-3 - 51 font-feature-settings: var(--default-mono-font-feature-settings, normal); | 124 font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CA @ 51:4-76 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CA @ 124:2-78 - 52 font-variation-settings: var(--default-mono-font-variation-settings, normal); | 125 font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CB @ 52:4-80 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... CB @ 125:2-82 - 53 font-size: 1em; | 126 font-size: 1em; /* 4 */ - ^^^^^^^^^^^^^^ CC @ 53:4-18 | ^^^^^^^^^^^^^^ CC @ 126:2-16 - | 127 } + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 123 Monaco, + ^ BZ @ 50:4 | ^ BZ @ 123:0 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 124 Consolas, + ^ CA @ 50:4 | ^ CA @ 124:0 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 125 'Liberation Mono', + ^ CB @ 50:4 | ^ CB @ 125:0 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 126 'Courier New', + ^ CC @ 50:4 | ^ CC @ 126:0 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 127 monospace + ^ CD @ 50:4 | ^ CD @ 127:0 + 50 font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco... | 128 ); /* 1 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... CE @ 50:4-148 | ^^^ CE @ 128:0-3 + 51 font-feature-settings: var(--default-mono-font-feature-settings, normal); | 129 font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CF @ 51:4-76 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CF @ 129:2-78 + 52 font-variation-settings: var(--default-mono-font-variation-settings, normal); | 130 font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CG @ 52:4-80 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... CG @ 130:2-82 + 53 font-size: 1em; | 131 font-size: 1em; /* 4 */ + ^^^^^^^^^^^^^^ CH @ 53:4-18 | ^^^^^^^^^^^^^^ CH @ 131:2-16 + | 132 } 54 } | - 55 small { | 133 small { - ^^^^^^ CD @ 55:2-8 | ^^^^^^ CD @ 133:0-6 - 56 font-size: 80%; | 134 font-size: 80%; - ^^^^^^^^^^^^^^ CE @ 56:4-18 | ^^^^^^^^^^^^^^ CE @ 134:2-16 - | 135 } + 55 small { | 138 small { + ^^^^^^ CI @ 55:2-8 | ^^^^^^ CI @ 138:0-6 + 56 font-size: 80%; | 139 font-size: 80%; + ^^^^^^^^^^^^^^ CJ @ 56:4-18 | ^^^^^^^^^^^^^^ CJ @ 139:2-16 + | 140 } 57 } | - 58 sub, sup { | 141 sub, - ^ CF @ 58:2 | ^ CF @ 141:0 - 58 sub, sup { | 142 sup { - ^^^^^^^^^ CG @ 58:2-11 | ^^^^ CG @ 142:0-4 - 59 font-size: 75%; | 143 font-size: 75%; - ^^^^^^^^^^^^^^ CH @ 59:4-18 | ^^^^^^^^^^^^^^ CH @ 143:2-16 - 60 line-height: 0; | 144 line-height: 0; - ^^^^^^^^^^^^^^ CI @ 60:4-18 | ^^^^^^^^^^^^^^ CI @ 144:2-16 - 61 position: relative; | 145 position: relative; - ^^^^^^^^^^^^^^^^^^ CJ @ 61:4-22 | ^^^^^^^^^^^^^^^^^^ CJ @ 145:2-20 - 62 vertical-align: baseline; | 146 vertical-align: baseline; - ^^^^^^^^^^^^^^^^^^^^^^^^ CK @ 62:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ CK @ 146:2-26 - | 147 } + 58 sub, sup { | 146 sub, + ^ CK @ 58:2 | ^ CK @ 146:0 + 58 sub, sup { | 147 sup { + ^^^^^^^^^ CL @ 58:2-11 | ^^^^ CL @ 147:0-4 + 59 font-size: 75%; | 148 font-size: 75%; + ^^^^^^^^^^^^^^ CM @ 59:4-18 | ^^^^^^^^^^^^^^ CM @ 148:2-16 + 60 line-height: 0; | 149 line-height: 0; + ^^^^^^^^^^^^^^ CN @ 60:4-18 | ^^^^^^^^^^^^^^ CN @ 149:2-16 + 61 position: relative; | 150 position: relative; + ^^^^^^^^^^^^^^^^^^ CO @ 61:4-22 | ^^^^^^^^^^^^^^^^^^ CO @ 150:2-20 + 62 vertical-align: baseline; | 151 vertical-align: baseline; + ^^^^^^^^^^^^^^^^^^^^^^^^ CP @ 62:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ CP @ 151:2-26 + | 152 } 63 } | - 64 sub { | 149 sub { - ^^^^ CL @ 64:2-6 | ^^^^ CL @ 149:0-4 - 65 bottom: -0.25em; | 150 bottom: -0.25em; - ^^^^^^^^^^^^^^^ CM @ 65:4-19 | ^^^^^^^^^^^^^^^ CM @ 150:2-17 - | 151 } + 64 sub { | 154 sub { + ^^^^ CQ @ 64:2-6 | ^^^^ CQ @ 154:0-4 + 65 bottom: -0.25em; | 155 bottom: -0.25em; + ^^^^^^^^^^^^^^^ CR @ 65:4-19 | ^^^^^^^^^^^^^^^ CR @ 155:2-17 + | 156 } 66 } | - 67 sup { | 153 sup { - ^^^^ CN @ 67:2-6 | ^^^^ CN @ 153:0-4 - 68 top: -0.5em; | 154 top: -0.5em; - ^^^^^^^^^^^ CO @ 68:4-15 | ^^^^^^^^^^^ CO @ 154:2-13 - | 155 } + 67 sup { | 158 sup { + ^^^^ CS @ 67:2-6 | ^^^^ CS @ 158:0-4 + 68 top: -0.5em; | 159 top: -0.5em; + ^^^^^^^^^^^ CT @ 68:4-15 | ^^^^^^^^^^^ CT @ 159:2-13 + | 160 } 69 } | - 70 table { | 163 table { - ^^^^^^ CP @ 70:2-8 | ^^^^^^ CP @ 163:0-6 - 71 text-indent: 0; | 164 text-indent: 0; /* 1 */ - ^^^^^^^^^^^^^^ CQ @ 71:4-18 | ^^^^^^^^^^^^^^ CQ @ 164:2-16 - 72 border-color: inherit; | 165 border-color: inherit; /* 2 */ - ^^^^^^^^^^^^^^^^^^^^^ CR @ 72:4-25 | ^^^^^^^^^^^^^^^^^^^^^ CR @ 165:2-23 - 73 border-collapse: collapse; | 166 border-collapse: collapse; /* 3 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^ CS @ 73:4-29 | ^^^^^^^^^^^^^^^^^^^^^^^^^ CS @ 166:2-27 - | 167 } + 70 table { | 168 table { + ^^^^^^ CU @ 70:2-8 | ^^^^^^ CU @ 168:0-6 + 71 text-indent: 0; | 169 text-indent: 0; /* 1 */ + ^^^^^^^^^^^^^^ CV @ 71:4-18 | ^^^^^^^^^^^^^^ CV @ 169:2-16 + 72 border-color: inherit; | 170 border-color: inherit; /* 2 */ + ^^^^^^^^^^^^^^^^^^^^^ CW @ 72:4-25 | ^^^^^^^^^^^^^^^^^^^^^ CW @ 170:2-23 + 73 border-collapse: collapse; | 171 border-collapse: collapse; /* 3 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^ CX @ 73:4-29 | ^^^^^^^^^^^^^^^^^^^^^^^^^ CX @ 171:2-27 + | 172 } 74 } | - 75 :-moz-focusring:where(:not(iframe)) { | 173 :-moz-focusring:where(:not(iframe)) { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CT @ 75:2-38 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CT @ 173:0-36 - 76 outline: auto; | 174 outline: auto; - ^^^^^^^^^^^^^ CU @ 76:4-17 | ^^^^^^^^^^^^^ CU @ 174:2-15 - | 175 } + 75 :-moz-focusring:where(:not(iframe)) { | 178 :-moz-focusring:where(:not(iframe)) { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CY @ 75:2-38 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ CY @ 178:0-36 + 76 outline: auto; | 179 outline: auto; + ^^^^^^^^^^^^^ CZ @ 76:4-17 | ^^^^^^^^^^^^^ CZ @ 179:2-15 + | 180 } 77 } | - 78 progress { | 181 progress { - ^^^^^^^^^ CV @ 78:2-11 | ^^^^^^^^^ CV @ 181:0-9 - 79 vertical-align: baseline; | 182 vertical-align: baseline; - ^^^^^^^^^^^^^^^^^^^^^^^^ CW @ 79:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ CW @ 182:2-26 - | 183 } + 78 progress { | 186 progress { + ^^^^^^^^^ DA @ 78:2-11 | ^^^^^^^^^ DA @ 186:0-9 + 79 vertical-align: baseline; | 187 vertical-align: baseline; + ^^^^^^^^^^^^^^^^^^^^^^^^ DB @ 79:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ DB @ 187:2-26 + | 188 } 80 } | - 81 summary { | 189 summary { - ^^^^^^^^ CX @ 81:2-10 | ^^^^^^^^ CX @ 189:0-8 - 82 display: list-item; | 190 display: list-item; - ^^^^^^^^^^^^^^^^^^ CY @ 82:4-22 | ^^^^^^^^^^^^^^^^^^ CY @ 190:2-20 - | 191 } + 81 summary { | 194 summary { + ^^^^^^^^ DC @ 81:2-10 | ^^^^^^^^ DC @ 194:0-8 + 82 display: list-item; | 195 display: list-item; + ^^^^^^^^^^^^^^^^^^ DD @ 82:4-22 | ^^^^^^^^^^^^^^^^^^ DD @ 195:2-20 + | 196 } 83 } | - 84 ol, ul, menu { | 197 ol, - ^ CZ @ 84:2 | ^ CZ @ 197:0 - 84 ol, ul, menu { | 198 ul, - ^ DA @ 84:2 | ^ DA @ 198:0 - 84 ol, ul, menu { | 199 menu { - ^^^^^^^^^^^^^ DB @ 84:2-15 | ^^^^^ DB @ 199:0-5 - 85 list-style: none; | 200 list-style: none; - ^^^^^^^^^^^^^^^^ DC @ 85:4-20 | ^^^^^^^^^^^^^^^^ DC @ 200:2-18 - | 201 } + 84 ol, ul, menu { | 202 ol, + ^ DE @ 84:2 | ^ DE @ 202:0 + 84 ol, ul, menu { | 203 ul, + ^ DF @ 84:2 | ^ DF @ 203:0 + 84 ol, ul, menu { | 204 menu { + ^^^^^^^^^^^^^ DG @ 84:2-15 | ^^^^^ DG @ 204:0-5 + 85 list-style: none; | 205 list-style: none; + ^^^^^^^^^^^^^^^^ DH @ 85:4-20 | ^^^^^^^^^^^^^^^^ DH @ 205:2-18 + | 206 } 86 } | - 87 img, svg, video, canvas, audio, iframe, embed, object { | 209 img, - ^ DD @ 87:2 | ^ DD @ 209:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 210 svg, - ^ DE @ 87:2 | ^ DE @ 210:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 211 video, - ^ DF @ 87:2 | ^ DF @ 211:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 212 canvas, - ^ DG @ 87:2 | ^ DG @ 212:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 213 audio, - ^ DH @ 87:2 | ^ DH @ 213:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 214 iframe, + 87 img, svg, video, canvas, audio, iframe, embed, object { | 214 img, ^ DI @ 87:2 | ^ DI @ 214:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 215 embed, + 87 img, svg, video, canvas, audio, iframe, embed, object { | 215 svg, ^ DJ @ 87:2 | ^ DJ @ 215:0 - 87 img, svg, video, canvas, audio, iframe, embed, object { | 216 object { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DK @ 87:2-56 | ^^^^^^^ DK @ 216:0-7 - 88 display: block; | 217 display: block; /* 1 */ - ^^^^^^^^^^^^^^ DL @ 88:4-18 | ^^^^^^^^^^^^^^ DL @ 217:2-16 - 89 vertical-align: middle; | 218 vertical-align: middle; /* 2 */ - ^^^^^^^^^^^^^^^^^^^^^^ DM @ 89:4-26 | ^^^^^^^^^^^^^^^^^^^^^^ DM @ 218:2-24 - | 219 } + 87 img, svg, video, canvas, audio, iframe, embed, object { | 216 video, + ^ DK @ 87:2 | ^ DK @ 216:0 + 87 img, svg, video, canvas, audio, iframe, embed, object { | 217 canvas, + ^ DL @ 87:2 | ^ DL @ 217:0 + 87 img, svg, video, canvas, audio, iframe, embed, object { | 218 audio, + ^ DM @ 87:2 | ^ DM @ 218:0 + 87 img, svg, video, canvas, audio, iframe, embed, object { | 219 iframe, + ^ DN @ 87:2 | ^ DN @ 219:0 + 87 img, svg, video, canvas, audio, iframe, embed, object { | 220 embed, + ^ DO @ 87:2 | ^ DO @ 220:0 + 87 img, svg, video, canvas, audio, iframe, embed, object { | 221 object { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DP @ 87:2-56 | ^^^^^^^ DP @ 221:0-7 + 88 display: block; | 222 display: block; /* 1 */ + ^^^^^^^^^^^^^^ DQ @ 88:4-18 | ^^^^^^^^^^^^^^ DQ @ 222:2-16 + 89 vertical-align: middle; | 223 vertical-align: middle; /* 2 */ + ^^^^^^^^^^^^^^^^^^^^^^ DR @ 89:4-26 | ^^^^^^^^^^^^^^^^^^^^^^ DR @ 223:2-24 + | 224 } 90 } | - 91 img, video { | 225 img, - ^ DN @ 91:2 | ^ DN @ 225:0 - 91 img, video { | 226 video { - ^^^^^^^^^^^ DO @ 91:2-13 | ^^^^^^ DO @ 226:0-6 - 92 max-width: 100%; | 227 max-width: 100%; - ^^^^^^^^^^^^^^^ DP @ 92:4-19 | ^^^^^^^^^^^^^^^ DP @ 227:2-17 - 93 height: auto; | 228 height: auto; - ^^^^^^^^^^^^ DQ @ 93:4-16 | ^^^^^^^^^^^^ DQ @ 228:2-14 - | 229 } + 91 img, video { | 230 img, + ^ DS @ 91:2 | ^ DS @ 230:0 + 91 img, video { | 231 video { + ^^^^^^^^^^^ DT @ 91:2-13 | ^^^^^^ DT @ 231:0-6 + 92 max-width: 100%; | 232 max-width: 100%; + ^^^^^^^^^^^^^^^ DU @ 92:4-19 | ^^^^^^^^^^^^^^^ DU @ 232:2-17 + 93 height: auto; | 233 height: auto; + ^^^^^^^^^^^^ DV @ 93:4-16 | ^^^^^^^^^^^^ DV @ 233:2-14 + | 234 } 94 } | - 95 button, input, select, optgroup, textarea, ::file-selector-button { | 238 button, - ^ DR @ 95:2 | ^ DR @ 238:0 - 95 button, input, select, optgroup, textarea, ::file-selector-button { | 239 input, - ^ DS @ 95:2 | ^ DS @ 239:0 - 95 button, input, select, optgroup, textarea, ::file-selector-button { | 240 select, - ^ DT @ 95:2 | ^ DT @ 240:0 - 95 button, input, select, optgroup, textarea, ::file-selector-button { | 241 optgroup, - ^ DU @ 95:2 | ^ DU @ 241:0 - 95 button, input, select, optgroup, textarea, ::file-selector-button { | 242 textarea, - ^ DV @ 95:2 | ^ DV @ 242:0 - 95 button, input, select, optgroup, textarea, ::file-selector-button { | 243 ::file-selector-button { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DW @ 95:2-68 | ^^^^^^^^^^^^^^^^^^^^^^^ DW @ 243:0-23 - 96 font: inherit; | 244 font: inherit; /* 1 */ - ^^^^^^^^^^^^^ DX @ 96:4-17 | ^^^^^^^^^^^^^ DX @ 244:2-15 - 97 font-feature-settings: inherit; | 245 font-feature-settings: inherit; /* 1 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DY @ 97:4-34 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DY @ 245:2-32 - 98 font-variation-settings: inherit; | 246 font-variation-settings: inherit; /* 1 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DZ @ 98:4-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DZ @ 246:2-34 - 99 letter-spacing: inherit; | 247 letter-spacing: inherit; /* 1 */ - ^^^^^^^^^^^^^^^^^^^^^^^ EA @ 99:4-27 | ^^^^^^^^^^^^^^^^^^^^^^^ EA @ 247:2-25 - 100 color: inherit; | 248 color: inherit; /* 1 */ - ^^^^^^^^^^^^^^ EB @ 100:4-18 | ^^^^^^^^^^^^^^ EB @ 248:2-16 - 101 border-radius: 0; | 249 border-radius: 0; /* 2 */ - ^^^^^^^^^^^^^^^^ EC @ 101:4-20 | ^^^^^^^^^^^^^^^^ EC @ 249:2-18 - 102 background-color: transparent; | 250 background-color: transparent; /* 3 */ - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ED @ 102:4-33 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ED @ 250:2-31 - 103 opacity: 1; | 251 opacity: 1; /* 4 */ - ^^^^^^^^^^ EE @ 103:4-14 | ^^^^^^^^^^ EE @ 251:2-12 - | 252 } + 95 button, input, select, optgroup, textarea, ::file-selector-button { | 243 button, + ^ DW @ 95:2 | ^ DW @ 243:0 + 95 button, input, select, optgroup, textarea, ::file-selector-button { | 244 input, + ^ DX @ 95:2 | ^ DX @ 244:0 + 95 button, input, select, optgroup, textarea, ::file-selector-button { | 245 select, + ^ DY @ 95:2 | ^ DY @ 245:0 + 95 button, input, select, optgroup, textarea, ::file-selector-button { | 246 optgroup, + ^ DZ @ 95:2 | ^ DZ @ 246:0 + 95 button, input, select, optgroup, textarea, ::file-selector-button { | 247 textarea, + ^ EA @ 95:2 | ^ EA @ 247:0 + 95 button, input, select, optgroup, textarea, ::file-selector-button { | 248 ::file-selector-button { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EB @ 95:2-68 | ^^^^^^^^^^^^^^^^^^^^^^^ EB @ 248:0-23 + 96 font: inherit; | 249 font: inherit; /* 1 */ + ^^^^^^^^^^^^^ EC @ 96:4-17 | ^^^^^^^^^^^^^ EC @ 249:2-15 + 97 font-feature-settings: inherit; | 250 font-feature-settings: inherit; /* 1 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ED @ 97:4-34 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ED @ 250:2-32 + 98 font-variation-settings: inherit; | 251 font-variation-settings: inherit; /* 1 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EE @ 98:4-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EE @ 251:2-34 + 99 letter-spacing: inherit; | 252 letter-spacing: inherit; /* 1 */ + ^^^^^^^^^^^^^^^^^^^^^^^ EF @ 99:4-27 | ^^^^^^^^^^^^^^^^^^^^^^^ EF @ 252:2-25 + 100 color: inherit; | 253 color: inherit; /* 1 */ + ^^^^^^^^^^^^^^ EG @ 100:4-18 | ^^^^^^^^^^^^^^ EG @ 253:2-16 + 101 border-radius: 0; | 254 border-radius: 0; /* 2 */ + ^^^^^^^^^^^^^^^^ EH @ 101:4-20 | ^^^^^^^^^^^^^^^^ EH @ 254:2-18 + 102 background-color: transparent; | 255 background-color: transparent; /* 3 */ + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EI @ 102:4-33 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EI @ 255:2-31 + 103 opacity: 1; | 256 opacity: 1; /* 4 */ + ^^^^^^^^^^ EJ @ 103:4-14 | ^^^^^^^^^^ EJ @ 256:2-12 + | 257 } 104 } | - 105 :where(select:is([multiple], [size])) optgroup { | 258 :where(select:is([multiple], [size])) optgroup { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EF @ 105:2-49 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EF @ 258:0-47 - 106 font-weight: bolder; | 259 font-weight: bolder; - ^^^^^^^^^^^^^^^^^^^ EG @ 106:4-23 | ^^^^^^^^^^^^^^^^^^^ EG @ 259:2-21 - | 260 } + 105 :where(select:is([multiple], [size])) optgroup { | 263 :where(select:is([multiple], [size])) optgroup { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EK @ 105:2-49 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EK @ 263:0-47 + 106 font-weight: bolder; | 264 font-weight: bolder; + ^^^^^^^^^^^^^^^^^^^ EL @ 106:4-23 | ^^^^^^^^^^^^^^^^^^^ EL @ 264:2-21 + | 265 } 107 } | - 108 :where(select:is([multiple], [size])) optgroup option { | 266 :where(select:is([multiple], [size])) optgroup option { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EH @ 108:2-56 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EH @ 266:0-54 - 109 padding-inline-start: 20px; | 267 padding-inline-start: 20px; - ^^^^^^^^^^^^^^^^^^^^^^^^^^ EI @ 109:4-30 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ EI @ 267:2-28 - | 268 } + 108 :where(select:is([multiple], [size])) optgroup option { | 271 :where(select:is([multiple], [size])) optgroup option { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EM @ 108:2-56 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EM @ 271:0-54 + 109 padding-inline-start: 20px; | 272 padding-inline-start: 20px; + ^^^^^^^^^^^^^^^^^^^^^^^^^^ EN @ 109:4-30 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ EN @ 272:2-28 + | 273 } 110 } | - 111 ::file-selector-button { | 274 ::file-selector-button { - ^^^^^^^^^^^^^^^^^^^^^^^ EJ @ 111:2-25 | ^^^^^^^^^^^^^^^^^^^^^^^ EJ @ 274:0-23 - 112 margin-inline-end: 4px; | 275 margin-inline-end: 4px; - ^^^^^^^^^^^^^^^^^^^^^^ EK @ 112:4-26 | ^^^^^^^^^^^^^^^^^^^^^^ EK @ 275:2-24 - | 276 } + 111 ::file-selector-button { | 279 ::file-selector-button { + ^^^^^^^^^^^^^^^^^^^^^^^ EO @ 111:2-25 | ^^^^^^^^^^^^^^^^^^^^^^^ EO @ 279:0-23 + 112 margin-inline-end: 4px; | 280 margin-inline-end: 4px; + ^^^^^^^^^^^^^^^^^^^^^^ EP @ 112:4-26 | ^^^^^^^^^^^^^^^^^^^^^^ EP @ 280:2-24 + | 281 } 113 } | - 114 ::placeholder { | 282 ::placeholder { - ^^^^^^^^^^^^^^ EL @ 114:2-16 | ^^^^^^^^^^^^^^ EL @ 282:0-14 - 115 opacity: 1; | 283 opacity: 1; - ^^^^^^^^^^ EM @ 115:4-14 | ^^^^^^^^^^ EM @ 283:2-12 - | 284 } + 114 ::placeholder { | 287 ::placeholder { + ^^^^^^^^^^^^^^ EQ @ 114:2-16 | ^^^^^^^^^^^^^^ EQ @ 287:0-14 + 115 opacity: 1; | 288 opacity: 1; + ^^^^^^^^^^ ER @ 115:4-14 | ^^^^^^^^^^ ER @ 288:2-12 + | 289 } 116 } | - 117 @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { | 291 @supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or - ^ EN @ 117:2 | ^ EN @ 291:0 - 117 @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { | 292 (contain-intrinsic-size: 1px) /* Safari 17+ */ { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... EO @ 117:2-92 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EO @ 292:0-49 - 118 ::placeholder { | 293 ::placeholder { - ^^^^^^^^^^^^^^ EP @ 118:4-18 | ^^^^^^^^^^^^^^ EP @ 293:2-16 - 119 color: currentcolor; | 294 color: color-mix(in oklab, currentcolor 50%, transparent); - ^^^^^^^^^^^^^^^^^^^ EQ @ 119:6-25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EQ @ 294:4-61 - | 295 } - | 296 } + 117 @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { | 296 @supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or + ^ ES @ 117:2 | ^ ES @ 296:0 + 117 @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { | 297 (contain-intrinsic-size: 1px) /* Safari 17+ */ { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... ET @ 117:2-92 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ET @ 297:0-49 + 118 ::placeholder { | 298 ::placeholder { + ^^^^^^^^^^^^^^ EU @ 118:4-18 | ^^^^^^^^^^^^^^ EU @ 298:2-16 + 119 color: currentcolor; | 299 color: color-mix(in oklab, currentcolor 50%, transparent); + ^^^^^^^^^^^^^^^^^^^ EV @ 119:6-25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EV @ 299:4-61 + | 300 } + | 301 } 120 @supports (color: color-mix(in lab, red, red)) { | - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EQ @ 120:6-53 | + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EV @ 120:6-53 | 121 color: color-mix(in oklab, currentcolor 50%, transparent); | - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EQ @ 121:8-65 | + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EV @ 121:8-65 | 122 } | 123 } | 124 } | - 125 textarea { | 302 textarea { - ^^^^^^^^^ ER @ 125:2-11 | ^^^^^^^^^ ER @ 302:0-9 - 126 resize: vertical; | 303 resize: vertical; - ^^^^^^^^^^^^^^^^ ES @ 126:4-20 | ^^^^^^^^^^^^^^^^ ES @ 303:2-18 - | 304 } + 125 textarea { | 307 textarea { + ^^^^^^^^^ EW @ 125:2-11 | ^^^^^^^^^ EW @ 307:0-9 + 126 resize: vertical; | 308 resize: vertical; + ^^^^^^^^^^^^^^^^ EX @ 126:4-20 | ^^^^^^^^^^^^^^^^ EX @ 308:2-18 + | 309 } 127 } | - 128 ::-webkit-search-decoration { | 310 ::-webkit-search-decoration { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ET @ 128:2-30 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ET @ 310:0-28 - 129 -webkit-appearance: none; | 311 -webkit-appearance: none; - ^^^^^^^^^^^^^^^^^^^^^^^^ EU @ 129:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ EU @ 311:2-26 - | 312 } + 128 ::-webkit-search-decoration { | 315 ::-webkit-search-decoration { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EY @ 128:2-30 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EY @ 315:0-28 + 129 -webkit-appearance: none; | 316 -webkit-appearance: none; + ^^^^^^^^^^^^^^^^^^^^^^^^ EZ @ 129:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ EZ @ 316:2-26 + | 317 } 130 } | - 131 ::-webkit-date-and-time-value { | 319 ::-webkit-date-and-time-value { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EV @ 131:2-32 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ EV @ 319:0-30 - 132 min-height: 1lh; | 320 min-height: 1lh; /* 1 */ - ^^^^^^^^^^^^^^^ EW @ 132:4-19 | ^^^^^^^^^^^^^^^ EW @ 320:2-17 - 133 text-align: inherit; | 321 text-align: inherit; /* 2 */ - ^^^^^^^^^^^^^^^^^^^ EX @ 133:4-23 | ^^^^^^^^^^^^^^^^^^^ EX @ 321:2-21 - | 322 } + 131 ::-webkit-date-and-time-value { | 324 ::-webkit-date-and-time-value { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FA @ 131:2-32 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FA @ 324:0-30 + 132 min-height: 1lh; | 325 min-height: 1lh; /* 1 */ + ^^^^^^^^^^^^^^^ FB @ 132:4-19 | ^^^^^^^^^^^^^^^ FB @ 325:2-17 + 133 text-align: inherit; | 326 text-align: inherit; /* 2 */ + ^^^^^^^^^^^^^^^^^^^ FC @ 133:4-23 | ^^^^^^^^^^^^^^^^^^^ FC @ 326:2-21 + | 327 } 134 } | - 135 ::-webkit-datetime-edit { | 328 ::-webkit-datetime-edit { - ^^^^^^^^^^^^^^^^^^^^^^^^ EY @ 135:2-26 | ^^^^^^^^^^^^^^^^^^^^^^^^ EY @ 328:0-24 - 136 display: inline-flex; | 329 display: inline-flex; - ^^^^^^^^^^^^^^^^^^^^ EZ @ 136:4-24 | ^^^^^^^^^^^^^^^^^^^^ EZ @ 329:2-22 - | 330 } + 135 ::-webkit-datetime-edit { | 333 ::-webkit-datetime-edit { + ^^^^^^^^^^^^^^^^^^^^^^^^ FD @ 135:2-26 | ^^^^^^^^^^^^^^^^^^^^^^^^ FD @ 333:0-24 + 136 display: inline-flex; | 334 display: inline-flex; + ^^^^^^^^^^^^^^^^^^^^ FE @ 136:4-24 | ^^^^^^^^^^^^^^^^^^^^ FE @ 334:2-22 + | 335 } 137 } | - 138 ::-webkit-datetime-edit-fields-wrapper { | 336 ::-webkit-datetime-edit-fields-wrapper { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FA @ 138:2-41 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FA @ 336:0-39 - 139 padding: 0; | 337 padding: 0; - ^^^^^^^^^^ FB @ 139:4-14 | ^^^^^^^^^^ FB @ 337:2-12 - | 338 } + 138 ::-webkit-datetime-edit-fields-wrapper { | 341 ::-webkit-datetime-edit-fields-wrapper { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FF @ 138:2-41 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FF @ 341:0-39 + 139 padding: 0; | 342 padding: 0; + ^^^^^^^^^^ FG @ 139:4-14 | ^^^^^^^^^^ FG @ 342:2-12 + | 343 } 140 } | - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 340 ::-webkit-datetime-edit, - ^ FC @ 141:2 | ^ FC @ 340:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 341 ::-webkit-datetime-edit-year-field, - ^ FD @ 141:2 | ^ FD @ 341:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 342 ::-webkit-datetime-edit-month-field, - ^ FE @ 141:2 | ^ FE @ 342:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 343 ::-webkit-datetime-edit-day-field, - ^ FF @ 141:2 | ^ FF @ 343:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 344 ::-webkit-datetime-edit-hour-field, - ^ FG @ 141:2 | ^ FG @ 344:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 345 ::-webkit-datetime-edit-minute-field, + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 345 ::-webkit-datetime-edit, ^ FH @ 141:2 | ^ FH @ 345:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 346 ::-webkit-datetime-edit-second-field, + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 346 ::-webkit-datetime-edit-year-field, ^ FI @ 141:2 | ^ FI @ 346:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 347 ::-webkit-datetime-edit-millisecond-field, + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 347 ::-webkit-datetime-edit-month-field, ^ FJ @ 141:2 | ^ FJ @ 347:0 - 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 348 ::-webkit-datetime-edit-meridiem-field { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... FK @ 141:2-329 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FK @ 348:0-39 - 142 padding-block: 0; | 349 padding-block: 0; - ^^^^^^^^^^^^^^^^ FL @ 142:4-20 | ^^^^^^^^^^^^^^^^ FL @ 349:2-18 - | 350 } + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 348 ::-webkit-datetime-edit-day-field, + ^ FK @ 141:2 | ^ FK @ 348:0 + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 349 ::-webkit-datetime-edit-hour-field, + ^ FL @ 141:2 | ^ FL @ 349:0 + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 350 ::-webkit-datetime-edit-minute-field, + ^ FM @ 141:2 | ^ FM @ 350:0 + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 351 ::-webkit-datetime-edit-second-field, + ^ FN @ 141:2 | ^ FN @ 351:0 + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 352 ::-webkit-datetime-edit-millisecond-field, + ^ FO @ 141:2 | ^ FO @ 352:0 + 141 ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month... | 353 ::-webkit-datetime-edit-meridiem-field { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... FP @ 141:2-329 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FP @ 353:0-39 + 142 padding-block: 0; | 354 padding-block: 0; + ^^^^^^^^^^^^^^^^ FQ @ 142:4-20 | ^^^^^^^^^^^^^^^^ FQ @ 354:2-18 + | 355 } 143 } | - 144 ::-webkit-calendar-picker-indicator { | 356 ::-webkit-calendar-picker-indicator { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FM @ 144:2-38 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FM @ 356:0-36 - 145 line-height: 1; | 357 line-height: 1; - ^^^^^^^^^^^^^^ FN @ 145:4-18 | ^^^^^^^^^^^^^^ FN @ 357:2-16 - | 358 } + 144 ::-webkit-calendar-picker-indicator { | 361 ::-webkit-calendar-picker-indicator { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FR @ 144:2-38 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FR @ 361:0-36 + 145 line-height: 1; | 362 line-height: 1; + ^^^^^^^^^^^^^^ FS @ 145:4-18 | ^^^^^^^^^^^^^^ FS @ 362:2-16 + | 363 } 146 } | - 147 :-moz-ui-invalid { | 364 :-moz-ui-invalid { - ^^^^^^^^^^^^^^^^^ FO @ 147:2-19 | ^^^^^^^^^^^^^^^^^ FO @ 364:0-17 - 148 box-shadow: none; | 365 box-shadow: none; - ^^^^^^^^^^^^^^^^ FP @ 148:4-20 | ^^^^^^^^^^^^^^^^ FP @ 365:2-18 - | 366 } + 147 :-moz-ui-invalid { | 369 :-moz-ui-invalid { + ^^^^^^^^^^^^^^^^^ FT @ 147:2-19 | ^^^^^^^^^^^^^^^^^ FT @ 369:0-17 + 148 box-shadow: none; | 370 box-shadow: none; + ^^^^^^^^^^^^^^^^ FU @ 148:4-20 | ^^^^^^^^^^^^^^^^ FU @ 370:2-18 + | 371 } 149 } | - 150 button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-but... | 372 button, - ^ FQ @ 150:2 | ^ FQ @ 372:0 - 150 button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-but... | 373 input:where([type='button'], [type='reset'], [type='submit']), - ^ FR @ 150:2 | ^ FR @ 373:0 - 150 button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-but... | 374 ::file-selector-button { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... FS @ 150:2-96 | ^^^^^^^^^^^^^^^^^^^^^^^ FS @ 374:0-23 - 151 appearance: button; | 375 appearance: button; - ^^^^^^^^^^^^^^^^^^ FT @ 151:4-22 | ^^^^^^^^^^^^^^^^^^ FT @ 375:2-20 - | 376 } + 150 button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-but... | 377 button, + ^ FV @ 150:2 | ^ FV @ 377:0 + 150 button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-but... | 378 input:where([type='button'], [type='reset'], [type='submit']), + ^ FW @ 150:2 | ^ FW @ 378:0 + 150 button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-but... | 379 ::file-selector-button { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^... FX @ 150:2-96 | ^^^^^^^^^^^^^^^^^^^^^^^ FX @ 379:0-23 + 151 appearance: button; | 380 appearance: button; + ^^^^^^^^^^^^^^^^^^ FY @ 151:4-22 | ^^^^^^^^^^^^^^^^^^ FY @ 380:2-20 + | 381 } 152 } | - 153 ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { | 382 ::-webkit-inner-spin-button, - ^ FU @ 153:2 | ^ FU @ 382:0 - 153 ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { | 383 ::-webkit-outer-spin-button { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FV @ 153:2-59 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FV @ 383:0-28 - 154 height: auto; | 384 height: auto; - ^^^^^^^^^^^^ FW @ 154:4-16 | ^^^^^^^^^^^^ FW @ 384:2-14 - | 385 } + 153 ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { | 387 ::-webkit-inner-spin-button, + ^ FZ @ 153:2 | ^ FZ @ 387:0 + 153 ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { | 388 ::-webkit-outer-spin-button { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GA @ 153:2-59 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GA @ 388:0-28 + 154 height: auto; | 389 height: auto; + ^^^^^^^^^^^^ GB @ 154:4-16 | ^^^^^^^^^^^^ GB @ 389:2-14 + | 390 } 155 } | - 156 [hidden]:where(:not([hidden='until-found'])) { | 391 [hidden]:where(:not([hidden='until-found'])) { - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FX @ 156:2-47 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FX @ 391:0-45 - 157 display: none !important; | 392 display: none !important; - ^^^^^^^^^^^^^^^^^^^^^^^^ FY @ 157:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ FY @ 392:2-26 - | 393 } + 156 [hidden]:where(:not([hidden='until-found'])) { | 396 [hidden]:where(:not([hidden='until-found'])) { + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GC @ 156:2-47 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GC @ 396:0-45 + 157 display: none !important; | 397 display: none !important; + ^^^^^^^^^^^^^^^^^^^^^^^^ GD @ 157:4-28 | ^^^^^^^^^^^^^^^^^^^^^^^^ GD @ 397:2-26 + | 398 } 158 } | 159 } | | --- index.css --- 160 @layer utilities; | 5 @import './utilities.css' layer(utilities); - ^^^^^^^^^^^^^^^^ FZ @ 160:0-16 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FZ @ 5:0-42 + ^^^^^^^^^^^^^^^^ GE @ 160:0-16 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GE @ 5:0-42 | --- input.css --- 161 .foo { | 3 .foo { - ^^^^^ GA @ 161:0-5 | ^^^^^ GA @ 3:0-5 + ^^^^^ GF @ 161:0-5 | ^^^^^ GF @ 3:0-5 162 text-decoration-line: underline; | 4 @apply underline; - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GB @ 162:2-33 | ^^^^^^^^^ GB @ 4:9-18 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ GG @ 162:2-33 | ^^^^^^^^^ GG @ 4:9-18 | 5 } 163 } | 164 | diff --git a/packages/tailwindcss/theme.css b/packages/tailwindcss/theme.css index 78b0fc588..05364f5c2 100644 --- a/packages/tailwindcss/theme.css +++ b/packages/tailwindcss/theme.css @@ -1,7 +1,7 @@ @theme default { --font-sans: - ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', From a854b35bcfc0ab1ba5f9cf9ac0b0ecd60a57fcb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Rouleau?= <1059139+benface@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:39:24 -0400 Subject: [PATCH 14/60] Prevent `@tailwindcss/upgrade` from rewriting files inside `node_modules` when run from a workspace subpackage (#20329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #20328. ## What changed When `@tailwindcss/upgrade` is invoked from a subpackage of a workspace (e.g. `pnpm --filter …` or `cd packages/foo && pnpm exec upgrade`), it traverses `../../node_modules/…` and applies its v3 → v4 migration transform (`@tailwind utilities;` → `@import 'tailwindcss/utilities' layer(utilities);`) to files inside the installed `tailwindcss` package itself — a self-referential import that later detonates as an infinite CSS-resolution loop and reads like a "cache corruption" bug (matches the report in #19726 / #20328). Root cause: `globby`'s `isGitIgnored` only walks `.gitignore` files at or below its `cwd`. When invoked from a subpackage, the workspace-root `.gitignore` (which almost always lists `node_modules`) is never consulted, and `../../node_modules/…` paths come back as **not** ignored. The fix anchors `isGitIgnored` at the git repository root instead of the subpackage: - New helper: `gitRoot(cwd)` in `src/utils/git.ts` — thin wrapper over `git rev-parse --show-toplevel`. - `analyze(stylesheets, { base })` in `src/codemods/css/analyze.ts` now calls `isGitIgnored({ cwd: gitRoot(base) ?? base })`. Falls back to the previous behavior when not inside a git repository or when `git` is unavailable. ## Test Added an integration test that reproduces the exact scenario: a pnpm workspace with a subpackage that imports `tailwindcss/utilities.css`, upgrade run from the subpackage, then asserts `packages/css/node_modules/tailwindcss/utilities.css` still holds the pristine `@tailwind utilities;` directive. Verified the test fails on `main` and passes with this change. The 415 existing upgrade unit tests still pass. Two npm-related snapshot failures in `upgrade-errors.test.ts` (`half-upgraded v3 project to v4 (bun|npm)`) pre-exist this change — they're about newer npm's `npm notice run` output that isn't in the snapshots, unrelated to what this PR touches. --- Edit by @RobinMalfait: going to run on all [ci-all] so we can make sure it works on Windows as well. --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + integrations/upgrade/index.test.ts | 55 +++++++++++++++++++ .../src/codemods/css/analyze.ts | 7 ++- packages/@tailwindcss-upgrade/src/index.ts | 2 +- .../@tailwindcss-upgrade/src/utils/git.ts | 12 ++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b9c3d4f..fe2b3eb09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319)) - Lazily load `@parcel/watcher` when using the `--watch` flag in `@tailwindcss/cli`, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/issues/20325)) - Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#19767](https://github.com/tailwindlabs/tailwindcss/issues/19767), [#19768](https://github.com/tailwindlabs/tailwindcss/issues/19768)) +- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20328](https://github.com/tailwindlabs/tailwindcss/issues/20328)) ## [4.3.2] - 2026-06-26 diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 9e236e8fd..c8fa4c86b 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -3371,6 +3371,61 @@ test( }, ) +// https://github.com/tailwindlabs/tailwindcss/issues/20328 +test( + 'ignored files should not be touched when upgrading from a nested directory', + { + fs: { + 'pnpm-workspace.yaml': yaml` + # + packages: + - packages/* + `, + 'package.json': json` + { + "name": "root", + "private": true + } + `, + 'packages/css/package.json': json` + { + "name": "css-pkg", + "private": true, + "devDependencies": { + "tailwindcss": "^4", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'packages/css/src/input.css': css` + @import 'tailwindcss/utilities.css' layer(utilities) source(none); + `, + 'packages/css/src/index.html': html`
Hi
`, + + // Ensure files/folders ignored by a `.gitignore` in the root take effect + // when executing the upgrade tool from a sub-package. + '.gitignore': txt` + node_modules/ + `, + }, + }, + async ({ root, exec, fs, expect }) => { + await exec('git init', { cwd: root }) + + await exec('pnpm exec upgrade --force', { + cwd: path.join(root, 'packages/css'), + }) + + expect(await fs.dumpFiles('packages/css/node_modules/tailwindcss/utilities.css')) + .toMatchInlineSnapshot(` + " + --- packages/css/node_modules/tailwindcss/utilities.css --- + @tailwind utilities; + " + `) + }, +) + test( 'upgrade