diff --git a/.changepacks/changepack_log_tailwind_v4_theme.json b/.changepacks/changepack_log_tailwind_v4_theme.json new file mode 100644 index 000000000..b9ac3422e --- /dev/null +++ b/.changepacks/changepack_log_tailwind_v4_theme.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Tailwind classes in a className follow Tailwind CSS v4: colors are the v4 default palette (oklch) and the colors and shadows of devup.json theme are usable as Tailwind theme values (bg-primary, text-text, shadow-card, as var(--token)); the opacity modifier (bg-red-500/50, /[71.37%], /(--alpha)) mixes with color-mix as v4 does; a trailing or leading ! makes every declaration important; blur, brightness, contrast, grayscale, hue-rotate, invert, saturate, sepia and their backdrop-* forms compose through Tailwind's custom properties instead of overwriting filter and backdrop-filter; gradients use bg-linear-*, bg-conic-* and bg-radial-* with from-*, via-* and to-* composing through the gradient variables (bg-gradient-to-* is not defined by v4 and stays as written); animate-* brings the @keyframes it names and transition, duration, delay and ease follow v4; space-* and divide-* apply to the children like v4 (:where(& > :not(:last-child))); @property rules are emitted only for the custom properties a class uses. Anything the build cannot reproduce exactly (drop-shadow-*) stays as written in the className. No new build errors", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 3b2346aed..9902a1c96 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{LazyLock, RwLock}; #[derive(Default, Debug)] @@ -6,6 +6,7 @@ struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, typography: Vec, + colors: BTreeSet, } static TOKEN_REGISTRY: LazyLock> = @@ -27,6 +28,28 @@ pub fn set_typography_keys(keys: Vec) { } } +/// Registers the names of the theme's colors, which Tailwind classes such as +/// `bg-primary` can use +pub fn set_color_tokens(names: BTreeSet) { + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + registry.colors = names; + } +} + +/// Whether the theme defines the color `name`, a CSS variable of that name +pub fn is_color_token(name: &str) -> bool { + TOKEN_REGISTRY + .read() + .is_ok_and(|registry| registry.colors.contains(name)) +} + +/// Whether the theme defines the shadow `name`, a CSS variable of that name +pub fn is_shadow_token(name: &str) -> bool { + TOKEN_REGISTRY + .read() + .is_ok_and(|registry| registry.shadow.contains_key(name)) +} + /// Typography names defined by the registered theme, so a dynamic /// `typography` value under a selector can resolve to one class per name. pub fn get_typography_keys() -> Vec { @@ -122,6 +145,23 @@ mod tests { assert_eq!(get_typography_keys(), Vec::::new()); } + #[test] + #[serial] + fn test_color_and_shadow_tokens() { + set_color_tokens(BTreeSet::from(["primary".to_string()])); + let mut shadow = BTreeMap::new(); + shadow.insert("card".to_string(), vec![0]); + set_theme_token_levels(BTreeMap::new(), shadow); + + assert!(is_color_token("primary")); + assert!(!is_color_token("card")); + assert!(is_shadow_token("card")); + assert!(!is_shadow_token("primary")); + set_color_tokens(BTreeSet::new()); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + assert!(!is_color_token("primary")); + } + #[test] fn test_is_responsive_theme_token_without_prefix() { let value = std::hint::black_box("noprefix"); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..4749b28f4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15,6 +15,10 @@ mod source_map; mod style_values; mod stylex; mod tailwind; +mod tailwind_children; +mod tailwind_color; +mod tailwind_effects; +mod tailwind_motion; mod util_type; mod utils; mod vanilla_extract; diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index b8b1ce7da..08a56e2dc 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -3,7 +3,7 @@ use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{PROPERTY_RULES, PROPERTY_RULES_FILE, parse_class}; +use crate::tailwind::{PROPERTY_RULES_FILE, parse_class}; use crate::utils::{get_str_by_property_key, merge_object_expressions}; use crate::{ExtractStyleProp, ExtractStyleValue}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -15,6 +15,7 @@ use oxc_ast::ast::{ }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; +use std::collections::BTreeSet; /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. @@ -314,7 +315,7 @@ pub fn get_class_name_expression<'a>( style_order, filename, styles: Vec::new(), - property_rules: false, + rules: BTreeSet::new(), }; let compiled = class_name_prop .as_ref() @@ -364,19 +365,19 @@ struct TailwindClassName<'f> { style_order: Option, filename: Option<&'f str>, styles: Vec, - /// Whether a compiled class uses the custom properties Tailwind registers - property_rules: bool, + /// The global rules the compiled classes need, @property and @keyframes + rules: BTreeSet<&'static str>, } impl TailwindClassName<'_> { fn into_styles(self) -> Vec { let mut styles = self.styles; - if self.property_rules { - styles.push(ExtractStyleValue::Css(ExtractCss { - css: PROPERTY_RULES.to_string(), + styles.extend(self.rules.into_iter().map(|rule| { + ExtractStyleValue::Css(ExtractCss { + css: rule.to_string(), file: PROPERTY_RULES_FILE.to_string(), - })); - } + }) + })); styles } @@ -393,7 +394,7 @@ impl TailwindClassName<'_> { let whole = (!open_start || start > 0) && (!open_end || end < text.len()); if let Some(tailwind) = whole.then(|| parse_class(class)).flatten() { compiled.push_str(&text[written..start]); - self.property_rules |= tailwind.uses_properties(); + self.rules.extend(tailwind.rules()); let mut separator = ""; for mut style in tailwind.styles() { if let Some(order) = self.style_order { diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap index 8baa4082f..e860275c9 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap index 833afb951..1304e0565 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap @@ -3,6 +3,46 @@ source: libs/extractor/src/tailwind.rs expression: sort_styles(styles) --- { + Static( + ExtractStaticStyle { + property: "--tw-duration", + value: "200ms", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 10px 15px -3px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 4px 6px -4px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -36,7 +76,17 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "box-shadow", - value: "0 10px 15px -3px rgb(0 0 0 / .1),0 4px 6px -4px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -55,8 +105,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", + property: "display", + value: "flex", level: 0, selector: None, style_order: None, @@ -65,8 +115,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "display", - value: "flex", + property: "justify-content", + value: "space-between", level: 0, selector: None, style_order: None, @@ -75,8 +125,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "justify-content", - value: "space-between", + property: "padding", + value: "1rem", level: 0, selector: None, style_order: None, @@ -85,8 +135,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "padding", - value: "1rem", + property: "transition-duration", + value: "200ms", level: 0, selector: None, style_order: None, @@ -96,7 +146,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "transition-duration", - value: "200ms", + value: "var(--tw-duration,150ms)", level: 0, selector: None, style_order: None, @@ -113,4 +163,14 @@ expression: sort_styles(styles) layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-timing-function", + value: "var(--tw-ease,cubic-bezier(.4,0,.2,1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), } diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap index 429e176b5..a3b0ca15a 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 1, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap index 1513c6081..abef745f6 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap index 405c547e6..eecec6300 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap index 419ad0a7d..6c84131ee 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap index 494738fe9..f0b2b97df 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap index 23f909148..0822cf9a9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap index 5e6e6069a..5069aa833 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap @@ -14,6 +14,36 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 20px 25px -5px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 8px 10px -6px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -37,13 +67,9 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#1F2937", + value: "#FFF", level: 0, - selector: Some( - Selector( - ":root[data-theme=dark] &", - ), - ), + selector: None, style_order: None, layer: None, }, @@ -51,9 +77,13 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(27.8% .033 256.848)", level: 0, - selector: None, + selector: Some( + Selector( + ":root[data-theme=dark] &", + ), + ), style_order: None, layer: None, }, @@ -61,7 +91,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#FFF", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -71,7 +101,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-color", - value: "#374151", + value: "oklch(37.3% .034 259.733)", level: 0, selector: Some( Selector( @@ -85,7 +115,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-color", - value: "#E5E7EB", + value: "oklch(92.8% .006 264.531)", level: 0, selector: None, style_order: None, @@ -115,7 +145,17 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -134,8 +174,8 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", + property: "color", + value: "#FFF", level: 0, selector: None, style_order: None, @@ -145,9 +185,13 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#111827", + value: "#FFF", level: 0, - selector: None, + selector: Some( + Selector( + ":root[data-theme=dark] &", + ), + ), style_order: None, layer: None, }, @@ -155,7 +199,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#374151", + value: "oklch(21% .034 264.665)", level: 0, selector: None, style_order: None, @@ -165,7 +209,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#6B7280", + value: "oklch(37.3% .034 259.733)", level: 0, selector: None, style_order: None, @@ -175,13 +219,9 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#9CA3AF", + value: "oklch(55.1% .027 264.364)", level: 0, - selector: Some( - Selector( - ":root[data-theme=dark] &", - ), - ), + selector: None, style_order: None, layer: None, }, @@ -189,7 +229,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#D1D5DB", + value: "oklch(70.7% .022 261.325)", level: 0, selector: Some( Selector( @@ -203,17 +243,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#FFF", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - Static( - ExtractStaticStyle { - property: "color", - value: "#FFF", + value: "oklch(87.2% .01 258.338)", level: 0, selector: Some( Selector( @@ -436,6 +466,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-duration", + value: "var(--tw-duration,150ms)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "transition-property", @@ -446,6 +486,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-timing-function", + value: "var(--tw-ease,cubic-bezier(.4,0,.2,1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "width", @@ -458,10 +508,52 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-duration{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ease{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-leading{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", file: "@devup-ui/tailwind", }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap index 757687d59..c40210ab3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap @@ -4,6 +4,36 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 20px 25px -5px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 8px 10px -6px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "background", @@ -47,7 +77,17 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -64,16 +104,6 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), Static( ExtractStaticStyle { property: "display", @@ -124,6 +154,36 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap index b318297c2..9606d3cd4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap @@ -36,7 +36,13 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap index 20f358653..f6a66523a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap index 3d4bcb1db..3d1f6db09 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap index 65171e2bd..5078bf11f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap index 96adc8f43..fb37f986e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#22C55E", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: Some( @@ -19,7 +19,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(72.3% .219 149.579)", level: 0, selector: None, style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap index 96f1eff06..f503be46e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap index 2c1b7dcdc..dccb572cf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap @@ -49,7 +49,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#111827", + value: "oklch(21% .034 264.665)", level: 0, selector: Some( Selector( @@ -63,7 +63,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#1F2937", + value: "oklch(27.8% .033 256.848)", level: 0, selector: Some( At { @@ -83,7 +83,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { @@ -204,7 +204,25 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-ring-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap index 20c321150..925089cf7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#111827", + value: "oklch(21% .034 264.665)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap index 0a19f6804..51bbbbde7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#22C55E", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(72.3% .219 149.579)", level: 0, selector: None, style_order: None, @@ -56,7 +56,7 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-leading{syntax:\"*\";inherits:false}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index d683b5b7c..c06d5fb6e 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -18,6 +18,10 @@ use css::{ use phf::{phf_map, phf_set}; use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::tailwind_children::{CHILDREN, children_utility}; +use crate::tailwind_color::color_value; +use crate::tailwind_effects::effect_utility; +use crate::tailwind_motion::{keyframes_of, motion_utility}; /// A declaration: property and value pub type Declaration = (Cow<'static, str>, Cow<'static, str>); @@ -138,25 +142,104 @@ static ARIA_VARIANTS: phf::Set<&'static str> = phf_set! { "selected", }; +/// `@property` rule of a custom property: with an initial value, or without +macro_rules! property { + ($name:literal, $syntax:literal, $initial:literal) => { + ( + $name, + concat!( + "@property ", + $name, + "{syntax:\"", + $syntax, + "\";inherits:false;initial-value:", + $initial, + "}" + ), + ) + }; + ($name:literal) => { + ( + $name, + concat!("@property ", $name, "{syntax:\"*\";inherits:false}"), + ) + }; +} + /// The custom properties Tailwind registers, so that each element starts from /// their initial value instead of inheriting its parent's -pub const PROPERTY_RULES: &str = concat!( - "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", - "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", - "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-rotate-x{syntax:\"*\";inherits:false}", - "@property --tw-rotate-y{syntax:\"*\";inherits:false}", - "@property --tw-rotate-z{syntax:\"*\";inherits:false}", - "@property --tw-skew-x{syntax:\"*\";inherits:false}", - "@property --tw-skew-y{syntax:\"*\";inherits:false}", - "@property --tw-leading{syntax:\"*\";inherits:false}", - "@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}", - "@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", -); - -/// The stylesheet keeps [`PROPERTY_RULES`] under this name, which no source +static PROPERTIES: &[(&str, &str)] = &[ + property!("--tw-translate-x", "*", "0"), + property!("--tw-translate-y", "*", "0"), + property!("--tw-scale-x", "*", "1"), + property!("--tw-scale-y", "*", "1"), + property!("--tw-scale-z", "*", "1"), + property!("--tw-rotate-x"), + property!("--tw-rotate-y"), + property!("--tw-rotate-z"), + property!("--tw-skew-x"), + property!("--tw-skew-y"), + property!("--tw-leading"), + property!("--tw-content", "*", "\"\""), + property!("--tw-scroll-snap-strictness", "*", "proximity"), + property!("--tw-blur"), + property!("--tw-brightness"), + property!("--tw-contrast"), + property!("--tw-grayscale"), + property!("--tw-hue-rotate"), + property!("--tw-invert"), + property!("--tw-saturate"), + property!("--tw-sepia"), + property!("--tw-drop-shadow"), + property!("--tw-backdrop-blur"), + property!("--tw-backdrop-brightness"), + property!("--tw-backdrop-contrast"), + property!("--tw-backdrop-grayscale"), + property!("--tw-backdrop-hue-rotate"), + property!("--tw-backdrop-invert"), + property!("--tw-backdrop-opacity"), + property!("--tw-backdrop-saturate"), + property!("--tw-backdrop-sepia"), + property!("--tw-gradient-position"), + property!("--tw-gradient-from", "", "#0000"), + property!("--tw-gradient-via", "", "#0000"), + property!("--tw-gradient-to", "", "#0000"), + property!("--tw-gradient-stops"), + property!("--tw-gradient-via-stops"), + property!("--tw-gradient-from-position", "", "0%"), + property!("--tw-gradient-via-position", "", "50%"), + property!("--tw-gradient-to-position", "", "100%"), + property!("--tw-space-x-reverse", "*", "0"), + property!("--tw-space-y-reverse", "*", "0"), + property!("--tw-divide-x-reverse", "*", "0"), + property!("--tw-divide-y-reverse", "*", "0"), + property!("--tw-border-style", "*", "solid"), + property!("--tw-duration"), + property!("--tw-ease"), + property!("--tw-shadow", "*", "0 0 #0000"), + property!("--tw-shadow-color"), + property!("--tw-shadow-alpha", "", "100%"), + property!("--tw-drop-shadow-color"), + property!("--tw-drop-shadow-alpha", "", "100%"), + property!("--tw-drop-shadow-size"), + property!("--tw-inset-shadow", "*", "0 0 #0000"), + property!("--tw-inset-shadow-color"), + property!("--tw-inset-shadow-alpha", "", "100%"), + property!("--tw-ring-color"), + property!("--tw-ring-shadow", "*", "0 0 #0000"), + property!("--tw-inset-ring-color"), + property!("--tw-inset-ring-shadow", "*", "0 0 #0000"), +]; + +/// Whether `value` reads the custom property `name`, whole and not as the +/// start of a longer name +fn reads(value: &str, name: &str) -> bool { + value.match_indices(name).any(|(start, _)| { + !value[start + name.len()..] + .starts_with(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} +/// The stylesheet keeps the rules of [`TailwindClass::rules`] under this name, which no source /// file has, so that no file's update removes them pub const PROPERTY_RULES_FILE: &str = "@devup-ui/tailwind"; @@ -219,12 +302,21 @@ impl TailwindClass { }) } - /// Whether the declarations use the custom properties of [`PROPERTY_RULES`] + /// The global rules the declarations need: the @property rules of the + /// custom properties they set or read, and the @keyframes they name #[must_use] - pub fn uses_properties(&self) -> bool { - self.declarations + pub fn rules(&self) -> Vec<&'static str> { + let mut rules: Vec<&'static str> = PROPERTIES .iter() - .any(|(property, value)| property.starts_with("--tw-") || value.contains("var(--tw-")) + .filter(|(name, _)| { + self.declarations + .iter() + .any(|(property, value)| property == name || reads(value, name)) + }) + .map(|&(_, rule)| rule) + .collect(); + rules.extend(keyframes_of(&self.declarations)); + rules } } @@ -397,283 +489,8 @@ fn arbitrary_variant(inner: &str) -> Option { }) } -/// Tailwind color values -static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { - // Inherit/Current/Transparent - "inherit" => "inherit", - "current" => "currentColor", - "transparent" => "transparent", - // Black and White - "black" => "#000", - "white" => "#fff", - // Slate - "slate-50" => "#f8fafc", - "slate-100" => "#f1f5f9", - "slate-200" => "#e2e8f0", - "slate-300" => "#cbd5e1", - "slate-400" => "#94a3b8", - "slate-500" => "#64748b", - "slate-600" => "#475569", - "slate-700" => "#334155", - "slate-800" => "#1e293b", - "slate-900" => "#0f172a", - "slate-950" => "#020617", - // Gray - "gray-50" => "#f9fafb", - "gray-100" => "#f3f4f6", - "gray-200" => "#e5e7eb", - "gray-300" => "#d1d5db", - "gray-400" => "#9ca3af", - "gray-500" => "#6b7280", - "gray-600" => "#4b5563", - "gray-700" => "#374151", - "gray-800" => "#1f2937", - "gray-900" => "#111827", - "gray-950" => "#030712", - // Zinc - "zinc-50" => "#fafafa", - "zinc-100" => "#f4f4f5", - "zinc-200" => "#e4e4e7", - "zinc-300" => "#d4d4d8", - "zinc-400" => "#a1a1aa", - "zinc-500" => "#71717a", - "zinc-600" => "#52525b", - "zinc-700" => "#3f3f46", - "zinc-800" => "#27272a", - "zinc-900" => "#18181b", - "zinc-950" => "#09090b", - // Neutral - "neutral-50" => "#fafafa", - "neutral-100" => "#f5f5f5", - "neutral-200" => "#e5e5e5", - "neutral-300" => "#d4d4d4", - "neutral-400" => "#a3a3a3", - "neutral-500" => "#737373", - "neutral-600" => "#525252", - "neutral-700" => "#404040", - "neutral-800" => "#262626", - "neutral-900" => "#171717", - "neutral-950" => "#0a0a0a", - // Stone - "stone-50" => "#fafaf9", - "stone-100" => "#f5f5f4", - "stone-200" => "#e7e5e4", - "stone-300" => "#d6d3d1", - "stone-400" => "#a8a29e", - "stone-500" => "#78716c", - "stone-600" => "#57534e", - "stone-700" => "#44403c", - "stone-800" => "#292524", - "stone-900" => "#1c1917", - "stone-950" => "#0c0a09", - // Red - "red-50" => "#fef2f2", - "red-100" => "#fee2e2", - "red-200" => "#fecaca", - "red-300" => "#fca5a5", - "red-400" => "#f87171", - "red-500" => "#ef4444", - "red-600" => "#dc2626", - "red-700" => "#b91c1c", - "red-800" => "#991b1b", - "red-900" => "#7f1d1d", - "red-950" => "#450a0a", - // Orange - "orange-50" => "#fff7ed", - "orange-100" => "#ffedd5", - "orange-200" => "#fed7aa", - "orange-300" => "#fdba74", - "orange-400" => "#fb923c", - "orange-500" => "#f97316", - "orange-600" => "#ea580c", - "orange-700" => "#c2410c", - "orange-800" => "#9a3412", - "orange-900" => "#7c2d12", - "orange-950" => "#431407", - // Amber - "amber-50" => "#fffbeb", - "amber-100" => "#fef3c7", - "amber-200" => "#fde68a", - "amber-300" => "#fcd34d", - "amber-400" => "#fbbf24", - "amber-500" => "#f59e0b", - "amber-600" => "#d97706", - "amber-700" => "#b45309", - "amber-800" => "#92400e", - "amber-900" => "#78350f", - "amber-950" => "#451a03", - // Yellow - "yellow-50" => "#fefce8", - "yellow-100" => "#fef9c3", - "yellow-200" => "#fef08a", - "yellow-300" => "#fde047", - "yellow-400" => "#facc15", - "yellow-500" => "#eab308", - "yellow-600" => "#ca8a04", - "yellow-700" => "#a16207", - "yellow-800" => "#854d0e", - "yellow-900" => "#713f12", - "yellow-950" => "#422006", - // Lime - "lime-50" => "#f7fee7", - "lime-100" => "#ecfccb", - "lime-200" => "#d9f99d", - "lime-300" => "#bef264", - "lime-400" => "#a3e635", - "lime-500" => "#84cc16", - "lime-600" => "#65a30d", - "lime-700" => "#4d7c0f", - "lime-800" => "#3f6212", - "lime-900" => "#365314", - "lime-950" => "#1a2e05", - // Green - "green-50" => "#f0fdf4", - "green-100" => "#dcfce7", - "green-200" => "#bbf7d0", - "green-300" => "#86efac", - "green-400" => "#4ade80", - "green-500" => "#22c55e", - "green-600" => "#16a34a", - "green-700" => "#15803d", - "green-800" => "#166534", - "green-900" => "#14532d", - "green-950" => "#052e16", - // Emerald - "emerald-50" => "#ecfdf5", - "emerald-100" => "#d1fae5", - "emerald-200" => "#a7f3d0", - "emerald-300" => "#6ee7b7", - "emerald-400" => "#34d399", - "emerald-500" => "#10b981", - "emerald-600" => "#059669", - "emerald-700" => "#047857", - "emerald-800" => "#065f46", - "emerald-900" => "#064e3b", - "emerald-950" => "#022c22", - // Teal - "teal-50" => "#f0fdfa", - "teal-100" => "#ccfbf1", - "teal-200" => "#99f6e4", - "teal-300" => "#5eead4", - "teal-400" => "#2dd4bf", - "teal-500" => "#14b8a6", - "teal-600" => "#0d9488", - "teal-700" => "#0f766e", - "teal-800" => "#115e59", - "teal-900" => "#134e4a", - "teal-950" => "#042f2e", - // Cyan - "cyan-50" => "#ecfeff", - "cyan-100" => "#cffafe", - "cyan-200" => "#a5f3fc", - "cyan-300" => "#67e8f9", - "cyan-400" => "#22d3ee", - "cyan-500" => "#06b6d4", - "cyan-600" => "#0891b2", - "cyan-700" => "#0e7490", - "cyan-800" => "#155e75", - "cyan-900" => "#164e63", - "cyan-950" => "#083344", - // Sky - "sky-50" => "#f0f9ff", - "sky-100" => "#e0f2fe", - "sky-200" => "#bae6fd", - "sky-300" => "#7dd3fc", - "sky-400" => "#38bdf8", - "sky-500" => "#0ea5e9", - "sky-600" => "#0284c7", - "sky-700" => "#0369a1", - "sky-800" => "#075985", - "sky-900" => "#0c4a6e", - "sky-950" => "#082f49", - // Blue - "blue-50" => "#eff6ff", - "blue-100" => "#dbeafe", - "blue-200" => "#bfdbfe", - "blue-300" => "#93c5fd", - "blue-400" => "#60a5fa", - "blue-500" => "#3b82f6", - "blue-600" => "#2563eb", - "blue-700" => "#1d4ed8", - "blue-800" => "#1e40af", - "blue-900" => "#1e3a8a", - "blue-950" => "#172554", - // Indigo - "indigo-50" => "#eef2ff", - "indigo-100" => "#e0e7ff", - "indigo-200" => "#c7d2fe", - "indigo-300" => "#a5b4fc", - "indigo-400" => "#818cf8", - "indigo-500" => "#6366f1", - "indigo-600" => "#4f46e5", - "indigo-700" => "#4338ca", - "indigo-800" => "#3730a3", - "indigo-900" => "#312e81", - "indigo-950" => "#1e1b4b", - // Violet - "violet-50" => "#f5f3ff", - "violet-100" => "#ede9fe", - "violet-200" => "#ddd6fe", - "violet-300" => "#c4b5fd", - "violet-400" => "#a78bfa", - "violet-500" => "#8b5cf6", - "violet-600" => "#7c3aed", - "violet-700" => "#6d28d9", - "violet-800" => "#5b21b6", - "violet-900" => "#4c1d95", - "violet-950" => "#2e1065", - // Purple - "purple-50" => "#faf5ff", - "purple-100" => "#f3e8ff", - "purple-200" => "#e9d5ff", - "purple-300" => "#d8b4fe", - "purple-400" => "#c084fc", - "purple-500" => "#a855f7", - "purple-600" => "#9333ea", - "purple-700" => "#7e22ce", - "purple-800" => "#6b21a8", - "purple-900" => "#581c87", - "purple-950" => "#3b0764", - // Fuchsia - "fuchsia-50" => "#fdf4ff", - "fuchsia-100" => "#fae8ff", - "fuchsia-200" => "#f5d0fe", - "fuchsia-300" => "#f0abfc", - "fuchsia-400" => "#e879f9", - "fuchsia-500" => "#d946ef", - "fuchsia-600" => "#c026d3", - "fuchsia-700" => "#a21caf", - "fuchsia-800" => "#86198f", - "fuchsia-900" => "#701a75", - "fuchsia-950" => "#4a044e", - // Pink - "pink-50" => "#fdf2f8", - "pink-100" => "#fce7f3", - "pink-200" => "#fbcfe8", - "pink-300" => "#f9a8d4", - "pink-400" => "#f472b6", - "pink-500" => "#ec4899", - "pink-600" => "#db2777", - "pink-700" => "#be185d", - "pink-800" => "#9d174d", - "pink-900" => "#831843", - "pink-950" => "#500724", - // Rose - "rose-50" => "#fff1f2", - "rose-100" => "#ffe4e6", - "rose-200" => "#fecdd3", - "rose-300" => "#fda4af", - "rose-400" => "#fb7185", - "rose-500" => "#f43f5e", - "rose-600" => "#e11d48", - "rose-700" => "#be123c", - "rose-800" => "#9f1239", - "rose-900" => "#881337", - "rose-950" => "#4c0519", -}; - /// Spacing scale (Tailwind default: 1 unit = 0.25rem = 4px) -static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { +pub(crate) static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "0" => "0px", "px" => "1px", "0.5" => "0.125rem", @@ -825,18 +642,6 @@ static Z_INDEX_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "auto" => "auto", }; -/// Box shadow scale -static BOX_SHADOW_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "sm" => "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "" => "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "md" => "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - "lg" => "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", - "xl" => "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", - "2xl" => "0 25px 50px -12px rgb(0 0 0 / 0.25)", - "inner" => "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)", - "none" => "0 0 #0000", -}; - /// Border width scale static BORDER_WIDTH_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "0" => "0px", @@ -846,33 +651,33 @@ static BORDER_WIDTH_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "8" => "8px", }; -/// Transition duration scale -static DURATION_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "0" => "0s", - "75" => "75ms", - "100" => "100ms", - "150" => "150ms", - "200" => "200ms", - "300" => "300ms", - "500" => "500ms", - "700" => "700ms", - "1000" => "1000ms", -}; - -/// Ease timing functions -static EASE_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "linear" => "linear", - "in" => "cubic-bezier(0.4, 0, 1, 1)", - "out" => "cubic-bezier(0, 0, 0.2, 1)", - "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", -}; +/// The declarations `class` compiles to, `None` when it stays as written +#[cfg(test)] +pub(crate) fn declarations_of(class: &str) -> Option> { + parse_class(class).map(|class| { + class + .declarations + .into_iter() + .map(|(property, value)| (property.into_owned(), value.into_owned())) + .collect() + }) +} /// The class compiled, or `None` when it is not a Tailwind class understood in /// full, which must then stay in the className as written #[must_use] pub fn parse_class(class: &str) -> Option { let mut parts = split_top_level(class, ':'); - let mut declarations = utility(parts.pop()?)?; + let (name, important) = important(parts.pop()?); + let Utility { + mut declarations, + selector: utility_selector, + } = utility(name)?; + if important { + for (_, value) in &mut declarations { + *value = Cow::Owned(format!("{value} !important")); + } + } let mut level = 0; let mut selectors = vec![String::from("&")]; let mut at_rules = Vec::new(); @@ -895,6 +700,12 @@ pub fn parse_class(class: &str) -> Option { at_rules.extend(variant.at_rule); content |= variant.content; } + if utility_selector != "&" { + selectors = selectors + .iter() + .map(|selector| utility_selector.replace('&', selector)) + .collect(); + } if content && !declarations .iter() @@ -951,8 +762,39 @@ static NEGATABLE_PROPERTIES: phf::Set<&'static str> = phf_set! { "scroll-margin", }; -/// The declarations of a utility, which a leading `-` negates -fn utility(name: &str) -> Option> { +/// `name` without the `!` that makes its declarations important, trailing as +/// v4 writes it or leading as v3 did +fn important(name: &str) -> (&str, bool) { + name.strip_suffix('!') + .or_else(|| name.strip_prefix('!')) + .map_or((name, false), |name| (name, true)) +} + +/// What a utility declares, and on which element: itself, or the children of +/// the element it is on +struct Utility { + declarations: Vec, + /// Where the declarations apply, `&` being the element + selector: &'static str, +} + +/// The declarations of a utility and the selector they apply under +fn utility(name: &str) -> Option { + if let Some(declarations) = children_utility(name) { + return Some(Utility { + declarations, + selector: CHILDREN, + }); + } + Some(Utility { + declarations: element_utility(name)?, + selector: "&", + }) +} + +/// The declarations of a utility on the element itself, which a leading `-` +/// negates +fn element_utility(name: &str) -> Option> { let (negative, name) = name .strip_prefix('-') .map_or((false, name), |name| (true, name)); @@ -963,6 +805,14 @@ fn utility(name: &str) -> Option> { arbitrary_property(name) }; } + if let Some(declarations) = effect_utility(name, negative) { + return Some(declarations); + } + if !negative { + if let Some(declarations) = motion_utility(name) { + return Some(declarations); + } + } if let Some((root, value, variable)) = split_arbitrary(name) { return arbitrary_utility(root, value, variable, negative); } @@ -1197,7 +1047,7 @@ const TRANSFORM: &str = "var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate /// What an arbitrary value is, for the roots that take several kinds #[derive(Clone, Copy, PartialEq, Eq)] -enum ValueType { +pub(crate) enum ValueType { Length, Number, Image, @@ -1208,7 +1058,7 @@ enum ValueType { } /// The type a hint names, or that the value has -fn value_type(hint: Option<&str>, value: &str) -> Option { +pub(crate) fn value_type(hint: Option<&str>, value: &str) -> Option { Some(match hint { Some("length" | "percentage" | "line-width" | "absolute-size" | "relative-size") => { ValueType::Length @@ -1270,10 +1120,6 @@ fn arbitrary_declarations( } "scale-x" | "scale-y" => return Some(axes(root, "scale", &value, SCALE)), "skew" | "skew-x" | "skew-y" => return Some(skew(root, &value)), - "blur" | "brightness" | "contrast" | "saturate" => { - return Some(vec![function("filter", root, &value)]); - } - "backdrop-blur" => return Some(vec![function("backdrop-filter", "blur", &value)]), _ => {} } let value_type = value_type(hint, &value)?; @@ -1331,14 +1177,6 @@ fn each(properties: &[&'static str], value: &str) -> Vec { .collect() } -/// `property: name(value)` -fn function(property: &'static str, name: &str, value: &str) -> Declaration { - ( - Cow::Borrowed(property), - Cow::Owned(format!("{name}({value})")), - ) -} - /// `value` on the axes `root` names, each in its `--tw--` /// variable, which `property` reads as `composite` fn axes(root: &str, name: &'static str, value: &str, composite: &'static str) -> Vec { @@ -1620,7 +1458,7 @@ fn decode_underscores(value: &str) -> String { /// An arbitrary value as CSS, the way Tailwind decodes it: underscores are /// spaces except in `url()` and in the name `var()` reads, and the operators /// in math functions get the spaces CSS requires around them -fn decode_arbitrary_value(value: &str) -> String { +pub(crate) fn decode_arbitrary_value(value: &str) -> String { if !value.contains('(') { return decode_underscores(value); } @@ -1795,7 +1633,7 @@ fn add_whitespace_around_math_operators(input: &str) -> String { } /// `value` split at each top-level `separator`, outside brackets and parentheses -fn split_top_level(value: &str, separator: char) -> Vec<&str> { +pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> { let mut parts = Vec::new(); let mut depth = 0usize; let mut start = 0; @@ -1933,6 +1771,35 @@ fn tw(property: &'static str, value: impl Into>) -> ParsedUtil (property, value.into()) } +/// A declaration +pub(crate) fn decl( + property: impl Into>, + value: impl Into>, +) -> Declaration { + (property.into(), value.into()) +} + +/// Whether alue is a whole number written without a sign or leading zeros, +/// which is what v4 takes for the bare values of most utilities +pub(crate) fn is_positive_integer(value: &str) -> bool { + value == "0" + || (!value.starts_with('0') + && value.len() < 16 + && value.bytes().all(|byte| byte.is_ascii_digit()) + && !value.is_empty()) +} + +/// [value] as the CSS it stands for, or (--variable) as ar(--variable) +pub(crate) fn arbitrary_or_variable(argument: &str) -> Option { + if let Some(value) = bracketed(argument) { + return (!value.is_empty() && is_balanced(value)).then(|| decode_arbitrary_value(value)); + } + let variable = argument + .strip_prefix('(') + .and_then(|argument| argument.strip_suffix(')'))?; + (variable.starts_with("--") && is_balanced(variable)).then(|| format!("var({variable})")) +} + /// The one declaration of a utility from the tables below fn parse_utility(class: &str) -> Option { // Layout utilities @@ -1975,16 +1842,6 @@ fn parse_utility(class: &str) -> Option { return Some(result); } - // Filters - if let Some(result) = parse_filter_utility(class) { - return Some(result); - } - - // Transitions & Animation - if let Some(result) = parse_transition_utility(class) { - return Some(result); - } - // Transform origin if let Some(result) = parse_transform_utility(class) { return Some(result); @@ -2575,24 +2432,6 @@ fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str> } } - // Space between - if let Some(rest) = class.strip_prefix("space-x-") { - if rest == "reverse" { - return Some(tw("--tw-space-x-reverse", Cow::Borrowed("1"))); - } - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("column-gap", Cow::Borrowed(value))); - } - } - if let Some(rest) = class.strip_prefix("space-y-") { - if rest == "reverse" { - return Some(tw("--tw-space-y-reverse", Cow::Borrowed("1"))); - } - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("row-gap", Cow::Borrowed(value))); - } - } - None } @@ -2759,8 +2598,8 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Text color (font sizes are `compound_utility`'s) if let Some(rest) = class.strip_prefix("text-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("color", color)); } // Text alignment match rest { @@ -2898,8 +2737,8 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Background color if let Some(rest) = class.strip_prefix("bg-") { // Check if it's a color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("background-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("background-color", color)); } // Background attachment match rest { @@ -2958,42 +2797,6 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s "none" => return Some(tw("background-image", Cow::Borrowed("none"))), _ => {} } - - // Gradient directions - if let Some(dir) = rest.strip_prefix("gradient-to-") { - let direction = match dir { - "t" => Cow::Borrowed("to top"), - "tr" => Cow::Borrowed("to top right"), - "r" => Cow::Borrowed("to right"), - "br" => Cow::Borrowed("to bottom right"), - "b" => Cow::Borrowed("to bottom"), - "bl" => Cow::Borrowed("to bottom left"), - "l" => Cow::Borrowed("to left"), - "tl" => Cow::Borrowed("to top left"), - _ => return None, - }; - return Some(tw( - "background-image", - format!("linear-gradient({direction}, var(--tw-gradient-stops))"), - )); - } - } - - // Gradient color stops - if let Some(rest) = class.strip_prefix("from-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-from", Cow::Borrowed(color))); - } - } - if let Some(rest) = class.strip_prefix("via-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-via", Cow::Borrowed(color))); - } - } - if let Some(rest) = class.strip_prefix("to-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-to", Cow::Borrowed(color))); - } } None @@ -3006,8 +2809,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) // Border width if let Some(rest) = class.strip_prefix("border-") { // Border color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("border-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("border-color", color)); } // Border width per side @@ -3088,8 +2891,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "dotted" => return Some(tw("outline-style", Cow::Borrowed("dotted"))), "double" => return Some(tw("outline-style", Cow::Borrowed("double"))), _ => { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("outline-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("outline-color", color)); } } } @@ -3130,8 +2933,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) } "inset" => return Some(tw("--tw-ring-inset", Cow::Borrowed("inset"))), _ => { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-ring-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("--tw-ring-color", color)); } } } @@ -3143,55 +2946,11 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) )); } - // Divide - if let Some(rest) = class.strip_prefix("divide-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-divide-color", Cow::Borrowed(color))); - } - match rest { - "x" => return Some(tw("--tw-divide-x-reverse", Cow::Borrowed("0"))), - "x-0" => return Some(tw("border-inline-width", Cow::Borrowed("0px"))), - "x-2" => return Some(tw("border-inline-width", Cow::Borrowed("2px"))), - "x-4" => return Some(tw("border-inline-width", Cow::Borrowed("4px"))), - "x-8" => return Some(tw("border-inline-width", Cow::Borrowed("8px"))), - "x-reverse" => return Some(tw("--tw-divide-x-reverse", Cow::Borrowed("1"))), - "y" => return Some(tw("--tw-divide-y-reverse", Cow::Borrowed("0"))), - "y-0" => return Some(tw("border-block-width", Cow::Borrowed("0px"))), - "y-2" => return Some(tw("border-block-width", Cow::Borrowed("2px"))), - "y-4" => return Some(tw("border-block-width", Cow::Borrowed("4px"))), - "y-8" => return Some(tw("border-block-width", Cow::Borrowed("8px"))), - "y-reverse" => return Some(tw("--tw-divide-y-reverse", Cow::Borrowed("1"))), - "solid" => return Some(tw("border-style", Cow::Borrowed("solid"))), - "dashed" => return Some(tw("border-style", Cow::Borrowed("dashed"))), - "dotted" => return Some(tw("border-style", Cow::Borrowed("dotted"))), - "double" => return Some(tw("border-style", Cow::Borrowed("double"))), - "none" => return Some(tw("border-style", Cow::Borrowed("none"))), - _ => {} - } - } - None } /// Parse effects utilities (shadow, opacity, mix-blend, etc.) fn parse_effects_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Box shadow - if let Some(rest) = class.strip_prefix("shadow-") { - if let Some(&value) = BOX_SHADOW_SCALE.get(rest) { - return Some(tw("box-shadow", Cow::Borrowed(value))); - } - // Shadow color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-shadow-color", Cow::Borrowed(color))); - } - } - if class == "shadow" { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)"), - )); - } - // Opacity if let Some(rest) = class.strip_prefix("opacity-") { if let Some(&value) = OPACITY_SCALE.get(rest) { @@ -3222,309 +2981,6 @@ static BLEND_MODES: phf::Set<&'static str> = phf_set! { "luminosity", }; -/// Parse filter utilities (blur, brightness, contrast, etc.) -fn parse_filter_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Blur - if let Some(rest) = class.strip_prefix("blur-") { - let value = match rest { - "none" => Cow::Borrowed("0"), - "sm" => Cow::Borrowed("4px"), - "md" => Cow::Borrowed("12px"), - "lg" => Cow::Borrowed("16px"), - "xl" => Cow::Borrowed("24px"), - "2xl" => Cow::Borrowed("40px"), - "3xl" => Cow::Borrowed("64px"), - _ => return None, - }; - return Some(tw("filter", format!("blur({value})"))); - } - if class == "blur" { - return Some(tw("filter", Cow::Borrowed("blur(8px)"))); - } - - // Brightness - if let Some(rest) = class.strip_prefix("brightness-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "90" => Cow::Borrowed(".9"), - "95" => Cow::Borrowed(".95"), - "100" => Cow::Borrowed("1"), - "105" => Cow::Borrowed("1.05"), - "110" => Cow::Borrowed("1.1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("brightness({value})"))); - } - - // Contrast - if let Some(rest) = class.strip_prefix("contrast-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "100" => Cow::Borrowed("1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("contrast({value})"))); - } - - // Drop shadow - if let Some(rest) = class.strip_prefix("drop-shadow-") { - let value = match rest { - "sm" => Cow::Borrowed("drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))"), - "md" => Cow::Borrowed( - "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))", - ), - "lg" => Cow::Borrowed( - "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))", - ), - "xl" => Cow::Borrowed( - "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))", - ), - "2xl" => Cow::Borrowed("drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))"), - "none" => Cow::Borrowed("drop-shadow(0 0 #0000)"), - _ => return None, - }; - return Some(tw("filter", value)); - } - if class == "drop-shadow" { - return Some(tw( - "filter", - Cow::Borrowed( - "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))", - ), - )); - } - - // Grayscale - if class == "grayscale" { - return Some(tw("filter", Cow::Borrowed("grayscale(100%)"))); - } - if class == "grayscale-0" { - return Some(tw("filter", Cow::Borrowed("grayscale(0)"))); - } - - // Hue rotate - if let Some(rest) = class.strip_prefix("hue-rotate-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "15" => Cow::Borrowed("15deg"), - "30" => Cow::Borrowed("30deg"), - "60" => Cow::Borrowed("60deg"), - "90" => Cow::Borrowed("90deg"), - "180" => Cow::Borrowed("180deg"), - _ => return None, - }; - return Some(tw("filter", format!("hue-rotate({value})"))); - } - - // Invert - if class == "invert" { - return Some(tw("filter", Cow::Borrowed("invert(100%)"))); - } - if class == "invert-0" { - return Some(tw("filter", Cow::Borrowed("invert(0)"))); - } - - // Saturate - if let Some(rest) = class.strip_prefix("saturate-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "100" => Cow::Borrowed("1"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("saturate({value})"))); - } - - // Sepia - if class == "sepia" { - return Some(tw("filter", Cow::Borrowed("sepia(100%)"))); - } - if class == "sepia-0" { - return Some(tw("filter", Cow::Borrowed("sepia(0)"))); - } - - // Backdrop filters - if let Some(rest) = class.strip_prefix("backdrop-blur-") { - let value = match rest { - "none" => Cow::Borrowed("0"), - "sm" => Cow::Borrowed("4px"), - "md" => Cow::Borrowed("12px"), - "lg" => Cow::Borrowed("16px"), - "xl" => Cow::Borrowed("24px"), - "2xl" => Cow::Borrowed("40px"), - "3xl" => Cow::Borrowed("64px"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("blur({value})"))); - } - if class == "backdrop-blur" { - return Some(tw("backdrop-filter", Cow::Borrowed("blur(8px)"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-brightness-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "90" => Cow::Borrowed(".9"), - "95" => Cow::Borrowed(".95"), - "100" => Cow::Borrowed("1"), - "105" => Cow::Borrowed("1.05"), - "110" => Cow::Borrowed("1.1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("brightness({value})"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-contrast-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "100" => Cow::Borrowed("1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("contrast({value})"))); - } - - if class == "backdrop-grayscale" { - return Some(tw("backdrop-filter", Cow::Borrowed("grayscale(100%)"))); - } - if class == "backdrop-grayscale-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("grayscale(0)"))); - } - - if class == "backdrop-invert" { - return Some(tw("backdrop-filter", Cow::Borrowed("invert(100%)"))); - } - if class == "backdrop-invert-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("invert(0)"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-opacity-") { - if let Some(&value) = OPACITY_SCALE.get(rest) { - return Some(tw("backdrop-filter", format!("opacity({value})"))); - } - } - - if let Some(rest) = class.strip_prefix("backdrop-saturate-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "100" => Cow::Borrowed("1"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("saturate({value})"))); - } - - if class == "backdrop-sepia" { - return Some(tw("backdrop-filter", Cow::Borrowed("sepia(100%)"))); - } - if class == "backdrop-sepia-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("sepia(0)"))); - } - - None -} - -/// Parse transition and animation utilities -fn parse_transition_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Transition - match class { - "transition-none" => return Some(tw("transition-property", Cow::Borrowed("none"))), - "transition-all" => return Some(tw("transition-property", Cow::Borrowed("all"))), - "transition" => { - return Some(tw( - "transition-property", - Cow::Borrowed( - "color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter", - ), - )); - } - "transition-colors" => { - return Some(tw( - "transition-property", - Cow::Borrowed( - "color, background-color, border-color, text-decoration-color, fill, stroke", - ), - )); - } - "transition-opacity" => return Some(tw("transition-property", Cow::Borrowed("opacity"))), - "transition-shadow" => return Some(tw("transition-property", Cow::Borrowed("box-shadow"))), - "transition-transform" => { - return Some(tw("transition-property", Cow::Borrowed("transform"))); - } - _ => {} - } - - // Duration - if let Some(rest) = class.strip_prefix("duration-") { - if let Some(&value) = DURATION_SCALE.get(rest) { - return Some(tw("transition-duration", Cow::Borrowed(value))); - } - } - - // Ease (timing function) - if let Some(rest) = class.strip_prefix("ease-") { - if let Some(&value) = EASE_SCALE.get(rest) { - return Some(tw("transition-timing-function", Cow::Borrowed(value))); - } - } - - // Delay - if let Some(rest) = class.strip_prefix("delay-") { - if let Some(&value) = DURATION_SCALE.get(rest) { - return Some(tw("transition-delay", Cow::Borrowed(value))); - } - } - - // Animation - match class { - "animate-none" => return Some(tw("animation", Cow::Borrowed("none"))), - "animate-spin" => { - return Some(tw("animation", Cow::Borrowed("spin 1s linear infinite"))); - } - "animate-ping" => { - return Some(tw( - "animation", - Cow::Borrowed("ping 1s cubic-bezier(0, 0, 0.2, 1) infinite"), - )); - } - "animate-pulse" => { - return Some(tw( - "animation", - Cow::Borrowed("pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite"), - )); - } - "animate-bounce" => { - return Some(tw("animation", Cow::Borrowed("bounce 1s infinite"))); - } - _ => {} - } - - None -} - /// Parse transform origin utilities (translate, rotate, scale and skew are /// `transform_utility`'s) fn parse_transform_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { @@ -3554,8 +3010,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static if rest == "auto" { return Some(tw("accent-color", Cow::Borrowed("auto"))); } - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("accent-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("accent-color", color)); } } @@ -3575,8 +3031,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Caret color if let Some(rest) = class.strip_prefix("caret-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("caret-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("caret-color", color)); } } @@ -3706,8 +3162,8 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { if rest == "none" { return Some(tw("fill", Cow::Borrowed("none"))); } - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("fill", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("fill", color)); } } @@ -3724,8 +3180,8 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { _ => {} } // Stroke color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("stroke", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("stroke", color)); } } @@ -3822,12 +3278,12 @@ mod tests { } #[rstest] - #[case("bg-red-500", "background-color", "#ef4444")] - #[case("bg-blue-500", "background-color", "#3b82f6")] + #[case("bg-red-500", "background-color", "oklch(63.7% 0.237 25.331)")] + #[case("bg-blue-500", "background-color", "oklch(62.3% 0.214 259.815)")] #[case("bg-black", "background-color", "#000")] #[case("bg-white", "background-color", "#fff")] #[case("bg-transparent", "background-color", "transparent")] - #[case("text-red-500", "color", "#ef4444")] + #[case("text-red-500", "color", "oklch(63.7% 0.237 25.331)")] #[case("text-white", "color", "#fff")] fn test_parse_color_utilities( #[case] class: &str, @@ -3958,18 +3414,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case( - "transition-colors", - "color, background-color, border-color, text-decoration-color, fill, stroke" - )] - #[case("transition-transform", "transform")] - fn test_parse_transition_property_utilities(#[case] class: &str, #[case] expected_value: &str) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, "transition-property"); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("rounded", "border-radius", "0.25rem")] #[case("rounded-none", "border-radius", "0px")] @@ -3977,7 +3421,7 @@ mod tests { #[case("rounded-lg", "border-radius", "0.5rem")] #[case("border", "border-width", "1px")] #[case("border-2", "border-width", "2px")] - #[case("border-red-500", "border-color", "#ef4444")] + #[case("border-red-500", "border-color", "oklch(63.7% 0.237 25.331)")] fn test_parse_border_utilities( #[case] class: &str, #[case] expected_prop: &str, @@ -4101,34 +3545,6 @@ mod tests { assert_eq!(parsed.value, "span 3 / span 3"); } - #[test] - fn test_parse_transition_utilities() { - let parsed = parse_single_class("transition").expect("Should parse"); - assert_eq!(parsed.property, "transition-property"); - - let parsed = parse_single_class("duration-300").expect("Should parse"); - assert_eq!(parsed.property, "transition-duration"); - assert_eq!(parsed.value, "300ms"); - - let parsed = parse_single_class("ease-in-out").expect("Should parse"); - assert_eq!(parsed.property, "transition-timing-function"); - } - - #[test] - fn test_parse_filter_utilities() { - let parsed = parse_single_class("blur").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "blur(8px)"); - - let parsed = parse_single_class("blur-lg").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "blur(16px)"); - - let parsed = parse_single_class("grayscale").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "grayscale(100%)"); - } - #[test] fn test_parse_interactivity_utilities() { let parsed = parse_single_class("cursor-pointer").expect("Should parse"); @@ -4148,7 +3564,7 @@ mod tests { fn test_parse_svg_utilities() { let parsed = parse_single_class("fill-red-500").expect("Should parse"); assert_eq!(parsed.property, "fill"); - assert_eq!(parsed.value, "#ef4444"); + assert_eq!(parsed.value, "oklch(63.7% 0.237 25.331)"); let parsed = parse_single_class("stroke-black").expect("Should parse"); assert_eq!(parsed.property, "stroke"); @@ -4182,7 +3598,6 @@ mod tests { #[case("z-[999]", "z-index", "999")] #[case("font-[Arial]", "font-family", "Arial")] #[case("tracking-[0.2em]", "letter-spacing", "0.2em")] - #[case("duration-[500ms]", "transition-duration", "500ms")] #[case("delay-[200ms]", "transition-delay", "200ms")] #[case("aspect-[16/9]", "aspect-ratio", "16/9")] #[case("columns-[3]", "columns", "3")] @@ -4241,22 +3656,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("blur-[10px]", "filter", "blur(10px)")] - #[case("brightness-[1.2]", "filter", "brightness(1.2)")] - #[case("contrast-[1.5]", "filter", "contrast(1.5)")] - #[case("saturate-[2]", "filter", "saturate(2)")] - #[case("backdrop-blur-[5px]", "backdrop-filter", "blur(5px)")] - fn test_parse_arbitrary_filter_values( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // ==================== WAVE 3: Layout & Flex/Grid Utilities ==================== // Wave 3.1: aspect-ratio utilities (lines 1198-1204) @@ -4399,8 +3798,6 @@ mod tests { #[case("mr-4", "margin-right", "1rem")] #[case("mb-4", "margin-bottom", "1rem")] #[case("ml-4", "margin-left", "1rem")] - #[case("space-x-4", "column-gap", "1rem")] - #[case("space-y-4", "row-gap", "1rem")] #[case("space-x-reverse", "--tw-space-x-reverse", "1")] #[case("space-y-reverse", "--tw-space-y-reverse", "1")] fn test_parse_logical_spacing_utilities( @@ -4607,75 +4004,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case( - "bg-gradient-to-t", - "background-image", - "linear-gradient(to top, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-tr", - "background-image", - "linear-gradient(to top right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-r", - "background-image", - "linear-gradient(to right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-br", - "background-image", - "linear-gradient(to bottom right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-b", - "background-image", - "linear-gradient(to bottom, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-bl", - "background-image", - "linear-gradient(to bottom left, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-l", - "background-image", - "linear-gradient(to left, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-tl", - "background-image", - "linear-gradient(to top left, var(--tw-gradient-stops))" - )] - fn test_parse_background_gradient( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - // Wave 5.2: Gradient stops (lines 2057-2068) - #[rstest] - #[case("from-red-500", "--tw-gradient-from", "#ef4444")] - #[case("from-blue-500", "--tw-gradient-from", "#3b82f6")] - #[case("via-red-500", "--tw-gradient-via", "#ef4444")] - #[case("via-blue-500", "--tw-gradient-via", "#3b82f6")] - #[case("to-red-500", "--tw-gradient-to", "#ef4444")] - #[case("to-blue-500", "--tw-gradient-to", "#3b82f6")] - fn test_parse_gradient_stops( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Sides round both of their corners #[rstest] #[case("rounded-t-lg", &[("border-top-left-radius", "0.5rem"), ("border-top-right-radius", "0.5rem")])] @@ -4771,37 +4099,7 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("divide-x", "--tw-divide-x-reverse", "0")] - #[case("divide-y", "--tw-divide-y-reverse", "0")] - #[case("divide-x-2", "border-inline-width", "2px")] - #[case("divide-y-2", "border-block-width", "2px")] - #[case("divide-x-reverse", "--tw-divide-x-reverse", "1")] - #[case("divide-y-reverse", "--tw-divide-y-reverse", "1")] - fn test_parse_divide_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.5: Effects (lines 2328-2350) - #[rstest] - #[case("shadow-red-500", "--tw-shadow-color", "#ef4444")] - #[case("shadow-blue-500", "--tw-shadow-color", "#3b82f6")] - fn test_parse_shadow_color_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("bg-blend-normal", "background-blend-mode", "normal")] #[case("bg-blend-multiply", "background-blend-mode", "multiply")] @@ -4818,195 +4116,7 @@ mod tests { } // Wave 5.6: Filters & backdrop filters (lines 2365-2555) - #[rstest] - #[case("blur-none", "filter", "blur(0)")] - #[case("blur-sm", "filter", "blur(4px)")] - #[case("blur-md", "filter", "blur(12px)")] - #[case("blur-xl", "filter", "blur(24px)")] - #[case("blur-2xl", "filter", "blur(40px)")] - #[case("blur-3xl", "filter", "blur(64px)")] - fn test_parse_blur_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("brightness-0", "filter", "brightness(0)")] - #[case("brightness-50", "filter", "brightness(.5)")] - #[case("brightness-100", "filter", "brightness(1)")] - #[case("brightness-150", "filter", "brightness(1.5)")] - #[case("brightness-200", "filter", "brightness(2)")] - fn test_parse_brightness_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("contrast-0", "filter", "contrast(0)")] - #[case("contrast-50", "filter", "contrast(.5)")] - #[case("contrast-100", "filter", "contrast(1)")] - #[case("contrast-150", "filter", "contrast(1.5)")] - #[case("contrast-200", "filter", "contrast(2)")] - fn test_parse_contrast_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case( - "drop-shadow", - "filter", - "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))" - )] - #[case("drop-shadow-sm", "filter", "drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))")] - #[case( - "drop-shadow-md", - "filter", - "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))" - )] - #[case( - "drop-shadow-lg", - "filter", - "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))" - )] - #[case( - "drop-shadow-xl", - "filter", - "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))" - )] - #[case( - "drop-shadow-2xl", - "filter", - "drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))" - )] - #[case("drop-shadow-none", "filter", "drop-shadow(0 0 #0000)")] - fn test_parse_drop_shadow_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("hue-rotate-0", "filter", "hue-rotate(0deg)")] - #[case("hue-rotate-15", "filter", "hue-rotate(15deg)")] - #[case("hue-rotate-30", "filter", "hue-rotate(30deg)")] - #[case("hue-rotate-60", "filter", "hue-rotate(60deg)")] - #[case("hue-rotate-90", "filter", "hue-rotate(90deg)")] - #[case("hue-rotate-180", "filter", "hue-rotate(180deg)")] - fn test_parse_hue_rotate_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("invert-0", "filter", "invert(0)")] - #[case("invert", "filter", "invert(100%)")] - #[case("saturate-0", "filter", "saturate(0)")] - #[case("saturate-50", "filter", "saturate(.5)")] - #[case("saturate-100", "filter", "saturate(1)")] - #[case("saturate-150", "filter", "saturate(1.5)")] - #[case("saturate-200", "filter", "saturate(2)")] - #[case("sepia-0", "filter", "sepia(0)")] - #[case("sepia", "filter", "sepia(100%)")] - fn test_parse_filter_effects_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-blur", "backdrop-filter", "blur(8px)")] - #[case("backdrop-blur-sm", "backdrop-filter", "blur(4px)")] - #[case("backdrop-blur-md", "backdrop-filter", "blur(12px)")] - #[case("backdrop-blur-lg", "backdrop-filter", "blur(16px)")] - #[case("backdrop-blur-xl", "backdrop-filter", "blur(24px)")] - #[case("backdrop-blur-2xl", "backdrop-filter", "blur(40px)")] - #[case("backdrop-blur-3xl", "backdrop-filter", "blur(64px)")] - #[case("backdrop-blur-none", "backdrop-filter", "blur(0)")] - fn test_parse_backdrop_blur_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-brightness-0", "backdrop-filter", "brightness(0)")] - #[case("backdrop-brightness-100", "backdrop-filter", "brightness(1)")] - #[case("backdrop-contrast-0", "backdrop-filter", "contrast(0)")] - #[case("backdrop-contrast-100", "backdrop-filter", "contrast(1)")] - #[case("backdrop-grayscale-0", "backdrop-filter", "grayscale(0)")] - #[case("backdrop-grayscale", "backdrop-filter", "grayscale(100%)")] - #[case("backdrop-invert-0", "backdrop-filter", "invert(0)")] - #[case("backdrop-invert", "backdrop-filter", "invert(100%)")] - #[case("backdrop-opacity-0", "backdrop-filter", "opacity(0)")] - #[case("backdrop-opacity-100", "backdrop-filter", "opacity(1)")] - #[case("backdrop-saturate-0", "backdrop-filter", "saturate(0)")] - #[case("backdrop-saturate-100", "backdrop-filter", "saturate(1)")] - #[case("backdrop-sepia-0", "backdrop-filter", "sepia(0)")] - #[case("backdrop-sepia", "backdrop-filter", "sepia(100%)")] - fn test_parse_backdrop_filter_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.7: Transitions & animations (lines 2600-2618) - #[rstest] - #[case("delay-0", "transition-delay", "0s")] - #[case("delay-75", "transition-delay", "75ms")] - #[case("delay-100", "transition-delay", "100ms")] - #[case("delay-150", "transition-delay", "150ms")] - #[case("delay-200", "transition-delay", "200ms")] - #[case("delay-300", "transition-delay", "300ms")] - #[case("delay-500", "transition-delay", "500ms")] - #[case("delay-700", "transition-delay", "700ms")] - #[case("delay-1000", "transition-delay", "1000ms")] - fn test_parse_delay_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("animate-none", "animation", "none")] #[case("animate-spin", "animation", "spin 1s linear infinite")] @@ -5182,10 +4292,10 @@ mod tests { // Wave 5.9: Interactivity (lines 2732-2842) #[rstest] #[case("accent-auto", "accent-color", "auto")] - #[case("accent-red-500", "accent-color", "#ef4444")] + #[case("accent-red-500", "accent-color", "oklch(63.7% 0.237 25.331)")] #[case("appearance-none", "appearance", "none")] #[case("appearance-auto", "appearance", "auto")] - #[case("caret-red-500", "caret-color", "#ef4444")] + #[case("caret-red-500", "caret-color", "oklch(63.7% 0.237 25.331)")] fn test_parse_interactivity_accent_caret( #[case] class: &str, #[case] expected_prop: &str, @@ -5510,8 +4620,8 @@ mod tests { #[rstest] #[case("outline-black", "outline-color", "#000")] #[case("outline-white", "outline-color", "#fff")] - #[case("outline-red-500", "outline-color", "#ef4444")] - #[case("outline-blue-500", "outline-color", "#3b82f6")] + #[case("outline-red-500", "outline-color", "oklch(63.7% 0.237 25.331)")] + #[case("outline-blue-500", "outline-color", "oklch(62.3% 0.214 259.815)")] fn test_parse_outline_color( #[case] class: &str, #[case] expected_prop: &str, @@ -5539,8 +4649,8 @@ mod tests { #[rstest] #[case("ring-black", "--tw-ring-color", "#000")] #[case("ring-white", "--tw-ring-color", "#fff")] - #[case("ring-red-500", "--tw-ring-color", "#ef4444")] - #[case("ring-blue-500", "--tw-ring-color", "#3b82f6")] + #[case("ring-red-500", "--tw-ring-color", "oklch(63.7% 0.237 25.331)")] + #[case("ring-blue-500", "--tw-ring-color", "oklch(62.3% 0.214 259.815)")] fn test_parse_ring_color( #[case] class: &str, #[case] expected_prop: &str, @@ -5552,53 +4662,7 @@ mod tests { } // Wave 6.15: Divide utilities (lines 2293-2313) - #[rstest] - #[case("divide-black", "--tw-divide-color", "#000")] - #[case("divide-white", "--tw-divide-color", "#fff")] - #[case("divide-red-500", "--tw-divide-color", "#ef4444")] - fn test_parse_divide_color( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse divide color"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("divide-solid", "border-style", "solid")] - #[case("divide-dashed", "border-style", "dashed")] - #[case("divide-dotted", "border-style", "dotted")] - #[case("divide-double", "border-style", "double")] - #[case("divide-none", "border-style", "none")] - fn test_parse_divide_style( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse divide style"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 6.16: Filter fallbacks (lines 2390, 2405, 2419) - #[test] - fn test_brightness_unknown_value_returns_none() { - let result = parse_single_class("brightness-unknown"); - assert!(result.is_none()); - let result = parse_single_class("brightness-999"); - assert!(result.is_none()); - } - - #[test] - fn test_contrast_unknown_value_returns_none() { - let result = parse_single_class("contrast-unknown"); - assert!(result.is_none()); - let result = parse_single_class("contrast-999"); - assert!(result.is_none()); - } - #[test] fn test_drop_shadow_unknown_value_returns_none() { let result = parse_single_class("drop-shadow-unknown"); @@ -5608,49 +4672,6 @@ mod tests { } // Wave 6.17: Backdrop filter extended values (lines 2504-2555) - #[rstest] - #[case("backdrop-brightness-105", "backdrop-filter", "brightness(1.05)")] - #[case("backdrop-brightness-110", "backdrop-filter", "brightness(1.1)")] - #[case("backdrop-brightness-125", "backdrop-filter", "brightness(1.25)")] - #[case("backdrop-brightness-150", "backdrop-filter", "brightness(1.5)")] - #[case("backdrop-brightness-200", "backdrop-filter", "brightness(2)")] - fn test_parse_backdrop_brightness_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-brightness"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-contrast-125", "backdrop-filter", "contrast(1.25)")] - #[case("backdrop-contrast-150", "backdrop-filter", "contrast(1.5)")] - #[case("backdrop-contrast-200", "backdrop-filter", "contrast(2)")] - fn test_parse_backdrop_contrast_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-contrast"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-saturate-150", "backdrop-filter", "saturate(1.5)")] - #[case("backdrop-saturate-200", "backdrop-filter", "saturate(2)")] - fn test_parse_backdrop_saturate_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-saturate"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[test] fn test_backdrop_brightness_unknown_returns_none() { let result = parse_single_class("backdrop-brightness-unknown"); @@ -5873,22 +4894,6 @@ mod tests { } // Wave 7.8: shadow without suffix (line 2333) - #[rstest] - #[case( - "shadow", - "box-shadow", - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)" - )] - fn test_shadow_without_suffix( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse shadow"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.9: mix-blend- prefix (line 2345) #[rstest] #[case("mix-blend-normal", "mix-blend-mode", "normal")] @@ -5928,38 +4933,8 @@ mod tests { } // Wave 7.11: grayscale-0 (line 2432) - #[rstest] - #[case("grayscale-0", "filter", "grayscale(0)")] - fn test_grayscale_zero( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse grayscale"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.12: hue-rotate- unknown value (line 2444) - #[rstest] - #[case("hue-rotate-unknown")] - #[case("hue-rotate-999")] - #[case("hue-rotate-45")] - fn test_hue_rotate_unknown_returns_none(#[case] class: &str) { - let result = parse_single_class(class); - assert!(result.is_none()); - } - // Wave 7.13: saturate- unknown value (line 2465) - #[rstest] - #[case("saturate-unknown")] - #[case("saturate-999")] - #[case("saturate-75")] - fn test_saturate_unknown_returns_none(#[case] class: &str) { - let result = parse_single_class(class); - assert!(result.is_none()); - } - // ============================================================================ // WAVE 8: Coverage Gap Tests for Lines 816, 866, 295 // ============================================================================ @@ -6211,10 +5186,6 @@ mod tests { #[case("nth-0:p-4")] #[case("nth-x:p-4")] #[case("print:[@media_screen]:p-4")] - #[case("p-4!")] - #[case("!p-4")] - #[case("bg-red-500/50")] - #[case("text-red-500/6")] #[case("text-sm/x")] #[case("text-sm/[_]")] #[case("text-sm/(x)")] @@ -6417,8 +5388,6 @@ mod tests { #[case("font-[number:var(--w)]", "font-weight", "var(--w)")] #[case("font-[Inter_Var]", "font-family", "Inter Var")] #[case("font-[family-name:var(--f)]", "font-family", "var(--f)")] - #[case("blur-[2px]", "filter", "blur(2px)")] - #[case("backdrop-blur-[2px]", "backdrop-filter", "blur(2px)")] #[case("w-[calc(100%_-_2rem)]", "width", "calc(100% - 2rem)")] #[case("m-[var(--x_y)]", "margin", "var(--x_y)")] #[case( @@ -6508,11 +5477,20 @@ mod tests { styles[0].selector().map(ToString::to_string).as_deref(), Some("@media(hover:hover) &:hover") ); - assert!(!class.uses_properties()); + assert_eq!(class.rules(), Vec::<&str>::new()); let class = parse_class("selection:translate-x-4").unwrap(); assert_eq!(class.styles().count(), 4); - assert!(class.uses_properties()); - assert!(parse_class("before:block").unwrap().uses_properties()); + assert_eq!( + class.rules(), + vec![ + "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", + ] + ); + assert_eq!( + parse_class("before:block").unwrap().rules(), + vec!["@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}"] + ); } } diff --git a/libs/extractor/src/tailwind_children.rs b/libs/extractor/src/tailwind_children.rs new file mode 100644 index 000000000..b1195d9eb --- /dev/null +++ b/libs/extractor/src/tailwind_children.rs @@ -0,0 +1,326 @@ +//! `space-*` and `divide-*`: utilities that style the children of the element, +//! all but the last, through the selector Tailwind CSS v4 writes for them + +use crate::tailwind::{ + Declaration, SPACING_SCALE, arbitrary_or_variable, decl, is_positive_integer, +}; +use crate::tailwind_color::color_value; + +/// The children `space-*` and `divide-*` style, which `:where` leaves to any +/// utility of the child itself +pub const CHILDREN: &str = ":where(& > :not(:last-child))"; + +/// The value of the spacing `argument`: a step of the scale, or an arbitrary +/// value, negated by a leading `-` +fn spacing(argument: &str, negative: bool) -> Option { + if let Some(&value) = SPACING_SCALE.get(argument) { + return Some(if negative && value != "0px" { + format!("-{value}") + } else { + value.to_string() + }); + } + let value = arbitrary_or_variable(argument)?; + Some(if negative { + format!("calc({value} * -1)") + } else { + value + }) +} +/// The margins of `space-x-*` and `space-y-*` +fn space_utility(axis: char, argument: &str, negative: bool) -> Option> { + let reverse = format!("--tw-space-{axis}-reverse"); + if argument == "reverse" { + return (!negative).then(|| vec![decl(reverse, "1")]); + } + let value = spacing(argument, negative)?; + let (start, end) = if axis == 'x' { + ("margin-inline-start", "margin-inline-end") + } else { + ("margin-block-start", "margin-block-end") + }; + if matches!(value.as_str(), "0px" | "0") { + return Some(vec![decl(start, "0"), decl(end, "0")]); + } + Some(vec![ + decl(start, format!("calc({value} * var({reverse}))")), + decl(end, format!("calc({value} * calc(1 - var({reverse})))")), + ]) +} + +/// The border width of `divide-x-*` and `divide-y-*`: a number of pixels, +/// or an arbitrary value +fn divide_width(argument: &str) -> Option { + if is_positive_integer(argument) { + return Some(format!("{argument}px")); + } + arbitrary_or_variable(argument) +} + +/// The borders of `divide-x-*` and `divide-y-*` +fn divide_width_utility(axis: char, argument: &str) -> Option> { + let reverse = format!("--tw-divide-{axis}-reverse"); + if argument == "-reverse" { + return Some(vec![decl(reverse, "1")]); + } + let width = if argument.is_empty() { + String::from("1px") + } else { + divide_width(argument.strip_prefix('-')?)? + }; + let start = format!("calc({width} * var({reverse}))"); + let end = format!("calc({width} * calc(1 - var({reverse})))"); + Some(if axis == 'x' { + vec![ + decl("border-inline-style", "var(--tw-border-style)"), + decl("border-inline-start-width", start), + decl("border-inline-end-width", end), + ] + } else { + vec![ + decl("border-bottom-style", "var(--tw-border-style)"), + decl("border-top-style", "var(--tw-border-style)"), + decl("border-top-width", start), + decl("border-bottom-width", end), + ] + }) +} +/// The declarations of `space-*` and `divide-*`, which style the children +pub fn children_utility(name: &str) -> Option> { + let (negative, name) = name + .strip_prefix('-') + .map_or((false, name), |name| (true, name)); + if let Some(argument) = name.strip_prefix("space-x-") { + return space_utility('x', argument, negative); + } + if let Some(argument) = name.strip_prefix("space-y-") { + return space_utility('y', argument, negative); + } + if negative { + return None; + } + let argument = name.strip_prefix("divide-")?; + if let Some(declarations) = argument + .strip_prefix('x') + .and_then(|argument| divide_width_utility('x', argument)) + .or_else(|| { + argument + .strip_prefix('y') + .and_then(|argument| divide_width_utility('y', argument)) + }) + { + return Some(declarations); + } + if matches!(argument, "solid" | "dashed" | "dotted" | "double" | "none") { + return Some(vec![ + decl("--tw-border-style", argument.to_string()), + decl("border-style", argument.to_string()), + ]); + } + color_value(argument).map(|color| vec![decl("border-color", color)]) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::tailwind::{declarations_of, parse_class}; + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + #[rstest] + #[case("space-x-0", &[("margin-inline-start", "0"), ("margin-inline-end", "0")])] + #[case("space-x-px", &[ + ("margin-inline-start", "calc(1px * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(1px * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("space-x-4", &[ + ("margin-inline-start", "calc(1rem * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(1rem * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-4", &[ + ("margin-inline-start", "calc(-1rem * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(-1rem * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-px", &[ + ("margin-inline-start", "calc(-1px * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(-1px * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-y-0", &[("margin-block-start", "0"), ("margin-block-end", "0")])] + #[case("space-y-2.5", &[ + ("margin-block-start", "calc(0.625rem * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(0.625rem * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-y-[3px]", &[ + ("margin-block-start", "calc(3px * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(3px * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-x-(--gap)", &[ + ("margin-inline-start", "calc(var(--gap) * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(var(--gap) * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-[3px]", &[ + ("margin-inline-start", "calc(calc(3px * -1) * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(calc(3px * -1) * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-y-(--gap)", &[ + ("margin-block-start", "calc(calc(var(--gap) * -1) * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(calc(var(--gap) * -1) * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-x-reverse", &[("--tw-space-x-reverse", "1")])] + #[case("space-y-reverse", &[("--tw-space-y-reverse", "1")])] + fn space_adds_margins_between_the_children( + #[case] class: &str, + #[case] expected: &[(&str, &str)], + ) { + expect(class, expected); + } + + #[rstest] + #[case("divide-x", "x", "1px")] + #[case("divide-x-0", "x", "0px")] + #[case("divide-x-2", "x", "2px")] + #[case("divide-x-8", "x", "8px")] + #[case("divide-x-[3px]", "x", "3px")] + #[case("divide-x-(--w)", "x", "var(--w)")] + #[case("divide-y", "y", "1px")] + #[case("divide-y-4", "y", "4px")] + fn divide_borders_the_children(#[case] class: &str, #[case] axis: &str, #[case] width: &str) { + if axis == "x" { + expect( + class, + &[ + ("border-inline-style", "var(--tw-border-style)"), + ( + "border-inline-start-width", + &format!("calc({width} * var(--tw-divide-x-reverse))"), + ), + ( + "border-inline-end-width", + &format!("calc({width} * calc(1 - var(--tw-divide-x-reverse)))"), + ), + ], + ); + } else { + expect( + class, + &[ + ("border-bottom-style", "var(--tw-border-style)"), + ("border-top-style", "var(--tw-border-style)"), + ( + "border-top-width", + &format!("calc({width} * var(--tw-divide-y-reverse))"), + ), + ( + "border-bottom-width", + &format!("calc({width} * calc(1 - var(--tw-divide-y-reverse)))"), + ), + ], + ); + } + } + + #[test] + fn divide_reverse_style_and_color() { + expect("divide-x-reverse", &[("--tw-divide-x-reverse", "1")]); + expect("divide-y-reverse", &[("--tw-divide-y-reverse", "1")]); + for style in ["solid", "dashed", "dotted", "double", "none"] { + expect( + &format!("divide-{style}"), + &[("--tw-border-style", style), ("border-style", style)], + ); + } + expect( + "divide-red-500", + &[("border-color", "oklch(63.7% 0.237 25.331)")], + ); + expect( + "divide-yellow-500/50", + &[( + "border-color", + "color-mix(in oklab, oklch(79.5% 0.184 86.047) 50%, transparent)", + )], + ); + } + + #[rstest] + #[case("divide-xyz")] + #[case("divide-x-")] + #[case("divide-x-1.5")] + #[case("divide-x-05")] + #[case("divide-x-reverse-2")] + #[case("divide-y-abc")] + #[case("divide-purple")] + #[case("divide-")] + #[case("-divide-x-2")] + #[case("-divide-red-500")] + #[case("space-x-")] + #[case("space-x-reverse-1")] + #[case("space-x-1.3")] + #[case("space-x-huge")] + #[case("-space-x-reverse")] + #[case("space-z-4")] + fn unknown_children_utilities_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn children_utilities_apply_under_variants_to_the_children() { + let class = parse_class("space-x-4").unwrap(); + assert_eq!(class.conditions.len(), 1); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(CHILDREN) + ); + let class = parse_class("md:space-x-4").unwrap(); + assert_eq!(class.level, 2); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(CHILDREN) + ); + let class = parse_class("focus:divide-x-2").unwrap(); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(":where(&:focus > :not(:last-child))") + ); + let class = parse_class("space-x-4!").unwrap(); + assert!( + class + .declarations + .iter() + .all(|(_, value)| value.ends_with(" !important")) + ); + } + + #[test] + fn children_rules_register_their_variables() { + assert_eq!( + parse_class("space-x-4").unwrap().rules(), + vec!["@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}"] + ); + assert_eq!( + parse_class("divide-x-2").unwrap().rules(), + vec![ + "@property --tw-divide-x-reverse{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}", + ] + ); + } +} diff --git a/libs/extractor/src/tailwind_color.rs b/libs/extractor/src/tailwind_color.rs new file mode 100644 index 000000000..db1aaf193 --- /dev/null +++ b/libs/extractor/src/tailwind_color.rs @@ -0,0 +1,545 @@ +//! The colors a Tailwind class names: the devup theme's, then the default +//! palette of Tailwind CSS v4, with the opacity modifier (`/50`, `/[0.5]`, +//! `/(--alpha)`) applied the way v4 writes it + +use std::borrow::Cow; + +use css::theme_tokens::is_color_token; +use phf::phf_map; + +use crate::tailwind::{decode_arbitrary_value, split_top_level}; + +/// The colors of the Tailwind CSS v4 default theme (`--color-*`) +static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { + "inherit" => "inherit", + "current" => "currentcolor", + "transparent" => "transparent", + "red-50" => "oklch(97.1% 0.013 17.38)", + "red-100" => "oklch(93.6% 0.032 17.717)", + "red-200" => "oklch(88.5% 0.062 18.334)", + "red-300" => "oklch(80.8% 0.114 19.571)", + "red-400" => "oklch(70.4% 0.191 22.216)", + "red-500" => "oklch(63.7% 0.237 25.331)", + "red-600" => "oklch(57.7% 0.245 27.325)", + "red-700" => "oklch(50.5% 0.213 27.518)", + "red-800" => "oklch(44.4% 0.177 26.899)", + "red-900" => "oklch(39.6% 0.141 25.723)", + "red-950" => "oklch(25.8% 0.092 26.042)", + "orange-50" => "oklch(98% 0.016 73.684)", + "orange-100" => "oklch(95.4% 0.038 75.164)", + "orange-200" => "oklch(90.1% 0.076 70.697)", + "orange-300" => "oklch(83.7% 0.128 66.29)", + "orange-400" => "oklch(75% 0.183 55.934)", + "orange-500" => "oklch(70.5% 0.213 47.604)", + "orange-600" => "oklch(64.6% 0.222 41.116)", + "orange-700" => "oklch(55.3% 0.195 38.402)", + "orange-800" => "oklch(47% 0.157 37.304)", + "orange-900" => "oklch(40.8% 0.123 38.172)", + "orange-950" => "oklch(26.6% 0.079 36.259)", + "amber-50" => "oklch(98.7% 0.022 95.277)", + "amber-100" => "oklch(96.2% 0.059 95.617)", + "amber-200" => "oklch(92.4% 0.12 95.746)", + "amber-300" => "oklch(87.9% 0.169 91.605)", + "amber-400" => "oklch(82.8% 0.189 84.429)", + "amber-500" => "oklch(76.9% 0.188 70.08)", + "amber-600" => "oklch(66.6% 0.179 58.318)", + "amber-700" => "oklch(55.5% 0.163 48.998)", + "amber-800" => "oklch(47.3% 0.137 46.201)", + "amber-900" => "oklch(41.4% 0.112 45.904)", + "amber-950" => "oklch(27.9% 0.077 45.635)", + "yellow-50" => "oklch(98.7% 0.026 102.212)", + "yellow-100" => "oklch(97.3% 0.071 103.193)", + "yellow-200" => "oklch(94.5% 0.129 101.54)", + "yellow-300" => "oklch(90.5% 0.182 98.111)", + "yellow-400" => "oklch(85.2% 0.199 91.936)", + "yellow-500" => "oklch(79.5% 0.184 86.047)", + "yellow-600" => "oklch(68.1% 0.162 75.834)", + "yellow-700" => "oklch(55.4% 0.135 66.442)", + "yellow-800" => "oklch(47.6% 0.114 61.907)", + "yellow-900" => "oklch(42.1% 0.095 57.708)", + "yellow-950" => "oklch(28.6% 0.066 53.813)", + "lime-50" => "oklch(98.6% 0.031 120.757)", + "lime-100" => "oklch(96.7% 0.067 122.328)", + "lime-200" => "oklch(93.8% 0.127 124.321)", + "lime-300" => "oklch(89.7% 0.196 126.665)", + "lime-400" => "oklch(84.1% 0.238 128.85)", + "lime-500" => "oklch(76.8% 0.233 130.85)", + "lime-600" => "oklch(64.8% 0.2 131.684)", + "lime-700" => "oklch(64.8% 0.2 131.684)", + "lime-800" => "oklch(45.3% 0.124 130.933)", + "lime-900" => "oklch(40.5% 0.101 131.063)", + "lime-950" => "oklch(27.4% 0.072 132.109)", + "green-50" => "oklch(98.2% 0.018 155.826)", + "green-100" => "oklch(96.2% 0.044 156.743)", + "green-200" => "oklch(92.5% 0.084 155.995)", + "green-300" => "oklch(87.1% 0.15 154.449)", + "green-400" => "oklch(79.2% 0.209 151.711)", + "green-500" => "oklch(72.3% 0.219 149.579)", + "green-600" => "oklch(62.7% 0.194 149.214)", + "green-700" => "oklch(52.7% 0.154 150.069)", + "green-800" => "oklch(44.8% 0.119 151.328)", + "green-900" => "oklch(39.3% 0.095 152.535)", + "green-950" => "oklch(26.6% 0.065 152.934)", + "emerald-50" => "oklch(97.9% 0.021 166.113)", + "emerald-100" => "oklch(95% 0.052 163.051)", + "emerald-200" => "oklch(90.5% 0.093 164.15)", + "emerald-300" => "oklch(84.5% 0.143 164.978)", + "emerald-400" => "oklch(76.5% 0.177 163.223)", + "emerald-500" => "oklch(69.6% 0.17 162.48)", + "emerald-600" => "oklch(59.6% 0.145 163.225)", + "emerald-700" => "oklch(50.8% 0.118 165.612)", + "emerald-800" => "oklch(43.2% 0.095 166.913)", + "emerald-900" => "oklch(37.8% 0.077 168.94)", + "emerald-950" => "oklch(26.2% 0.051 172.552)", + "teal-50" => "oklch(98.4% 0.014 180.72)", + "teal-100" => "oklch(95.3% 0.051 180.801)", + "teal-200" => "oklch(91% 0.096 180.426)", + "teal-300" => "oklch(85.5% 0.138 181.071)", + "teal-400" => "oklch(77.7% 0.152 181.912)", + "teal-500" => "oklch(70.4% 0.14 182.503)", + "teal-600" => "oklch(60% 0.118 184.704)", + "teal-700" => "oklch(51.1% 0.096 186.391)", + "teal-800" => "oklch(43.7% 0.078 188.216)", + "teal-900" => "oklch(38.6% 0.063 188.416)", + "teal-950" => "oklch(27.7% 0.046 192.524)", + "cyan-50" => "oklch(98.4% 0.019 200.873)", + "cyan-100" => "oklch(95.6% 0.045 203.388)", + "cyan-200" => "oklch(91.7% 0.08 205.041)", + "cyan-300" => "oklch(86.5% 0.127 207.078)", + "cyan-400" => "oklch(78.9% 0.154 211.53)", + "cyan-500" => "oklch(71.5% 0.143 215.221)", + "cyan-600" => "oklch(60.9% 0.126 221.723)", + "cyan-700" => "oklch(52% 0.105 223.128)", + "cyan-800" => "oklch(45% 0.085 224.283)", + "cyan-900" => "oklch(39.8% 0.07 227.392)", + "cyan-950" => "oklch(30.2% 0.056 229.695)", + "sky-50" => "oklch(97.7% 0.013 236.62)", + "sky-100" => "oklch(95.1% 0.026 236.824)", + "sky-200" => "oklch(90.1% 0.058 230.902)", + "sky-300" => "oklch(82.8% 0.111 230.318)", + "sky-400" => "oklch(74.6% 0.16 232.661)", + "sky-500" => "oklch(68.5% 0.169 237.323)", + "sky-600" => "oklch(58.8% 0.158 241.966)", + "sky-700" => "oklch(50% 0.134 242.749)", + "sky-800" => "oklch(44.3% 0.11 240.79)", + "sky-900" => "oklch(39.1% 0.09 240.876)", + "sky-950" => "oklch(29.3% 0.066 243.157)", + "blue-50" => "oklch(97% 0.014 254.604)", + "blue-100" => "oklch(93.2% 0.032 255.585)", + "blue-200" => "oklch(88.2% 0.059 254.128)", + "blue-300" => "oklch(80.9% 0.105 251.813)", + "blue-400" => "oklch(70.7% 0.165 254.624)", + "blue-500" => "oklch(62.3% 0.214 259.815)", + "blue-600" => "oklch(54.6% 0.245 262.881)", + "blue-700" => "oklch(48.8% 0.243 264.376)", + "blue-800" => "oklch(42.4% 0.199 265.638)", + "blue-900" => "oklch(37.9% 0.146 265.522)", + "blue-950" => "oklch(28.2% 0.091 267.935)", + "indigo-50" => "oklch(96.2% 0.018 272.314)", + "indigo-100" => "oklch(93% 0.034 272.788)", + "indigo-200" => "oklch(87% 0.065 274.039)", + "indigo-300" => "oklch(78.5% 0.115 274.713)", + "indigo-400" => "oklch(67.3% 0.182 276.935)", + "indigo-500" => "oklch(58.5% 0.233 277.117)", + "indigo-600" => "oklch(51.1% 0.262 276.966)", + "indigo-700" => "oklch(45.7% 0.24 277.023)", + "indigo-800" => "oklch(39.8% 0.195 277.366)", + "indigo-900" => "oklch(35.9% 0.144 278.697)", + "indigo-950" => "oklch(25.7% 0.09 281.288)", + "violet-50" => "oklch(96.9% 0.016 293.756)", + "violet-100" => "oklch(94.3% 0.029 294.588)", + "violet-200" => "oklch(89.4% 0.057 293.283)", + "violet-300" => "oklch(81.1% 0.111 293.571)", + "violet-400" => "oklch(70.2% 0.183 293.541)", + "violet-500" => "oklch(60.6% 0.25 292.717)", + "violet-600" => "oklch(54.1% 0.281 293.009)", + "violet-700" => "oklch(49.1% 0.27 292.581)", + "violet-800" => "oklch(43.2% 0.232 292.759)", + "violet-900" => "oklch(38% 0.189 293.745)", + "violet-950" => "oklch(28.3% 0.141 291.089)", + "purple-50" => "oklch(97.7% 0.014 308.299)", + "purple-100" => "oklch(94.6% 0.033 307.174)", + "purple-200" => "oklch(90.2% 0.063 306.703)", + "purple-300" => "oklch(82.7% 0.119 306.383)", + "purple-400" => "oklch(71.4% 0.203 305.504)", + "purple-500" => "oklch(62.7% 0.265 303.9)", + "purple-600" => "oklch(55.8% 0.288 302.321)", + "purple-700" => "oklch(49.6% 0.265 301.924)", + "purple-800" => "oklch(43.8% 0.218 303.724)", + "purple-900" => "oklch(38.1% 0.176 304.987)", + "purple-950" => "oklch(29.1% 0.149 302.717)", + "fuchsia-50" => "oklch(97.7% 0.017 320.058)", + "fuchsia-100" => "oklch(95.2% 0.037 318.852)", + "fuchsia-200" => "oklch(90.3% 0.076 319.62)", + "fuchsia-300" => "oklch(83.3% 0.145 321.434)", + "fuchsia-400" => "oklch(74% 0.238 322.16)", + "fuchsia-500" => "oklch(66.7% 0.295 322.15)", + "fuchsia-600" => "oklch(59.1% 0.293 322.896)", + "fuchsia-700" => "oklch(51.8% 0.253 323.949)", + "fuchsia-800" => "oklch(45.2% 0.211 324.591)", + "fuchsia-900" => "oklch(40.1% 0.17 325.612)", + "fuchsia-950" => "oklch(29.3% 0.136 325.661)", + "pink-50" => "oklch(97.1% 0.014 343.198)", + "pink-100" => "oklch(94.8% 0.028 342.258)", + "pink-200" => "oklch(89.9% 0.061 343.231)", + "pink-300" => "oklch(82.3% 0.12 346.018)", + "pink-400" => "oklch(71.8% 0.202 349.761)", + "pink-500" => "oklch(65.6% 0.241 354.308)", + "pink-600" => "oklch(59.2% 0.249 0.584)", + "pink-700" => "oklch(52.5% 0.223 3.958)", + "pink-800" => "oklch(45.9% 0.187 3.815)", + "pink-900" => "oklch(40.8% 0.153 2.432)", + "pink-950" => "oklch(28.4% 0.109 3.907)", + "rose-50" => "oklch(96.9% 0.015 12.422)", + "rose-100" => "oklch(94.1% 0.03 12.58)", + "rose-200" => "oklch(89.2% 0.058 10.001)", + "rose-300" => "oklch(81% 0.117 11.638)", + "rose-400" => "oklch(71.2% 0.194 13.428)", + "rose-500" => "oklch(64.5% 0.246 16.439)", + "rose-600" => "oklch(58.6% 0.253 17.585)", + "rose-700" => "oklch(51.4% 0.222 16.935)", + "rose-800" => "oklch(45.5% 0.188 13.697)", + "rose-900" => "oklch(41% 0.159 10.272)", + "rose-950" => "oklch(27.1% 0.105 12.094)", + "slate-50" => "oklch(98.4% 0.003 247.858)", + "slate-100" => "oklch(96.8% 0.007 247.896)", + "slate-200" => "oklch(92.9% 0.013 255.508)", + "slate-300" => "oklch(86.9% 0.022 252.894)", + "slate-400" => "oklch(70.4% 0.04 256.788)", + "slate-500" => "oklch(55.4% 0.046 257.417)", + "slate-600" => "oklch(44.6% 0.043 257.281)", + "slate-700" => "oklch(37.2% 0.044 257.287)", + "slate-800" => "oklch(27.9% 0.041 260.031)", + "slate-900" => "oklch(20.8% 0.042 265.755)", + "slate-950" => "oklch(12.9% 0.042 264.695)", + "gray-50" => "oklch(98.5% 0.002 247.839)", + "gray-100" => "oklch(96.7% 0.003 264.542)", + "gray-200" => "oklch(92.8% 0.006 264.531)", + "gray-300" => "oklch(87.2% 0.01 258.338)", + "gray-400" => "oklch(70.7% 0.022 261.325)", + "gray-500" => "oklch(55.1% 0.027 264.364)", + "gray-600" => "oklch(44.6% 0.03 256.802)", + "gray-700" => "oklch(37.3% 0.034 259.733)", + "gray-800" => "oklch(27.8% 0.033 256.848)", + "gray-900" => "oklch(21% 0.034 264.665)", + "gray-950" => "oklch(13% 0.028 261.692)", + "zinc-50" => "oklch(98.5% 0 none)", + "zinc-100" => "oklch(96.7% 0.001 286.375)", + "zinc-200" => "oklch(92% 0.004 286.32)", + "zinc-300" => "oklch(87.1% 0.006 286.286)", + "zinc-400" => "oklch(70.5% 0.015 286.067)", + "zinc-500" => "oklch(55.2% 0.016 285.938)", + "zinc-600" => "oklch(44.2% 0.017 285.786)", + "zinc-700" => "oklch(37% 0.013 285.805)", + "zinc-800" => "oklch(27.4% 0.006 286.033)", + "zinc-900" => "oklch(21% 0.006 285.885)", + "zinc-950" => "oklch(14.1% 0.005 285.823)", + "neutral-50" => "oklch(98.5% 0 none)", + "neutral-100" => "oklch(97% 0 none)", + "neutral-200" => "oklch(92.2% 0 none)", + "neutral-300" => "oklch(87% 0 none)", + "neutral-400" => "oklch(70.8% 0 none)", + "neutral-500" => "oklch(55.6% 0 none)", + "neutral-600" => "oklch(43.9% 0 none)", + "neutral-700" => "oklch(37.1% 0 none)", + "neutral-800" => "oklch(26.9% 0 none)", + "neutral-900" => "oklch(20.5% 0 none)", + "neutral-950" => "oklch(14.5% 0 none)", + "stone-50" => "oklch(98.5% 0.001 106.423)", + "stone-100" => "oklch(97% 0.001 106.424)", + "stone-200" => "oklch(92.3% 0.003 48.717)", + "stone-300" => "oklch(86.9% 0.005 56.366)", + "stone-400" => "oklch(70.9% 0.01 56.259)", + "stone-500" => "oklch(55.3% 0.013 58.071)", + "stone-600" => "oklch(44.4% 0.011 73.639)", + "stone-700" => "oklch(37.4% 0.01 67.558)", + "stone-800" => "oklch(26.8% 0.007 34.298)", + "stone-900" => "oklch(21.6% 0.006 56.043)", + "stone-950" => "oklch(14.7% 0.004 49.25)", + "mauve-50" => "oklch(98.5% 0 none)", + "mauve-100" => "oklch(96% 0.003 325.6)", + "mauve-200" => "oklch(92.2% 0.005 325.62)", + "mauve-300" => "oklch(86.5% 0.012 325.68)", + "mauve-400" => "oklch(71.1% 0.019 323.02)", + "mauve-500" => "oklch(54.2% 0.034 322.5)", + "mauve-600" => "oklch(43.5% 0.029 321.78)", + "mauve-700" => "oklch(36.4% 0.029 323.89)", + "mauve-800" => "oklch(26.3% 0.024 320.12)", + "mauve-900" => "oklch(21.2% 0.019 322.12)", + "mauve-950" => "oklch(14.5% 0.008 326)", + "olive-50" => "oklch(98.8% 0.003 106.5)", + "olive-100" => "oklch(96.6% 0.005 106.5)", + "olive-200" => "oklch(93% 0.007 106.5)", + "olive-300" => "oklch(88% 0.011 106.6)", + "olive-400" => "oklch(73.7% 0.021 106.9)", + "olive-500" => "oklch(58% 0.031 107.3)", + "olive-600" => "oklch(46.6% 0.025 107.3)", + "olive-700" => "oklch(39.4% 0.023 107.4)", + "olive-800" => "oklch(28.6% 0.016 107.4)", + "olive-900" => "oklch(22.8% 0.013 107.4)", + "olive-950" => "oklch(15.3% 0.006 107.1)", + "mist-50" => "oklch(98.7% 0.002 197.1)", + "mist-100" => "oklch(96.3% 0.002 197.1)", + "mist-200" => "oklch(92.5% 0.005 214.3)", + "mist-300" => "oklch(87.2% 0.007 219.6)", + "mist-400" => "oklch(72.3% 0.014 214.4)", + "mist-500" => "oklch(56% 0.021 213.5)", + "mist-600" => "oklch(45% 0.017 213.2)", + "mist-700" => "oklch(37.8% 0.015 216)", + "mist-800" => "oklch(27.5% 0.011 216.9)", + "mist-900" => "oklch(21.8% 0.008 223.9)", + "mist-950" => "oklch(14.8% 0.004 228.8)", + "taupe-50" => "oklch(98.6% 0.002 67.8)", + "taupe-100" => "oklch(96% 0.002 17.2)", + "taupe-200" => "oklch(92.2% 0.005 34.3)", + "taupe-300" => "oklch(86.8% 0.007 39.5)", + "taupe-400" => "oklch(71.4% 0.014 41.2)", + "taupe-500" => "oklch(54.7% 0.021 43.1)", + "taupe-600" => "oklch(43.8% 0.017 39.3)", + "taupe-700" => "oklch(36.7% 0.016 35.7)", + "taupe-800" => "oklch(26.8% 0.011 36.5)", + "taupe-900" => "oklch(21.4% 0.009 43.1)", + "taupe-950" => "oklch(14.7% 0.004 49.3)", + "black" => "#000", + "white" => "#fff", +}; + +/// Whether `value` is a multiple of 0.25 written without needless zeros, which +/// is what v4 takes for an opacity +fn is_opacity(value: &str) -> bool { + value + .parse::() + .is_ok_and(|number| number >= 0.0 && number % 0.25 == 0.0 && number.to_string() == value) +} + +/// The alpha of an opacity modifier, as `color-mix` takes it +fn alpha(modifier: &str) -> Option { + if let Some(inner) = modifier + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + { + return Some(percentage(&decode_arbitrary_value(inner))); + } + if let Some(inner) = modifier + .strip_prefix('(') + .and_then(|inner| inner.strip_suffix(')')) + { + return inner.starts_with("--").then(|| format!("var({inner})")); + } + is_opacity(modifier).then(|| format!("{modifier}%")) +} + +/// `0.5` as the `50%` v4 turns a number into, anything else as written +fn percentage(value: &str) -> String { + value + .parse::() + .ok() + .filter(|number| { + number.is_finite() && value.starts_with(|c: char| c.is_ascii_digit() || c == '.') + }) + .map_or_else( + || value.to_string(), + |number| format!("{}%", number * 100.0), + ) +} + +/// `color` at `alpha`, which is `color` itself at a hundred percent +fn with_alpha(color: &str, alpha: &str) -> String { + if alpha == "100%" { + color.to_string() + } else { + format!("color-mix(in oklab, {color} {alpha}, transparent)") + } +} + +/// The color of the theme or the palette named `name` +fn named_color(name: &str) -> Option> { + if is_color_token(name) { + return Some(Cow::Owned(format!("var(--{name})"))); + } + TAILWIND_COLORS.get(name).map(|&color| Cow::Borrowed(color)) +} + +/// The color a class names (`red-500`, `primary`, `black/50`), `None` when it +/// names none +pub fn color_value(name: &str) -> Option> { + let mut parts = split_top_level(name, '/'); + let modifier = if parts.len() == 2 { parts.pop() } else { None }; + let [name] = parts[..] else { + return None; + }; + let color = named_color(name)?; + match modifier { + None => Some(color), + Some(_) => with_modifier(&color, modifier), + } +} + +/// `color` with the opacity `modifier`, if there is one. `inherit` has no +/// color to mix. +pub fn with_modifier(color: &str, modifier: Option<&str>) -> Option> { + let Some(modifier) = modifier else { + return Some(Cow::Owned(color.to_string())); + }; + if color == "inherit" { + return None; + } + Some(Cow::Owned(with_alpha(color, &alpha(modifier)?))) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeSet; + + use css::theme_tokens::set_color_tokens; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::tailwind::{decl, declarations_of}; + + fn color(class: &str) -> Option { + let mut declarations = declarations_of(class)?; + assert_eq!(declarations.len(), 1, "{class}: {declarations:?}"); + Some(declarations.remove(0).1) + } + + #[rstest] + #[case("bg-red-500", "oklch(63.7% 0.237 25.331)")] + #[case("bg-blue-500", "oklch(62.3% 0.214 259.815)")] + #[case("bg-slate-50", "oklch(98.4% 0.003 247.858)")] + #[case("bg-neutral-50", "oklch(98.5% 0 none)")] + #[case("bg-mauve-950", "oklch(14.5% 0.008 326)")] + #[case("bg-black", "#000")] + #[case("bg-white", "#fff")] + #[case("bg-transparent", "transparent")] + #[case("bg-current", "currentcolor")] + #[case("bg-inherit", "inherit")] + #[case("text-emerald-400", "oklch(76.5% 0.177 163.223)")] + #[case("border-rose-950", "oklch(27.1% 0.105 12.094)")] + #[case("outline-sky-300", "oklch(82.8% 0.111 230.318)")] + #[case("accent-lime-600", "oklch(64.8% 0.2 131.684)")] + #[case("caret-fuchsia-100", "oklch(95.2% 0.037 318.852)")] + #[case("fill-zinc-900", "oklch(21% 0.006 285.885)")] + #[case("stroke-stone-200", "oklch(92.3% 0.003 48.717)")] + fn palette_is_the_default_theme_of_v4(#[case] class: &str, #[case] expected: &str) { + assert_eq!(color(class).as_deref(), Some(expected)); + } + + #[rstest] + #[case( + "bg-red-500/50", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)" + )] + #[case( + "bg-red-500/0", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 0%, transparent)" + )] + #[case( + "bg-red-500/12.5", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 12.5%, transparent)" + )] + #[case("bg-red-500/100", "oklch(63.7% 0.237 25.331)")] + #[case( + "bg-red-500/[71.37%]", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 71.37%, transparent)" + )] + #[case( + "bg-red-500/[0.5]", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)" + )] + #[case( + "bg-red-500/(--alpha)", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--alpha), transparent)" + )] + #[case("bg-current/50", "color-mix(in oklab, currentcolor 50%, transparent)")] + #[case( + "bg-transparent/50", + "color-mix(in oklab, transparent 50%, transparent)" + )] + #[case("bg-black/10", "color-mix(in oklab, #000 10%, transparent)")] + #[case("text-white/75", "color-mix(in oklab, #fff 75%, transparent)")] + fn opacity_modifier_mixes_with_transparent(#[case] class: &str, #[case] expected: &str) { + assert_eq!(color(class).as_deref(), Some(expected)); + } + + #[rstest] + #[case("bg-inherit/50")] + #[case("bg-red-500/")] + #[case("bg-red-500/abc")] + #[case("bg-red-500/-5")] + #[case("bg-red-500/12.3")] + #[case("bg-red-500/012")] + #[case("bg-red-500/(alpha)")] + #[case("bg-red-500/50/50")] + #[case("bg-red-501")] + #[case("bg-unknown")] + fn invalid_color_stays_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn number_alphas_are_percentages() { + assert_eq!(percentage("0.5"), "50%"); + assert_eq!(percentage(".25"), "25%"); + assert_eq!(percentage("71.37%"), "71.37%"); + assert_eq!(percentage("var(--a)"), "var(--a)"); + assert_eq!(percentage("+1"), "+1"); + assert_eq!(percentage("inf"), "inf"); + assert!(is_opacity("0.25")); + assert!(!is_opacity("0.30")); + assert!(!is_opacity("-1")); + } + + #[test] + #[serial] + fn theme_colors_are_the_css_variables_of_the_theme() { + set_color_tokens(BTreeSet::from([ + String::from("primary"), + String::from("text"), + String::from("red-500"), + ])); + assert_eq!(color("bg-primary").as_deref(), Some("var(--primary)")); + assert_eq!(color("text-text").as_deref(), Some("var(--text)")); + assert_eq!(color("border-primary").as_deref(), Some("var(--primary)")); + assert_eq!( + color("bg-primary/50").as_deref(), + Some("color-mix(in oklab, var(--primary) 50%, transparent)") + ); + assert_eq!(color("bg-red-500").as_deref(), Some("var(--red-500)")); + assert_eq!( + color("bg-blue-500").as_deref(), + Some("oklch(62.3% 0.214 259.815)") + ); + assert_eq!(declarations_of("bg-secondary"), None); + set_color_tokens(BTreeSet::new()); + assert_eq!(declarations_of("bg-primary"), None); + } + + #[rstest] + #[case("p-4!", &[("padding", "1rem !important")])] + #[case("!p-4", &[("padding", "1rem !important")])] + #[case("-m-4!", &[("margin", "-1rem !important")])] + #[case("[color:red]!", &[("color", "red !important")])] + #[case("bg-red-500/50!", &[("background-color", "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent) !important")])] + #[case("blur-sm!", &[ + ("--tw-blur", "blur(8px) !important"), + ("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,) !important"), + ])] + fn important_marks_every_declaration(#[case] class: &str, #[case] expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected)); + } + + #[test] + fn important_applies_under_variants() { + let class = crate::tailwind::parse_class("hover:p-4!").unwrap(); + assert_eq!(class.declarations, vec![decl("padding", "1rem !important")]); + assert_eq!(crate::tailwind::parse_class("p-4!!"), None); + assert_eq!(crate::tailwind::parse_class("!"), None); + } +} diff --git a/libs/extractor/src/tailwind_effects.rs b/libs/extractor/src/tailwind_effects.rs new file mode 100644 index 000000000..c4ce46500 --- /dev/null +++ b/libs/extractor/src/tailwind_effects.rs @@ -0,0 +1,1049 @@ +//! Filters, backdrop filters, shadows and gradients: the Tailwind CSS v4 +//! utilities that compose through custom properties, so that `blur-sm` and +//! `grayscale` on one element add up instead of overwriting each other + +use std::borrow::Cow; + +use css::theme_tokens::is_shadow_token; + +use crate::tailwind::{ + Declaration, ValueType, arbitrary_or_variable, decl, decode_arbitrary_value, + is_positive_integer, split_top_level, value_type, +}; +use crate::tailwind_color::{color_value, with_modifier}; + +/// What `filter` holds: every function the filter utilities set +const FILTER: &str = "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,)"; + +/// What `backdrop-filter` holds +const BACKDROP_FILTER: &str = "var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)"; + +/// How a bare number after a filter utility reads +#[derive(Clone, Copy)] +enum Bare { + Percent, + Degrees, + Opacity, + Blur, +} + +/// A function the filter utilities set +struct FilterFunction { + root: &'static str, + bare: Bare, + /// The value of the utility without one + default: Option<&'static str>, + /// Whether a leading `-` negates it + negative: bool, +} + +static FILTER_FUNCTIONS: [FilterFunction; 9] = [ + FilterFunction { + root: "blur", + bare: Bare::Blur, + default: Some("8px"), + negative: false, + }, + FilterFunction { + root: "brightness", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "contrast", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "grayscale", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, + FilterFunction { + root: "hue-rotate", + bare: Bare::Degrees, + default: None, + negative: true, + }, + FilterFunction { + root: "invert", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, + FilterFunction { + root: "opacity", + bare: Bare::Opacity, + default: None, + negative: false, + }, + FilterFunction { + root: "saturate", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "sepia", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, +]; + +/// The sizes of `blur-*` +fn blur_size(name: &str) -> Option<&'static str> { + Some(match name { + "xs" => "4px", + "sm" => "8px", + "md" => "12px", + "lg" => "16px", + "xl" => "24px", + "2xl" => "40px", + "3xl" => "64px", + _ => return None, + }) +} + +/// The value of a bare number such as the `50` of `brightness-50` +fn bare_value(bare: Bare, value: &str) -> Option { + match bare { + Bare::Percent if is_positive_integer(value) => Some(format!("{value}%")), + Bare::Degrees if is_positive_integer(value) => Some(format!("{value}deg")), + Bare::Opacity if is_opacity_number(value) => Some(format!("{value}%")), + Bare::Blur => blur_size(value).map(str::to_string), + _ => None, + } +} + +/// A multiple of 0.25, written without needless zeros +fn is_opacity_number(value: &str) -> bool { + value + .parse::() + .is_ok_and(|number| number >= 0.0 && number % 0.25 == 0.0 && number.to_string() == value) +} + +/// What a filter utility puts in the function: its default, a size, an +/// arbitrary value or a bare number +fn function_value( + function: &FilterFunction, + argument: Option<&str>, + negative: bool, +) -> Option { + let value = match argument { + None => function.default?.to_string(), + Some(argument) => { + arbitrary_or_variable(argument).or_else(|| bare_value(function.bare, argument))? + } + }; + match (negative, function.negative) { + (false, _) => Some(value), + (true, true) => Some(format!("calc({value} * -1)")), + (true, false) => None, + } +} + +/// The declarations of a filter or backdrop filter utility such as `blur-sm`, +/// `backdrop-opacity-50` or `filter-none` +fn filter_utility(name: &str, negative: bool) -> Option> { + let (backdrop, name) = name + .strip_prefix("backdrop-") + .map_or((false, name), |name| (true, name)); + let composed = if backdrop { BACKDROP_FILTER } else { FILTER }; + let property = if backdrop { + "backdrop-filter" + } else { + "filter" + }; + let apply = |value: Cow<'static, str>| { + let mut declarations = Vec::with_capacity(3); + if backdrop { + declarations.push(decl("-webkit-backdrop-filter", value.clone())); + } + declarations.push(decl(property, value)); + declarations + }; + if !negative { + if name == "filter" { + return Some(apply(Cow::Borrowed(composed))); + } + if let Some(argument) = name.strip_prefix("filter-") { + let value = if argument == "none" { + Cow::Borrowed("none") + } else { + Cow::Owned(arbitrary_or_variable(argument)?) + }; + return Some(apply(value)); + } + } + let function = FILTER_FUNCTIONS + .iter() + .filter(|function| backdrop || function.root != "opacity") + .find(|function| { + name == function.root + || name + .strip_prefix(function.root) + .is_some_and(|rest| rest.starts_with('-')) + })?; + let argument = name + .strip_prefix(function.root) + .and_then(|rest| rest.strip_prefix('-')); + let prefix = if backdrop { "--tw-backdrop-" } else { "--tw-" }; + // `blur-none` empties the function, which `var(--tw-blur,)` then skips + let value = if argument == Some("none") && function.root == "blur" && !negative { + String::from(" ") + } else { + format!( + "{}({})", + function.root, + function_value(function, argument, negative)? + ) + }; + let mut declarations = vec![decl(format!("{prefix}{}", function.root), value)]; + declarations.extend(apply(Cow::Borrowed(composed))); + Some(declarations) +} + +/// What `box-shadow` holds: the shadows `shadow-*` and `ring-*` set, each +/// nothing until one does +const BOX_SHADOW: &str = "var(--tw-inset-shadow,0 0 #0000), var(--tw-inset-ring-shadow,0 0 #0000), var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)"; + +/// The shadows of the default theme, each color to be replaced by `--tw-shadow-color` +fn shadow_size(name: &str) -> Option<&'static str> { + Some(match name { + "2xs" => "0 1px rgb(0 0 0 / 0.05)", + "xs" => "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "sm" => "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "md" => "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "lg" => "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", + "xl" => "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + "2xl" => "0 25px 50px -12px rgb(0 0 0 / 0.25)", + _ => return None, + }) +} + +/// `shadow` with the color of each of its layers taken from `--tw-shadow-color` +/// when a `shadow-` utility sets one +fn recolored(shadow: &str) -> String { + split_top_level(shadow, ',') + .into_iter() + .map(|layer| { + let layer = layer.trim(); + layer.find("rgb(").map_or_else( + || layer.to_string(), + |color| { + format!( + "{}var(--tw-shadow-color, {})", + &layer[..color], + &layer[color..] + ) + }, + ) + }) + .collect::>() + .join(", ") +} + +/// The declarations of a `shadow` utility: a size, `none`, a theme shadow or +/// a color +fn shadow_utility(name: &str) -> Option> { + let argument = if name == "shadow" { + "sm" + } else { + name.strip_prefix("shadow-") + .filter(|argument| !argument.is_empty())? + }; + let composed = + |shadow: String| vec![decl("--tw-shadow", shadow), decl("box-shadow", BOX_SHADOW)]; + if let Some(shadow) = shadow_size(argument) { + return Some(composed(recolored(shadow))); + } + match argument { + "none" => return Some(composed(String::from("0 0 #0000"))), + "inherit" => return Some(vec![decl("--tw-shadow-color", "inherit")]), + _ => {} + } + if is_shadow_token(argument) { + return Some(composed(format!("var(--{argument})"))); + } + let color = shadow_color(argument)?; + Some(vec![decl( + "--tw-shadow-color", + format!("color-mix(in oklab, {color} var(--tw-shadow-alpha), transparent)"), + )]) +} + +/// A color written in an argument +enum Argument { + /// The argument is no arbitrary value + Named, + /// An arbitrary value that is a color + Color(Cow<'static, str>), + /// An arbitrary value that is not, or a modifier that cannot be applied + Invalid, +} + +/// The color of `shadow-`, a named one or an arbitrary one +fn shadow_color(argument: &str) -> Option> { + match arbitrary_color(argument) { + Argument::Named => color_value(argument), + Argument::Color(color) => Some(color), + Argument::Invalid => None, + } +} + +/// `[value]` or `[value]/50` read as a color +fn arbitrary_color(argument: &str) -> Argument { + let parts = split_top_level(argument, '/'); + let Some(value) = parts[0] + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + else { + return Argument::Named; + }; + let value = decode_arbitrary_value(value); + let single = !value.is_empty() && split_top_level(&value, ' ').len() == 1; + if !single || value_type(None, &value) != Some(ValueType::Color) { + return Argument::Invalid; + } + let modifier = match parts[1..] { + [] => None, + [modifier] => Some(modifier), + _ => return Argument::Invalid, + }; + with_modifier(&value, modifier).map_or(Argument::Invalid, Argument::Color) +} +/// The directions of `bg-linear-*` +fn linear_direction(name: &str) -> Option<&'static str> { + Some(match name { + "to-t" => "to top", + "to-tr" => "to top right", + "to-r" => "to right", + "to-br" => "to bottom right", + "to-b" => "to bottom", + "to-bl" => "to bottom left", + "to-l" => "to left", + "to-tl" => "to top left", + _ => return None, + }) +} + +/// The color space the gradient `modifier` (`/oklch`, `/longer`) interpolates in +fn interpolation(modifier: Option<&str>) -> Option { + let Some(modifier) = modifier else { + return Some(String::from("in oklab")); + }; + if let Some(value) = modifier + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + return Some(decode_arbitrary_value(value)); + } + Some(match modifier { + "longer" | "shorter" | "increasing" | "decreasing" => { + format!("in oklch {modifier} hue") + } + "oklab" | "oklch" | "srgb" | "hsl" => format!("in {modifier}"), + _ => return None, + }) +} + +/// A gradient: its position and the image drawn from the stops +fn gradient(position: String, function: &str, fallback: Option<&str>) -> Vec { + let stops = fallback.map_or_else( + || String::from("var(--tw-gradient-stops)"), + |fallback| format!("var(--tw-gradient-stops,{fallback})"), + ); + vec![ + decl("--tw-gradient-position", position), + decl("background-image", format!("{function}-gradient({stops})")), + ] +} + +/// `bg-linear-*`, `bg-conic-*` and `bg-radial-*` +fn gradient_utility(name: &str, negative: bool) -> Option> { + let rest = name.strip_prefix("bg-")?; + let parts = split_top_level(rest, '/'); + let (&root, modifier) = (parts.first()?, parts.get(1).copied()); + if parts.len() > 2 { + return None; + } + let (kind, argument) = match root.split_once('-') { + Some((kind, argument)) => (kind, Some(argument)), + None => (root, None), + }; + if !matches!(kind, "linear" | "conic" | "radial") { + return None; + } + if let Some(value) = argument.and_then(arbitrary_or_variable) { + if modifier.is_some() || negative { + return None; + } + return Some(gradient(value.clone(), kind, Some(&value))); + } + let method = interpolation(modifier)?; + let angle = |degrees: &str| { + is_positive_integer(degrees).then(|| { + if negative { + format!("calc({degrees}deg * -1)") + } else { + format!("{degrees}deg") + } + }) + }; + let position = match (kind, argument) { + ("linear", Some(argument)) => match linear_direction(argument) { + Some(direction) if !negative => format!("{direction} {method}"), + Some(_) => return None, + None => format!("{} {method}", angle(argument)?), + }, + ("conic", Some(argument)) => format!("from {} {method}", angle(argument)?), + ("conic" | "radial", None) if !negative => method, + _ => return None, + }; + Some(gradient(position, kind, None)) +} + +/// The stops a color stop utility sets +const STOPS: &str = "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))"; + +/// The stops once `via` is set +const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; + +/// A color or a position of a gradient stop: `from-red-500`, `via-10%`, +/// `to-[#fff]` +fn stop_utility(name: &str) -> Option> { + if name == "via-none" { + return Some(vec![decl("--tw-gradient-via-stops", "initial")]); + } + let (stop, rest) = name.split_once('-')?; + if !matches!(stop, "from" | "via" | "to") { + return None; + } + let position = |value: String| { + let property = match stop { + "from" => "--tw-gradient-from-position", + "via" => "--tw-gradient-via-position", + _ => "--tw-gradient-to-position", + }; + Some(vec![decl(property, value)]) + }; + if let Some(percent) = rest.strip_suffix('%') { + return if is_positive_integer(percent) { + position(rest.to_string()) + } else { + None + }; + } + if let Some(value) = rest + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + let value = decode_arbitrary_value(value); + if value_type(None, &value) == Some(ValueType::Length) { + return position(value); + } + } + let color = match arbitrary_color(rest) { + Argument::Named => color_value(rest)?, + Argument::Color(color) => color, + Argument::Invalid => return None, + }; + Some(match stop { + "from" => vec![ + decl("--tw-gradient-from", color), + decl("--tw-gradient-stops", STOPS), + ], + "via" => vec![ + decl("--tw-gradient-via", color), + decl("--tw-gradient-via-stops", VIA_STOPS), + decl("--tw-gradient-stops", "var(--tw-gradient-via-stops)"), + ], + _ => vec![ + decl("--tw-gradient-to", color), + decl("--tw-gradient-stops", STOPS), + ], + }) +} + +/// The declarations of the utilities of this module, `None` for any other +pub fn effect_utility(name: &str, negative: bool) -> Option> { + if let Some(declarations) = filter_utility(name, negative) { + return Some(declarations); + } + if let Some(declarations) = gradient_utility(name, negative) { + return Some(declarations); + } + if negative { + return None; + } + shadow_utility(name).or_else(|| stop_utility(name)) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use css::theme_tokens::set_theme_token_levels; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::tailwind::{TailwindClass, declarations_of, parse_class}; + + fn written(class: &str) -> Vec<(String, String)> { + declarations_of(class).unwrap_or_else(|| panic!("{class} stays as written")) + } + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(written(class), expected, "{class}"); + } + + fn filter(variable: &str, value: &str) -> Vec<(String, String)> { + vec![ + (variable.to_string(), value.to_string()), + ("filter".to_string(), FILTER.to_string()), + ] + } + + fn backdrop(variable: &str, value: &str) -> Vec<(String, String)> { + vec![ + (variable.to_string(), value.to_string()), + ( + "-webkit-backdrop-filter".to_string(), + BACKDROP_FILTER.to_string(), + ), + ("backdrop-filter".to_string(), BACKDROP_FILTER.to_string()), + ] + } + + #[rstest] + #[case("blur", "blur(8px)")] + #[case("blur-xs", "blur(4px)")] + #[case("blur-sm", "blur(8px)")] + #[case("blur-md", "blur(12px)")] + #[case("blur-lg", "blur(16px)")] + #[case("blur-xl", "blur(24px)")] + #[case("blur-2xl", "blur(40px)")] + #[case("blur-3xl", "blur(64px)")] + #[case("blur-none", " ")] + #[case("blur-[2px]", "blur(2px)")] + #[case("blur-(--radius)", "blur(var(--radius))")] + fn blur_composes_through_its_variable(#[case] class: &str, #[case] value: &str) { + assert_eq!(written(class), filter("--tw-blur", value)); + } + + #[rstest] + #[case("brightness-50", "--tw-brightness", "brightness(50%)")] + #[case("brightness-125", "--tw-brightness", "brightness(125%)")] + #[case("brightness-[1.75]", "--tw-brightness", "brightness(1.75)")] + #[case("contrast-200", "--tw-contrast", "contrast(200%)")] + #[case("grayscale", "--tw-grayscale", "grayscale(100%)")] + #[case("grayscale-0", "--tw-grayscale", "grayscale(0%)")] + #[case("grayscale-25", "--tw-grayscale", "grayscale(25%)")] + #[case("hue-rotate-90", "--tw-hue-rotate", "hue-rotate(90deg)")] + #[case("-hue-rotate-90", "--tw-hue-rotate", "hue-rotate(calc(90deg * -1))")] + #[case( + "-hue-rotate-[15deg]", + "--tw-hue-rotate", + "hue-rotate(calc(15deg * -1))" + )] + #[case("invert", "--tw-invert", "invert(100%)")] + #[case("invert-0", "--tw-invert", "invert(0%)")] + #[case("saturate-150", "--tw-saturate", "saturate(150%)")] + #[case("sepia", "--tw-sepia", "sepia(100%)")] + #[case("sepia-50", "--tw-sepia", "sepia(50%)")] + fn filter_functions_set_their_own_variable( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + assert_eq!(written(class), filter(variable, value)); + } + + #[rstest] + #[case("backdrop-blur", "--tw-backdrop-blur", "blur(8px)")] + #[case("backdrop-blur-xl", "--tw-backdrop-blur", "blur(24px)")] + #[case("backdrop-blur-none", "--tw-backdrop-blur", " ")] + #[case("backdrop-blur-[2px]", "--tw-backdrop-blur", "blur(2px)")] + #[case( + "backdrop-brightness-75", + "--tw-backdrop-brightness", + "brightness(75%)" + )] + #[case("backdrop-contrast-50", "--tw-backdrop-contrast", "contrast(50%)")] + #[case("backdrop-grayscale", "--tw-backdrop-grayscale", "grayscale(100%)")] + #[case( + "backdrop-hue-rotate-15", + "--tw-backdrop-hue-rotate", + "hue-rotate(15deg)" + )] + #[case( + "-backdrop-hue-rotate-15", + "--tw-backdrop-hue-rotate", + "hue-rotate(calc(15deg * -1))" + )] + #[case("backdrop-invert-25", "--tw-backdrop-invert", "invert(25%)")] + #[case("backdrop-opacity-50", "--tw-backdrop-opacity", "opacity(50%)")] + #[case("backdrop-opacity-12.5", "--tw-backdrop-opacity", "opacity(12.5%)")] + #[case("backdrop-saturate-200", "--tw-backdrop-saturate", "saturate(200%)")] + #[case("backdrop-sepia", "--tw-backdrop-sepia", "sepia(100%)")] + fn backdrop_filter_functions_compose_too( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + assert_eq!(written(class), backdrop(variable, value)); + } + + #[test] + fn filter_and_backdrop_filter_read_every_function() { + expect("filter", &[("filter", FILTER)]); + expect("filter-none", &[("filter", "none")]); + expect("filter-[url(#a)]", &[("filter", "url(#a)")]); + expect("filter-(--f)", &[("filter", "var(--f)")]); + expect( + "backdrop-filter", + &[ + ("-webkit-backdrop-filter", BACKDROP_FILTER), + ("backdrop-filter", BACKDROP_FILTER), + ], + ); + expect( + "backdrop-filter-none", + &[ + ("-webkit-backdrop-filter", "none"), + ("backdrop-filter", "none"), + ], + ); + expect( + "backdrop-filter-[blur(2px)]", + &[ + ("-webkit-backdrop-filter", "blur(2px)"), + ("backdrop-filter", "blur(2px)"), + ], + ); + assert_eq!(FILTER.matches("var(--tw-").count(), 9); + assert_eq!(BACKDROP_FILTER.matches("var(--tw-backdrop-").count(), 9); + } + + #[test] + fn two_filters_add_up_instead_of_overwriting() { + let blur = written("blur-sm"); + let grayscale = written("grayscale"); + let shared = blur.last().unwrap(); + assert_eq!(shared, grayscale.last().unwrap()); + assert_ne!(blur[0].0, grayscale[0].0); + let rules = [ + parse_class("blur-sm").unwrap(), + parse_class("grayscale").unwrap(), + ] + .iter() + .flat_map(TailwindClass::rules) + .collect::>(); + assert!(rules.contains(&"@property --tw-blur{syntax:\"*\";inherits:false}")); + assert!(rules.contains(&"@property --tw-grayscale{syntax:\"*\";inherits:false}")); + } + + #[rstest] + #[case("blur-huge")] + #[case("blur-")] + #[case("blur-[]")] + #[case("blur-(radius)")] + #[case("-blur-sm")] + #[case("brightness")] + #[case("brightness-")] + #[case("brightness-05")] + #[case("brightness-1.5")] + #[case("brightness-sm")] + #[case("-brightness-50")] + #[case("hue-rotate")] + #[case("opacity-blur")] + #[case("backdrop-opacity")] + #[case("backdrop-opacity-13.3")] + #[case("backdrop-blur-none/50")] + #[case("filter-blur")] + #[case("filter-[]")] + #[case("-filter")] + #[case("-filter-none")] + #[case("backdrop-")] + #[case("blurry")] + #[case("drop-shadow-md")] + fn unknown_filters_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("shadow-2xs", "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05))")] + #[case("shadow-xs", "0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05))")] + #[case( + "shadow", + "0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-sm", + "0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-md", + "0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-lg", + "0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-xl", + "0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-2xl", + "0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25))" + )] + #[case("shadow-none", "0 0 #0000")] + fn shadow_sizes_follow_the_default_theme(#[case] class: &str, #[case] shadow: &str) { + expect( + class, + &[("--tw-shadow", shadow), ("box-shadow", BOX_SHADOW)], + ); + } + + #[test] + #[serial] + fn shadow_tokens_are_the_css_variables_of_the_theme() { + set_theme_token_levels( + BTreeMap::new(), + BTreeMap::from([(String::from("card"), vec![0, 3])]), + ); + expect( + "shadow-card", + &[("--tw-shadow", "var(--card)"), ("box-shadow", BOX_SHADOW)], + ); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + assert_eq!(declarations_of("shadow-card"), None); + } + + #[rstest] + #[case( + "shadow-red-500", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-red-500/50", + "color-mix(in oklab, color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-[#fff]", + "color-mix(in oklab, #fff var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-[#fff]/50", + "color-mix(in oklab, color-mix(in oklab, #fff 50%, transparent) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-current", + "color-mix(in oklab, currentcolor var(--tw-shadow-alpha), transparent)" + )] + fn shadow_colors_set_the_color_the_shadows_read(#[case] class: &str, #[case] color: &str) { + expect(class, &[("--tw-shadow-color", color)]); + } + + #[test] + fn shadow_inherit_and_unknown_shadows() { + expect("shadow-inherit", &[("--tw-shadow-color", "inherit")]); + for class in [ + "shadow-", + "shadow-huge", + "shadow-red-500/13.3", + "shadow-[1px_1px_red]/50", + "shadow-[#fff]/1/2", + ] { + assert_eq!(declarations_of(class), None, "{class}"); + } + } + + #[test] + fn shadow_recoloring_keeps_what_has_no_color() { + assert_eq!(recolored("0 0 #0000"), "0 0 #0000"); + assert_eq!( + recolored("0 1px rgb(0 0 0 / 0.05), 0 0 #0000"), + "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05)), 0 0 #0000" + ); + } + + #[rstest] + #[case("bg-linear-to-t", "to top in oklab")] + #[case("bg-linear-to-tr", "to top right in oklab")] + #[case("bg-linear-to-r", "to right in oklab")] + #[case("bg-linear-to-br", "to bottom right in oklab")] + #[case("bg-linear-to-b", "to bottom in oklab")] + #[case("bg-linear-to-bl", "to bottom left in oklab")] + #[case("bg-linear-to-l", "to left in oklab")] + #[case("bg-linear-to-tl", "to top left in oklab")] + #[case("bg-linear-45", "45deg in oklab")] + #[case("-bg-linear-45", "calc(45deg * -1) in oklab")] + #[case("bg-linear-0", "0deg in oklab")] + #[case("bg-linear-to-r/srgb", "to right in srgb")] + #[case("bg-linear-to-r/hsl", "to right in hsl")] + #[case("bg-linear-to-r/oklch", "to right in oklch")] + #[case("bg-linear-to-r/oklab", "to right in oklab")] + #[case("bg-linear-to-r/longer", "to right in oklch longer hue")] + #[case("bg-linear-to-r/shorter", "to right in oklch shorter hue")] + #[case("bg-linear-to-r/increasing", "to right in oklch increasing hue")] + #[case("bg-linear-to-r/decreasing", "to right in oklch decreasing hue")] + #[case("bg-linear-90/[in_hsl_longer_hue]", "90deg in hsl longer hue")] + fn linear_gradients_use_the_syntax_of_v4(#[case] class: &str, #[case] position: &str) { + expect( + class, + &[ + ("--tw-gradient-position", position), + ( + "background-image", + "linear-gradient(var(--tw-gradient-stops))", + ), + ], + ); + } + + #[test] + fn conic_and_radial_gradients() { + for (class, position, image) in [ + ( + "bg-conic", + "in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic/srgb", + "in srgb", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic-180", + "from 180deg in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "-bg-conic-180", + "from calc(180deg * -1) in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic-90/longer", + "from 90deg in oklch longer hue", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-radial", + "in oklab", + "radial-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-radial/oklch", + "in oklch", + "radial-gradient(var(--tw-gradient-stops))", + ), + ] { + expect( + class, + &[ + ("--tw-gradient-position", position), + ("background-image", image), + ], + ); + } + } + + #[test] + fn arbitrary_gradients_carry_their_own_stops() { + expect( + "bg-linear-[25deg,red_5%,yellow_60%,lime_90%,teal]", + &[ + ( + "--tw-gradient-position", + "25deg,red 5%,yellow 60%,lime 90%,teal", + ), + ( + "background-image", + "linear-gradient(var(--tw-gradient-stops,25deg,red 5%,yellow 60%,lime 90%,teal))", + ), + ], + ); + expect( + "bg-radial-[at_50%_75%,#fff,#000]", + &[ + ("--tw-gradient-position", "at 50% 75%,#fff,#000"), + ( + "background-image", + "radial-gradient(var(--tw-gradient-stops,at 50% 75%,#fff,#000))", + ), + ], + ); + expect( + "bg-conic-(--stops)", + &[ + ("--tw-gradient-position", "var(--stops)"), + ( + "background-image", + "conic-gradient(var(--tw-gradient-stops,var(--stops)))", + ), + ], + ); + } + + #[rstest] + #[case("bg-gradient-to-r")] + #[case("bg-gradient-to-br")] + #[case("bg-linear")] + #[case("bg-linear-")] + #[case("bg-linear-sideways")] + #[case("bg-linear-4.5")] + #[case("bg-linear-045")] + #[case("-bg-linear-to-r")] + #[case("-bg-linear")] + #[case("-bg-radial")] + #[case("-bg-conic")] + #[case("-bg-linear-[10deg,red,blue]")] + #[case("bg-linear-[10deg,red,blue]/oklch")] + #[case("bg-linear-to-r/neon")] + #[case("bg-linear-to-r/oklch/srgb")] + #[case("bg-radial-45")] + #[case("bg-conic-x")] + #[case("bg-sweep-45")] + fn gradients_v4_does_not_define_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + const STOPS: &str = "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))"; + const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; + + #[test] + fn gradient_stops_compose_through_variables() { + expect( + "from-red-500", + &[ + ("--tw-gradient-from", "oklch(63.7% 0.237 25.331)"), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "via-white", + &[ + ("--tw-gradient-via", "#fff"), + ("--tw-gradient-via-stops", VIA_STOPS), + ("--tw-gradient-stops", "var(--tw-gradient-via-stops)"), + ], + ); + expect( + "to-blue-500/50", + &[ + ( + "--tw-gradient-to", + "color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent)", + ), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "from-[#f00]", + &[ + ("--tw-gradient-from", "#f00"), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "from-[#f00]/50", + &[ + ( + "--tw-gradient-from", + "color-mix(in oklab, #f00 50%, transparent)", + ), + ("--tw-gradient-stops", STOPS), + ], + ); + expect("via-none", &[("--tw-gradient-via-stops", "initial")]); + } + + #[test] + fn gradient_stop_positions() { + expect("from-10%", &[("--tw-gradient-from-position", "10%")]); + expect("via-30%", &[("--tw-gradient-via-position", "30%")]); + expect("to-90%", &[("--tw-gradient-to-position", "90%")]); + expect("from-[12px]", &[("--tw-gradient-from-position", "12px")]); + expect("to-[40%]", &[("--tw-gradient-to-position", "40%")]); + } + + #[rstest] + #[case("from-")] + #[case("from-1.5%")] + #[case("from-05%")] + #[case("from-%")] + #[case("from-[12px]/50")] + #[case("from-[url(a.png)]")] + #[case("from-[]")] + #[case("from-nothing")] + #[case("from-red-500/")] + #[case("-from-red-500")] + #[case("via-none/50")] + #[case("up-red-500")] + fn unknown_gradient_stops_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn gradient_rules_register_the_stop_variables() { + let rules = parse_class("from-red-500").unwrap().rules(); + for name in [ + "--tw-gradient-position", + "--tw-gradient-from", + "--tw-gradient-to", + "--tw-gradient-stops", + "--tw-gradient-via-stops", + "--tw-gradient-from-position", + "--tw-gradient-to-position", + ] { + assert!( + rules + .iter() + .any(|rule| rule.starts_with(&format!("@property {name}{{"))), + "{name}" + ); + } + let rules = parse_class("via-30%").unwrap().rules(); + assert_eq!( + rules, + vec![ + "@property --tw-gradient-via-position{syntax:\"\";inherits:false;initial-value:50%}" + ] + ); + } + + #[test] + fn modifiers_and_variants_apply_to_composed_utilities() { + let class = parse_class("hover:blur-sm").unwrap(); + assert_eq!(class.declarations, written_pairs("blur-sm")); + assert_eq!(class.conditions.len(), 1); + assert_eq!(effect_utility("shadow-md", true), None); + assert_eq!(effect_utility("nothing", false), None); + } + + fn written_pairs( + class: &str, + ) -> Vec<( + std::borrow::Cow<'static, str>, + std::borrow::Cow<'static, str>, + )> { + written(class) + .into_iter() + .map(|(property, value)| (property.into(), value.into())) + .collect() + } +} diff --git a/libs/extractor/src/tailwind_motion.rs b/libs/extractor/src/tailwind_motion.rs new file mode 100644 index 000000000..11106c185 --- /dev/null +++ b/libs/extractor/src/tailwind_motion.rs @@ -0,0 +1,338 @@ +//! Transitions and animations the way Tailwind CSS v4 writes them: the +//! duration and easing a `transition` uses are the custom properties +//! `duration-*` and `ease-*` set, and `animate-*` brings the `@keyframes` it +//! names + +use crate::tailwind::{Declaration, arbitrary_or_variable, decl, is_positive_integer}; + +/// The easing a transition has when no `ease-*` sets one +const TIMING: &str = "var(--tw-ease, cubic-bezier(0.4, 0, 0.2, 1))"; + +/// The duration a transition has when no `duration-*` sets one +const DURATION: &str = "var(--tw-duration, 150ms)"; + +/// What `transition` makes transition +const TRANSITION: &str = "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events"; + +/// What `transition-colors` makes transition +const TRANSITION_COLORS: &str = "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to"; + +/// The `@keyframes` of the animations of the default theme +pub static KEYFRAMES: [(&str, &str); 4] = [ + ("spin", "@keyframes spin{to{transform:rotate(360deg)}}"), + ( + "ping", + "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}", + ), + ("pulse", "@keyframes pulse{50%{opacity:0.5}}"), + ( + "bounce", + "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}", + ), +]; + +/// The value of `animate-` for the animations of the default theme +fn animation(name: &str) -> Option<&'static str> { + Some(match name { + "spin" => "spin 1s linear infinite", + "ping" => "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", + "pulse" => "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "bounce" => "bounce 1s infinite", + "none" => "none", + _ => return None, + }) +} + +/// A transition of `property`, with the default easing and duration +fn transition(property: &str) -> Vec { + vec![ + decl("transition-property", property.to_string()), + decl("transition-timing-function", TIMING), + decl("transition-duration", DURATION), + ] +} + +/// The theme easings of `ease-` +fn easing(name: &str) -> Option<&'static str> { + Some(match name { + "in" => "cubic-bezier(0.4, 0, 1, 1)", + "out" => "cubic-bezier(0, 0, 0.2, 1)", + "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", + "linear" => "linear", + _ => return None, + }) +} + +/// The declarations of a `transition`, `duration`, `delay`, `ease` or +/// `animate` utility, `None` for any other +pub fn motion_utility(name: &str) -> Option> { + if name == "transition" { + return Some(transition(TRANSITION)); + } + if let Some(argument) = name.strip_prefix("transition-") { + return Some(match argument { + "none" => vec![decl("transition-property", "none")], + "all" => transition("all"), + "colors" => transition(TRANSITION_COLORS), + "opacity" => transition("opacity"), + "shadow" => transition("box-shadow"), + "transform" => transition("transform, translate, scale, rotate"), + "discrete" => vec![decl("transition-behavior", "allow-discrete")], + "normal" => vec![decl("transition-behavior", "normal")], + _ => transition(&arbitrary_or_variable(argument)?), + }); + } + if let Some(argument) = name.strip_prefix("delay-") { + let value = arbitrary_or_variable(argument).or_else(|| milliseconds(argument))?; + return Some(vec![decl("transition-delay", value)]); + } + if let Some(argument) = name.strip_prefix("duration-") { + if argument == "initial" { + return Some(vec![decl("--tw-duration", "initial")]); + } + let value = arbitrary_or_variable(argument).or_else(|| milliseconds(argument))?; + return Some(vec![ + decl("--tw-duration", value.clone()), + decl("transition-duration", value), + ]); + } + if let Some(argument) = name.strip_prefix("ease-") { + if argument == "initial" { + return Some(vec![decl("--tw-ease", "initial")]); + } + let value = easing(argument) + .map(str::to_string) + .or_else(|| arbitrary_or_variable(argument))?; + return Some(vec![ + decl("--tw-ease", value.clone()), + decl("transition-timing-function", value), + ]); + } + let argument = name.strip_prefix("animate-")?; + let value = animation(argument) + .map(str::to_string) + .or_else(|| arbitrary_or_variable(argument))?; + Some(vec![decl("animation", value)]) +} + +/// `150` as `150ms` +fn milliseconds(value: &str) -> Option { + is_positive_integer(value).then(|| format!("{value}ms")) +} + +/// The `@keyframes` rules the declarations name in an `animation` +pub fn keyframes_of(declarations: &[Declaration]) -> impl Iterator + '_ { + declarations + .iter() + .filter(|(property, _)| property == "animation") + .flat_map(|(_, value)| value.split_whitespace()) + .filter_map(|word| { + KEYFRAMES + .iter() + .find(|(name, _)| *name == word) + .map(|&(_, rule)| rule) + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::tailwind::{declarations_of, parse_class}; + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + fn transition(property: &str) -> Vec<(&str, &str)> { + vec![ + ("transition-property", property), + ("transition-timing-function", TIMING), + ("transition-duration", DURATION), + ] + } + + #[test] + fn transitions_read_the_easing_and_duration_utilities_set() { + expect("transition", &transition(TRANSITION)); + expect("transition-all", &transition("all")); + expect("transition-colors", &transition(TRANSITION_COLORS)); + expect("transition-opacity", &transition("opacity")); + expect("transition-shadow", &transition("box-shadow")); + expect( + "transition-transform", + &transition("transform, translate, scale, rotate"), + ); + expect("transition-none", &[("transition-property", "none")]); + expect("transition-[height]", &transition("height")); + expect("transition-[height,opacity]", &transition("height,opacity")); + expect("transition-(--props)", &transition("var(--props)")); + expect( + "transition-discrete", + &[("transition-behavior", "allow-discrete")], + ); + expect("transition-normal", &[("transition-behavior", "normal")]); + assert_eq!(TIMING, "var(--tw-ease, cubic-bezier(0.4, 0, 0.2, 1))"); + assert_eq!(DURATION, "var(--tw-duration, 150ms)"); + } + + #[test] + fn transitions_cover_what_v4_transitions() { + for property in [ + "outline-color", + "--tw-gradient-from", + "translate", + "scale", + "rotate", + "-webkit-backdrop-filter", + "display", + "content-visibility", + "overlay", + "pointer-events", + ] { + assert!(TRANSITION.contains(property), "{property}"); + } + assert!(TRANSITION_COLORS.contains("outline-color")); + } + + #[rstest] + #[case("duration-0", "0ms")] + #[case("duration-75", "75ms")] + #[case("duration-150", "150ms")] + #[case("duration-250", "250ms")] + #[case("duration-1000", "1000ms")] + #[case("duration-[2s]", "2s")] + #[case("duration-(--speed)", "var(--speed)")] + fn duration_sets_the_variable_a_transition_reads(#[case] class: &str, #[case] value: &str) { + expect( + class, + &[("--tw-duration", value), ("transition-duration", value)], + ); + } + + #[rstest] + #[case("ease-in", "cubic-bezier(0.4, 0, 1, 1)")] + #[case("ease-out", "cubic-bezier(0, 0, 0.2, 1)")] + #[case("ease-in-out", "cubic-bezier(0.4, 0, 0.2, 1)")] + #[case("ease-linear", "linear")] + #[case("ease-[cubic-bezier(0.3,0,0,1)]", "cubic-bezier(0.3,0,0,1)")] + #[case("ease-(--curve)", "var(--curve)")] + fn ease_sets_the_variable_a_transition_reads(#[case] class: &str, #[case] value: &str) { + expect( + class, + &[("--tw-ease", value), ("transition-timing-function", value)], + ); + } + + #[test] + fn initial_and_delay() { + expect("duration-initial", &[("--tw-duration", "initial")]); + expect("ease-initial", &[("--tw-ease", "initial")]); + expect("delay-0", &[("transition-delay", "0ms")]); + expect("delay-150", &[("transition-delay", "150ms")]); + expect("delay-[3s]", &[("transition-delay", "3s")]); + expect("delay-(--wait)", &[("transition-delay", "var(--wait)")]); + } + + #[rstest] + #[case("transition-sideways")] + #[case("transition-[]")] + #[case("transition-")] + #[case("-transition")] + #[case("duration")] + #[case("duration-")] + #[case("duration-1.5")] + #[case("duration-050")] + #[case("duration-fast")] + #[case("-duration-150")] + #[case("delay-1s")] + #[case("delay-")] + #[case("ease")] + #[case("ease-bouncy")] + #[case("animate")] + #[case("animate-wobble")] + #[case("animate-")] + #[case("-animate-spin")] + fn unknown_motion_stays_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("animate-spin", "spin 1s linear infinite", "spin")] + #[case("animate-ping", "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", "ping")] + #[case( + "animate-pulse", + "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "pulse" + )] + #[case("animate-bounce", "bounce 1s infinite", "bounce")] + fn animations_bring_their_keyframes( + #[case] class: &str, + #[case] animation: &str, + #[case] keyframes: &str, + ) { + expect(class, &[("animation", animation)]); + let rules = parse_class(class).unwrap().rules(); + assert_eq!(rules.len(), 1); + assert!( + rules[0].starts_with(&format!("@keyframes {keyframes}{{")), + "{rules:?}" + ); + } + + #[test] + fn keyframes_match_the_default_theme_of_v4() { + let rule = |name: &str| { + KEYFRAMES + .iter() + .find(|(keyframes, _)| *keyframes == name) + .unwrap() + .1 + }; + assert_eq!( + rule("spin"), + "@keyframes spin{to{transform:rotate(360deg)}}" + ); + assert_eq!( + rule("ping"), + "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}" + ); + assert_eq!(rule("pulse"), "@keyframes pulse{50%{opacity:0.5}}"); + assert_eq!( + rule("bounce"), + "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}" + ); + } + + #[test] + fn arbitrary_animations_bring_the_keyframes_they_name() { + expect("animate-none", &[("animation", "none")]); + expect( + "animate-[wiggle_1s_ease-in-out_infinite]", + &[("animation", "wiggle 1s ease-in-out infinite")], + ); + expect("animate-(--spin)", &[("animation", "var(--spin)")]); + assert_eq!( + parse_class("animate-[wiggle_1s_ease-in-out_infinite]") + .unwrap() + .rules(), + Vec::<&str>::new() + ); + assert_eq!( + parse_class("animate-[spin_3s_linear_infinite]") + .unwrap() + .rules(), + vec!["@keyframes spin{to{transform:rotate(360deg)}}"] + ); + assert_eq!( + parse_class("animate-none").unwrap().rules(), + Vec::<&str>::new() + ); + } +} diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 3f3ea2732..4e37fb36b 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -10,7 +10,7 @@ use css::{ style_selector::{ AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, }, - theme_tokens::{set_theme_token_levels, set_typography_keys}, + theme_tokens::{set_color_tokens, set_theme_token_levels, set_typography_keys}, utils::compile_regex, write_merge_selector, }; @@ -481,6 +481,7 @@ impl StyleSheet { theme.get_shadow_token_levels(), ); set_typography_keys(theme.typography.keys().cloned().collect()); + set_color_tokens(theme.get_color_token_names()); self.theme = theme; } diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 2aeb60873..1b620e497 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -2,7 +2,7 @@ use css::optimize_value::optimize_value; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write as _; /// `ColorEntry` stores both the original key (for TypeScript interface) and CSS key (for CSS variables) @@ -818,6 +818,15 @@ impl Theme { declarations } + /// The CSS names of the colors every theme defines + #[must_use] + pub fn get_color_token_names(&self) -> BTreeSet { + self.colors + .values() + .flat_map(|theme| theme.css_entries().map(|(name, _)| name.clone())) + .collect() + } + #[must_use] pub fn get_length_token_levels(&self) -> BTreeMap> { token_levels(&self.length) @@ -1394,6 +1403,19 @@ mod tests { assert!(!color_theme.contains_key("primary.100")); } + #[test] + fn test_color_token_names_cover_every_theme() { + let theme: Theme = serde_json::from_str( + r##"{"colors": {"light": {"primary": "#100"}, "dark": {"primary": "#200", "text.muted": "#300"}}}"##, + ) + .unwrap(); + + assert_eq!( + theme.get_color_token_names(), + BTreeSet::from([String::from("primary"), String::from("text-muted")]) + ); + } + #[test] fn test_deep_nested_color_should_succeed() { // Deep nesting should be flattened with dashes