Merge branch 'main' into feat/ohos-support

This commit is contained in:
social4hyq 2026-07-27 19:56:02 +08:00 • committed by GitHub
commit ac52bfe80e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 4787 additions and 1634 deletions

View file

@ -50,7 +50,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6

View file

@ -58,7 +58,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- run: |
git config --global user.name "github-actions[bot]"

View file

@ -15,7 +15,7 @@ on:
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 24
PNPM_VERSION: '9.6.0'
PNPM_VERSION: '11.9.0'
OXIDE_LOCATION: ./crates/node
permissions:
@ -79,7 +79,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
@ -220,7 +220,7 @@ jobs:
run: |
echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}

View file

@ -22,7 +22,7 @@ permissions:
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 24
PNPM_VERSION: '9.6.0'
PNPM_VERSION: '11.9.0'
OXIDE_LOCATION: ./crates/node
concurrency:
@ -83,7 +83,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
@ -275,7 +275,7 @@ jobs:
fetch-depth: 20
persist-credentials: false
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
@ -365,7 +365,7 @@ jobs:
run: |
pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${RELEASE_CHANNEL} --no-git-checks
# The wasm package needs a special npm config that isn't read when pnpm --recursive is used
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${RELEASE_CHANNEL} --no-git-checks; popd;
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${RELEASE_CHANNEL} --no-git-checks --config.node-linker=hoisted; popd;
- name: Trigger Tailwind Play update
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@ -378,3 +378,38 @@ jobs:
ref: 'main',
workflow_id: 'upgrade-tailwindcss.yml'
})
notify:
if: ${{ always() && (needs.build.result == 'failure' || needs.build-freebsd.result == 'failure' || needs.release.result == 'failure') }}
needs:
- build
- build-freebsd
- release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Resolve release label
id: release
env:
INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }}
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
tag_name="${RELEASE_TAG:-${GITHUB_REF_NAME}}"
echo "label=release ${tag_name}" >> $GITHUB_OUTPUT
elif [[ "$INPUT_CHANNEL" == "release" ]]; then
version=$(node -p "require('./packages/tailwindcss/package.json').version")
echo "label=release v${version}" >> $GITHUB_OUTPUT
else
sha_short=$(git rev-parse --short HEAD)
echo "label=insiders release 0.0.0-insiders.${sha_short}" >> $GITHUB_OUTPUT
fi
- name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) for `${{ steps.release.outputs.label }}` has failed.'

View file

@ -7,21 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Nothing yet!
- Support bare spacing values for `auto-rows-*` and `auto-cols-*` utilities (e.g. `auto-rows-12` and `auto-cols-16`) ([#20229](https://github.com/tailwindlabs/tailwindcss/pull/20229))
## [4.3.3] - 2026-07-16
### Fixed
- Ensure `@tailwindcss/cli` in `--watch` mode doesn't crash on Windows when `@source` points to a directory that doesn't exist ([#20242](https://github.com/tailwindlabs/tailwindcss/pull/20242))
- Ensure `@tailwindcss/vite` doesn't crash in Deno v2.8.x when `context.parentURL` is not a valid URL ([#20245](https://github.com/tailwindlabs/tailwindcss/pull/20245))
- Support `--watch --poll[=ms]` in `@tailwindcss/cli` 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))
- Ensure `theme('colors.foo')` in JS plugins resolves correctly when both `--color-foo` and `--color-foo-bar` exist ([#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))
- Parse selectors like `[data-foo]div` as two selectors instead of one ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303))
- Ensure `@tailwindcss/postcss` rebuilds when a preprocessor like Sass changes the input CSS without changing 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, such as in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124))
- Prevent achromatic theme colors from shifting hue when mixed in polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/pull/20314))
- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `<length>` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319))
- Load `@parcel/watcher` only when needed in `@tailwindcss/cli --watch` mode, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/pull/20325))
- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#20318](https://github.com/tailwindlabs/tailwindcss/pull/20318))
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20329](https://github.com/tailwindlabs/tailwindcss/pull/20329))
- Ensure earlier `@source` rules pointing to nested files are scanned when later `@source` rules point to files in parent folders ([#20335](https://github.com/tailwindlabs/tailwindcss/pull/20335))
- Prevent `@tailwindcss/vite` from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet ([#20336](https://github.com/tailwindlabs/tailwindcss/pull/20336))
## [4.3.2] - 2026-06-26
### Fixed
- Support bare spacing values for `auto-rows-*` and `auto-cols-*` utilities (e.g. `auto-rows-12` and `auto-cols-16`) ([#20229](https://github.com/tailwindlabs/tailwindcss/pull/20229))
- Prevent `@tailwindcss/cli` in `--watch` mode from crashing on Windows when `@source` points to a directory that doesn't exist ([#20242](https://github.com/tailwindlabs/tailwindcss/pull/20242))
- Prevent `@tailwindcss/vite` from crashing in Deno v2.8.x when `context.parentURL` is not a valid URL ([#20245](https://github.com/tailwindlabs/tailwindcss/pull/20245))
- Ensure `@tailwindcss/cli` in `--watch` mode rebuilds when the input CSS file changes in an ignored directory ([#20246](https://github.com/tailwindlabs/tailwindcss/pull/20246))
- Ensure `@variant` rules generated by `addBase` can use custom variants defined later ([#20247](https://github.com/tailwindlabs/tailwindcss/pull/20247))
- Ensure `@tailwindcss/vite` doesn't crash during HMR when scanned files or directories are deleted ([#20259](https://github.com/tailwindlabs/tailwindcss/pull/20259))
- Ensure `text-[--spacing(…)]` generates `font-size` instead of `color` ([#20260](https://github.com/tailwindlabs/tailwindcss/pull/20260))
- Allow `@variant` rules used in `addBase(…)` to use custom variants defined later ([#20247](https://github.com/tailwindlabs/tailwindcss/pull/20247))
- Prevent `@tailwindcss/vite` from crashing during HMR when scanned files or directories are deleted ([#20259](https://github.com/tailwindlabs/tailwindcss/pull/20259))
- Generate `font-size` instead of `color` declarations for `text-[--spacing(…)]` ([#20260](https://github.com/tailwindlabs/tailwindcss/pull/20260))
- Prevent `@source` patterns from scanning unrelated sibling files and folders ([#20263](https://github.com/tailwindlabs/tailwindcss/pull/20263))
- Extract class candidates adjacent to Template Toolkit delimiters like `%]…[%` in `.tt`, `.tt2`, and `.tx` files ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269))
- Extract class candidates from conditional Maud syntax like `p.text-black[condition]` ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269))
- Prevent `@position-try` rules from triggering unknown at-rule warnings when optimizing CSS ([#20277](https://github.com/tailwindlabs/tailwindcss/pull/20277))
- Support class suggestions for named opacity modifiers from `--opacity` theme values ([#20287](https://github.com/tailwindlabs/tailwindcss/pull/20287))
- Prevent type errors in `@tailwindcss/postcss` when used with newer PostCSS patch releases ([#20289](https://github.com/tailwindlabs/tailwindcss/pull/20289))
## [4.3.1] - 2026-06-12
@ -4071,7 +4095,9 @@ No release notes
- Everything!
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.1...HEAD
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.3...HEAD
[4.3.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.2...v4.3.3
[4.3.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.1...v4.3.2
[4.3.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.0...v4.3.1
[4.3.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.4...v4.3.0
[4.2.4]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.3...v4.2.4

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-android-arm-eabi",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-android-arm64",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-darwin-arm64",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-darwin-x64",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-freebsd-x64",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm-gnueabihf",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm64-gnu",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm64-musl",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-x64-gnu",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-x64-musl",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1 +0,0 @@
node-linker=hoisted

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-wasm32-wasi",
"version": "4.3.1",
"version": "4.3.3",
"cpu": [
"wasm32"
],

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-win32-arm64-msvc",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-win32-x64-msvc",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide",
"version": "4.3.1",
"version": "4.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -165,6 +165,11 @@ impl Scanner {
self.scanner.get_files()
}
#[napi(getter)]
pub fn scanned_files(&self) -> Vec<String> {
self.scanner.get_scanned_files()
}
#[napi(getter)]
pub fn globs(&mut self) -> Vec<GlobEntry> {
self

View file

@ -78,11 +78,12 @@ pub struct Scanner {
/// Track unique set of candidates
candidates: FxHashSet<String>,
/// 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<PathBuf, SystemTime>,
/// Files that were scanned during the last `scan()` call.
scanned_files: Vec<String>,
/// 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<String> {
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::<Vec<_>>();
@ -256,6 +258,11 @@ impl Scanner {
.collect()
}
#[tracing::instrument(skip_all)]
pub fn get_scanned_files(&self) -> Vec<String> {
self.scanned_files.clone()
}
#[tracing::instrument(skip_all)]
pub fn get_globs(&mut self) -> Vec<GlobEntry> {
if let Some(globs) = &self.globs {
@ -353,14 +360,14 @@ impl Scanner {
}
#[tracing::instrument(skip_all)]
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>) {
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>, Vec<String>) {
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<PathBuf> = 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<SystemTime>);
/// 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<WalkEntry> {
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<WalkEntry> {
}
}
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(Vec::new()));
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(vec![]));
walker.build_parallel().run(|| {
let mut buf = FlushOnDrop {
@ -608,14 +624,15 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
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 {

View file

@ -148,6 +148,16 @@ fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
})
.collect::<Vec<_>>();
// Bases of restricted patterns. Each of these becomes its own walk root with its own
// `*` + `!<pattern>` rules, so an ancestor base must not ignore them recursively.
let pattern_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, .. } => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
let mut restricted_roots: FxHashSet<PathBuf> = FxHashSet::default();
let mut expanded = vec![];
@ -167,14 +177,17 @@ fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
}
// Ignore everything in the directory. We will later add the specific patterns we are
// interested in. When another source root is nested in this base, only ignore direct
// children so the nested source can still be walked from its own root.
// interested in.
if restricted_roots.insert(base.clone()) {
let pattern = if unrestricted_roots.iter().any(|root| root.starts_with(base)) {
"/*"
} else {
"*"
};
// When another source root is nested inside this base — an unrestricted root, or the
// base of another restricted pattern (which is walked from its own root with its own
// rules) — only ignore direct children so the nested root can still be walked.
let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base))
|| pattern_roots
.iter()
.any(|root| root != base && root.starts_with(base));
let pattern = if has_nested_root { "/*" } else { "*" };
expanded.push(SourceEntry::Ignored {
base: base.clone(),

View file

@ -106,6 +106,18 @@ mod scanner {
globs
}
fn normalize_files(files: Vec<String>, base: &Path) -> Vec<String> {
let base_dir =
format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
let mut files = files
.iter()
.map(|file| file.replace('\\', "/").replace(&base_dir, ""))
.collect::<Vec<_>>();
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::<String>::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 {
@ -1238,6 +1297,67 @@ mod scanner {
assert_eq!(files, vec!["src/bar.html", "src/foo.html"]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20333
#[test]
fn it_should_combine_nested_and_root_restricted_sources() {
// The restriction (`*`) added for the root-level file must not prevent walking into the
// `nested` folder that another explicit source points into. At the same time, relaxing
// that restriction to `/*` must not accidentally open up sibling folders (`ignore-me`)
// or files that no explicit source points at.
let paths_with_content = &[
("nested/component.html", "content-['nested/component.html']"),
("nested/ignore-me.html", "content-['nested/ignore-me.html']"),
("ignore-me/component.html", "content-['ignore-me']"),
("component-sources.classes.txt", "content-['classes.txt']"),
("ignore-me.txt", "content-['ignore-me.txt']"),
];
let ScanResult {
candidates, files, ..
} = scan_with_globs(
paths_with_content,
vec![
"@source './nested/component.html'",
"@source './component-sources.classes.txt'",
],
);
assert_eq!(
candidates,
vec![
"content-['classes.txt']",
"content-['nested/component.html']"
]
);
assert_eq!(
files,
vec!["component-sources.classes.txt", "nested/component.html"]
);
// Same setup, but with the root-level source declared first
let ScanResult {
candidates, files, ..
} = scan_with_globs(
paths_with_content,
vec![
"@source './component-sources.classes.txt'",
"@source './nested/component.html'",
],
);
assert_eq!(
candidates,
vec![
"content-['classes.txt']",
"content-['nested/component.html']"
]
);
assert_eq!(
files,
vec!["component-sources.classes.txt", "nested/component.html"]
);
}
#[test]
fn it_should_allow_later_ignores_to_override_restricted_sources() {
let ScanResult {

View file

@ -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`
<div class="underline"></div>
`,
},
},
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`
<div class="underline flex"></div>
`)
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
},
)
test(
"watch mode with unknown @source paths shouldn't crash on Windows",
{
@ -639,6 +673,15 @@ describe.each([
'git ignore files outside of a repo are not considered',
{
fs: {
// Make the test root a pnpm workspace that includes `home/project` so
// the install below resolves the transitive dependency overrides that
// the test harness injects into this `pnpm-workspace.yaml`.
'pnpm-workspace.yaml': yaml`
#
packages:
- home/project
`,
// Ignore everything in the "home" directory
'home/.gitignore': '*',
@ -676,7 +719,7 @@ describe.each([
installDependencies: false,
},
async ({ fs, root, exec }) => {
await exec(`pnpm install --ignore-workspace`, {
await exec(`pnpm install`, {
cwd: path.join(root, 'home/project'),
})
@ -2788,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);
@ -2808,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;
@ -2860,7 +2903,7 @@ test(
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {

View file

@ -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;
}
}
}

View file

@ -5,6 +5,7 @@
"devDependencies": {
"dedent": "catalog:",
"fast-glob": "^3.3.3",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.1",
"yaml": "^2.9.0"
}
}

View file

@ -28,7 +28,7 @@ test(
},
},
async ({ fs, exec, expect }) => {
let output = await exec('npx @tailwindcss/upgrade')
let output = await exec('pnpm exec upgrade')
expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.')
// Files should not be modified
@ -100,7 +100,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -158,7 +158,7 @@ test(
})
// Ensure the v4 project compiles correctly
await exec('npx tailwindcss --input src/input.css --output dist/out.css')
await exec('pnpm exec tailwindcss --input src/input.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [
candidate`flex!`,
@ -202,9 +202,10 @@ test(
`,
'notes/unrelated.txt': `order-[0] bg-[--my-red]`,
},
retry: 0,
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./**/*.{html,php,txt}')).toMatchInlineSnapshot(`
"
@ -262,7 +263,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -334,7 +335,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -406,7 +407,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -483,7 +484,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -586,7 +587,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -694,7 +695,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
await fs.expectFileToContain(
'postcss.config.js',
@ -765,7 +766,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
@ -812,7 +813,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent)
@ -864,7 +865,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
@ -940,7 +941,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain(
@ -1007,7 +1008,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
"
@ -1054,7 +1055,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
"
@ -1098,7 +1099,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1168,9 +1169,10 @@ test(
}
`,
},
retry: 0,
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1279,9 +1281,10 @@ test(
}
`,
},
retry: 0,
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1414,7 +1417,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
let output = await exec('npx @tailwindcss/upgrade --force')
let output = await exec('pnpm exec upgrade --force')
expect(output).toMatch(
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
@ -1538,7 +1541,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
@ -1763,8 +1766,8 @@ test(
},
},
async ({ exec, expect }) => {
let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch(
(e) => e.toString(),
let output = await exec('pnpm exec upgrade --force', {}, { ignoreStdErr: true }).catch((e) =>
e.toString(),
)
expect(output).toMatch('Could not determine configuration file for:')
@ -1835,7 +1838,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
@ -1971,7 +1974,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
@ -2091,7 +2094,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
"
@ -2171,7 +2174,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -2253,9 +2256,10 @@ test(
}
`,
},
retry: 0,
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
"
@ -2309,7 +2313,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
let pkg = JSON.parse(await fs.read('package.json'))
@ -2388,7 +2392,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
// Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(`
@ -2486,7 +2490,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade --force')
await exec('pnpm exec upgrade --force')
// Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(`
@ -2593,7 +2597,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade ./src/index.css')
await exec('pnpm exec upgrade ./src/index.css')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -2719,7 +2723,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -2732,8 +2736,8 @@ test(
text-red-500/10
text-red-500/semitransparent
text-red-500/transparent
text-red-500/50
text-red-500/50.5
text-red-500/semitransparent
text-red-500/50.50
text-red-500/50.50
text-red-500/50%
text-red-500/100%"
@ -2805,7 +2809,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -2873,7 +2877,7 @@ test(
},
},
async ({ exec, root, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
let before = await fs.dumpFiles('./src/**/*.{css,html}')
expect(before).toMatchInlineSnapshot(`
@ -2915,7 +2919,7 @@ test(
}
// Run the upgrade again
let output = await exec('npx @tailwindcss/upgrade')
let output = await exec('pnpm exec upgrade')
expect(output).toContain('No changes were made to your repository')
let after = await fs.dumpFiles('./src/**/*.{css,html}')
@ -2994,7 +2998,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -3043,7 +3047,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/{*,.env,.env.*}')).toMatchInlineSnapshot(`
"
@ -3114,7 +3118,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./{src,templates}/**/*')).toMatchInlineSnapshot(`
"
@ -3190,6 +3194,10 @@ test(
let originalKeepFile = await fs.read('src/keep.php')
let originalTemplate = await fs.read('src/templates/template-0.php')
// NOTE: This test intentionally keeps `npx` instead of `pnpm exec`. It uses
// a `NODE_OPTIONS` hook that overrides `fs.writeFile` to simulate a bad
// write, and `pnpm exec` may write a bin shim on startup which would trip
// that hook before template migration begins.
let process = await spawn('npx @tailwindcss/upgrade --force', {
env: {
NODE_OPTIONS: '--require=./hook.cjs',
@ -3257,7 +3265,7 @@ test(
},
},
async ({ root, exec, fs, expect }) => {
let stdout = await exec('npx @tailwindcss/upgrade', {
let stdout = await exec('pnpm exec upgrade', {
cwd: path.join(root, 'project-a'),
})
@ -3331,7 +3339,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
let stdout = await exec('npx @tailwindcss/upgrade')
let stdout = await exec('pnpm exec upgrade')
expect(stdout).not.toContain(
'Running this command will add the dependency to the workspace root',
@ -3363,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`<div class="flex text-red-500">Hi</div>`,
// 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 <style> blocks carefully',
{
@ -3407,7 +3470,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(`
"

View file

@ -164,7 +164,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(`
"
@ -370,7 +370,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(
await fs.dumpFiles(
@ -484,7 +484,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -586,7 +586,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(`
"
@ -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);
}
/*
@ -672,7 +672,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -750,7 +750,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -824,7 +824,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -934,7 +934,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(`
"
@ -1032,7 +1032,7 @@ test(
},
},
async ({ root, exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade', {
await exec('pnpm exec upgrade', {
cwd: path.join(root, 'frontend'),
})
@ -1119,7 +1119,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
"
@ -1194,7 +1194,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
"
@ -1270,7 +1270,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
"
@ -1346,7 +1346,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1408,7 +1408,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1470,7 +1470,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1509,7 +1509,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1580,7 +1580,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -1683,7 +1683,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -1793,7 +1793,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -1895,7 +1895,7 @@ describe('border compatibility', () => {
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
@ -1993,7 +1993,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -2064,7 +2064,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"
@ -2141,7 +2141,7 @@ test(
},
},
async ({ exec, fs, expect }) => {
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
"

View file

@ -31,6 +31,7 @@ test(
@tailwind utilities;
`,
},
retry: 0,
},
async ({ exec, expect }) => {
// Ensure we are in a git repo
@ -39,7 +40,7 @@ test(
await exec('git commit -m "before migration"')
// Fully upgrade to v4
await exec('npx @tailwindcss/upgrade')
await exec('pnpm exec upgrade')
// Undo all changes to the current repo. This will bring the repo back to a
// v3 state, but the `node_modules` will now have v4 installed.
@ -47,7 +48,7 @@ test(
// Re-running the upgrade should result in an error
return expect(() => {
return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => {
return exec('pnpm exec upgrade', {}, { ignoreStdErr: true }).catch((e) => {
// Replacing the current version with a hardcoded `v4` to make it stable
// when we release new minor/patch versions.
return Promise.reject(
@ -55,7 +56,7 @@ test(
)
})
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Command failed: npx @tailwindcss/upgrade
"Command failed: pnpm exec upgrade
≈ tailwindcss v4.0.0
│ ↳ Upgrading from Tailwind CSS \`v4.0.0\`
@ -105,6 +106,7 @@ test(
@tailwind utilities;
`,
},
retry: 0,
},
async ({ exec, expect }) => {
// Use `bun` to install dependencies
@ -192,6 +194,7 @@ test(
@tailwind utilities;
`,
},
retry: 0,
},
async ({ exec, expect }) => {
// Use `npm` to install dependencies

View file

@ -8,10 +8,14 @@ import path from 'node:path'
import { promisify, stripVTControlCharacters } from 'node:util'
import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
import { test as defaultTest, type ExpectStatic } from 'vitest'
import * as Yaml from 'yaml'
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
import { escape } from '../packages/tailwindcss/src/utils/escape'
const REPO_ROOT = path.join(__dirname, '..')
const ROOT_PNPM_WORKSPACE = Yaml.parse(
await fs.readFile(path.join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf8'),
)
const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
)
@ -40,6 +44,7 @@ interface TestConfig {
timeout?: number
installDependencies?: boolean
retry?: number
}
interface TestContext {
root: string
@ -94,7 +99,7 @@ export function test(
name,
{
timeout: config.timeout ?? TEST_TIMEOUT,
retry: process.env.CI ? 2 : 0,
retry: config.retry ?? (process.env.CI ? 2 : 0),
only: only || (!process.env.CI && debug),
skip,
concurrent,
@ -121,6 +126,39 @@ export function test(
execOptions: ExecOptions = {},
) {
let cwd = childProcessOptions.cwd ?? root
let originalCommand = command
// Avoid `pnpm exec upgrade` on Windows because the upgrader runs
// `pnpm add`, which rewrites Windows `.cmd` shims while the current
// shim is still executing.
//
// Pretty sure this is a pnpm v11 bug on Windows.
if (IS_WINDOWS && command.includes('pnpm exec upgrade')) {
for (let base = cwd; ; base = path.dirname(base)) {
let upgradeBin = path.join(
base,
'node_modules',
'@tailwindcss',
'upgrade',
'dist',
'index.mjs',
)
try {
await fs.access(upgradeBin)
command = command.replace('pnpm exec upgrade', `node "${upgradeBin}"`)
break
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error
}
let parent = path.dirname(base)
if (parent === base) {
throw new Error(`Unable to resolve @tailwindcss/upgrade from ${cwd}`)
}
}
}
if (debug && cwd !== root) {
let relative = path.relative(root, cwd)
if (relative[0] !== '.') relative = `./${relative}`
@ -140,6 +178,11 @@ export function test(
},
(error, stdout, stderr) => {
if (error) {
if (command !== originalCommand) {
error.message = error.message.replace(command, originalCommand)
let mappedError = error as any
mappedError.cmd = originalCommand
}
if (execOptions.ignoreStdErr !== true) console.error(stderr)
if (only || debug) {
console.error(stdout)
@ -306,6 +349,8 @@ export function test(
if (filename.endsWith('package.json')) {
content = await overwriteVersionsInPackageJson(content)
} else if (filename.endsWith('pnpm-workspace.yaml')) {
content = overwriteVersionsInPnpmWorkspace(content)
}
// Ensure that files written on Windows use \r\n line ending
@ -424,6 +469,21 @@ export function test(
node_modules/
`
// Disable Git's automatic line ending conversion in these throwaway test
// repositories. Tools like pnpm write files (e.g. `pnpm-lock.yaml`) with
// LF endings, which on Windows (where `core.autocrlf=true` is common)
// makes Git emit noisy "LF will be replaced by CRLF" warnings when it
// touches those files.
config.fs['.gitattributes'] ??= txt`
* -text
`
// Ensure there is always a root `pnpm-workspace.yaml` so that transitive
// dependency overrides (and the build allow list) are injected into it.
// As of pnpm v10, these can no longer live in the `pnpm.overrides` field
// of `package.json`.
config.fs['pnpm-workspace.yaml'] ??= ''
for (let [filename, content] of Object.entries(config.fs)) {
if (content.toString().startsWith('symlink:')) {
// The symlink path is relative to the target destination's path
@ -441,14 +501,12 @@ export function test(
let shouldInstallDependencies = config.installDependencies ?? true
try {
// In debug mode, the directory is going to be inside the pnpm workspace
// of the tailwindcss package. This means that `pnpm install` will run
// pnpm install on the workspace instead (expect if the root dir defines
// a separate workspace). We work around this by using the
// `--ignore-workspace` flag.
// Every test project gets its own root `pnpm-workspace.yaml` (see
// above). This makes the test directory its own workspace root, so even
// in debug mode — where the directory lives inside this repository's
// pnpm workspace — `pnpm install` won't attach to the parent workspace.
if (shouldInstallDependencies) {
let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
await context.exec(`pnpm install`)
}
} catch (error: any) {
console.error(error)
@ -526,28 +584,42 @@ async function overwriteVersionsInPackageJson(content: string): Promise<string>
}
}
return JSON.stringify(json, null, 2)
}
function overwriteVersionsInPnpmWorkspace(content: string): string {
let workspace = content.trim() === '' ? {} : Yaml.parse(content)
// Inherit information from the root workspace
workspace.allowBuilds = {
...ROOT_PNPM_WORKSPACE.allowBuilds,
...workspace.allowBuilds,
}
// Inject transitive dependency overwrite. This is necessary because
// @tailwindcss/vite internally depends on a specific version of
// @tailwindcss/oxide and we instead want to resolve it to the locally built
// version.
json.pnpm ||= {}
json.pnpm.overrides ||= {}
//
// As of pnpm v10, the `pnpm.overrides` field in `package.json` is no longer
// read. Overrides have to be defined in `pnpm-workspace.yaml` instead.
workspace.overrides ||= {}
for (let pkg of PUBLIC_PACKAGES) {
if (pkg === 'tailwindcss') {
// We want to be explicit about the `tailwindcss` package so our tests can
// also import v3 without conflicting v4 tarballs.
json.pnpm.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
json.pnpm.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
} else {
json.pnpm.overrides[pkg] = resolveVersion(pkg)
workspace.overrides[pkg] = resolveVersion(pkg)
}
}
return JSON.stringify(json, null, 2)
return Yaml.stringify(workspace)
}
function resolveVersion(dependency: string) {

View file

@ -584,6 +584,233 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20320
// https://github.com/tailwindlabs/tailwindcss/issues/19903
test(
'editing scanned files that Vite can process as modules does not trigger a full reload',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^8"
}
}
`,
'project-a/vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [
tailwindcss(),
{
// A plugin that processes a custom file type into a JS
// module, similar to e.g. \`.vue\` or \`.svelte\` files
name: 'custom-file-type',
transform(code, id) {
if (id.endsWith('.custom')) {
return { code: 'export default ' + JSON.stringify(code), map: null }
}
},
},
{
// Log all HMR payloads to a file so the test can assert on
// them
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
return send(payload)
}
}
},
},
],
})
`,
'project-a/index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'project-a/src/main.ts': ts`
import compA from './comp-a.custom'
import snippet from './snippet.php?raw'
console.log(compA, snippet)
`,
'project-a/src/snippet.php': html`
<div
class="content-['src/snippet.php']"
></div>
`,
'project-a/src/comp-a.custom': html`
<div
class="content-['src/comp-a.custom']"
></div>
`,
'project-a/src/comp-b.custom': html`
<div
class="content-['src/comp-b.custom']"
></div>
`,
'project-a/src/lazy.tsx': jsx`
export default function Lazy() {
return <div className="content-['src/lazy.tsx']" />
}
`,
'project-a/src/unimported.css': css`
.unimported {
color: red;
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss';
@source '../../project-b/**/*.php';
`,
'project-b/src/index.php': html`
<div
class="content-['project-b/src/index.php']"
></div>
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['src/lazy.tsx']`)
expect(styles).toContain(candidate`content-['src/comp-b.custom']`)
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
})
// Load `main.ts`, `comp-a.custom`, and `snippet.php?raw` as real
// modules, like a browser visiting the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/comp-a.custom?import`)
await fetch(`${url}/src/snippet.php?raw`)
// Changing a scanned `.tsx` file that is not part of the module graph
// (e.g. a lazily-loaded route that hasn't been visited yet) should not
// trigger a full reload, but new classes should still apply
//
// https://github.com/tailwindlabs/tailwindcss/issues/20320
{
await fs.write(
'project-a/src/lazy.tsx',
jsx`
export default function Lazy() {
return <div className="content-['updated:src/lazy.tsx']" />
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`)
})
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
}
// The same holds for a custom file type as long as some file of the
// same type was processed as a module before
{
await fs.write(
'project-a/src/comp-b.custom',
html`<div class="content-['updated:src/comp-b.custom']"></div>`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`)
})
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
}
// Changing a scanned stylesheet that is not part of the module graph
// (e.g. a component stylesheet that a framework plugin compiles into
// the component) should not trigger a full reload either
//
// https://github.com/tailwindlabs/tailwindcss/issues/19903
{
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
await fs.write(
'project-a/src/unimported.css',
css`
.unimported {
color: blue;
}
`,
)
// Wait until the change was handled and an update was pushed
await retryAssertion(async () => {
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
}
// Changing an external file (e.g. a PHP template) should still trigger
// a full reload. This must work even though `snippet.php` is part of
// the module graph via the `?raw` import: a query import only pulls
// the file's contents into the graph (and creates an untransformed
// module node for the underlying file), it is not evidence that Vite
// processes `.php` files as modules.
{
await fs.write(
'project-b/src/index.php',
html`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
await retryAssertion(async () => {
expect(await fs.read('project-a/hmr.log')).toContain('full-reload')
})
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`)
}
},
)
test(
`source(none) disables looking at the module graph`,
{
@ -1090,6 +1317,14 @@ test(
</head>
`,
'src/index.js': js`import Plotly from 'plotly.js/lib/core'`,
// `plotly.js` pulls in `es5-ext`, which has a build script. As of pnpm
// v10, build scripts only run when explicitly allowed.
'pnpm-workspace.yaml': yaml`
#
allowBuilds:
es5-ext: true
`,
},
},
async ({ exec, expect, fs }) => {
@ -1137,7 +1372,7 @@ test(
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="hover:flex">Hello, world!</div>
<div class="focus:text-[black]">Hello, world!</div>
</body>
`,
'src/index.css': css`
@ -1149,16 +1384,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, '-<hash>.css'))
.toMatchInlineSnapshot(`
"
--- dist/assets/index-<hash>.css ---
.focus\\:text-\\[black\\]:focus {
color: black;
}
"
`)
},
)
@ -1192,7 +1427,7 @@ test(
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="hover:flex">Hello, world!</div>
<div class="hover:text-[black]">Hello, world!</div>
</body>
`,
'src/index.css': css`
@ -1204,15 +1439,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, '-<hash>.css'))
.toMatchInlineSnapshot(`
"
--- dist/assets/index-<hash>.css ---
@media (hover: hover) {
.hover\\:text-\\[black\\]:hover {
color: #000;
}
}
"
`)
},
)

View file

@ -8,6 +8,7 @@ import {
retryAssertion,
test,
ts,
yaml,
} from '../utils'
const SETUP = {
@ -21,14 +22,14 @@ const SETUP = {
"nitropack": "2.11.0",
"tailwindcss": "workspace:^",
"vue": "latest"
},
"pnpm": {
"overrides": {
"nuxi": "3.28.0"
}
}
}
`,
'pnpm-workspace.yaml': yaml`
#
overrides:
nuxi: 3.28.0
`,
'nuxt.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'

View file

@ -572,7 +572,7 @@ test(
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
@ -643,7 +643,7 @@ test(
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from 'node:util'
import { candidate, css, html, json, test, ts } from '../utils'
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
test(
'production build',
@ -248,3 +248,133 @@ test(
}
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20320
test(
'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6",
"@tailwindcss/vite": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
{
// Log update and full-reload HMR payloads to a file so the
// test can assert on them. Custom events are not logged
// because \`@vitejs/plugin-vue\` sends a \`file-changed\` event
// for every file change, including changes to the log file
// itself, which would cause an infinite feedback loop.
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
if (payload.type === 'update' || payload.type === 'full-reload') {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
}
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/index.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<template>
<div class="content-['src/App.vue']">Hello Vue!</div>
</template>
`,
// This file is scanned by Tailwind but never imported, so it is not
// part of the loaded module graph (e.g. a lazy route that hasn't been
// visited yet)
'src/LazyRoute.vue': html`
<template>
<div class="content-['src/LazyRoute.vue']">Lazy</div>
</template>
`,
},
},
async ({ spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['src/App.vue']`)
expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`)
})
// Load `main.ts` and `App.vue` as real modules, like a browser visiting
// the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/App.vue`)
// Changing the scanned but unloaded `.vue` file should not trigger a
// full reload, but new classes should still apply
await fs.write(
'src/LazyRoute.vue',
html`
<template>
<div class="content-['updated:src/LazyRoute.vue']">Lazy</div>
</template>
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
},
)

View file

@ -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,13 +58,7 @@
"tsup": "^8.5.1",
"turbo": "^2.9.18",
"typescript": "^5.9.3",
"vitest": "^4.1.7"
"vitest": "^4.1.9"
},
"packageManager": "pnpm@9.6.0",
"pnpm": {
"patchedDependencies": {
"@parcel/watcher@2.5.1": "patches/@parcel__watcher@2.5.1.patch",
"lightningcss@1.32.0": "patches/lightningcss@1.32.0.patch"
}
}
"packageManager": "pnpm@11.9.0"
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/browser",
"version": "4.3.1",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"main": "./dist/index.global.js",

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/cli",
"version": "4.3.1",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {

View file

@ -1,4 +1,3 @@
import watcher from '@parcel/watcher'
import {
compile,
env,
@ -21,11 +20,13 @@ import {
highlight,
println,
relative,
wordWrap,
} from '../../utils/renderer'
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',
@ -73,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<T>(fn: () => T): Promise<T> {
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)
}
}
@ -129,7 +163,17 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// 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 +196,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let previous = {
css: '',
optimizedCss: '',
output: null as string | null,
}
async function write(
@ -208,13 +253,18 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
}
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 +325,14 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
let [compiler, scanner] = await handleError(() => createCompiler(input, I))
let cleanupWatchers: (() => Promise<void>)[] = []
// Watch for changes
if (args['--watch']) {
let cleanupWatchers: (() => Promise<void>)[] = []
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 {
@ -292,36 +346,15 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// 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 +411,9 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
// 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
@ -443,12 +476,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// 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)}`)
@ -490,9 +518,184 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
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
logPollingMessage(formatError(err))
}
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…`)}`)
}
}
}
// 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<typeof import('@parcel/watcher')> {
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.
@ -594,6 +797,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<void>, pollInterval: number) {
let disposed = false
let timer: ReturnType<typeof setTimeout> | 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(

View file

@ -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 } = {}) {

View file

@ -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,
"_": [],
}
`)
})

View file

@ -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

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/node",
"version": "4.3.1",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {

View file

@ -83,7 +83,7 @@ it('should measure async callbacks via the `span` api', async () => {
`)
let [, duration] = stripVTControlCharacters(output).match(/\[(.*)\]/)!
expect(parseFloat(duration)).toBeGreaterThanOrEqual(500)
expect(parseFloat(duration)).toBeGreaterThanOrEqual(500 - 2 /* Margin of error */)
})
})

View file

@ -68,6 +68,13 @@ export function optimize(
return false
}
// Ignore warnings about unknown at-rules.
//
// TODO: drop `@position-try` once https://github.com/parcel-bundler/lightningcss/pull/1238 lands
if (/Unknown at rule: @position-try/.test(warning.message)) {
return false
}
return true
})

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/postcss",
"version": "4.3.1",
"version": "4.3.3",
"description": "PostCSS plugin for Tailwind CSS, a utility-first CSS framework for rapidly building custom user interfaces",
"license": "MIT",
"repository": {

View file

@ -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;
@ -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;
}

View file

@ -20,6 +20,16 @@ function inputCssFilePath() {
const css = dedent
async function run(plugin: any, from: string, input: string): Promise<string> {
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<string> {
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 } }),

View file

@ -20,6 +20,7 @@ const DEBUG = env.DEBUG
interface CacheEntry {
mtimes: Map<string, number>
inputCss: string
compiler: null | ReturnType<typeof compileAst>
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<string, number>(),
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')

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/standalone",
"version": "4.3.1",
"version": "4.3.3",
"private": true,
"description": "Standalone CLI for Tailwind CSS",
"license": "MIT",

View file

@ -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<string | undefined>)
undefined | ((path: string, encoding: BufferEncoding) => Promise<string | undefined>)
declare var __tw_load: undefined | ((path: string) => Promise<object | undefined>)

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/upgrade",
"version": "4.3.1",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {

View file

@ -4,11 +4,14 @@ import postcss, { type Result } from 'postcss'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { Stylesheet, type StylesheetConnection } from '../../stylesheet'
import { gitRoot } from '../../utils/git'
import { error, highlight, relative } from '../../utils/renderer'
import { resolveCssId } from '../../utils/resolve'
export async function analyze(stylesheets: Stylesheet[]) {
let isIgnored = await isGitIgnored()
export async function analyze(stylesheets: Stylesheet[], { base }: { base: string }) {
// Use the project's git root, instead of the `base` to ensure all
// `.gitignore` rules are being used.
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
let processingQueue: (() => Promise<Result>)[] = []
let stylesheetsByFile = new DefaultMap<string, Stylesheet | null>((file) => {
// We don't want to process ignored files (like node_modules)

View file

@ -19,7 +19,7 @@ import { prepareConfig } from './codemods/template/prepare-config'
import { help } from './commands/help'
import { Stylesheet } from './stylesheet'
import { args, type Arg } from './utils/args'
import { isRepoDirty } from './utils/git'
import { gitRoot, isRepoDirty } from './utils/git'
import { pkg } from './utils/packages'
import { eprintln, error, header, highlight, info, relative, success } from './utils/renderer'
import * as version from './utils/version'
@ -43,7 +43,9 @@ if (flags['--help']) {
async function run() {
let base = process.cwd()
let isIgnored = await isGitIgnored({ cwd: base })
// Use the project's git root, instead of the `base` to ensure all
// `.gitignore` rules are being used.
let isIgnored = await isGitIgnored({ cwd: gitRoot(base) ?? base })
eprintln(header())
eprintln()
@ -125,7 +127,7 @@ async function run() {
// Analyze the stylesheets
try {
await analyzeStylesheets(stylesheets)
await analyzeStylesheets(stylesheets, { base })
} catch (e: any) {
error(`${e?.message ?? e}`, { prefix: '↳ ' })
}

View file

@ -1,5 +1,17 @@
import { execSync } from 'node:child_process'
export function gitRoot(cwd?: string): string | null {
try {
return execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
cwd,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
return null
}
}
export function isRepoDirty(cwd?: string) {
try {
let stdout = execSync('git status --porcelain', { encoding: 'utf-8', cwd })

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/vite",
"version": "4.3.1",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {

View file

@ -25,6 +25,7 @@ const DEBUG = env.DEBUG
const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/
const COMMON_JS_PROXY_RE = /\?commonjs-proxy/
const INLINE_STYLE_ID_RE = /[?&]index=\d+\.css$/
const JS_EXTENSIONS_RE = /^\.[cm]?[jt]sx?$/
export type PluginOptions = {
/**
@ -45,7 +46,7 @@ function createCustomResolver(
// It does matter that this is a file, otherwise we would go up a directory,
// which means that we would be resolving files from a parent folder first,
// instead of the current folder we are in.
let importer = path.resolve(base, '__placeholder__.css')
let importer = path.resolve(base, '__placeholder__.ts')
for (let resolver of resolvers) {
let resolved = await resolver(id, importer)
@ -76,6 +77,13 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
let config: ResolvedConfig | null = null
let rootsByEnv = new DefaultMap<string, Map<string, Root>>((env: string) => new Map())
// File extensions that Vite (or one of its plugins) has been seen to process
// as a module. Plugins don't get added or removed while the dev server is
// running (changing the Vite config restarts the server), so once we've seen
// evidence for a file type we don't need to scan the module graphs for it
// again.
let viteProcessedExtensions = new Set<string>()
let isSSR = false
let shouldOptimize = true
let minify = true
@ -271,29 +279,87 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
// 'asset'. But it will also have a `HARD_INVALIDATED` state and will
// do a full page reload already.
//
// Empty modules can be skipped since it means it's not `addWatchFile`d and thus irrelevant to Tailwind.
// Empty modules can be skipped since it means it's not
// `addWatchFile`d and thus irrelevant to Tailwind.
let isExternalFile =
modules.length > 0 &&
modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return
// Skip if the module exists in other environments. SSR framework has
// its own server side hmr/reload mechanism when handling server
// only modules. See https://v6.vite.dev/guide/migration.html
// Skip files that Vite (or one of its plugins) processes as a
// module — in this environment (e.g. a lazily-loaded route that
// hasn't been visited yet) or in another one (e.g. an SSR-only
// module). Such a file can only affect the page through Vite's own
// pipeline, so a full reload would only destroy client state. Any
// changes to the generated CSS still go through the regular
// `css-update` flow because the file is registered via
// `addWatchFile`.
//
// If the file exists as a real module in another environment, then
// that environment is responsible for it. E.g. an SSR framework
// has its own server side hmr/reload mechanism when handling
// server only modules. See https://v6.vite.dev/guide/migration.html
// > Updates to an SSR-only module no longer triggers a full page reload in the client. ...
for (let environment of Object.values(server.environments)) {
if (environment.name === this.environment.name) continue
let modules = environment.moduleGraph.getModulesByFile(file)
if (modules) {
for (let module of modules) {
if (module.type !== 'asset') {
for (let mod of modules) {
if (mod.type !== 'asset') {
return
}
}
}
}
// Otherwise the file is not loaded as a module anywhere, so
// determine whether its file _type_ would be processed by Vite
// when requested by the browser (in which case the file just isn't
// loaded yet, e.g. a lazily-loaded route that hasn't been visited).
// Vite has no API to answer this without actually running the
// plugin pipeline, so instead:
//
// Files Vite handles natively (the JS/TS and CSS families) are always
// processed by Vite. This includes stylesheets that never show up as
// their own module because a framework plugin compiles them into a
// component (e.g. Angular), in which case that plugin owns their HMR.
let extension = path.extname(file)
if (JS_EXTENSIONS_RE.test(extension) || vite.isCSSRequest(file)) return
// For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an
// SSG plugin), if a file with the same extension exists as a real
// module in any environment's module graph, then a plugin evidently
// handles this file type and the changed file just isn't loaded
// (yet).
if (extension !== '') {
if (viteProcessedExtensions.has(extension)) return
for (let environment of Object.values(server.environments)) {
for (let mod of environment.moduleGraph.idToModuleMap.values()) {
if (!mod.file?.endsWith(extension)) continue
if (mod.type === 'asset') continue
// Only count modules that the plugin pipeline actually
// transformed. Vite also creates untransformed placeholder
// nodes (e.g. for the file underlying a `?raw` import) that
// are not evidence that a plugin handles this file type.
if (mod.transformResult == null) continue
// Similarly, ignore query imports (e.g. `./template.html?raw`,
// or the `?html-proxy` modules Vite creates for inline
// scripts): they pull a file's _contents_ into the graph
// without a plugin processing the file type. A scanned
// `.html` template must still trigger a full reload even if
// some other `.html` file is imported with `?raw`.
if (!mod.id || mod.id.includes('?')) continue
viteProcessedExtensions.add(extension)
return
}
}
}
for (let env of new Set([this.environment.name, 'client'])) {
let roots = rootsByEnv.get(env)
if (roots.size === 0) continue

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/webpack",
"version": "4.3.1",
"version": "4.3.3",
"description": "A webpack loader for Tailwind CSS v4.",
"license": "MIT",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "tailwindcss",
"version": "4.3.1",
"version": "4.3.3",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT",
"repository": {

View file

@ -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',
@ -170,7 +175,7 @@ table {
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}

View file

@ -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;
@ -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;
}

File diff suppressed because it is too large Load diff

View file

@ -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<string, AstNode[]> = {}
let toRemove = new Set<AstNode>()
// 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<string>()
// Track nodes lists where we want to dedupe declarations
let dedupeDeclarationsInNodes = new Set<AstNode[]>()
// The final, new AST
let result: AstNode[] = []
// Track whether we should skip a node in the `exit` phase
let skipExit = new Set<AstNode>()
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

View file

@ -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 },

View file

@ -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
}

View file

@ -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',
])
})
})

View file

@ -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())
}
}
},
})

View file

@ -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)',

View file

@ -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 () => {

View file

@ -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"',

View file

@ -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(
@ -3288,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;
}
}

View file

@ -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 <selector>)` is part of the
// condition, not a utility being defined.
node.value === ':nth-child' ||
node.value === ':nth-last-child')
) {
return WalkAction.Skip
}
})

View file

@ -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
})()

View file

@ -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

View file

@ -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<typeof compile>[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;
}
"
`)
@ -904,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);
@ -914,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;
}
"
`)
@ -934,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;
}
"
`)
@ -1154,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)'],

View file

@ -70,11 +70,13 @@ function spacing(
// - We can assume that the `--spacing` value is not set to a `0`-like value.
// Otherwise `p-<anything>` 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 `<length>`, 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
}

View file

@ -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())

View file

@ -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' },
],
},
],
},
@ -445,7 +470,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 +482,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([
{
@ -529,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', () => {

View file

@ -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<T extends SelectorAstNode>(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
@ -196,20 +270,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 +312,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 +367,7 @@ export function parse(input: string) {
break
}
// Start of a function call.
// Start of a function call
//
// E.g.:
//
@ -300,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' &&
@ -312,7 +391,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) {
@ -329,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 <complex-selector-list>` 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
@ -347,7 +458,7 @@ export function parse(input: string) {
break
}
// End of a function call.
// End of a function call
//
// E.g.:
//
@ -356,7 +467,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 +487,7 @@ export function parse(input: string) {
break
}
// Split compound selectors.
// Split compound selectors
//
// E.g.:
//
@ -384,16 +495,16 @@ 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
}
// 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 +512,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 +521,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 +546,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 +562,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 +612,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))
}

View file

@ -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 { | 173 :-moz-focusring {
^^^^^^^^^^^^^^^^ CT @ 75:2-18 | ^^^^^^^^^^^^^^^^ CT @ 173:0-16
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 |
@ -865,25 +875,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 |
"
`)
})

View file

@ -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',
@ -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;
@ -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));
@ -30176,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('')
})
@ -30285,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('')
})
@ -30622,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('')
})

View file

@ -519,6 +519,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: desc.themeKeys,
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
])
@ -2490,6 +2491,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--border-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
hasDefaultValue: true,
},
@ -2960,6 +2962,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--background-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -3039,6 +3042,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--background-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -3306,6 +3310,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--background-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -3329,6 +3334,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--background-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
])
@ -3843,6 +3849,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--fill', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
])
@ -3894,6 +3901,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--stroke', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -4151,6 +4159,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--text-decoration-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -4609,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}%`
}
}
@ -4739,6 +4748,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--drop-shadow-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -5196,6 +5206,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--outline-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
hasDefaultValue: true,
},
@ -5386,6 +5397,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--text-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -5415,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}%`
}
}
@ -5508,6 +5520,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--text-shadow-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -5560,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}%`
}
}
@ -5661,6 +5674,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--box-shadow-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -5685,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}%`
}
}
@ -5788,6 +5802,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--box-shadow-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -5868,6 +5883,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--ring-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -5941,6 +5957,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--ring-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{
@ -6008,6 +6025,7 @@ export function createUtilities(theme: Theme) {
{
values: ['current', 'inherit', 'transparent'],
valueThemeKeys: ['--ring-offset-color', '--color'],
modifierThemeKeys: ['--opacity'],
modifiers: Array.from({ length: 21 }, (_, index) => `${index * 5}`),
},
{

View file

@ -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)
}

View file

@ -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 () => {

View file

@ -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

View file

@ -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',
@ -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);

View file

@ -20,11 +20,5 @@
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"typescript": "^6.0.3"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3"
}
}
}

View file

@ -20,11 +20,5 @@
"@types/react-dom": "19.2.3",
"autoprefixer": "^10.5.0",
"typescript": "^6.0.3"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3"
}
}
}

743
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,14 @@ packages:
- 'playgrounds/*'
- 'integrations'
patchedDependencies:
'@parcel/watcher@2.5.1': patches/@parcel__watcher@2.5.1.patch
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
@ -17,7 +21,25 @@ 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'
- bun
- esbuild
- sharp
- tree-sitter
- tree-sitter-javascript
- tree-sitter-typescript
allowBuilds:
'@parcel/watcher': true
bun: true
esbuild: true
sharp: true
tree-sitter: true
tree-sitter-javascript: true
tree-sitter-typescript: true

View file

@ -21,18 +21,25 @@ let workspaces = new Map()
for (let path of paths) {
let pkg = await fs.readFile(path, 'utf8').then(JSON.parse)
if (pkg.private) continue
workspaces.set(pkg.name, { version: pkg.version ?? '', dir: dirname(path) })
workspaces.set(pkg.name, {
version: pkg.version ?? '',
dir: dirname(path),
hasBundledDependencies: pkg.bundledDependencies?.length > 0 ?? false,
})
}
// Clean dist folder
await fs.rm(path.join(root, 'dist'), { recursive: true, force: true })
Promise.all(
[...workspaces.entries()].map(async ([name, { dir }]) => {
[...workspaces.entries()].map(async ([name, { dir, hasBundledDependencies }]) => {
function pack() {
return new Promise((resolve) => {
// `bundledDependencies` can only be packed with the hoisted node linker
// since pnpm v10. We opt into it for those packages only, instead of
// changing the node linker for the entire workspace.
exec(
`pnpm pack --pack-gzip-level=0 --pack-destination="${path.join(root, 'dist').replace(/\\/g, '\\\\')}"`,
`pnpm pack --pack-gzip-level=0 --pack-destination="${path.join(root, 'dist').replace(/\\/g, '\\\\')}"${hasBundledDependencies ? ' --config.node-linker=hoisted' : ''}`,
{ cwd: dir },
(err, stdout, stderr) => {
if (err) {