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/.changepacks/changepack_log_tailwind_v4_utilities.json b/.changepacks/changepack_log_tailwind_v4_utilities.json
new file mode 100644
index 000000000..f43315b48
--- /dev/null
+++ b/.changepacks/changepack_log_tailwind_v4_utilities.json
@@ -0,0 +1,13 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/plugin-utils/package.json": "Patch",
+ "packages/vite-plugin/package.json": "Patch",
+ "packages/webpack-plugin/package.json": "Patch",
+ "packages/next-plugin/package.json": "Patch",
+ "packages/rsbuild-plugin/package.json": "Patch",
+ "packages/bun-plugin/package.json": "Patch"
+ },
+ "note": "Tailwind classes in a className read the Tailwind CSS of the project: the file that imports tailwindcss (the tailwind.css option of devup.json, else a conventional path) gives @theme variables (colors, spacing, shadows, blurs, radii, easings, animations with their @keyframes, default transitions, breakpoints), @utility utilities and @custom-variant variants, and the Vite, webpack and Next.js plugins read it again when it or a CSS file it imports changes. Anything that cannot be read exactly, and the classes of namespaces the build does not read, stay as written. More of Tailwind CSS v4 compiles: ring-*, inset-ring-*, ring-offset-* and drop-shadow-* compose with shadows and filters, borders and outlines draw in --tw-border-style and --tw-outline-style (a border now shows its style), line-clamp, text decoration, border-spacing, logical and scroll spacing, tables, perspective, font-stretch and more; the arguments of clsx(), classnames() and classNames() and [...].join(' ') compile in place. Radii follow v4 (rounded-sm is 0.25rem, rounded-full is calc(infinity * 1px)). No new build errors",
+ "date": "2026-10-01T00:00:00.000Z"
+}
\ No newline at end of file
diff --git a/apps/landing/src/app/(detail)/docs/LeftMenu.tsx b/apps/landing/src/app/(detail)/docs/LeftMenu.tsx
index 85250e4ff..0eeebcf36 100644
--- a/apps/landing/src/app/(detail)/docs/LeftMenu.tsx
+++ b/apps/landing/src/app/(detail)/docs/LeftMenu.tsx
@@ -54,6 +54,10 @@ export function LeftMenu() {
to: '/docs/migration/stylex',
children: 'StyleX',
},
+ {
+ to: '/docs/migration/tailwind',
+ children: 'Tailwind CSS',
+ },
]}
>
Migration
diff --git a/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx b/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx
new file mode 100644
index 000000000..64314212c
--- /dev/null
+++ b/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx
@@ -0,0 +1,101 @@
+export const metadata = {
+ title: 'Migration',
+ alternates: {
+ canonical: '/docs/migration/tailwind',
+ },
+}
+
+# Tailwind CSS
+
+Tailwind CSS classes written in the `className` of a Devup UI component (`Box`, `Flex`, `Text` ...) are compiled at build time to Devup UI classes, the same way style props are. Nothing runs in the browser, and no Tailwind build is needed for those classes. The classes follow Tailwind CSS **v4**.
+
+```tsx
+
+```
+
+## When a class compiles
+
+A class compiles only when the build understands **all** of it: every variant (`md:`, `hover:`, `data-[state=open]:`, `[&>*]:` ...), the utility, its value and its modifiers (`/50`, `!`). Then it becomes the declarations Tailwind CSS v4 writes for it.
+
+Every other class **stays in the `className` exactly as written**, so whatever else defines it keeps working. This covers:
+
+- your own classes (`card`) and plugin classes (`prose`),
+- a class Tailwind CSS v4 does not define (`bg-gradient-to-r`, which v3 had),
+- a class the build cannot reproduce exactly (see [what stays as written](#what-stays-as-written)),
+- a class whose theme value the project's Tailwind CSS defines in a way the build cannot read.
+
+A class is never rewritten into something different from what Tailwind CSS would generate: it compiles to the declarations of v4 or it is left alone. A class with a Tailwind name compiles with the Tailwind meaning, so do not give a class of your own the name of a Tailwind utility.
+
+## Where classes are read
+
+Classes compile in the `className` of Devup UI components, and the build only reads what it can know:
+
+- string literals, template literals, `cond ? 'a' : 'b'`, `a && 'b'`, `a || 'b'` and parentheses,
+- the arguments of `clsx(...)`, `classnames(...)` and `classNames(...)` (strings, arrays, and the keys of objects), and `['a', cond && 'b'].join(' ')`.
+
+Other helpers (`cn`, `twMerge`, `cva`) may merge or drop classes by rules the build does not run, so their strings are not compiled. A class that is only known at runtime (a variable, a prop, a computed string) cannot be compiled either. In every case the `className` keeps working as before, as plain text.
+
+A plain DOM element (`
`) or a component that is not a Devup UI component is **not** touched: its `className` is passed through as written, so Tailwind (its own build) can style it. Use `` when you want the class compiled.
+
+## Theme values
+
+Colors from Tailwind CSS v4's default palette (`bg-red-500`, in `oklch`) and the values of `devup.json` work together:
+
+- `devup.json` colors are Tailwind colors named by their token: `bg-primary`, `text-text`, `border-primary/50` become `var(--primary)`, `var(--text)` and so on. When a name is both a `devup.json` color and a palette color, the `devup.json` color is used.
+- `devup.json` shadow tokens work as `shadow-` (`shadow-card`).
+- Breakpoints are the ones of Devup UI (`theme.breakpoints` in `devup.json`), not Tailwind's: `sm:`, `md:`, `lg:`, `xl:` and `2xl:` are the first five breakpoints after the base. Change them in `devup.json`.
+- `dark:` follows the Devup UI theme (`prefers-color-scheme` until a theme is set with `data-theme`).
+
+## Tailwind CSS definitions
+
+The CSS file of your project that imports `tailwindcss` can define more, as it does for Tailwind itself:
+
+```css
+@import 'tailwindcss';
+
+@theme {
+ --color-brand: oklch(60% 0.2 250);
+ --spacing-gutter: 3rem;
+ --breakpoint-3xl: 120rem;
+ --animate-wiggle: wiggle 1s ease-in-out infinite;
+ @keyframes wiggle {
+ 50% {
+ transform: rotate(3deg);
+ }
+ }
+}
+
+@utility content-auto {
+ content-visibility: auto;
+}
+
+@custom-variant theme-midnight (&:where([data-theme='midnight'] *));
+```
+
+Every plugin reads the file when the build starts. The Vite, webpack and Next.js plugins also read it again when it, or a CSS file it imports with a relative path, changes (the Rsbuild and Bun plugins do not watch it, so restart them after a change). So `bg-brand`, `p-gutter`, `3xl:flex`, `animate-wiggle`, `content-auto` and `theme-midnight:bg-black` compile.
+
+The file is found, in this order: the `tailwind.css` option of `devup.json`, else the first of `src/app/globals.css`, `app/globals.css`, `src/globals.css`, `src/index.css`, `src/styles/globals.css`, `src/styles.css`, `src/style.css`, `src/app.css`, `styles/globals.css`, `app/app.css` that imports `tailwindcss`.
+
+```json
+{
+ "tailwind": { "css": "src/styles/tailwind.css" }
+}
+```
+
+Use `"css": false` to read none.
+
+What is read: `@theme` variables of the color, spacing (`--spacing`, `--spacing-*`), shadow, blur, radius, ease, animate, `default-transition-*` and breakpoint namespaces (and `@keyframes` inside `@theme`), resets such as `--color-*: initial` and `--*: initial`, `@utility` with plain declarations, and `@custom-variant` as a selector list, or a block holding `@slot;` in a selector, `@media` or `@supports`. A `@custom-variant` with the name of a built-in variant (`dark`) replaces it.
+
+What is not read keeps its classes as written: functional `@utility` (`tab-*` with `--value()`), nested rules and `@apply` in `@utility`, `@plugin`, `@config`, values using `theme()`, and a file whose braces do not balance. If the project changes the values of a namespace the build does not read (`--text-*`, `--font-*`, `--leading-*`, `--tracking-*`, `--container-*`, `--perspective-*`, `--aspect-*`), the classes that read it (`text-lg`, `font-bold`, `max-w-sm` ...) also stay as written, instead of compiling to a default the project replaced.
+
+## Classes and style props on one element
+
+Tailwind classes and Devup UI props both become one atomic class per property in the same stylesheet, so they follow the same rules as two props:
+
+- The Tailwind classes of an element compile first, its Devup props after them. For the **same property** (`p-4` and `p={12}`), the prop comes later in the stylesheet and wins, whichever comes first in the JSX.
+- Properties are ordered with shorthands before longhands. `bg="green"` is `background`, while `bg-red-500` is `background-color`, which comes after it, so the Tailwind class wins there. Use one of the two for a property when both would set it.
+- Responsive and `hover:` styles come after the base styles at the same property, as with props.
+
+## What stays as written
+
+Utilities this build does not compile stay in the `className`: container (`container`, `@container` queries), the masks (`mask-*`), `text-shadow-*`, `inset-shadow-*`, composed font-variant-numeric (`tabular-nums`), 3D transform steps (`rotate-x-*`, `translate-z-*`, `scale-z-*`), `contain-*`, `outline-hidden`, and the variants and values that are not listed in Tailwind CSS v4.
diff --git a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap
index d47163a56..ee141312e 100644
--- a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap
+++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap
@@ -61,4 +61,5 @@ Theme {
},
length: {},
shadows: {},
+ tailwind_css: "",
}
diff --git a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap
index daca7d9dd..d1d972a7a 100644
--- a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap
+++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap
@@ -892,4 +892,5 @@ Theme {
},
length: {},
shadows: {},
+ tailwind_css: "",
}
diff --git a/e2e/exported-routes.ts b/e2e/exported-routes.ts
index 5c8edd2bc..bc0b228e6 100644
--- a/e2e/exported-routes.ts
+++ b/e2e/exported-routes.ts
@@ -83,6 +83,7 @@ export const EXPECTED_EXPORTED_ROUTES = [
'/docs/migration/overview',
'/docs/migration/styled-components',
'/docs/migration/stylex',
+ '/docs/migration/tailwind',
'/docs/migration/vanilla-extract',
'/docs/overview',
'/docs/quick-start',
diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs
index 576ba53b9..090497b79 100644
--- a/libs/css/src/lib.rs
+++ b/libs/css/src/lib.rs
@@ -12,6 +12,7 @@ pub mod optimize_value;
pub mod rm_css_comment;
mod selector_separator;
pub mod style_selector;
+pub mod tailwind_definitions;
pub mod theme_tokens;
pub mod utils;
diff --git a/libs/css/src/tailwind_definitions.rs b/libs/css/src/tailwind_definitions.rs
new file mode 100644
index 000000000..d0d366833
--- /dev/null
+++ b/libs/css/src/tailwind_definitions.rs
@@ -0,0 +1,574 @@
+//! The definitions a project's Tailwind CSS file makes: `@theme` variables,
+//! `@utility` utilities and `@custom-variant` variants, read at build time
+//!
+//! A definition that cannot be read exactly is left out, so the classes that
+//! use it stay as written; a file that cannot be read at all defines nothing.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::{LazyLock, RwLock};
+
+use crate::style_selector::AtRuleKind;
+
+/// A `@custom-variant`: the selectors the element matches, any one of them
+/// (`&` being the element), and the at-rule the styles go in
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct CustomVariant {
+ pub selectors: Vec,
+ pub at_rule: Option<(AtRuleKind, String)>,
+}
+
+#[derive(Debug, Default)]
+struct Definitions {
+ theme: BTreeMap,
+ /// Namespaces `--ns-*: initial` empties, such as `--color-`
+ resets: BTreeSet,
+ /// `--*: initial` empties every namespace
+ reset_all: bool,
+ utilities: BTreeMap>,
+ variants: BTreeMap,
+ keyframes: BTreeMap,
+}
+
+static DEFINITIONS: LazyLock> =
+ LazyLock::new(|| RwLock::new(Definitions::default()));
+
+/// One statement of a stylesheet
+#[derive(Debug, PartialEq, Eq)]
+enum Item {
+ /// `prelude;`, which a declaration is as well
+ Statement(String),
+ /// `prelude { body }`
+ Block(String, String),
+}
+
+/// `css` without its comments
+fn strip_comments(css: &str) -> String {
+ let mut out = String::with_capacity(css.len());
+ let mut rest = css;
+ while let Some(start) = rest.find("/*") {
+ out.push_str(&rest[..start]);
+ rest = rest[start + 2..]
+ .find("*/")
+ .map_or("", |end| &rest[start + 2 + end + 2..]);
+ }
+ out.push_str(rest);
+ out
+}
+
+/// The statements and blocks of `css`, `None` when its brackets or strings do
+/// not balance
+fn parse_items(css: &str) -> Option> {
+ let mut items = Vec::new();
+ let mut prelude = String::new();
+ let mut body = String::new();
+ let mut depth = 0usize;
+ let mut parens = 0usize;
+ let mut quote: Option = None;
+ for c in css.chars() {
+ let target = if depth == 0 { &mut prelude } else { &mut body };
+ if let Some(open) = quote {
+ target.push(c);
+ if c == open {
+ quote = None;
+ }
+ continue;
+ }
+ match c {
+ '"' | '\'' => {
+ quote = Some(c);
+ target.push(c);
+ }
+ '(' => {
+ parens += 1;
+ target.push(c);
+ }
+ ')' => {
+ parens = parens.checked_sub(1)?;
+ target.push(c);
+ }
+ '{' if parens == 0 => {
+ depth += 1;
+ if depth > 1 {
+ body.push(c);
+ }
+ }
+ '}' if parens == 0 => {
+ depth = depth.checked_sub(1)?;
+ if depth == 0 {
+ items.push(Item::Block(
+ prelude.trim().to_string(),
+ std::mem::take(&mut body),
+ ));
+ prelude.clear();
+ } else {
+ body.push(c);
+ }
+ }
+ ';' if parens == 0 && depth == 0 => {
+ items.push(Item::Statement(prelude.trim().to_string()));
+ prelude.clear();
+ }
+ _ => target.push(c),
+ }
+ }
+ let balanced = depth == 0 && parens == 0 && quote.is_none();
+ if balanced && !prelude.trim().is_empty() {
+ items.push(Item::Statement(prelude.trim().to_string()));
+ }
+ balanced.then_some(items)
+}
+
+/// `value` without the whitespace CSS does not need
+fn compact(value: &str) -> String {
+ let mut out = String::with_capacity(value.len());
+ let mut pending_space = false;
+ for c in value.trim().chars() {
+ if c.is_whitespace() {
+ pending_space = true;
+ continue;
+ }
+ let tight = matches!(c, '{' | '}' | ':' | ';' | ',');
+ if pending_space && !tight && !out.ends_with(['{', '}', ':', ';', ',']) {
+ out.push(' ');
+ }
+ pending_space = false;
+ out.push(c);
+ }
+ out
+}
+
+/// `name: value` as the pair it declares
+fn declaration(statement: &str) -> Option<(String, String)> {
+ let (name, value) = statement.split_once(':')?;
+ let name = name.trim();
+ let value = value.trim();
+ let valid = !name.is_empty()
+ && name
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'*'));
+ (valid && !value.is_empty()).then(|| (name.to_string(), value.to_string()))
+}
+
+/// The declarations of `body`, `None` when it holds anything else
+fn declarations(body: &str) -> Option> {
+ parse_items(body)?
+ .into_iter()
+ .map(|item| match item {
+ Item::Statement(statement) => declaration(&statement),
+ Item::Block(..) => None,
+ })
+ .collect()
+}
+
+impl Definitions {
+ fn read_theme(&mut self, body: &str) -> Option<()> {
+ for item in parse_items(body)? {
+ match item {
+ Item::Statement(statement) => {
+ let Some((name, value)) = declaration(&statement) else {
+ continue;
+ };
+ self.theme_variable(name, value);
+ }
+ Item::Block(prelude, block) => {
+ if let Some(name) = prelude.strip_prefix("@keyframes") {
+ let name = name.trim().to_string();
+ let rule = compact(&format!("@keyframes {name}{{{block}}}"));
+ self.keyframes.insert(name, rule);
+ }
+ }
+ }
+ }
+ Some(())
+ }
+
+ fn theme_variable(&mut self, name: String, value: String) {
+ if !name.starts_with("--") {
+ return;
+ }
+ if value == "initial" {
+ if name == "--*" {
+ self.reset_all = true;
+ } else if let Some(namespace) = name.strip_suffix('*') {
+ self.resets.insert(namespace.to_string());
+ }
+ return;
+ }
+ if name.contains('*') || value.contains("theme(") {
+ return;
+ }
+ self.theme.insert(name, value);
+ }
+
+ fn read_utility(&mut self, name: &str, body: &str) {
+ if name.is_empty() || name.contains('*') || name.contains(char::is_whitespace) {
+ return;
+ }
+ if let Some(declarations) = declarations(body) {
+ self.utilities.insert(name.to_string(), declarations);
+ }
+ }
+
+ fn read_custom_variant(&mut self, rest: &str, body: Option<&str>) {
+ let rest = rest.trim();
+ let (name, definition) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
+ let definition = definition.trim();
+ let variant = match body {
+ None => definition
+ .strip_prefix('(')
+ .and_then(|inner| inner.strip_suffix(')'))
+ .map(selector_variant),
+ Some(body) if definition.is_empty() => block_variant(body),
+ Some(_) => None,
+ };
+ if let Some(variant) = variant.filter(|_| !name.is_empty()) {
+ self.variants.insert(name.to_string(), variant);
+ }
+ }
+}
+
+/// The selectors of `(…)`, which must hold the element as `&`
+fn selector_variant(selectors: &str) -> CustomVariant {
+ CustomVariant {
+ selectors: split_selectors(selectors),
+ at_rule: None,
+ }
+}
+
+/// `selectors` split at the commas outside any bracket
+fn split_selectors(selectors: &str) -> Vec {
+ let mut parts = Vec::new();
+ let mut depth = 0usize;
+ let mut start = 0;
+ for (index, c) in selectors.char_indices() {
+ match c {
+ '(' | '[' => depth += 1,
+ ')' | ']' => depth = depth.saturating_sub(1),
+ ',' if depth == 0 => {
+ parts.push(selectors[start..index].trim().to_string());
+ start = index + 1;
+ }
+ _ => {}
+ }
+ }
+ parts.push(selectors[start..].trim().to_string());
+ parts
+}
+
+/// The variant a `@custom-variant name { … }` block holds: `@slot;` alone,
+/// or one selector or at-rule around it
+fn block_variant(body: &str) -> Option {
+ match parse_items(body)?.as_slice() {
+ [Item::Statement(statement)] if statement == "@slot" => Some(selector_variant("&")),
+ [Item::Block(prelude, inner)] if compact(inner).trim_end_matches(';') == "@slot" => {
+ around_slot(prelude)
+ }
+ _ => None,
+ }
+}
+
+/// The variant `prelude { @slot; }` is: an at-rule or a selector
+fn around_slot(prelude: &str) -> Option {
+ for (name, kind) in [
+ ("@media", AtRuleKind::Media),
+ ("@supports", AtRuleKind::Supports),
+ ] {
+ if let Some(query) = prelude.strip_prefix(name) {
+ return Some(CustomVariant {
+ selectors: Vec::new(),
+ at_rule: Some((kind, compact(query))),
+ });
+ }
+ }
+ (!prelude.starts_with('@')).then(|| selector_variant(prelude))
+}
+/// `var(--name)` written whole in `value`
+fn sole_variable(value: &str) -> Option<&str> {
+ value
+ .strip_prefix("var(")
+ .and_then(|inner| inner.strip_suffix(')'))
+ .map(str::trim)
+ .filter(|name| name.starts_with("--") && !name.contains([',', ' ', '(']))
+}
+
+fn read(css: &str) -> Option {
+ let css = strip_comments(css);
+ let mut definitions = Definitions::default();
+ for item in parse_items(&css)? {
+ match item {
+ Item::Block(prelude, body) => {
+ let (at, rest) = prelude
+ .split_once(char::is_whitespace)
+ .unwrap_or((prelude.as_str(), ""));
+ match at {
+ "@theme" => definitions.read_theme(&body)?,
+ "@utility" => definitions.read_utility(rest.trim(), &body),
+ "@custom-variant" => definitions.read_custom_variant(rest, Some(&body)),
+ _ => {}
+ }
+ }
+ Item::Statement(statement) => {
+ if let Some(rest) = statement.strip_prefix("@custom-variant") {
+ definitions.read_custom_variant(rest, None);
+ }
+ }
+ }
+ }
+ definitions.resolve_references();
+ Some(definitions)
+}
+
+impl Definitions {
+ /// A theme variable that is another one (`--color-brand: var(--color-mint)`)
+ /// is that one's value, as the theme variables are not written out
+ fn resolve_references(&mut self) {
+ for _ in 0..8 {
+ let snapshot = self.theme.clone();
+ for value in self.theme.values_mut() {
+ if let Some(target) = sole_variable(value).and_then(|name| snapshot.get(name)) {
+ value.clone_from(target);
+ }
+ }
+ }
+ }
+}
+
+/// Reads the definitions of the Tailwind CSS in `css`, which replace the ones
+/// read before; `""` clears them
+pub fn set_tailwind_css(css: &str) {
+ let definitions = read(css).unwrap_or_default();
+ if let Ok(mut registry) = DEFINITIONS.write() {
+ *registry = definitions;
+ }
+}
+
+fn with(read: impl FnOnce(&Definitions) -> Option) -> Option {
+ DEFINITIONS.read().ok().and_then(|registry| read(®istry))
+}
+
+/// The value of the theme variable `---`
+#[must_use]
+pub fn theme_value(namespace: &str, key: &str) -> Option {
+ with(|definitions| {
+ definitions
+ .theme
+ .get(&format!("--{namespace}-{key}"))
+ .cloned()
+ })
+}
+
+/// The value of the theme variable `--`
+#[must_use]
+pub fn theme_variable(name: &str) -> Option {
+ with(|definitions| definitions.theme.get(&format!("--{name}")).cloned())
+}
+
+/// Whether the theme empties the default values of `---*`
+#[must_use]
+pub fn namespace_is_reset(namespace: &str) -> bool {
+ with(|definitions| {
+ Some(definitions.reset_all || definitions.resets.contains(&format!("--{namespace}-")))
+ })
+ .unwrap_or(false)
+}
+
+/// The keys of the theme variables `---`
+#[must_use]
+pub fn theme_keys(namespace: &str) -> Vec {
+ let prefix = format!("--{namespace}-");
+ with(|definitions| {
+ Some(
+ definitions
+ .theme
+ .keys()
+ .filter_map(|name| name.strip_prefix(&prefix).map(str::to_string))
+ .collect(),
+ )
+ })
+ .unwrap_or_default()
+}
+
+/// The declarations of the `@utility` named `name`
+#[must_use]
+pub fn custom_utility(name: &str) -> Option> {
+ with(|definitions| definitions.utilities.get(name).cloned())
+}
+
+/// The `@custom-variant` named `name`
+#[must_use]
+pub fn custom_variant(name: &str) -> Option {
+ with(|definitions| definitions.variants.get(name).cloned())
+}
+
+/// The `@keyframes` rule the theme defines for `name`
+#[must_use]
+pub fn theme_keyframes(name: &str) -> Option {
+ with(|definitions| definitions.keyframes.get(name).cloned())
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+mod tests {
+ use super::*;
+ use serial_test::serial;
+
+ #[test]
+ #[serial]
+ fn theme_variables_are_read_by_namespace() {
+ set_tailwind_css(
+ "@import \"tailwindcss\";\n/* c */ @theme { --color-brand: #f00; --spacing-gutter: 3rem; --breakpoint-3xl: 120rem; --font-display: 'Inter', sans-serif; }",
+ );
+ assert_eq!(theme_value("color", "brand").as_deref(), Some("#f00"));
+ assert_eq!(theme_value("spacing", "gutter").as_deref(), Some("3rem"));
+ assert_eq!(theme_value("breakpoint", "3xl").as_deref(), Some("120rem"));
+ assert_eq!(
+ theme_value("font", "display").as_deref(),
+ Some("'Inter', sans-serif")
+ );
+ assert_eq!(theme_value("color", "nothing"), None);
+ assert_eq!(theme_variable("color-brand").as_deref(), Some("#f00"));
+ assert_eq!(theme_keys("color"), vec!["brand"]);
+ set_tailwind_css("");
+ assert_eq!(theme_value("color", "brand"), None);
+ assert_eq!(theme_keys("color"), Vec::::new());
+ }
+
+ #[test]
+ #[serial]
+ fn theme_options_and_resets() {
+ set_tailwind_css(
+ "@theme inline { --color-*: initial; --color-a: red; --color-b: var(--color-a); --color-c: var(--color-b); --x-*: theme(foo); --color-d: theme(--color-a); }",
+ );
+ assert!(namespace_is_reset("color"));
+ assert!(!namespace_is_reset("shadow"));
+ assert_eq!(theme_value("color", "b").as_deref(), Some("red"));
+ assert_eq!(theme_value("color", "c").as_deref(), Some("red"));
+ assert_eq!(theme_value("color", "d"), None);
+ set_tailwind_css("@theme { --*: initial; --color-a: red }");
+ assert!(namespace_is_reset("shadow"));
+ set_tailwind_css("@theme { --color-a: var(--other, red); color: red; --: 1; --a b: 2 }");
+ assert_eq!(
+ theme_value("color", "a").as_deref(),
+ Some("var(--other, red)")
+ );
+ assert!(!namespace_is_reset("color"));
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn theme_keyframes_are_kept_compact() {
+ set_tailwind_css(
+ "@theme { --animate-wiggle: wiggle 1s ease-in-out infinite;\n @keyframes wiggle { 0%, 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } } @media print {} }",
+ );
+ assert_eq!(
+ theme_keyframes("wiggle").as_deref(),
+ Some(
+ "@keyframes wiggle{0%,100%{transform:rotate(-3deg);}50%{transform:rotate(3deg);}}"
+ )
+ );
+ assert_eq!(theme_keyframes("other"), None);
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn utilities_are_static_declarations() {
+ set_tailwind_css(
+ "@layer base { a { color: red } } @utility content-auto { content-visibility: auto; contain-intrinsic-size: 0 500px } @utility tab-* { tab-size: --value(integer); } @utility nested { &:hover { color: red } } @utility empty { } @utility two words { a: b }",
+ );
+ assert_eq!(
+ custom_utility("content-auto"),
+ Some(vec![
+ ("content-visibility".to_string(), "auto".to_string()),
+ ("contain-intrinsic-size".to_string(), "0 500px".to_string()),
+ ])
+ );
+ assert_eq!(custom_utility("tab-*"), None);
+ assert_eq!(custom_utility("nested"), None);
+ assert_eq!(custom_utility("empty"), Some(vec![]));
+ assert_eq!(custom_utility("two"), None);
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn custom_variants() {
+ set_tailwind_css(
+ "@custom-variant theme-midnight (&:where([data-theme=\"midnight\"] *));\n@custom-variant dark { &:where(.dark, .dark *) { @slot; } }\n@custom-variant pointer-hover { @media (hover: hover) { @slot; } }\n@custom-variant wide { @supports (display: grid) { @slot; } }\n@custom-variant any (&:hover, &:focus);\n@custom-variant plain { @slot; }\n@custom-variant bad { &:hover { color: red; } }\n@custom-variant worse { &:hover { &:focus { @slot; } } @slot; }\n@custom-variant bare;\n@custom-variant (&:hover);\n@custom-variant named-block extra { &:hover { @slot; } }\n@custom-variant at { @layer x { @slot; } }",
+ );
+ assert_eq!(
+ custom_variant("theme-midnight").unwrap().selectors,
+ vec!["&:where([data-theme=\"midnight\"] *)"]
+ );
+ assert_eq!(
+ custom_variant("dark").unwrap().selectors,
+ vec!["&:where(.dark, .dark *)"]
+ );
+ assert_eq!(
+ custom_variant("pointer-hover").unwrap(),
+ CustomVariant {
+ selectors: vec![],
+ at_rule: Some((AtRuleKind::Media, "(hover:hover)".to_string())),
+ }
+ );
+ assert_eq!(
+ custom_variant("wide").unwrap().at_rule,
+ Some((AtRuleKind::Supports, "(display:grid)".to_string()))
+ );
+ assert_eq!(
+ custom_variant("any").unwrap().selectors,
+ vec!["&:hover", "&:focus"]
+ );
+ assert_eq!(custom_variant("plain").unwrap().selectors, vec!["&"]);
+ for name in ["bad", "worse", "bare", "named-block", "at", ""] {
+ assert_eq!(custom_variant(name), None, "{name}");
+ }
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn a_file_that_cannot_be_read_defines_nothing() {
+ for css in [
+ "@theme { --color-a: red;",
+ "@theme { --color-a: red; }}",
+ "@theme { --color-a: url(\"a }",
+ "@utility a { b: c) }",
+ "@theme { --color-a: (red }",
+ ] {
+ set_tailwind_css("@theme { --color-keep: red }");
+ set_tailwind_css(css);
+ assert_eq!(theme_value("color", "a"), None, "{css}");
+ assert_eq!(theme_value("color", "keep"), None, "{css}");
+ }
+ set_tailwind_css("");
+ }
+
+ #[test]
+ fn items_and_text_helpers() {
+ assert_eq!(strip_comments("a/* x */b/* open"), "ab");
+ assert_eq!(
+ parse_items("@import \"a;b\"; .a { b: c }").unwrap(),
+ vec![
+ Item::Statement("@import \"a;b\"".to_string()),
+ Item::Block(".a".to_string(), " b: c ".to_string()),
+ ]
+ );
+ assert_eq!(
+ parse_items("a: b").unwrap(),
+ vec![Item::Statement("a: b".to_string())]
+ );
+ assert_eq!(parse_items(" ").unwrap(), vec![]);
+ assert_eq!(compact(" a { b : c ; } "), "a{b:c;}");
+ assert_eq!(compact("a b"), "a b");
+ assert_eq!(split_selectors("&:a(b, c), &d"), vec!["&:a(b, c)", "&d"]);
+ assert_eq!(sole_variable("var(--a)"), Some("--a"));
+ assert_eq!(sole_variable("var(--a, red)"), None);
+ assert_eq!(sole_variable("red"), None);
+ assert_eq!(declaration("a"), None);
+ assert_eq!(declaration("a:"), None);
+ assert_eq!(declaration("a b: c"), None);
+ assert_eq!(declarations("a: b; c { d: e }"), None);
+ }
+}
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..4e764871b 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -15,6 +15,12 @@ mod source_map;
mod style_values;
mod stylex;
mod tailwind;
+mod tailwind_children;
+mod tailwind_color;
+mod tailwind_effects;
+mod tailwind_misc;
+mod tailwind_motion;
+mod tailwind_theme;
mod util_type;
mod utils;
mod vanilla_extract;
@@ -14639,6 +14645,81 @@ const Button = styled.button({ bg: 'red' })
assert_eq!(output.code, expected);
}
+ #[rstest]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ ""
+ )]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "",
+ "-"
+ )]
+ #[case(
+ "",
+ "-"
+ )]
+ #[case(
+ "",
+ "-"
+ )]
+ #[serial]
+ fn test_tailwind_helper_calls(#[case] jsx: &str, #[case] expected: &str) {
+ reset_class_map();
+ reset_file_map();
+ let output = extract(
+ "test.tsx",
+ &format!("import {{Box}} from '@devup-ui/core'\n{jsx}\n"),
+ ExtractOption {
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
+ single_css: true,
+ import_main_css: false,
+ import_aliases: HashMap::new(),
+ },
+ )
+ .unwrap();
+ let expected = expected.strip_prefix('-').map_or_else(
+ || format!("import \"@devup-ui/core/devup-ui.css\";\n{expected};\n"),
+ |code| format!("{code};\n"),
+ );
+ assert_eq!(output.code, expected);
+ }
#[test]
#[serial]
fn test_tailwind_classname_extraction() {
diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs
index b8b1ce7da..c43e1e818 100644
--- a/libs/extractor/src/prop_modify_utils.rs
+++ b/libs/extractor/src/prop_modify_utils.rs
@@ -3,18 +3,21 @@ 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};
use oxc_ast::ast::JSXAttributeName::Identifier;
use oxc_ast::ast::{
- Expression, IdentifierName, JSXAttributeItem, JSXAttributeName, JSXAttributeValue,
- LogicalOperator, ObjectPropertyKind, PropertyKey, PropertyKind, StaticMemberExpression, Str,
- StringLiteral, TemplateElement, TemplateElementValue, TemplateLiteral,
+ Argument, ArrayExpressionElement, CallExpression, Expression, IdentifierName, JSXAttributeItem,
+ JSXAttributeName, JSXAttributeValue, LogicalOperator, ObjectPropertyKind, PropertyKey,
+ PropertyKind, StaticMemberExpression, Str, StringLiteral, TemplateElement,
+ TemplateElementValue, TemplateLiteral,
};
use oxc_ast::builder::AstBuilder;
use oxc_span::SPAN;
+use std::borrow::Cow;
+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 +317,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 +367,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>,
}
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 +396,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 {
@@ -472,11 +475,91 @@ impl TailwindClassName<'_> {
ast_builder,
))
}
- // Variables, calls and the like only hold their classes at runtime
+ Expression::CallExpression(call) => self.compile_call(ast_builder, call),
+ Expression::ArrayExpression(array) => {
+ let mut compiled = array.clone_in(ast_builder.allocator());
+ let mut changed = false;
+ for element in &mut compiled.elements {
+ if let Some(expression) = element
+ .as_expression()
+ .and_then(|expression| self.compile_expression(ast_builder, expression))
+ {
+ *element = ArrayExpressionElement::from(expression);
+ changed = true;
+ }
+ }
+ changed.then_some(Expression::ArrayExpression(compiled))
+ }
+ Expression::ObjectExpression(object) => {
+ let mut compiled = object.clone_in(ast_builder.allocator());
+ let mut changed = false;
+ for property in &mut compiled.properties {
+ let ObjectPropertyKind::ObjectProperty(property) = property else {
+ continue;
+ };
+ let PropertyKey::StringLiteral(key) = &property.key else {
+ continue;
+ };
+ if property.computed {
+ continue;
+ }
+ if let Some(text) = self.compile_text(&key.value, false, false) {
+ property.key = PropertyKey::from(Expression::new_string_literal(
+ SPAN,
+ Str::from_in(&text, ast_builder.allocator()),
+ None,
+ ast_builder,
+ ));
+ changed = true;
+ }
+ }
+ changed.then_some(Expression::ObjectExpression(compiled))
+ }
+ // Variables and the like only hold their classes at runtime
_ => None,
}
}
+ /// `clsx(...)`, `classnames(...)` and `[...].join(" ")`, which join the
+ /// classes they are given as they are, so each class compiles where it is
+ /// written
+ fn compile_call<'a>(
+ &mut self,
+ ast_builder: &AstBuilder<'a>,
+ call: &CallExpression<'a>,
+ ) -> Option> {
+ let mut compiled = call.clone_in(ast_builder.allocator());
+ let mut changed = false;
+ match &call.callee {
+ Expression::Identifier(callee)
+ if matches!(callee.name.as_str(), "clsx" | "classnames" | "classNames") =>
+ {
+ for argument in &mut compiled.arguments {
+ if let Some(expression) = argument
+ .as_expression()
+ .and_then(|expression| self.compile_expression(ast_builder, expression))
+ {
+ *argument = Argument::from(expression);
+ changed = true;
+ }
+ }
+ }
+ Expression::StaticMemberExpression(member)
+ if member.property.name == "join"
+ && matches!(call.arguments.as_slice(), [Argument::StringLiteral(separator)] if separator.value == " ") =>
+ {
+ let array = self.compile_expression(ast_builder, &member.object)?;
+ let mut callee = member.clone_in(ast_builder.allocator());
+ callee.object = array;
+ compiled.callee = Expression::StaticMemberExpression(callee);
+ changed = true;
+ }
+ _ => {}
+ }
+ changed
+ .then(|| Expression::CallExpression(oxc_allocator::Box::new_in(compiled, ast_builder)))
+ }
+
fn compile_template<'a>(
&mut self,
ast_builder: &AstBuilder<'a>,
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..854d09b33 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
@@ -3,10 +3,24 @@ source: libs/extractor/src/tailwind.rs
expression: sort_styles(styles)
---
{
+ Static(
+ ExtractStaticStyle {
+ property: "--tw-outline-style",
+ value: "none",
+ level: 0,
+ selector: Some(
+ Selector(
+ "&:focus",
+ ),
+ ),
+ style_order: None,
+ layer: None,
+ },
+ ),
Static(
ExtractStaticStyle {
property: "background-color",
- value: "#3B82F6",
+ value: "oklch(62.3% .214 259.815)",
level: 0,
selector: Some(
At {
@@ -39,8 +53,8 @@ expression: sort_styles(styles)
),
Static(
ExtractStaticStyle {
- property: "outline",
- value: "2px solid transparent",
+ property: "outline-style",
+ value: "none",
level: 0,
selector: Some(
Selector(
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..9044aaced 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,
@@ -105,7 +135,37 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "border-radius",
- value: "9999px",
+ value: "calc(infinity * 1px)",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "border-top-style",
+ value: "var(--tw-border-style)",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
+ Static(
+ ExtractStaticStyle {
+ property: "border-top-width",
+ value: "1px",
+ 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: None,
style_order: None,
@@ -115,7 +175,7 @@ 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: Some(
At {
@@ -134,8 +194,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 +205,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 +219,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "#374151",
+ value: "oklch(21% .034 264.665)",
level: 0,
selector: None,
style_order: None,
@@ -165,7 +229,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "#6B7280",
+ value: "oklch(37.3% .034 259.733)",
level: 0,
selector: None,
style_order: None,
@@ -175,13 +239,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 +249,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "color",
- value: "#D1D5DB",
+ value: "oklch(70.7% .022 261.325)",
level: 0,
selector: Some(
Selector(
@@ -203,17 +263,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 +486,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 +506,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 +528,64 @@ 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-border-style{syntax:\"*\";inherits:false;initial-value:solid}",
+ file: "@devup-ui/tailwind",
+ },
+ ),
+ Css(
+ ExtractCss {
+ 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-offset-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// 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..4838b53c4 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,42 @@ 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-offset-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..146ffd849 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
@@ -4,6 +4,20 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev
---
ToBTreeSet {
styles: {
+ Static(
+ ExtractStaticStyle {
+ property: "--tw-ring-shadow",
+ value: "var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor)",
+ level: 0,
+ selector: Some(
+ Selector(
+ "&:focus",
+ ),
+ ),
+ style_order: None,
+ layer: None,
+ },
+ ),
Static(
ExtractStaticStyle {
property: "--tw-scale-x",
@@ -49,7 +63,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "background-color",
- value: "#111827",
+ value: "oklch(21% .034 264.665)",
level: 0,
selector: Some(
Selector(
@@ -63,7 +77,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "background-color",
- value: "#1F2937",
+ value: "oklch(27.8% .033 256.848)",
level: 0,
selector: Some(
At {
@@ -83,7 +97,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "background-color",
- value: "#3B82F6",
+ value: "oklch(62.3% .214 259.815)",
level: 0,
selector: Some(
At {
@@ -103,7 +117,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "box-shadow",
- value: "0 0 0 2px var(--tw-ring-color)",
+ 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(
Selector(
@@ -204,10 +218,70 @@ 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-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-color{syntax:\"*\";inherits:false}",
+ file: "@devup-ui/tailwind",
+ },
+ ),
+ Css(
+ ExtractCss {
+ css: "@property --tw-ring-inset{syntax:\"*\";inherits:false}",
+ file: "@devup-ui/tailwind",
+ },
+ ),
+ Css(
+ ExtractCss {
+ css: "@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}",
+ file: "@devup-ui/tailwind",
+ },
+ ),
+ Css(
+ ExtractCss {
+ css: "@property --tw-ring-offset-width{syntax:\"\";inherits:false;initial-value:0px}",
+ 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-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",
+ },
+ ),
+ 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_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..a32e13dde 100644
--- a/libs/extractor/src/tailwind.rs
+++ b/libs/extractor/src/tailwind.rs
@@ -11,6 +11,7 @@
use std::borrow::Cow;
+use css::tailwind_definitions::{custom_utility, custom_variant, theme_value, theme_variable};
use css::{
at_rule::split_at_rule_key,
style_selector::{AtRuleKind, StyleSelector},
@@ -18,6 +19,12 @@ 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_misc::misc_utility;
+use crate::tailwind_motion::{keyframes_of, motion_utility};
+use crate::tailwind_theme::{reads_unsupported_theme, scaled, themed};
/// A declaration: property and value
pub type Declaration = (Cow<'static, str>, Cow<'static, str>);
@@ -138,25 +145,111 @@ 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-outline-style", "*", "solid"),
+ property!("--tw-border-spacing-x", "", "0"),
+ property!("--tw-border-spacing-y", "", "0"),
+ 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-inset"),
+ property!("--tw-ring-offset-width", "", "0px"),
+ property!("--tw-ring-offset-color", "*", "#fff"),
+ property!("--tw-ring-offset-shadow", "*", "0 0 #0000"),
+ 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,23 +312,47 @@ 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> {
+ let mut rules: Vec> = 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)| Cow::Borrowed(rule))
+ .collect();
+ rules.extend(keyframes_of(&self.declarations));
+ rules
}
}
/// The variant a prefix such as `hover` or `md` stands for
fn variant(name: &str) -> Option {
+ if let Some(custom) = custom_variant(name) {
+ return Some(Variant {
+ selectors: custom.selectors.into_iter().map(Cow::Owned).collect(),
+ at_rule: custom
+ .at_rule
+ .map(|(kind, query)| (kind, Cow::Owned(query))),
+ ..Variant::default()
+ });
+ }
if let Some(&level) = RESPONSIVE_PREFIX_MAP.get(name) {
return Some(Variant {
level,
..Variant::default()
});
}
+ if let Some(width) = theme_value("breakpoint", name) {
+ return Some(Variant::at_rule(
+ AtRuleKind::Media,
+ format!("(min-width:{width})"),
+ ));
+ }
if let Some(&query) = MEDIA_VARIANTS.get(name) {
return Some(Variant::at_rule(AtRuleKind::Media, query));
}
@@ -397,283 +514,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! {
+static DEFAULT_SPACING: phf::Map<&'static str, &'static str> = phf_map! {
"0" => "0px",
"px" => "1px",
"0.5" => "0.125rem",
@@ -776,17 +618,31 @@ static FONT_WEIGHT_SCALE: phf::Map<&'static str, &'static str> = phf_map! {
"black" => "900",
};
+/// The spacing of a step: the project's @theme spacing, else Tailwind's
+pub(crate) fn spacing_scale(key: &str) -> Option> {
+ if let Some(value) = theme_value("spacing", key) {
+ return Some(Cow::Owned(value));
+ }
+ if let Some(scaled) = theme_variable("spacing").and_then(|base| scaled(key, &base)) {
+ return Some(Cow::Owned(scaled));
+ }
+ DEFAULT_SPACING
+ .get(key)
+ .map(|&value| Cow::Borrowed(value))
+ .or_else(|| scaled(key, "0.25rem").map(Cow::Owned))
+}
+
/// Border radius scale
static BORDER_RADIUS_SCALE: phf::Map<&'static str, &'static str> = phf_map! {
- "none" => "0px",
- "sm" => "0.125rem",
+ "xs" => "0.125rem",
+ "sm" => "0.25rem",
"" => "0.25rem",
"md" => "0.375rem",
"lg" => "0.5rem",
"xl" => "0.75rem",
"2xl" => "1rem",
"3xl" => "1.5rem",
- "full" => "9999px",
+ "4xl" => "2rem",
};
/// Opacity scale
@@ -825,54 +681,33 @@ 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",
- "" => "1px",
- "2" => "2px",
- "4" => "4px",
- "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 +730,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 +792,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 +835,30 @@ fn utility(name: &str) -> Option> {
arbitrary_property(name)
};
}
+ if !negative {
+ if let Some(declarations) = custom_utility(name) {
+ return Some(
+ declarations
+ .into_iter()
+ .map(|(property, value)| decl(property, value))
+ .collect(),
+ );
+ }
+ }
+ if reads_unsupported_theme(name) {
+ return None;
+ }
+ if let Some(declarations) = effect_utility(name, negative) {
+ return Some(declarations);
+ }
+ if let Some(declarations) = misc_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 +1093,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 +1104,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 +1166,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 +1223,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 {
@@ -1443,7 +1327,7 @@ fn compound_utility(name: &str, negative: bool) -> Option> {
);
}
if let Some(size) = name.strip_prefix("size-") {
- return Some(each(&["width", "height"], sizing_value(size)?));
+ return Some(each(&["width", "height"], &sizing_value(size)?));
}
if let Some(declarations) = radius_utility(name) {
return Some(declarations);
@@ -1464,11 +1348,11 @@ fn compound_utility(name: &str, negative: bool) -> Option> {
}
/// A spacing value `size-*` and `translate-*` take
-fn sizing_value(size: &str) -> Option<&'static str> {
+fn sizing_value(size: &str) -> Option> {
if matches!(size, "screen" | "svw" | "lvw" | "dvw") {
return None;
}
- SPACING_SCALE.get(size).copied()
+ spacing_scale(size)
}
/// `rounded`, `rounded-lg`, `rounded-t`, `rounded-t-lg`, …
@@ -1484,8 +1368,14 @@ fn radius_utility(name: &str) -> Option> {
_ => ("", rest),
}
};
- let value = BORDER_RADIUS_SCALE.get(size)?;
- Some(each(RADIUS_SIDES.get(side)?, value))
+ let value = match size {
+ "none" => Cow::Borrowed("0"),
+ "full" => Cow::Borrowed("calc(infinity * 1px)"),
+ _ => themed("radius", size, |size| {
+ BORDER_RADIUS_SCALE.get(size).copied()
+ })?,
+ };
+ Some(each(RADIUS_SIDES.get(side)?, &value))
}
/// `text-sm`, with the line height of the size unless `text-sm/6` sets one
@@ -1522,7 +1412,7 @@ fn line_height_value(name: &str) -> Option> {
.then(|| Cow::Owned(format!("var({variable})")));
}
if is_number(name) {
- return SPACING_SCALE.get(name).map(|&value| Cow::Borrowed(value));
+ return spacing_scale(name);
}
None
}
@@ -1620,7 +1510,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 +1685,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 +1823,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 +1894,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);
@@ -2193,38 +2102,38 @@ fn parse_layout_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
// Top/Right/Bottom/Left/Inset
if let Some(rest) = class.strip_prefix("top-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("top", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("top", value));
}
}
if let Some(rest) = class.strip_prefix("right-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("right", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("right", value));
}
}
if let Some(rest) = class.strip_prefix("bottom-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("bottom", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("bottom", value));
}
}
if let Some(rest) = class.strip_prefix("left-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("left", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("left", value));
}
}
if let Some(rest) = class.strip_prefix("inset-x-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("inset-inline", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("inset-inline", value));
}
}
if let Some(rest) = class.strip_prefix("inset-y-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("inset-block", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("inset-block", value));
}
}
if let Some(rest) = class.strip_prefix("inset-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("inset", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("inset", value));
}
}
@@ -2383,8 +2292,8 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st
_ => {
// Flex basis with spacing scale
if let Some(rest) = class.strip_prefix("basis-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("flex-basis", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("flex-basis", value));
}
}
@@ -2459,18 +2368,18 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st
// Gap
if let Some(rest) = class.strip_prefix("gap-x-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("column-gap", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("column-gap", value));
}
}
if let Some(rest) = class.strip_prefix("gap-y-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("row-gap", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("row-gap", value));
}
}
if let Some(rest) = class.strip_prefix("gap-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("gap", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("gap", value));
}
}
@@ -2483,113 +2392,95 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st
fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> {
// Padding
if let Some(rest) = class.strip_prefix("px-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-inline", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-inline", value));
}
}
if let Some(rest) = class.strip_prefix("py-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-block", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-block", value));
}
}
if let Some(rest) = class.strip_prefix("pt-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-top", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-top", value));
}
}
if let Some(rest) = class.strip_prefix("pr-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-right", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-right", value));
}
}
if let Some(rest) = class.strip_prefix("pb-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-bottom", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-bottom", value));
}
}
if let Some(rest) = class.strip_prefix("pl-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-left", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-left", value));
}
}
if let Some(rest) = class.strip_prefix("ps-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-inline-start", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-inline-start", value));
}
}
if let Some(rest) = class.strip_prefix("pe-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding-inline-end", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding-inline-end", value));
}
}
if let Some(rest) = class.strip_prefix("p-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("padding", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("padding", value));
}
}
// Margin
if let Some(rest) = class.strip_prefix("mx-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-inline", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-inline", value));
}
}
if let Some(rest) = class.strip_prefix("my-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-block", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-block", value));
}
}
if let Some(rest) = class.strip_prefix("mt-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-top", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-top", value));
}
}
if let Some(rest) = class.strip_prefix("mr-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-right", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-right", value));
}
}
if let Some(rest) = class.strip_prefix("mb-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-bottom", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-bottom", value));
}
}
if let Some(rest) = class.strip_prefix("ml-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-left", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-left", value));
}
}
if let Some(rest) = class.strip_prefix("ms-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-inline-start", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-inline-start", value));
}
}
if let Some(rest) = class.strip_prefix("me-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin-inline-end", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin-inline-end", value));
}
}
if let Some(rest) = class.strip_prefix("m-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("margin", Cow::Borrowed(value)));
- }
- }
-
- // 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)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("margin", value));
}
}
@@ -2600,8 +2491,8 @@ fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>
fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> {
// Width
if let Some(rest) = class.strip_prefix("w-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("width", Cow::Borrowed(value)));
+ if let Some(value) = spacing_scale(rest) {
+ return Some(tw("width", value));
}
}
@@ -2613,13 +2504,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
"min" => Cow::Borrowed("min-content"),
"max" => Cow::Borrowed("max-content"),
"fit" => Cow::Borrowed("fit-content"),
- _ => {
- if let Some(&v) = SPACING_SCALE.get(rest) {
- Cow::Borrowed(v)
- } else {
- return None;
- }
- }
+ _ => spacing_scale(rest)?,
};
return Some(tw("min-width", value));
}
@@ -2650,13 +2535,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
"screen-lg" => Cow::Borrowed("1024px"),
"screen-xl" => Cow::Borrowed("1280px"),
"screen-2xl" => Cow::Borrowed("1536px"),
- _ => {
- if let Some(&v) = SPACING_SCALE.get(rest) {
- Cow::Borrowed(v)
- } else {
- return None;
- }
- }
+ _ => spacing_scale(rest)?,
};
return Some(tw("max-width", value));
}
@@ -2668,13 +2547,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
"svh" => Cow::Borrowed("100svh"),
"lvh" => Cow::Borrowed("100lvh"),
"dvh" => Cow::Borrowed("100dvh"),
- _ => {
- if let Some(&v) = SPACING_SCALE.get(rest) {
- Cow::Borrowed(v)
- } else {
- return None;
- }
- }
+ _ => spacing_scale(rest)?,
};
return Some(tw("height", value));
}
@@ -2691,13 +2564,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
"min" => Cow::Borrowed("min-content"),
"max" => Cow::Borrowed("max-content"),
"fit" => Cow::Borrowed("fit-content"),
- _ => {
- if let Some(&v) = SPACING_SCALE.get(rest) {
- Cow::Borrowed(v)
- } else {
- return None;
- }
- }
+ _ => spacing_scale(rest)?,
};
return Some(tw("min-height", value));
}
@@ -2714,13 +2581,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
"min" => Cow::Borrowed("min-content"),
"max" => Cow::Borrowed("max-content"),
"fit" => Cow::Borrowed("fit-content"),
- _ => {
- if let Some(&v) = SPACING_SCALE.get(rest) {
- Cow::Borrowed(v)
- } else {
- return None;
- }
- }
+ _ => spacing_scale(rest)?,
};
return Some(tw("max-height", value));
}
@@ -2759,8 +2620,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 +2759,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 +2819,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,56 +2831,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)));
- }
-
- // Border width per side
- if let Some(width) = rest.strip_prefix("t-") {
- if let Some(&value) = BORDER_WIDTH_SCALE.get(width) {
- return Some(tw("border-top-width", Cow::Borrowed(value)));
- }
- }
- if let Some(width) = rest.strip_prefix("r-") {
- if let Some(&value) = BORDER_WIDTH_SCALE.get(width) {
- return Some(tw("border-right-width", Cow::Borrowed(value)));
- }
- }
- if let Some(width) = rest.strip_prefix("b-") {
- if let Some(&value) = BORDER_WIDTH_SCALE.get(width) {
- return Some(tw("border-bottom-width", Cow::Borrowed(value)));
- }
- }
- if let Some(width) = rest.strip_prefix("l-") {
- if let Some(&value) = BORDER_WIDTH_SCALE.get(width) {
- return Some(tw("border-left-width", Cow::Borrowed(value)));
- }
- }
- if let Some(width) = rest.strip_prefix("x-") {
- if let Some(&value) = BORDER_WIDTH_SCALE.get(width) {
- return Some(tw("border-inline-width", Cow::Borrowed(value)));
- }
- }
- if let Some(width) = rest.strip_prefix("y-") {
- if let Some(&value) = BORDER_WIDTH_SCALE.get(width) {
- return Some(tw("border-block-width", Cow::Borrowed(value)));
- }
- }
-
- // Border width
- if let Some(&value) = BORDER_WIDTH_SCALE.get(rest) {
- return Some(tw("border-width", Cow::Borrowed(value)));
- }
-
- // Border style
- match rest {
- "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"))),
- "hidden" => return Some(tw("border-style", Cow::Borrowed("hidden"))),
- "none" => return Some(tw("border-style", Cow::Borrowed("none"))),
- _ => {}
+ if let Some(color) = color_value(rest) {
+ return Some(tw("border-color", color));
}
// Border collapse (for tables)
@@ -3067,106 +2844,10 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
}
}
- // Standalone "border" (without suffix) - note: "border-0/2/4/8" variants are handled
- // via BORDER_WIDTH_SCALE lookup above in the strip_prefix("border-") branch
- if class == "border" {
- return Some(tw("border-width", Cow::Borrowed("1px")));
- }
-
- // Outline
+ // Outline color
if let Some(rest) = class.strip_prefix("outline-") {
- match rest {
- "none" => {
- return Some(tw("outline", Cow::Borrowed("2px solid transparent")));
- }
- "0" => return Some(tw("outline-width", Cow::Borrowed("0px"))),
- "1" => return Some(tw("outline-width", Cow::Borrowed("1px"))),
- "2" => return Some(tw("outline-width", Cow::Borrowed("2px"))),
- "4" => return Some(tw("outline-width", Cow::Borrowed("4px"))),
- "8" => return Some(tw("outline-width", Cow::Borrowed("8px"))),
- "dashed" => return Some(tw("outline-style", Cow::Borrowed("dashed"))),
- "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 class == "outline" {
- return Some(tw("outline-style", Cow::Borrowed("solid")));
- }
-
- // Ring
- if let Some(rest) = class.strip_prefix("ring-") {
- match rest {
- "0" => {
- return Some(tw("--tw-ring-offset-shadow", Cow::Borrowed("0 0 #0000")));
- }
- "1" => {
- return Some(tw(
- "box-shadow",
- Cow::Borrowed("0 0 0 1px var(--tw-ring-color)"),
- ));
- }
- "2" => {
- return Some(tw(
- "box-shadow",
- Cow::Borrowed("0 0 0 2px var(--tw-ring-color)"),
- ));
- }
- "4" => {
- return Some(tw(
- "box-shadow",
- Cow::Borrowed("0 0 0 4px var(--tw-ring-color)"),
- ));
- }
- "8" => {
- return Some(tw(
- "box-shadow",
- Cow::Borrowed("0 0 0 8px var(--tw-ring-color)"),
- ));
- }
- "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 class == "ring" {
- return Some(tw(
- "box-shadow",
- Cow::Borrowed("0 0 0 3px var(--tw-ring-color)"),
- ));
- }
-
- // 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"))),
- _ => {}
+ if let Some(color) = color_value(rest) {
+ return Some(tw("outline-color", color));
}
}
@@ -3175,23 +2856,6 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)
/// 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,398 +2886,83 @@ 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-") {
+/// 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>)> {
+ if let Some(rest) = class.strip_prefix("origin-") {
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"),
+ "center" => Cow::Borrowed("center"),
+ "top" => Cow::Borrowed("top"),
+ "top-right" => Cow::Borrowed("top right"),
+ "right" => Cow::Borrowed("right"),
+ "bottom-right" => Cow::Borrowed("bottom right"),
+ "bottom" => Cow::Borrowed("bottom"),
+ "bottom-left" => Cow::Borrowed("bottom left"),
+ "left" => Cow::Borrowed("left"),
+ "top-left" => Cow::Borrowed("top left"),
_ => return None,
};
- return Some(tw("filter", format!("blur({value})")));
- }
- if class == "blur" {
- return Some(tw("filter", Cow::Borrowed("blur(8px)")));
+ return Some(tw("transform-origin", value));
}
- // 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})")));
- }
+ None
+}
- // 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})")));
+/// Parse interactivity utilities (cursor, pointer-events, resize, etc.)
+fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> {
+ // Accent color
+ if let Some(rest) = class.strip_prefix("accent-") {
+ if rest == "auto" {
+ return Some(tw("accent-color", Cow::Borrowed("auto")));
+ }
+ if let Some(color) = color_value(rest) {
+ return Some(tw("accent-color", color));
+ }
}
- // 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))",
- ),
- ));
+ // Appearance
+ match class {
+ "appearance-none" => return Some(tw("appearance", Cow::Borrowed("none"))),
+ "appearance-auto" => return Some(tw("appearance", Cow::Borrowed("auto"))),
+ _ => {}
}
- // Grayscale
- if class == "grayscale" {
- return Some(tw("filter", Cow::Borrowed("grayscale(100%)")));
- }
- if class == "grayscale-0" {
- return Some(tw("filter", Cow::Borrowed("grayscale(0)")));
+ // Cursor
+ if let Some(rest) = class.strip_prefix("cursor-") {
+ return CURSORS
+ .contains(rest)
+ .then(|| tw("cursor", Cow::Owned(rest.to_string())));
}
- // 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})")));
+ // Caret color
+ if let Some(rest) = class.strip_prefix("caret-") {
+ if let Some(color) = color_value(rest) {
+ return Some(tw("caret-color", color));
+ }
}
- // Invert
- if class == "invert" {
- return Some(tw("filter", Cow::Borrowed("invert(100%)")));
- }
- if class == "invert-0" {
- return Some(tw("filter", Cow::Borrowed("invert(0)")));
+ // Pointer events
+ if let Some(rest) = class.strip_prefix("pointer-events-") {
+ return matches!(rest, "none" | "auto")
+ .then(|| tw("pointer-events", Cow::Owned(rest.to_string())));
}
- // 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})")));
+ // Resize
+ match class {
+ "resize-none" => return Some(tw("resize", Cow::Borrowed("none"))),
+ "resize-y" => return Some(tw("resize", Cow::Borrowed("vertical"))),
+ "resize-x" => return Some(tw("resize", Cow::Borrowed("horizontal"))),
+ "resize" => return Some(tw("resize", Cow::Borrowed("both"))),
+ _ => {}
}
- // 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>)> {
- if let Some(rest) = class.strip_prefix("origin-") {
- let value = match rest {
- "center" => Cow::Borrowed("center"),
- "top" => Cow::Borrowed("top"),
- "top-right" => Cow::Borrowed("top right"),
- "right" => Cow::Borrowed("right"),
- "bottom-right" => Cow::Borrowed("bottom right"),
- "bottom" => Cow::Borrowed("bottom"),
- "bottom-left" => Cow::Borrowed("bottom left"),
- "left" => Cow::Borrowed("left"),
- "top-left" => Cow::Borrowed("top left"),
- _ => return None,
- };
- return Some(tw("transform-origin", value));
- }
-
- None
-}
-
-/// Parse interactivity utilities (cursor, pointer-events, resize, etc.)
-fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> {
- // Accent color
- if let Some(rest) = class.strip_prefix("accent-") {
- 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)));
- }
- }
-
- // Appearance
- match class {
- "appearance-none" => return Some(tw("appearance", Cow::Borrowed("none"))),
- "appearance-auto" => return Some(tw("appearance", Cow::Borrowed("auto"))),
- _ => {}
- }
-
- // Cursor
- if let Some(rest) = class.strip_prefix("cursor-") {
- return CURSORS
- .contains(rest)
- .then(|| tw("cursor", Cow::Owned(rest.to_string())));
- }
-
- // 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)));
- }
- }
-
- // Pointer events
- if let Some(rest) = class.strip_prefix("pointer-events-") {
- return matches!(rest, "none" | "auto")
- .then(|| tw("pointer-events", Cow::Owned(rest.to_string())));
- }
-
- // Resize
- match class {
- "resize-none" => return Some(tw("resize", Cow::Borrowed("none"))),
- "resize-y" => return Some(tw("resize", Cow::Borrowed("vertical"))),
- "resize-x" => return Some(tw("resize", Cow::Borrowed("horizontal"))),
- "resize" => return Some(tw("resize", Cow::Borrowed("both"))),
- _ => {}
- }
-
- // Scroll behavior
- if let Some(rest) = class.strip_prefix("scroll-") {
- match rest {
- "auto" => return Some(tw("scroll-behavior", Cow::Borrowed("auto"))),
- "smooth" => return Some(tw("scroll-behavior", Cow::Borrowed("smooth"))),
- _ => {}
- }
- }
-
- // Scroll margin/padding
- if let Some(rest) = class.strip_prefix("scroll-m-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("scroll-margin", Cow::Borrowed(value)));
- }
- }
- if let Some(rest) = class.strip_prefix("scroll-p-") {
- if let Some(&value) = SPACING_SCALE.get(rest) {
- return Some(tw("scroll-padding", Cow::Borrowed(value)));
- }
+ // Scroll behavior
+ if let Some(rest) = class.strip_prefix("scroll-") {
+ match rest {
+ "auto" => return Some(tw("scroll-behavior", Cow::Borrowed("auto"))),
+ "smooth" => return Some(tw("scroll-behavior", Cow::Borrowed("smooth"))),
+ _ => {}
+ }
}
// Scroll snap
@@ -3706,8 +3055,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 +3073,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 +3171,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,26 +3307,12 @@ 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")]
- #[case("rounded-full", "border-radius", "9999px")]
+ #[case("rounded-none", "border-radius", "0")]
+ #[case("rounded-full", "border-radius", "calc(infinity * 1px)")]
#[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 +3436,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 +3455,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 +3489,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 +3547,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 +3689,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(
@@ -4492,427 +3780,47 @@ mod tests {
assert_eq!(parsed.value, expected_value);
}
- // `leading-*` also sets `--tw-leading`, which wins over a font size's line height
- #[rstest]
- #[case("leading-none", "1")]
- #[case("leading-tight", "1.25")]
- #[case("leading-snug", "1.375")]
- #[case("leading-normal", "1.5")]
- #[case("leading-relaxed", "1.625")]
- #[case("leading-loose", "2")]
- #[case("leading-3", "0.75rem")]
- #[case("leading-4", "1rem")]
- #[case("leading-5", "1.25rem")]
- #[case("leading-6", "1.5rem")]
- #[case("leading-7", "1.75rem")]
- #[case("leading-8", "2rem")]
- #[case("leading-9", "2.25rem")]
- #[case("leading-10", "2.5rem")]
- #[case("leading-[2]", "2")]
- #[case("leading-(--l)", "var(--l)")]
- fn test_parse_leading_utilities(#[case] class: &str, #[case] expected_value: &str) {
- assert_eq!(
- declarations(class),
- Some(owned(&[
- ("--tw-leading", expected_value),
- ("line-height", expected_value)
- ]))
- );
- }
-
- // Wave 4.4: List styles & alignment (lines 1947-1965)
- #[rstest]
- #[case("list-inside", "list-style-position", "inside")]
- #[case("list-outside", "list-style-position", "outside")]
- #[case("list-none", "list-style-type", "none")]
- #[case("list-disc", "list-style-type", "disc")]
- #[case("list-decimal", "list-style-type", "decimal")]
- #[case("align-baseline", "vertical-align", "baseline")]
- #[case("align-top", "vertical-align", "top")]
- #[case("align-middle", "vertical-align", "middle")]
- #[case("align-bottom", "vertical-align", "bottom")]
- #[case("content-none", "content", "none")]
- fn test_parse_list_align_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: Backgrounds, Borders, Effects, etc. ====================
-
- // Wave 5.1: Background utilities (lines 1981-2051)
- #[rstest]
- #[case("bg-fixed", "background-attachment", "fixed")]
- #[case("bg-local", "background-attachment", "local")]
- #[case("bg-scroll", "background-attachment", "scroll")]
- #[case("bg-clip-border", "background-clip", "border-box")]
- #[case("bg-clip-padding", "background-clip", "padding-box")]
- #[case("bg-clip-content", "background-clip", "content-box")]
- #[case("bg-clip-text", "background-clip", "text")]
- #[case("bg-origin-border", "background-origin", "border-box")]
- #[case("bg-origin-padding", "background-origin", "padding-box")]
- #[case("bg-origin-content", "background-origin", "content-box")]
- fn test_parse_background_attachment_clip_origin(
- #[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-bottom", "background-position", "bottom")]
- #[case("bg-center", "background-position", "center")]
- #[case("bg-left", "background-position", "left")]
- #[case("bg-left-bottom", "background-position", "left bottom")]
- #[case("bg-left-top", "background-position", "left top")]
- #[case("bg-right", "background-position", "right")]
- #[case("bg-right-bottom", "background-position", "right bottom")]
- #[case("bg-right-top", "background-position", "right top")]
- #[case("bg-top", "background-position", "top")]
- fn test_parse_background_position(
- #[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-repeat", "background-repeat", "repeat")]
- #[case("bg-no-repeat", "background-repeat", "no-repeat")]
- #[case("bg-repeat-x", "background-repeat", "repeat-x")]
- #[case("bg-repeat-y", "background-repeat", "repeat-y")]
- #[case("bg-repeat-round", "background-repeat", "round")]
- #[case("bg-repeat-space", "background-repeat", "space")]
- #[case("bg-auto", "background-size", "auto")]
- #[case("bg-cover", "background-size", "cover")]
- #[case("bg-contain", "background-size", "contain")]
- #[case("bg-none", "background-image", "none")]
- fn test_parse_background_repeat_size(
- #[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-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")])]
- #[case("rounded-r-lg", &[("border-top-right-radius", "0.5rem"), ("border-bottom-right-radius", "0.5rem")])]
- #[case("rounded-b-lg", &[("border-bottom-right-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])]
- #[case("rounded-l-lg", &[("border-top-left-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])]
- #[case("rounded-s-lg", &[("border-start-start-radius", "0.5rem"), ("border-end-start-radius", "0.5rem")])]
- #[case("rounded-e-lg", &[("border-start-end-radius", "0.5rem"), ("border-end-end-radius", "0.5rem")])]
- #[case("rounded-t", &[("border-top-left-radius", "0.25rem"), ("border-top-right-radius", "0.25rem")])]
- #[case("rounded-tl-lg", &[("border-top-left-radius", "0.5rem")])]
- #[case("rounded-tr-lg", &[("border-top-right-radius", "0.5rem")])]
- #[case("rounded-br-lg", &[("border-bottom-right-radius", "0.5rem")])]
- #[case("rounded-bl-lg", &[("border-bottom-left-radius", "0.5rem")])]
- #[case("rounded-ss-lg", &[("border-start-start-radius", "0.5rem")])]
- #[case("rounded-se-lg", &[("border-start-end-radius", "0.5rem")])]
- #[case("rounded-ee-lg", &[("border-end-end-radius", "0.5rem")])]
- #[case("rounded-es-lg", &[("border-end-start-radius", "0.5rem")])]
- #[case("rounded-t-[3px]", &[("border-top-left-radius", "3px"), ("border-top-right-radius", "3px")])]
- #[case("rounded-[3px]", &[("border-radius", "3px")])]
- fn test_parse_border_radius_corners(#[case] class: &str, #[case] expected: &[(&str, &str)]) {
- assert_eq!(declarations(class), Some(owned(expected)));
- }
-
- #[rstest]
- #[case("border-t-2", "border-top-width", "2px")]
- #[case("border-r-2", "border-right-width", "2px")]
- #[case("border-b-2", "border-bottom-width", "2px")]
- #[case("border-l-2", "border-left-width", "2px")]
- #[case("border-x-2", "border-inline-width", "2px")]
- #[case("border-y-2", "border-block-width", "2px")]
- fn test_parse_border_width_sides(
- #[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.4: Border styles, outline, ring, divide (lines 2211-2313)
- #[rstest]
- #[case("border-solid", "border-style", "solid")]
- #[case("border-dashed", "border-style", "dashed")]
- #[case("border-dotted", "border-style", "dotted")]
- #[case("border-double", "border-style", "double")]
- #[case("border-hidden", "border-style", "hidden")]
- #[case("border-none", "border-style", "none")]
- fn test_parse_border_styles(
- #[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("outline-none", "outline", "2px solid transparent")]
- #[case("outline", "outline-style", "solid")]
- #[case("outline-dashed", "outline-style", "dashed")]
- #[case("outline-dotted", "outline-style", "dotted")]
- #[case("outline-double", "outline-style", "double")]
- #[case("outline-0", "outline-width", "0px")]
- #[case("outline-1", "outline-width", "1px")]
- #[case("outline-2", "outline-width", "2px")]
- #[case("outline-4", "outline-width", "4px")]
- #[case("outline-8", "outline-width", "8px")]
- fn test_parse_outline_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("ring", "box-shadow", "0 0 0 3px var(--tw-ring-color)")]
- #[case("ring-0", "--tw-ring-offset-shadow", "0 0 #0000")]
- #[case("ring-1", "box-shadow", "0 0 0 1px var(--tw-ring-color)")]
- #[case("ring-2", "box-shadow", "0 0 0 2px var(--tw-ring-color)")]
- #[case("ring-inset", "--tw-ring-inset", "inset")]
- fn test_parse_ring_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("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")]
- #[case("bg-blend-screen", "background-blend-mode", "screen")]
- #[case("bg-blend-overlay", "background-blend-mode", "overlay")]
- fn test_parse_blend_mode_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.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);
- }
-
+ // `leading-*` also sets `--tw-leading`, which wins over a font size's line height
#[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);
+ #[case("leading-none", "1")]
+ #[case("leading-tight", "1.25")]
+ #[case("leading-snug", "1.375")]
+ #[case("leading-normal", "1.5")]
+ #[case("leading-relaxed", "1.625")]
+ #[case("leading-loose", "2")]
+ #[case("leading-3", "0.75rem")]
+ #[case("leading-4", "1rem")]
+ #[case("leading-5", "1.25rem")]
+ #[case("leading-6", "1.5rem")]
+ #[case("leading-7", "1.75rem")]
+ #[case("leading-8", "2rem")]
+ #[case("leading-9", "2.25rem")]
+ #[case("leading-10", "2.5rem")]
+ #[case("leading-[2]", "2")]
+ #[case("leading-(--l)", "var(--l)")]
+ fn test_parse_leading_utilities(#[case] class: &str, #[case] expected_value: &str) {
+ assert_eq!(
+ declarations(class),
+ Some(owned(&[
+ ("--tw-leading", expected_value),
+ ("line-height", expected_value)
+ ]))
+ );
}
+ // Wave 4.4: List styles & alignment (lines 1947-1965)
#[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("list-inside", "list-style-position", "inside")]
+ #[case("list-outside", "list-style-position", "outside")]
+ #[case("list-none", "list-style-type", "none")]
+ #[case("list-disc", "list-style-type", "disc")]
+ #[case("list-decimal", "list-style-type", "decimal")]
+ #[case("align-baseline", "vertical-align", "baseline")]
+ #[case("align-top", "vertical-align", "top")]
+ #[case("align-middle", "vertical-align", "middle")]
+ #[case("align-bottom", "vertical-align", "bottom")]
+ #[case("content-none", "content", "none")]
+ fn test_parse_list_align_utilities(
#[case] class: &str,
#[case] expected_prop: &str,
#[case] expected_value: &str,
@@ -4922,17 +3830,21 @@ mod tests {
assert_eq!(parsed.value, expected_value);
}
+ // ==================== WAVE 5: Backgrounds, Borders, Effects, etc. ====================
+
+ // Wave 5.1: Background utilities (lines 1981-2051)
#[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("bg-fixed", "background-attachment", "fixed")]
+ #[case("bg-local", "background-attachment", "local")]
+ #[case("bg-scroll", "background-attachment", "scroll")]
+ #[case("bg-clip-border", "background-clip", "border-box")]
+ #[case("bg-clip-padding", "background-clip", "padding-box")]
+ #[case("bg-clip-content", "background-clip", "content-box")]
+ #[case("bg-clip-text", "background-clip", "text")]
+ #[case("bg-origin-border", "background-origin", "border-box")]
+ #[case("bg-origin-padding", "background-origin", "padding-box")]
+ #[case("bg-origin-content", "background-origin", "content-box")]
+ fn test_parse_background_attachment_clip_origin(
#[case] class: &str,
#[case] expected_prop: &str,
#[case] expected_value: &str,
@@ -4943,15 +3855,16 @@ mod tests {
}
#[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("bg-bottom", "background-position", "bottom")]
+ #[case("bg-center", "background-position", "center")]
+ #[case("bg-left", "background-position", "left")]
+ #[case("bg-left-bottom", "background-position", "left bottom")]
+ #[case("bg-left-top", "background-position", "left top")]
+ #[case("bg-right", "background-position", "right")]
+ #[case("bg-right-bottom", "background-position", "right bottom")]
+ #[case("bg-right-top", "background-position", "right top")]
+ #[case("bg-top", "background-position", "top")]
+ fn test_parse_background_position(
#[case] class: &str,
#[case] expected_prop: &str,
#[case] expected_value: &str,
@@ -4962,21 +3875,17 @@ mod tests {
}
#[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("bg-repeat", "background-repeat", "repeat")]
+ #[case("bg-no-repeat", "background-repeat", "no-repeat")]
+ #[case("bg-repeat-x", "background-repeat", "repeat-x")]
+ #[case("bg-repeat-y", "background-repeat", "repeat-y")]
+ #[case("bg-repeat-round", "background-repeat", "round")]
+ #[case("bg-repeat-space", "background-repeat", "space")]
+ #[case("bg-auto", "background-size", "auto")]
+ #[case("bg-cover", "background-size", "cover")]
+ #[case("bg-contain", "background-size", "contain")]
+ #[case("bg-none", "background-image", "none")]
+ fn test_parse_background_repeat_size(
#[case] class: &str,
#[case] expected_prop: &str,
#[case] expected_value: &str,
@@ -4986,18 +3895,37 @@ mod tests {
assert_eq!(parsed.value, expected_value);
}
- // Wave 5.7: Transitions & animations (lines 2600-2618)
+ // Sides round both of their corners
+ #[rstest]
+ #[case("rounded-t-lg", &[("border-top-left-radius", "0.5rem"), ("border-top-right-radius", "0.5rem")])]
+ #[case("rounded-r-lg", &[("border-top-right-radius", "0.5rem"), ("border-bottom-right-radius", "0.5rem")])]
+ #[case("rounded-b-lg", &[("border-bottom-right-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])]
+ #[case("rounded-l-lg", &[("border-top-left-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])]
+ #[case("rounded-s-lg", &[("border-start-start-radius", "0.5rem"), ("border-end-start-radius", "0.5rem")])]
+ #[case("rounded-e-lg", &[("border-start-end-radius", "0.5rem"), ("border-end-end-radius", "0.5rem")])]
+ #[case("rounded-t", &[("border-top-left-radius", "0.25rem"), ("border-top-right-radius", "0.25rem")])]
+ #[case("rounded-tl-lg", &[("border-top-left-radius", "0.5rem")])]
+ #[case("rounded-tr-lg", &[("border-top-right-radius", "0.5rem")])]
+ #[case("rounded-br-lg", &[("border-bottom-right-radius", "0.5rem")])]
+ #[case("rounded-bl-lg", &[("border-bottom-left-radius", "0.5rem")])]
+ #[case("rounded-ss-lg", &[("border-start-start-radius", "0.5rem")])]
+ #[case("rounded-se-lg", &[("border-start-end-radius", "0.5rem")])]
+ #[case("rounded-ee-lg", &[("border-end-end-radius", "0.5rem")])]
+ #[case("rounded-es-lg", &[("border-end-start-radius", "0.5rem")])]
+ #[case("rounded-t-[3px]", &[("border-top-left-radius", "3px"), ("border-top-right-radius", "3px")])]
+ #[case("rounded-[3px]", &[("border-radius", "3px")])]
+ fn test_parse_border_radius_corners(#[case] class: &str, #[case] expected: &[(&str, &str)]) {
+ assert_eq!(declarations(class), Some(owned(expected)));
+ }
+
+ // Wave 5.4: Border styles, outline, ring, divide (lines 2211-2313)
+ // Wave 5.5: Effects (lines 2328-2350)
#[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("bg-blend-normal", "background-blend-mode", "normal")]
+ #[case("bg-blend-multiply", "background-blend-mode", "multiply")]
+ #[case("bg-blend-screen", "background-blend-mode", "screen")]
+ #[case("bg-blend-overlay", "background-blend-mode", "overlay")]
+ fn test_parse_blend_mode_utilities(
#[case] class: &str,
#[case] expected_prop: &str,
#[case] expected_value: &str,
@@ -5007,6 +3935,8 @@ mod tests {
assert_eq!(parsed.value, expected_value);
}
+ // Wave 5.6: Filters & backdrop filters (lines 2365-2555)
+ // Wave 5.7: Transitions & animations (lines 2600-2618)
#[rstest]
#[case("animate-none", "animation", "none")]
#[case("animate-spin", "animation", "spin 1s linear infinite")]
@@ -5182,10 +4112,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,
@@ -5459,14 +4389,14 @@ mod tests {
// Wave 6.11: Individual rounded variants (via BORDER_RADIUS_SCALE lookup)
#[rstest]
- #[case("rounded-none", "border-radius", "0px")]
- #[case("rounded-sm", "border-radius", "0.125rem")]
+ #[case("rounded-none", "border-radius", "0")]
+ #[case("rounded-sm", "border-radius", "0.25rem")]
#[case("rounded-md", "border-radius", "0.375rem")]
#[case("rounded-lg", "border-radius", "0.5rem")]
#[case("rounded-xl", "border-radius", "0.75rem")]
#[case("rounded-2xl", "border-radius", "1rem")]
#[case("rounded-3xl", "border-radius", "1.5rem")]
- #[case("rounded-full", "border-radius", "9999px")]
+ #[case("rounded-full", "border-radius", "calc(infinity * 1px)")]
fn test_parse_individual_rounded_variants(
#[case] class: &str,
#[case] expected_prop: &str,
@@ -5491,27 +4421,12 @@ mod tests {
assert_eq!(parsed.value, expected_value);
}
- #[rstest]
- #[case("border-0", "border-width", "0px")]
- #[case("border-2", "border-width", "2px")]
- #[case("border-4", "border-width", "4px")]
- #[case("border-8", "border-width", "8px")]
- fn test_parse_border_width_standalone(
- #[case] class: &str,
- #[case] expected_prop: &str,
- #[case] expected_value: &str,
- ) {
- let parsed = parse_single_class(class).expect("Should parse border width");
- assert_eq!(parsed.property, expected_prop);
- assert_eq!(parsed.value, expected_value);
- }
-
// Wave 6.13: Outline color (lines 2250-2251)
#[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,
@@ -5523,24 +4438,11 @@ mod tests {
}
// Wave 6.14: Ring utilities (lines 2273-2281)
- #[rstest]
- #[case("ring-4", "box-shadow", "0 0 0 4px var(--tw-ring-color)")]
- #[case("ring-8", "box-shadow", "0 0 0 8px var(--tw-ring-color)")]
- fn test_parse_ring_width_extended(
- #[case] class: &str,
- #[case] expected_prop: &str,
- #[case] expected_value: &str,
- ) {
- let parsed = parse_single_class(class).expect("Should parse ring width");
- assert_eq!(parsed.property, expected_prop);
- assert_eq!(parsed.value, expected_value);
- }
-
#[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 +4454,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 +4464,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");
@@ -5828,14 +4641,14 @@ mod tests {
// Wave 7.5: Individual rounded variants via BORDER_RADIUS_SCALE lookup
#[rstest]
#[case("rounded", "border-radius", "0.25rem")]
- #[case("rounded-none", "border-radius", "0px")]
- #[case("rounded-sm", "border-radius", "0.125rem")]
+ #[case("rounded-none", "border-radius", "0")]
+ #[case("rounded-sm", "border-radius", "0.25rem")]
#[case("rounded-md", "border-radius", "0.375rem")]
#[case("rounded-lg", "border-radius", "0.5rem")]
#[case("rounded-xl", "border-radius", "0.75rem")]
#[case("rounded-2xl", "border-radius", "1rem")]
#[case("rounded-3xl", "border-radius", "1.5rem")]
- #[case("rounded-full", "border-radius", "9999px")]
+ #[case("rounded-full", "border-radius", "calc(infinity * 1px)")]
fn test_rounded_variants_full_path(
#[case] class: &str,
#[case] expected_prop: &str,
@@ -5847,22 +4660,6 @@ mod tests {
}
// Wave 7.6: border-0/2/4/8 via BORDER_WIDTH_SCALE lookup
- #[rstest]
- #[case("border", "border-width", "1px")]
- #[case("border-0", "border-width", "0px")]
- #[case("border-2", "border-width", "2px")]
- #[case("border-4", "border-width", "4px")]
- #[case("border-8", "border-width", "8px")]
- fn test_border_width_standalone_full_path(
- #[case] class: &str,
- #[case] expected_prop: &str,
- #[case] expected_value: &str,
- ) {
- let parsed = parse_single_class(class).expect("Should parse border");
- assert_eq!(parsed.property, expected_prop);
- assert_eq!(parsed.value, expected_value);
- }
-
// Wave 7.7: divide- unknown value fallback (line 2313)
#[rstest]
#[case("divide-unknown")]
@@ -5873,22 +4670,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 +4709,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
// ============================================================================
@@ -5978,16 +4729,6 @@ mod tests {
}
// Wave 8.4: parse_tailwind_to_styles integration for border widths (via BORDER_WIDTH_SCALE)
- #[test]
- #[serial]
- fn test_parse_tailwind_to_styles_border_width_integration() {
- reset_class_map();
- reset_file_map();
-
- let styles = parse_tailwind_to_styles("border border-0 border-2 border-4 border-8");
- assert_eq!(styles.len(), 5);
- }
-
// ==================== Variants (Tailwind v4) ====================
#[rstest]
@@ -6211,10 +4952,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)")]
@@ -6394,19 +5131,13 @@ mod tests {
#[case("bg-[length:200px_100px]", "background-size", "200px 100px")]
#[case("bg-[bg-size:cover]", "background-size", "cover")]
#[case("bg-[position:top]", "background-position", "top")]
- #[case("border-[3px]", "border-width", "3px")]
- #[case("border-[2px_4px]", "border-width", "2px 4px")]
#[case("border-[thin]", "border-width", "thin")]
- #[case("border-[0]", "border-width", "0")]
#[case("border-[length:var(--x)]", "border-width", "var(--x)")]
#[case("border-[line-width:var(--x)]", "border-width", "var(--x)")]
#[case("border-[#fff]", "border-color", "#fff")]
#[case("border-[var(--x)]", "border-color", "var(--x)")]
- #[case("border-t-[3px]", "border-top-width", "3px")]
#[case("border-t-[red]", "border-top-color", "red")]
- #[case("border-x-[1px]", "border-inline-width", "1px")]
#[case("border-s-[red]", "border-inline-start-color", "red")]
- #[case("outline-[3px]", "outline-width", "3px")]
#[case("outline-[red]", "outline-color", "red")]
#[case("stroke-[3px]", "stroke-width", "3px")]
#[case("stroke-[2]", "stroke-width", "2")]
@@ -6417,8 +5148,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(
@@ -6496,6 +5225,21 @@ mod tests {
assert!(!is_balanced("("));
}
+ #[rstest]
+ #[case("inset-y-4", "inset-block", "1rem")]
+ #[case("inset-x-2", "inset-inline", "0.5rem")]
+ #[case("basis-4", "flex-basis", "1rem")]
+ #[case("basis-13", "flex-basis", "3.25rem")]
+ #[case("top-13", "top", "3.25rem")]
+ fn test_parse_spacing_scale_steps(
+ #[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);
+ }
#[test]
fn test_styles_and_properties() {
let class = parse_class("md:hover:p-4").unwrap();
@@ -6508,11 +5252,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..78d266b05
--- /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, arbitrary_or_variable, decl, is_positive_integer, spacing_scale,
+};
+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 `-`
+pub(crate) fn spacing(argument: &str, negative: bool) -> Option {
+ if let Some(value) = spacing_scale(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..fa48bf6b0
--- /dev/null
+++ b/libs/extractor/src/tailwind_color.rs
@@ -0,0 +1,559 @@
+//! 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::tailwind_definitions::{namespace_is_reset, theme_value};
+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})")));
+ }
+ if let Some(value) = theme_value("color", name) {
+ return Some(palette_reference(&value).unwrap_or(Cow::Owned(value)));
+ }
+ let universal = matches!(name, "inherit" | "current" | "transparent");
+ if namespace_is_reset("color") && !universal {
+ return None;
+ }
+ TAILWIND_COLORS.get(name).map(|&color| Cow::Borrowed(color))
+}
+
+/// The palette color a theme color that is ar(--color-) stands for
+fn palette_reference(value: &str) -> Option> {
+ let name = value.strip_prefix("var(--color-")?.strip_suffix(')')?;
+ 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..8b55e4290
--- /dev/null
+++ b/libs/extractor/src/tailwind_effects.rs
@@ -0,0 +1,1428 @@
+//! 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};
+use crate::tailwind_theme::themed;
+
+/// 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 => themed("blur", value, blur_size).map(Cow::into_owned),
+ _ => 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 if function.root == "blur" => themed("blur", "", |_| Some("8px"))?.into_owned(),
+ 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)",
+ "inner" => "inset 0 2px 4px 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,
+ })
+}
+
+/// Whether `token` of a shadow layer is its color
+fn is_shadow_color(token: &str) -> bool {
+ const FUNCTIONS: [&str; 10] = [
+ "rgb(",
+ "rgba(",
+ "hsl(",
+ "hsla(",
+ "hwb(",
+ "lab(",
+ "lch(",
+ "oklab(",
+ "oklch(",
+ "color-mix(",
+ ];
+ token.starts_with('#')
+ || FUNCTIONS.iter().any(|function| token.starts_with(function))
+ || (token.bytes().all(|byte| byte.is_ascii_alphabetic()) && token != "inset")
+}
+
+/// `shadow` with the color of each of its layers taken from `--tw-shadow-color`
+/// when a `shadow-` utility sets one
+fn recolored(shadow: &str, variable: &str) -> String {
+ split_top_level(shadow, ',')
+ .into_iter()
+ .map(|layer| {
+ split_top_level(layer.trim(), ' ')
+ .into_iter()
+ .map(|token| {
+ if is_shadow_color(token) {
+ format!("var({variable}, {token})")
+ } else {
+ token.to_string()
+ }
+ })
+ .collect::>()
+ .join(" ")
+ })
+ .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" {
+ ""
+ } 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) = themed("shadow", argument, shadow_size) {
+ return Some(composed(recolored(&shadow, "--tw-shadow-color")));
+ }
+ 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 drop shadows of the default theme
+fn drop_shadow_size(name: &str) -> Option<&'static str> {
+ Some(match name {
+ "xs" => "0 1px 1px rgb(0 0 0 / 0.05)",
+ "sm" => "0 1px 2px rgb(0 0 0 / 0.15)",
+ "md" => "0 3px 3px rgb(0 0 0 / 0.12)",
+ "lg" => "0 4px 4px rgb(0 0 0 / 0.15)",
+ "xl" => "0 9px 7px rgb(0 0 0 / 0.1)",
+ "2xl" => "0 25px 25px rgb(0 0 0 / 0.15)",
+ "" => "0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06)",
+ _ => return None,
+ })
+}
+
+/// `shadow`, a list of shadows, as the `drop-shadow()` functions of `filter`
+fn drop_shadows(shadow: &str) -> String {
+ split_top_level(shadow, ',')
+ .into_iter()
+ .map(|layer| format!("drop-shadow({})", layer.trim()))
+ .collect::>()
+ .join(" ")
+}
+
+/// The declarations of a `drop-shadow` utility: composed into `filter` with
+/// the other filters, its color apart from its size
+fn drop_shadow_utility(name: &str) -> Option> {
+ let argument = if name == "drop-shadow" {
+ ""
+ } else {
+ name.strip_prefix("drop-shadow-")
+ .filter(|argument| !argument.is_empty())?
+ };
+ let sized = |size: String, shadow: String| {
+ vec![
+ decl(
+ "--tw-drop-shadow-size",
+ drop_shadows(&recolored(&size, "--tw-drop-shadow-color")),
+ ),
+ decl("--tw-drop-shadow", shadow),
+ decl("filter", FILTER),
+ ]
+ };
+ if argument == "none" {
+ return Some(vec![decl("--tw-drop-shadow", " "), decl("filter", FILTER)]);
+ }
+ if argument == "inherit" {
+ return Some(vec![
+ decl("--tw-drop-shadow-color", "inherit"),
+ decl("--tw-drop-shadow", "var(--tw-drop-shadow-size)"),
+ ]);
+ }
+ if let Some(size) = themed("drop-shadow", argument, drop_shadow_size) {
+ return Some(sized(size.to_string(), drop_shadows(&size)));
+ }
+ if let Some(value) = arbitrary_or_variable(argument)
+ .filter(|_| argument.starts_with('['))
+ .filter(|value| {
+ value_type(None, value) != Some(ValueType::Color)
+ || split_top_level(value, ' ').len() > 1
+ })
+ {
+ return Some(sized(value, String::from("var(--tw-drop-shadow-size)")));
+ }
+ let color = shadow_color(argument)?;
+ Some(vec![
+ decl(
+ "--tw-drop-shadow-color",
+ format!("color-mix(in oklab, {color} var(--tw-drop-shadow-alpha), transparent)"),
+ ),
+ decl("--tw-drop-shadow", "var(--tw-drop-shadow-size)"),
+ ])
+}
+
+/// The color of a ring when no `ring-` sets one
+const RING_COLOR: &str = "currentcolor";
+
+/// The declarations of `ring`, `inset-ring` and `ring-offset` utilities: a
+/// width, which draws the ring, or a color, which the ring reads
+fn ring_utility(name: &str) -> Option> {
+ if name == "ring-inset" {
+ return Some(vec![decl("--tw-ring-inset", "inset")]);
+ }
+ let (root, argument) = if let Some(argument) = name.strip_prefix("ring-offset") {
+ ("ring-offset", argument)
+ } else if let Some(argument) = name.strip_prefix("inset-ring") {
+ ("inset-ring", argument)
+ } else {
+ ("ring", name.strip_prefix("ring")?)
+ };
+ let argument = if argument.is_empty() {
+ None
+ } else {
+ Some(
+ argument
+ .strip_prefix('-')
+ .filter(|argument| !argument.is_empty())?,
+ )
+ };
+ let width = argument.and_then(|argument| {
+ if is_positive_integer(argument) {
+ Some(format!("{argument}px"))
+ } else {
+ arbitrary_or_variable(argument)
+ .filter(|_| argument.starts_with('['))
+ .filter(|value| value_type(None, value) == Some(ValueType::Length))
+ }
+ });
+ let default_width = argument.is_none().then(|| String::from("1px"));
+ match (width.or(default_width), root) {
+ (Some(width), "ring") => Some(vec![
+ decl(
+ "--tw-ring-shadow",
+ format!(
+ "var(--tw-ring-inset, ) 0 0 0 calc({width} + var(--tw-ring-offset-width)) var(--tw-ring-color, {RING_COLOR})"
+ ),
+ ),
+ decl("box-shadow", BOX_SHADOW),
+ ]),
+ (Some(width), "inset-ring") => Some(vec![
+ decl(
+ "--tw-inset-ring-shadow",
+ format!("inset 0 0 0 {width} var(--tw-inset-ring-color, currentcolor)"),
+ ),
+ decl("box-shadow", BOX_SHADOW),
+ ]),
+ (Some(width), _) if argument.is_some() => Some(vec![
+ decl("--tw-ring-offset-width", width),
+ decl(
+ "--tw-ring-offset-shadow",
+ "var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)",
+ ),
+ ]),
+ (Some(_), _) => None,
+ (None, _) => {
+ let color = shadow_color(argument?)?;
+ let property = match root {
+ "ring" => "--tw-ring-color",
+ "inset-ring" => "--tw-inset-ring-color",
+ _ => "--tw-ring-offset-color",
+ };
+ Some(vec![decl(property, 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(|| ring_utility(name))
+ .or_else(|| drop_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
+ .iter()
+ .any(|rule| rule == "@property --tw-blur{syntax:\"*\";inherits:false}")
+ );
+ assert!(
+ rules
+ .iter()
+ .any(|rule| rule == "@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")]
+ 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", "--tw-shadow-color"), "0 0");
+ assert_eq!(recolored("inset 0 1px", "--tw-shadow-color"), "inset 0 1px");
+ assert_eq!(
+ recolored("0 0 #0000", "--tw-shadow-color"),
+ "0 0 var(--tw-shadow-color, #0000)"
+ );
+ assert_eq!(
+ recolored("1px 1px red", "--tw-shadow-color"),
+ "1px 1px var(--tw-shadow-color, red)"
+ );
+ assert_eq!(
+ recolored("0 1px rgb(0 0 0 / 0.05), 0 0 #0000", "--tw-shadow-color"),
+ "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05)), 0 0 var(--tw-shadow-color, #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()
+ }
+
+ #[rstest]
+ #[case("ring", "1px")]
+ #[case("ring-0", "0px")]
+ #[case("ring-2", "2px")]
+ #[case("ring-8", "8px")]
+ #[case("ring-[3px]", "3px")]
+ fn ring_widths_draw_the_ring_composed_with_the_shadows(
+ #[case] class: &str,
+ #[case] width: &str,
+ ) {
+ expect(
+ class,
+ &[
+ (
+ "--tw-ring-shadow",
+ &format!(
+ "var(--tw-ring-inset, ) 0 0 0 calc({width} + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor)"
+ ),
+ ),
+ ("box-shadow", BOX_SHADOW),
+ ],
+ );
+ }
+
+ #[test]
+ fn ring_colors_inset_rings_and_offsets() {
+ expect("ring-inset", &[("--tw-ring-inset", "inset")]);
+ expect(
+ "ring-red-500",
+ &[("--tw-ring-color", "oklch(63.7% 0.237 25.331)")],
+ );
+ expect(
+ "ring-red-500/50",
+ &[(
+ "--tw-ring-color",
+ "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)",
+ )],
+ );
+ expect("ring-[#f00]", &[("--tw-ring-color", "#f00")]);
+ expect(
+ "inset-ring",
+ &[
+ (
+ "--tw-inset-ring-shadow",
+ "inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor)",
+ ),
+ ("box-shadow", BOX_SHADOW),
+ ],
+ );
+ expect(
+ "inset-ring-4",
+ &[
+ (
+ "--tw-inset-ring-shadow",
+ "inset 0 0 0 4px var(--tw-inset-ring-color, currentcolor)",
+ ),
+ ("box-shadow", BOX_SHADOW),
+ ],
+ );
+ expect("inset-ring-white", &[("--tw-inset-ring-color", "#fff")]);
+ expect(
+ "ring-offset-2",
+ &[
+ ("--tw-ring-offset-width", "2px"),
+ (
+ "--tw-ring-offset-shadow",
+ "var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)",
+ ),
+ ],
+ );
+ expect(
+ "ring-offset-[3px]",
+ &[
+ ("--tw-ring-offset-width", "3px"),
+ (
+ "--tw-ring-offset-shadow",
+ "var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)",
+ ),
+ ],
+ );
+ expect("ring-offset-black", &[("--tw-ring-offset-color", "#000")]);
+ }
+
+ #[rstest]
+ #[case("ring-")]
+ #[case("ringo")]
+ #[case("ring-1.5")]
+ #[case("ring-02")]
+ #[case("ring-nothing")]
+ #[case("ring-[red]/50/1")]
+ #[case("ring-offset")]
+ #[case("ring-offset-")]
+ #[case("ring-offsetx")]
+ #[case("ring-offset-nothing")]
+ #[case("inset-ring-nothing")]
+ #[case("-ring-2")]
+ fn unknown_rings_stay_as_written(#[case] class: &str) {
+ assert_eq!(declarations_of(class), None, "{class}");
+ }
+
+ #[rstest]
+ #[case("drop-shadow-xs", "0 1px 1px", "rgb(0 0 0 / 0.05)")]
+ #[case("drop-shadow-sm", "0 1px 2px", "rgb(0 0 0 / 0.15)")]
+ #[case("drop-shadow-md", "0 3px 3px", "rgb(0 0 0 / 0.12)")]
+ #[case("drop-shadow-lg", "0 4px 4px", "rgb(0 0 0 / 0.15)")]
+ #[case("drop-shadow-xl", "0 9px 7px", "rgb(0 0 0 / 0.1)")]
+ #[case("drop-shadow-2xl", "0 25px 25px", "rgb(0 0 0 / 0.15)")]
+ fn drop_shadow_sizes_compose_into_filter(
+ #[case] class: &str,
+ #[case] offsets: &str,
+ #[case] color: &str,
+ ) {
+ expect(
+ class,
+ &[
+ (
+ "--tw-drop-shadow-size",
+ &format!("drop-shadow({offsets} var(--tw-drop-shadow-color, {color}))"),
+ ),
+ (
+ "--tw-drop-shadow",
+ &format!("drop-shadow({offsets} {color})"),
+ ),
+ ("filter", FILTER),
+ ],
+ );
+ }
+ #[test]
+ fn drop_shadow_without_a_size_none_colors_and_arbitrary_values() {
+ expect(
+ "drop-shadow",
+ &[
+ (
+ "--tw-drop-shadow-size",
+ "drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)))",
+ ),
+ (
+ "--tw-drop-shadow",
+ "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))",
+ ),
+ ("filter", FILTER),
+ ],
+ );
+ expect(
+ "drop-shadow-none",
+ &[("--tw-drop-shadow", " "), ("filter", FILTER)],
+ );
+ expect(
+ "drop-shadow-inherit",
+ &[
+ ("--tw-drop-shadow-color", "inherit"),
+ ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"),
+ ],
+ );
+ expect(
+ "drop-shadow-red-500",
+ &[
+ (
+ "--tw-drop-shadow-color",
+ "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--tw-drop-shadow-alpha), transparent)",
+ ),
+ ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"),
+ ],
+ );
+ expect(
+ "drop-shadow-[#0003]",
+ &[
+ (
+ "--tw-drop-shadow-color",
+ "color-mix(in oklab, #0003 var(--tw-drop-shadow-alpha), transparent)",
+ ),
+ ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"),
+ ],
+ );
+ expect(
+ "drop-shadow-[0_0_4px_#000]",
+ &[
+ (
+ "--tw-drop-shadow-size",
+ "drop-shadow(0 0 4px var(--tw-drop-shadow-color, #000))",
+ ),
+ ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"),
+ ("filter", FILTER),
+ ],
+ );
+ for class in [
+ "drop-shadow-",
+ "drop-shadow-huge",
+ "drop-shadow-md/50",
+ "drop-shadow-[]",
+ ] {
+ assert_eq!(declarations_of(class), None, "{class}");
+ }
+ }
+}
diff --git a/libs/extractor/src/tailwind_misc.rs b/libs/extractor/src/tailwind_misc.rs
new file mode 100644
index 000000000..2b05db026
--- /dev/null
+++ b/libs/extractor/src/tailwind_misc.rs
@@ -0,0 +1,580 @@
+//! Utilities of Tailwind CSS v4 that are a fixed set of declarations or one
+//! value on a few properties: text decoration and clamping, border widths and
+//! styles, spacing on logical sides, table and transform keywords
+
+use crate::tailwind::{
+ Declaration, ValueType, arbitrary_or_variable, decl, is_positive_integer, spacing_scale,
+ value_type,
+};
+use crate::tailwind_children::spacing;
+use crate::tailwind_color::color_value;
+
+/// The utilities that are one fixed list of declarations
+fn fixed(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
+ Some(match name {
+ "field-sizing-content" => &[("field-sizing", "content")],
+ "field-sizing-fixed" => &[("field-sizing", "fixed")],
+ "table-auto" => &[("table-layout", "auto")],
+ "table-fixed" => &[("table-layout", "fixed")],
+ "caption-top" => &[("caption-side", "top")],
+ "caption-bottom" => &[("caption-side", "bottom")],
+ "transform-flat" => &[("transform-style", "flat")],
+ "transform-3d" => &[("transform-style", "preserve-3d")],
+ "transform-none" => &[("transform", "none")],
+ "backface-visible" => &[("backface-visibility", "visible")],
+ "backface-hidden" => &[("backface-visibility", "hidden")],
+ "forced-color-adjust-none" => &[("forced-color-adjust", "none")],
+ "forced-color-adjust-auto" => &[("forced-color-adjust", "auto")],
+ "antialiased" => &[
+ ("-webkit-font-smoothing", "antialiased"),
+ ("-moz-osx-font-smoothing", "grayscale"),
+ ],
+ "subpixel-antialiased" => &[
+ ("-webkit-font-smoothing", "auto"),
+ ("-moz-osx-font-smoothing", "auto"),
+ ],
+ "scheme-normal" => &[("color-scheme", "normal")],
+ "scheme-dark" => &[("color-scheme", "dark")],
+ "scheme-light" => &[("color-scheme", "light")],
+ "scheme-light-dark" => &[("color-scheme", "light dark")],
+ "scheme-only-dark" => &[("color-scheme", "only dark")],
+ "scheme-only-light" => &[("color-scheme", "only light")],
+ "wrap-anywhere" => &[("overflow-wrap", "anywhere")],
+ "wrap-break-word" => &[("overflow-wrap", "break-word")],
+ "wrap-normal" => &[("overflow-wrap", "normal")],
+ "decoration-solid" => &[("text-decoration-style", "solid")],
+ "decoration-double" => &[("text-decoration-style", "double")],
+ "decoration-dotted" => &[("text-decoration-style", "dotted")],
+ "decoration-dashed" => &[("text-decoration-style", "dashed")],
+ "decoration-wavy" => &[("text-decoration-style", "wavy")],
+ "decoration-auto" => &[("text-decoration-thickness", "auto")],
+ "decoration-from-font" => &[("text-decoration-thickness", "from-font")],
+ "underline-offset-auto" => &[("text-underline-offset", "auto")],
+ "list-image-none" => &[("list-style-image", "none")],
+ "line-clamp-none" => &[
+ ("overflow", "visible"),
+ ("display", "block"),
+ ("-webkit-box-orient", "horizontal"),
+ ("-webkit-line-clamp", "unset"),
+ ],
+ "outline-solid" => &[("--tw-outline-style", "solid"), ("outline-style", "solid")],
+ "outline-dashed" => &[
+ ("--tw-outline-style", "dashed"),
+ ("outline-style", "dashed"),
+ ],
+ "outline-dotted" => &[
+ ("--tw-outline-style", "dotted"),
+ ("outline-style", "dotted"),
+ ],
+ "outline-double" => &[
+ ("--tw-outline-style", "double"),
+ ("outline-style", "double"),
+ ],
+ "outline-none" => &[("--tw-outline-style", "none"), ("outline-style", "none")],
+ "perspective-none" => &[("perspective", "none")],
+ "perspective-dramatic" => &[("perspective", "100px")],
+ "perspective-near" => &[("perspective", "300px")],
+ "perspective-normal" => &[("perspective", "500px")],
+ "perspective-midrange" => &[("perspective", "800px")],
+ "perspective-distant" => &[("perspective", "1200px")],
+ "font-stretch-normal" => &[("font-stretch", "normal")],
+ "font-stretch-ultra-condensed" => &[("font-stretch", "ultra-condensed")],
+ "font-stretch-extra-condensed" => &[("font-stretch", "extra-condensed")],
+ "font-stretch-condensed" => &[("font-stretch", "condensed")],
+ "font-stretch-semi-condensed" => &[("font-stretch", "semi-condensed")],
+ "font-stretch-semi-expanded" => &[("font-stretch", "semi-expanded")],
+ "font-stretch-expanded" => &[("font-stretch", "expanded")],
+ "font-stretch-extra-expanded" => &[("font-stretch", "extra-expanded")],
+ "font-stretch-ultra-expanded" => &[("font-stretch", "ultra-expanded")],
+ _ => return None,
+ })
+}
+
+/// The border styles `border-dashed` and the like set, which `border-`
+/// draws in
+const BORDER_STYLES: [&str; 6] = ["solid", "dashed", "dotted", "double", "hidden", "none"];
+
+/// The sides of `border--`: the property each side's style and
+/// width are in
+fn border_side(side: &str) -> Option<(&'static str, &'static str)> {
+ Some(match side {
+ "" => ("border-style", "border-width"),
+ "x" => ("border-inline-style", "border-inline-width"),
+ "y" => ("border-block-style", "border-block-width"),
+ "s" => ("border-inline-start-style", "border-inline-start-width"),
+ "e" => ("border-inline-end-style", "border-inline-end-width"),
+ "t" => ("border-top-style", "border-top-width"),
+ "r" => ("border-right-style", "border-right-width"),
+ "b" => ("border-bottom-style", "border-bottom-width"),
+ "l" => ("border-left-style", "border-left-width"),
+ _ => return None,
+ })
+}
+
+/// A number of pixels written as a bare number, or an arbitrary value
+fn pixels_or_value(argument: &str) -> Option {
+ if is_positive_integer(argument) {
+ return Some(format!("{argument}px"));
+ }
+ arbitrary_or_variable(argument)
+}
+
+/// A width in pixels written as a bare number, or as an arbitrary length: what
+/// `border-[3px]` is, while `border-[red]` is a color
+fn pixels(argument: &str) -> Option {
+ if is_positive_integer(argument) {
+ return Some(format!("{argument}px"));
+ }
+ arbitrary_or_variable(argument)
+ .filter(|_| argument.starts_with('['))
+ .filter(|value| value_type(None, value) == Some(ValueType::Length))
+}
+
+/// `border`, `border-2`, `border-x-4` and `border-dashed`: a width drawn in the
+/// style `--tw-border-style` holds
+fn border_utility(name: &str) -> Option> {
+ let rest = name.strip_prefix("border")?;
+ let rest = if rest.is_empty() {
+ rest
+ } else {
+ rest.strip_prefix('-')?
+ };
+ if let Some(style) = Some(rest).filter(|style| BORDER_STYLES.contains(style)) {
+ return Some(vec![
+ decl("--tw-border-style", style.to_string()),
+ decl("border-style", style.to_string()),
+ ]);
+ }
+ let (side, argument) = match rest.split_once('-') {
+ Some((side, argument)) if border_side(side).is_some() => (side, Some(argument)),
+ None if border_side(rest).is_some() => (rest, None),
+ _ => ("", Some(rest)),
+ };
+ let (style, property) = border_side(side)?;
+ let width = argument.map_or_else(|| Some(String::from("1px")), pixels)?;
+ Some(vec![
+ decl(style, "var(--tw-border-style)"),
+ decl(property, width),
+ ])
+}
+
+/// `outline` and `outline-2`: a width drawn in the style `--tw-outline-style`
+/// holds
+fn outline_width(name: &str) -> Option> {
+ let width = match name.strip_prefix("outline")? {
+ "" => String::from("1px"),
+ argument => pixels(argument.strip_prefix('-')?)?,
+ };
+ Some(vec![
+ decl("outline-style", "var(--tw-outline-style)"),
+ decl("outline-width", width),
+ ])
+}
+
+/// `border-spacing-*`, which sets both axes unless one is named
+fn border_spacing(name: &str) -> Option> {
+ let rest = name.strip_prefix("border-spacing-")?;
+ let (axes, argument): (&[&str], &str) = match rest.split_once('-') {
+ Some(("x", argument)) => (&["x"], argument),
+ Some(("y", argument)) => (&["y"], argument),
+ _ => (&["x", "y"], rest),
+ };
+ let value = spacing(argument, false)?;
+ let mut declarations: Vec = axes
+ .iter()
+ .map(|axis| decl(format!("--tw-border-spacing-{axis}"), value.clone()))
+ .collect();
+ declarations.push(decl(
+ "border-spacing",
+ "var(--tw-border-spacing-x) var(--tw-border-spacing-y)",
+ ));
+ Some(declarations)
+}
+
+/// The logical sides of `start-*` and `end-*`, and the margins and paddings of
+/// scrolling `scroll-m*` and `scroll-p*`
+fn spaced_properties(root: &str) -> Option<&'static [&'static str]> {
+ Some(match root {
+ "start" => &["inset-inline-start"],
+ "end" => &["inset-inline-end"],
+ "scroll-m" => &["scroll-margin"],
+ "scroll-mx" => &["scroll-margin-inline"],
+ "scroll-my" => &["scroll-margin-block"],
+ "scroll-ms" => &["scroll-margin-inline-start"],
+ "scroll-me" => &["scroll-margin-inline-end"],
+ "scroll-mt" => &["scroll-margin-top"],
+ "scroll-mr" => &["scroll-margin-right"],
+ "scroll-mb" => &["scroll-margin-bottom"],
+ "scroll-ml" => &["scroll-margin-left"],
+ "scroll-p" => &["scroll-padding"],
+ "scroll-px" => &["scroll-padding-inline"],
+ "scroll-py" => &["scroll-padding-block"],
+ "scroll-ps" => &["scroll-padding-inline-start"],
+ "scroll-pe" => &["scroll-padding-inline-end"],
+ "scroll-pt" => &["scroll-padding-top"],
+ "scroll-pr" => &["scroll-padding-right"],
+ "scroll-pb" => &["scroll-padding-bottom"],
+ "scroll-pl" => &["scroll-padding-left"],
+ "indent" => &["text-indent"],
+ _ => return None,
+ })
+}
+
+/// A number of pixels, negated
+fn negated_pixels(argument: &str) -> Option {
+ Some(format!("calc({} * -1)", pixels_or_value(argument)?))
+}
+
+/// The declarations of the utilities of this module, `None` for any other
+pub fn misc_utility(name: &str, negative: bool) -> Option> {
+ if negative {
+ return negated(name);
+ }
+ if let Some(declarations) = fixed(name) {
+ return Some(
+ declarations
+ .iter()
+ .map(|&(property, value)| decl(property, value))
+ .collect(),
+ );
+ }
+ if let Some(declarations) = border_utility(name)
+ .or_else(|| border_spacing(name))
+ .or_else(|| outline_width(name))
+ {
+ return Some(declarations);
+ }
+ plain(name)
+}
+
+/// The properties of a spaced utility such as `scroll-mt-2` and its argument
+fn split_spaced(name: &str) -> Option<(&'static [&'static str], &str)> {
+ name.match_indices('-').find_map(|(index, _)| {
+ spaced_properties(&name[..index]).map(|properties| (properties, &name[index + 1..]))
+ })
+}
+
+/// The utilities a leading `-` negates
+fn negated(name: &str) -> Option> {
+ if let Some(argument) = name.strip_prefix("underline-offset-") {
+ return Some(vec![decl(
+ "text-underline-offset",
+ negated_pixels(argument)?,
+ )]);
+ }
+ if let Some(argument) = name.strip_prefix("outline-offset-") {
+ return Some(vec![decl("outline-offset", negated_pixels(argument)?)]);
+ }
+ let (properties, argument) = split_spaced(name)?;
+ let value = spacing(argument, true)?;
+ Some(
+ properties
+ .iter()
+ .map(|&property| decl(property, value.clone()))
+ .collect(),
+ )
+}
+/// The functional utilities: `line-clamp-3`, `underline-offset-4`,
+/// `decoration-2`, `decoration-`, `outline-offset-2`, `font-stretch-75%`
+/// and the spaced ones
+fn plain(name: &str) -> Option> {
+ if let Some(lines) = name.strip_prefix("line-clamp-") {
+ let lines = if is_positive_integer(lines) {
+ lines.to_string()
+ } else {
+ arbitrary_or_variable(lines)?
+ };
+ return Some(vec![
+ decl("overflow", "hidden"),
+ decl("display", "-webkit-box"),
+ decl("-webkit-box-orient", "vertical"),
+ decl("-webkit-line-clamp", lines),
+ ]);
+ }
+ if let Some(argument) = name.strip_prefix("underline-offset-") {
+ return Some(vec![decl(
+ "text-underline-offset",
+ pixels_or_value(argument)?,
+ )]);
+ }
+ if let Some(argument) = name.strip_prefix("outline-offset-") {
+ return Some(vec![decl("outline-offset", pixels_or_value(argument)?)]);
+ }
+ if let Some(argument) = name.strip_prefix("decoration-") {
+ if let Some(width) = pixels(argument) {
+ return Some(vec![decl("text-decoration-thickness", width)]);
+ }
+ return Some(vec![decl("text-decoration-color", color_value(argument)?)]);
+ }
+ if let Some(percent) = name.strip_prefix("font-stretch-") {
+ let number = percent.strip_suffix('%')?;
+ let valid = is_positive_integer(number)
+ && number.parse::().is_ok_and(|n| (50..=200).contains(&n));
+ return valid.then(|| vec![decl("font-stretch", percent.to_string())]);
+ }
+ let (properties, argument) = split_spaced(name)?;
+ let value = spacing_scale(argument)
+ .map(String::from)
+ .or_else(|| arbitrary_or_variable(argument))?;
+ Some(
+ properties
+ .iter()
+ .map(|&property| decl(property, value.clone()))
+ .collect(),
+ )
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+mod tests {
+ use rstest::rstest;
+
+ use crate::tailwind::declarations_of;
+
+ 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("field-sizing-content", &[("field-sizing", "content")])]
+ #[case("table-fixed", &[("table-layout", "fixed")])]
+ #[case("caption-bottom", &[("caption-side", "bottom")])]
+ #[case("transform-3d", &[("transform-style", "preserve-3d")])]
+ #[case("transform-none", &[("transform", "none")])]
+ #[case("backface-hidden", &[("backface-visibility", "hidden")])]
+ #[case("forced-color-adjust-none", &[("forced-color-adjust", "none")])]
+ #[case("antialiased", &[("-webkit-font-smoothing", "antialiased"), ("-moz-osx-font-smoothing", "grayscale")])]
+ #[case("subpixel-antialiased", &[("-webkit-font-smoothing", "auto"), ("-moz-osx-font-smoothing", "auto")])]
+ #[case("scheme-light-dark", &[("color-scheme", "light dark")])]
+ #[case("wrap-anywhere", &[("overflow-wrap", "anywhere")])]
+ #[case("decoration-wavy", &[("text-decoration-style", "wavy")])]
+ #[case("decoration-from-font", &[("text-decoration-thickness", "from-font")])]
+ #[case("underline-offset-auto", &[("text-underline-offset", "auto")])]
+ #[case("list-image-none", &[("list-style-image", "none")])]
+ #[case("perspective-near", &[("perspective", "300px")])]
+ #[case("perspective-none", &[("perspective", "none")])]
+ #[case("font-stretch-ultra-condensed", &[("font-stretch", "ultra-condensed")])]
+ #[case("font-stretch-75%", &[("font-stretch", "75%")])]
+ #[case("outline-solid", &[("--tw-outline-style", "solid"), ("outline-style", "solid")])]
+ #[case("outline-dashed", &[("--tw-outline-style", "dashed"), ("outline-style", "dashed")])]
+ #[case("outline-dotted", &[("--tw-outline-style", "dotted"), ("outline-style", "dotted")])]
+ #[case("outline-double", &[("--tw-outline-style", "double"), ("outline-style", "double")])]
+ #[case("outline-none", &[("--tw-outline-style", "none"), ("outline-style", "none")])]
+ #[case("line-clamp-none", &[("overflow", "visible"), ("display", "block"), ("-webkit-box-orient", "horizontal"), ("-webkit-line-clamp", "unset")])]
+ fn fixed_utilities(#[case] class: &str, #[case] expected: &[(&str, &str)]) {
+ expect(class, expected);
+ }
+
+ #[rstest]
+ #[case("line-clamp-3", "3")]
+ #[case("line-clamp-(--lines)", "var(--lines)")]
+ #[case("line-clamp-[calc(1+1)]", "calc(1 + 1)")]
+ fn line_clamp_clamps_to_a_number_of_lines(#[case] class: &str, #[case] lines: &str) {
+ expect(
+ class,
+ &[
+ ("overflow", "hidden"),
+ ("display", "-webkit-box"),
+ ("-webkit-box-orient", "vertical"),
+ ("-webkit-line-clamp", lines),
+ ],
+ );
+ }
+
+ #[test]
+ fn text_decoration_offsets_and_outlines() {
+ expect("underline-offset-4", &[("text-underline-offset", "4px")]);
+ expect(
+ "-underline-offset-4",
+ &[("text-underline-offset", "calc(4px * -1)")],
+ );
+ expect(
+ "underline-offset-[3px]",
+ &[("text-underline-offset", "3px")],
+ );
+ expect("decoration-2", &[("text-decoration-thickness", "2px")]);
+ expect("decoration-[3px]", &[("text-decoration-thickness", "3px")]);
+ expect(
+ "decoration-red-500",
+ &[("text-decoration-color", "oklch(63.7% 0.237 25.331)")],
+ );
+ expect("outline-offset-2", &[("outline-offset", "2px")]);
+ expect("-outline-offset-2", &[("outline-offset", "calc(2px * -1)")]);
+ expect("outline-offset-(--o)", &[("outline-offset", "var(--o)")]);
+ expect(
+ "outline",
+ &[
+ ("outline-style", "var(--tw-outline-style)"),
+ ("outline-width", "1px"),
+ ],
+ );
+ expect(
+ "outline-4",
+ &[
+ ("outline-style", "var(--tw-outline-style)"),
+ ("outline-width", "4px"),
+ ],
+ );
+ expect(
+ "outline-[3px]",
+ &[
+ ("outline-style", "var(--tw-outline-style)"),
+ ("outline-width", "3px"),
+ ],
+ );
+ }
+
+ #[rstest]
+ #[case("border", "border-style", "border-width", "1px")]
+ #[case("border-0", "border-style", "border-width", "0px")]
+ #[case("border-2", "border-style", "border-width", "2px")]
+ #[case("border-[3px]", "border-style", "border-width", "3px")]
+ #[case("border-x", "border-inline-style", "border-inline-width", "1px")]
+ #[case("border-y-4", "border-block-style", "border-block-width", "4px")]
+ #[case(
+ "border-s-2",
+ "border-inline-start-style",
+ "border-inline-start-width",
+ "2px"
+ )]
+ #[case(
+ "border-e",
+ "border-inline-end-style",
+ "border-inline-end-width",
+ "1px"
+ )]
+ #[case("border-t", "border-top-style", "border-top-width", "1px")]
+ #[case("border-r-8", "border-right-style", "border-right-width", "8px")]
+ #[case("border-b-[2px]", "border-bottom-style", "border-bottom-width", "2px")]
+ #[case("border-l-2", "border-left-style", "border-left-width", "2px")]
+ fn border_widths_draw_in_the_border_style(
+ #[case] class: &str,
+ #[case] style: &str,
+ #[case] width_property: &str,
+ #[case] width: &str,
+ ) {
+ expect(
+ class,
+ &[(style, "var(--tw-border-style)"), (width_property, width)],
+ );
+ }
+
+ #[test]
+ fn border_styles_set_the_style_the_widths_draw_in() {
+ for style in ["solid", "dashed", "dotted", "double", "hidden", "none"] {
+ expect(
+ &format!("border-{style}"),
+ &[("--tw-border-style", style), ("border-style", style)],
+ );
+ }
+ }
+
+ #[test]
+ fn border_spacing_sets_both_axes_unless_one_is_named() {
+ expect(
+ "border-spacing-2",
+ &[
+ ("--tw-border-spacing-x", "0.5rem"),
+ ("--tw-border-spacing-y", "0.5rem"),
+ (
+ "border-spacing",
+ "var(--tw-border-spacing-x) var(--tw-border-spacing-y)",
+ ),
+ ],
+ );
+ expect(
+ "border-spacing-x-4",
+ &[
+ ("--tw-border-spacing-x", "1rem"),
+ (
+ "border-spacing",
+ "var(--tw-border-spacing-x) var(--tw-border-spacing-y)",
+ ),
+ ],
+ );
+ expect(
+ "border-spacing-y-[3px]",
+ &[
+ ("--tw-border-spacing-y", "3px"),
+ (
+ "border-spacing",
+ "var(--tw-border-spacing-x) var(--tw-border-spacing-y)",
+ ),
+ ],
+ );
+ }
+
+ #[test]
+ fn logical_sides_scroll_spacing_and_indent() {
+ expect("start-4", &[("inset-inline-start", "1rem")]);
+ expect("end-0", &[("inset-inline-end", "0px")]);
+ expect("-start-4", &[("inset-inline-start", "-1rem")]);
+ expect("scroll-mt-2", &[("scroll-margin-top", "0.5rem")]);
+ expect("scroll-mx-[3px]", &[("scroll-margin-inline", "3px")]);
+ expect("-scroll-ms-4", &[("scroll-margin-inline-start", "-1rem")]);
+ expect("scroll-pb-4", &[("scroll-padding-bottom", "1rem")]);
+ expect("scroll-p-2", &[("scroll-padding", "0.5rem")]);
+ expect("indent-8", &[("text-indent", "2rem")]);
+ expect("-indent-px", &[("text-indent", "-1px")]);
+ expect("indent-(--i)", &[("text-indent", "var(--i)")]);
+ for (class, property) in [
+ ("scroll-m-1", "scroll-margin"),
+ ("scroll-my-1", "scroll-margin-block"),
+ ("scroll-me-1", "scroll-margin-inline-end"),
+ ("scroll-mr-1", "scroll-margin-right"),
+ ("scroll-mb-1", "scroll-margin-bottom"),
+ ("scroll-ml-1", "scroll-margin-left"),
+ ("scroll-px-1", "scroll-padding-inline"),
+ ("scroll-py-1", "scroll-padding-block"),
+ ("scroll-ps-1", "scroll-padding-inline-start"),
+ ("scroll-pe-1", "scroll-padding-inline-end"),
+ ("scroll-pt-1", "scroll-padding-top"),
+ ("scroll-pr-1", "scroll-padding-right"),
+ ("scroll-pl-1", "scroll-padding-left"),
+ ] {
+ expect(class, &[(property, "0.25rem")]);
+ }
+ }
+
+ #[rstest]
+ #[case("borderx-2")]
+ #[case("border-x-")]
+ #[case("border-2.5")]
+ #[case("border-02")]
+ #[case("border-[url(x)]")]
+ #[case("border-spacing-")]
+ #[case("border-spacing-x-")]
+ #[case("border-spacing-huge")]
+ #[case("-border-2")]
+ #[case("outline-")]
+ #[case("outlinex")]
+ #[case("outline-nothing")]
+ #[case("outline-offset-")]
+ #[case("-outline-offset-huge")]
+ #[case("underline-offset-")]
+ #[case("-underline-offset-1.5")]
+ #[case("line-clamp-")]
+ #[case("line-clamp-1.5")]
+ #[case("decoration-")]
+ #[case("decoration-nothing")]
+ #[case("font-stretch-49%")]
+ #[case("font-stretch-201%")]
+ #[case("font-stretch-75")]
+ #[case("font-stretch-1.5%")]
+ #[case("font-stretch-normal-1")]
+ #[case("start-")]
+ #[case("start-huge")]
+ #[case("-start-huge")]
+ #[case("scroll-mq-4")]
+ #[case("-scroll-m-")]
+ #[case("-indent")]
+ #[case("-table-auto")]
+ #[case("antialiased-")]
+ fn unknown_utilities_stay_as_written(#[case] class: &str) {
+ assert_eq!(declarations_of(class), None, "{class}");
+ }
+}
diff --git a/libs/extractor/src/tailwind_motion.rs b/libs/extractor/src/tailwind_motion.rs
new file mode 100644
index 000000000..7f8ba011c
--- /dev/null
+++ b/libs/extractor/src/tailwind_motion.rs
@@ -0,0 +1,364 @@
+//! 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 std::borrow::Cow;
+
+use css::tailwind_definitions::{theme_keyframes, theme_variable};
+
+use crate::tailwind::{Declaration, arbitrary_or_variable, decl, is_positive_integer};
+use crate::tailwind_theme::themed;
+
+/// The easing a transition has when no `ease-*` sets one
+fn timing() -> String {
+ let default = theme_variable("default-transition-timing-function")
+ .unwrap_or_else(|| String::from("cubic-bezier(0.4, 0, 0.2, 1)"));
+ format!("var(--tw-ease, {default})")
+}
+
+/// The duration a transition has when no `duration-*` sets one
+fn duration() -> String {
+ let default =
+ theme_variable("default-transition-duration").unwrap_or_else(|| String::from("150ms"));
+ format!("var(--tw-duration, {default})")
+}
+
+/// 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",
+ _ => 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)",
+ _ => 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 = if argument == "linear" {
+ Some(String::from("linear"))
+ } else {
+ themed("ease", argument, easing)
+ .map(Cow::into_owned)
+ .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 = if argument == "none" {
+ Some(String::from("none"))
+ } else {
+ themed("animate", argument, animation)
+ .map(Cow::into_owned)
+ .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`: the
+/// project's, else those of the default theme
+pub fn keyframes_of(declarations: &[Declaration]) -> impl Iterator- > + '_ {
+ declarations
+ .iter()
+ .filter(|(property, _)| property == "animation")
+ .flat_map(|(_, value)| value.split_whitespace())
+ .filter_map(|word| {
+ theme_keyframes(word).map(Cow::Owned).or_else(|| {
+ KEYFRAMES
+ .iter()
+ .find(|(name, _)| *name == word)
+ .map(|&(_, rule)| Cow::Borrowed(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<(String, String)> {
+ vec![
+ ("transition-property".to_string(), property.to_string()),
+ ("transition-timing-function".to_string(), timing()),
+ ("transition-duration".to_string(), duration()),
+ ]
+ }
+
+ fn expect_owned(class: &str, expected: Vec<(String, String)>) {
+ assert_eq!(declarations_of(class), Some(expected), "{class}");
+ }
+
+ #[test]
+ fn transitions_read_the_easing_and_duration_utilities_set() {
+ expect_owned("transition", transition(TRANSITION));
+ expect_owned("transition-all", transition("all"));
+ expect_owned("transition-colors", transition(TRANSITION_COLORS));
+ expect_owned("transition-opacity", transition("opacity"));
+ expect_owned("transition-shadow", transition("box-shadow"));
+ expect_owned(
+ "transition-transform",
+ transition("transform, translate, scale, rotate"),
+ );
+ expect("transition-none", &[("transition-property", "none")]);
+ expect_owned("transition-[height]", transition("height"));
+ expect_owned("transition-[height,opacity]", transition("height,opacity"));
+ expect_owned("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/extractor/src/tailwind_theme.rs b/libs/extractor/src/tailwind_theme.rs
new file mode 100644
index 000000000..9946ded00
--- /dev/null
+++ b/libs/extractor/src/tailwind_theme.rs
@@ -0,0 +1,369 @@
+//! The theme values Tailwind utilities read: what the project's Tailwind CSS
+//! `@theme` defines, else Tailwind CSS v4's defaults
+
+use std::borrow::Cow;
+
+use css::tailwind_definitions::{namespace_is_reset, theme_value, theme_variable};
+
+/// The value of `---` (`--` for an empty key) that
+/// the project defines, else the default, which a project can empty
+pub fn themed(
+ namespace: &str,
+ key: &str,
+ default: impl FnOnce(&str) -> Option<&'static str>,
+) -> Option> {
+ let custom = if key.is_empty() {
+ theme_variable(namespace)
+ } else {
+ theme_value(namespace, key)
+ };
+ if let Some(value) = custom {
+ return Some(Cow::Owned(value));
+ }
+ if namespace_is_reset(namespace) {
+ return None;
+ }
+ default(key).map(Cow::Borrowed)
+}
+
+/// What a class prefix reads from the theme but this build cannot compile
+/// from `@theme`, as `(prefix, namespace)`
+static UNSUPPORTED: &[(&str, &str)] = &[
+ ("text-", "text"),
+ ("font-", "font"),
+ ("font-", "font-weight"),
+ ("leading-", "leading"),
+ ("tracking-", "tracking"),
+ ("max-w-", "container"),
+ ("min-w-", "container"),
+ ("w-", "container"),
+ ("inset-shadow-", "inset-shadow"),
+ ("perspective-", "perspective"),
+ ("aspect-", "aspect"),
+];
+
+/// Whether `name` is a utility whose theme values the project changes in a way
+/// this build does not read, which must stay as written
+pub fn reads_unsupported_theme(name: &str) -> bool {
+ UNSUPPORTED.iter().any(|(prefix, namespace)| {
+ name.strip_prefix(prefix).is_some_and(|key| {
+ let key = key.split('/').next().unwrap_or(key);
+ theme_value(namespace, key).is_some() || namespace_is_reset(namespace)
+ })
+ })
+}
+
+/// `n` times `base`, for a number `n` Tailwind scales the spacing by
+pub fn scaled(key: &str, base: &str) -> Option {
+ let number: f64 = key.parse().ok()?;
+ if number < 0.0 || number % 0.25 != 0.0 || number.to_string() != key {
+ return None;
+ }
+ let split = base.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
+ let (amount, unit) = base.split_at(split);
+ let amount: f64 = amount.parse().ok()?;
+ if unit.is_empty() || !unit.bytes().all(|byte| byte.is_ascii_alphabetic()) {
+ return None;
+ }
+ let product = (number * amount * 1e6).round() / 1e6;
+ Some(if product == 0.0 {
+ String::from("0px")
+ } else {
+ format!("{product}{unit}")
+ })
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+mod tests {
+ use css::tailwind_definitions::set_tailwind_css;
+ use serial_test::serial;
+
+ use super::*;
+
+ #[test]
+ #[serial]
+ fn themed_values_come_from_the_project_first() {
+ let default = |key: &str| (key == "sm").then_some("4px");
+ assert_eq!(themed("blur", "sm", default).as_deref(), Some("4px"));
+ assert_eq!(themed("blur", "xl", default), None);
+ set_tailwind_css("@theme { --blur-sm: 1px; --blur: 9px; --blur-huge: 99px; }");
+ assert_eq!(themed("blur", "sm", default).as_deref(), Some("1px"));
+ assert_eq!(themed("blur", "huge", default).as_deref(), Some("99px"));
+ assert_eq!(themed("blur", "", default).as_deref(), Some("9px"));
+ set_tailwind_css("@theme { --blur-*: initial; }");
+ assert_eq!(themed("blur", "sm", default), None);
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn utilities_that_read_namespaces_the_build_does_not_read() {
+ assert!(!reads_unsupported_theme("text-lg"));
+ set_tailwind_css(
+ "@theme { --text-lg: 2rem; --font-weight-bold: 800; --container-sm: 20rem; }",
+ );
+ assert!(reads_unsupported_theme("text-lg"));
+ assert!(reads_unsupported_theme("text-lg/6"));
+ assert!(!reads_unsupported_theme("text-sm"));
+ assert!(reads_unsupported_theme("font-bold"));
+ assert!(!reads_unsupported_theme("font-thin"));
+ assert!(reads_unsupported_theme("max-w-sm"));
+ assert!(!reads_unsupported_theme("p-4"));
+ set_tailwind_css("@theme { --*: initial; }");
+ assert!(reads_unsupported_theme("leading-tight"));
+ assert!(reads_unsupported_theme("w-4"));
+ set_tailwind_css("");
+ assert!(!reads_unsupported_theme("leading-tight"));
+ }
+
+ #[test]
+ fn spacing_scales_by_the_number() {
+ assert_eq!(scaled("13", "0.25rem").as_deref(), Some("3.25rem"));
+ assert_eq!(scaled("0.5", "4px").as_deref(), Some("2px"));
+ assert_eq!(scaled("3", "0.3rem").as_deref(), Some("0.9rem"));
+ assert_eq!(scaled("0", "0.25rem").as_deref(), Some("0px"));
+ assert_eq!(scaled("1.3", "0.25rem"), None);
+ assert_eq!(scaled("01", "0.25rem"), None);
+ assert_eq!(scaled("-1", "0.25rem"), None);
+ assert_eq!(scaled("x", "0.25rem"), None);
+ assert_eq!(scaled("4", "2"), None);
+ assert_eq!(scaled("4", "calc(1px)"), None);
+ assert_eq!(scaled("4", "var(--x)"), None);
+ assert_eq!(scaled("4", "rem"), None);
+ assert_eq!(scaled("4", "1px2"), None);
+ }
+
+ fn written(class: &str) -> Vec<(String, String)> {
+ crate::tailwind::declarations_of(class)
+ .unwrap_or_else(|| panic!("{class} stays as written"))
+ }
+
+ fn pairs(declarations: &[(&str, &str)]) -> Vec<(String, String)> {
+ declarations
+ .iter()
+ .map(|(property, value)| (property.to_string(), value.to_string()))
+ .collect()
+ }
+
+ #[test]
+ #[serial]
+ fn theme_colors_extend_and_reset_the_palette() {
+ set_tailwind_css(
+ "@theme { --color-brand: #0af; --color-mint: var(--color-emerald-500); --color-alias: var(--color-brand); }",
+ );
+ assert_eq!(written("bg-brand"), pairs(&[("background-color", "#0af")]));
+ assert_eq!(written("bg-alias"), pairs(&[("background-color", "#0af")]));
+ assert_eq!(
+ written("bg-mint"),
+ pairs(&[("background-color", "oklch(69.6% 0.17 162.48)")])
+ );
+ assert_eq!(
+ written("text-brand/50"),
+ pairs(&[("color", "color-mix(in oklab, #0af 50%, transparent)")])
+ );
+ assert_eq!(
+ written("bg-red-500"),
+ pairs(&[("background-color", "oklch(63.7% 0.237 25.331)")])
+ );
+ set_tailwind_css("@theme { --color-*: initial; --color-brand: #0af; }");
+ assert_eq!(written("bg-brand"), pairs(&[("background-color", "#0af")]));
+ assert_eq!(crate::tailwind::declarations_of("bg-white"), None);
+ assert_eq!(crate::tailwind::declarations_of("bg-red-500"), None);
+ assert_eq!(
+ written("bg-current"),
+ pairs(&[("background-color", "currentcolor")])
+ );
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn theme_spacing_scales_every_step() {
+ assert_eq!(written("p-13"), pairs(&[("padding", "3.25rem")]));
+ assert_eq!(written("p-4"), pairs(&[("padding", "1rem")]));
+ set_tailwind_css("@theme { --spacing: 0.3rem; --spacing-gutter: 3rem; }");
+ assert_eq!(written("p-4"), pairs(&[("padding", "1.2rem")]));
+ assert_eq!(written("mx-0"), pairs(&[("margin-inline", "0px")]));
+ assert_eq!(written("p-px"), pairs(&[("padding", "1px")]));
+ assert_eq!(written("gap-gutter"), pairs(&[("gap", "3rem")]));
+ assert_eq!(written("-mt-2"), pairs(&[("margin-top", "-0.6rem")]));
+ set_tailwind_css("@theme { --spacing: calc(1px + 1px); }");
+ assert_eq!(written("p-4"), pairs(&[("padding", "1rem")]));
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn theme_shadows_blurs_radii_and_easings() {
+ set_tailwind_css(
+ "@theme { --shadow-soft: 0 2px 8px #0003, inset 0 0 1px red; --blur-huge: 100px; --blur: 5px; --radius-pill: 40px; --radius: 3px; --ease-snappy: cubic-bezier(0.2, 0, 0, 1); }",
+ );
+ assert_eq!(
+ written("shadow-soft"),
+ pairs(&[
+ (
+ "--tw-shadow",
+ "0 2px 8px var(--tw-shadow-color, #0003), inset 0 0 1px var(--tw-shadow-color, red)"
+ ),
+ (
+ "box-shadow",
+ "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)"
+ ),
+ ])
+ );
+ assert_eq!(
+ written("blur-huge")[0],
+ ("--tw-blur".to_string(), "blur(100px)".to_string())
+ );
+ assert_eq!(written("blur")[0].1, "blur(5px)");
+ assert_eq!(written("blur-sm")[0].1, "blur(8px)");
+ assert_eq!(written("rounded-pill"), pairs(&[("border-radius", "40px")]));
+ assert_eq!(written("rounded"), pairs(&[("border-radius", "3px")]));
+ assert_eq!(written("rounded-lg"), pairs(&[("border-radius", "0.5rem")]));
+ assert_eq!(
+ written("ease-snappy"),
+ pairs(&[
+ ("--tw-ease", "cubic-bezier(0.2, 0, 0, 1)"),
+ ("transition-timing-function", "cubic-bezier(0.2, 0, 0, 1)"),
+ ])
+ );
+ set_tailwind_css(
+ "@theme { --shadow-*: initial; --blur-*: initial; --radius-*: initial; --ease-*: initial; --animate-*: initial; }",
+ );
+ for class in [
+ "shadow-md",
+ "shadow",
+ "blur-sm",
+ "blur",
+ "rounded-lg",
+ "rounded",
+ "ease-in",
+ "animate-spin",
+ ] {
+ assert_eq!(crate::tailwind::declarations_of(class), None, "{class}");
+ }
+ assert_eq!(
+ written("ease-linear"),
+ pairs(&[
+ ("--tw-ease", "linear"),
+ ("transition-timing-function", "linear")
+ ])
+ );
+ assert_eq!(written("animate-none"), pairs(&[("animation", "none")]));
+ assert_eq!(
+ written("rounded-full"),
+ pairs(&[("border-radius", "calc(infinity * 1px)")])
+ );
+ assert_eq!(written("rounded-none"), pairs(&[("border-radius", "0")]));
+ set_tailwind_css("");
+ }
+
+ #[test]
+ #[serial]
+ fn theme_animations_bring_their_keyframes() {
+ set_tailwind_css(
+ "@theme { --animate-wiggle: wiggle 1s ease-in-out infinite; @keyframes wiggle { 0%, 100% { transform: rotate(-3deg) } 50% { transform: rotate(3deg) } } --default-transition-duration: 300ms; --default-transition-timing-function: linear; }",
+ );
+ assert_eq!(
+ written("animate-wiggle"),
+ pairs(&[("animation", "wiggle 1s ease-in-out infinite")])
+ );
+ let rules = crate::tailwind::parse_class("animate-wiggle")
+ .unwrap()
+ .rules();
+ assert_eq!(
+ rules,
+ vec!["@keyframes wiggle{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}"]
+ );
+ assert_eq!(
+ written("transition-opacity")[1..],
+ pairs(&[
+ ("transition-timing-function", "var(--tw-ease, linear)"),
+ ("transition-duration", "var(--tw-duration, 300ms)"),
+ ])
+ );
+ set_tailwind_css("");
+ assert_eq!(crate::tailwind::declarations_of("animate-wiggle"), None);
+ }
+
+ #[test]
+ #[serial]
+ fn theme_breakpoints_and_custom_variants() {
+ set_tailwind_css(
+ "@theme { --breakpoint-3xl: 120rem; }
+@custom-variant theme-midnight (&:where([data-theme=\"midnight\"] *));
+@custom-variant dark (&:where(.dark, .dark *));
+@custom-variant pointer-hover { @media (hover: hover) { @slot; } }",
+ );
+ let conditions = |class: &str| {
+ crate::tailwind::parse_class(class)
+ .unwrap()
+ .conditions
+ .iter()
+ .map(|condition| condition.as_ref().map(ToString::to_string))
+ .collect::>()
+ };
+ assert_eq!(
+ conditions("3xl:p-4"),
+ vec![Some(String::from("@media(min-width:120rem)"))]
+ );
+ assert_eq!(
+ conditions("theme-midnight:p-4"),
+ vec![Some(String::from("&:where([data-theme=\"midnight\"] *)"))]
+ );
+ assert_eq!(
+ conditions("dark:p-4"),
+ vec![Some(String::from("&:where(.dark, .dark *)"))]
+ );
+ assert_eq!(
+ conditions("pointer-hover:p-4"),
+ vec![Some(String::from("@media(hover:hover)"))]
+ );
+ assert_eq!(crate::tailwind::parse_class("midnight:p-4"), None);
+ set_tailwind_css("");
+ assert_eq!(crate::tailwind::parse_class("3xl:p-4"), None);
+ assert_eq!(
+ conditions("dark:p-4"),
+ vec![Some(String::from(":root[data-theme=dark] &"))]
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn custom_utilities_are_their_declarations() {
+ set_tailwind_css(
+ "@utility content-auto { content-visibility: auto; contain-intrinsic-size: auto 500px; } @utility tab-* { tab-size: --value(integer); }",
+ );
+ assert_eq!(
+ written("content-auto"),
+ pairs(&[
+ ("content-visibility", "auto"),
+ ("contain-intrinsic-size", "auto 500px"),
+ ])
+ );
+ assert_eq!(
+ written("content-auto!"),
+ pairs(&[
+ ("content-visibility", "auto !important"),
+ ("contain-intrinsic-size", "auto 500px !important"),
+ ])
+ );
+ assert_eq!(crate::tailwind::declarations_of("-content-auto"), None);
+ assert_eq!(crate::tailwind::declarations_of("tab-4"), None);
+ set_tailwind_css("");
+ assert_eq!(crate::tailwind::declarations_of("content-auto"), None);
+ }
+
+ #[test]
+ #[serial]
+ fn utilities_of_changed_namespaces_stay_as_written() {
+ assert!(crate::tailwind::declarations_of("text-lg").is_some());
+ set_tailwind_css("@theme { --text-lg: 2rem; --font-weight-bold: 800; }");
+ assert_eq!(crate::tailwind::declarations_of("text-lg"), None);
+ assert_eq!(crate::tailwind::declarations_of("font-bold"), None);
+ assert!(crate::tailwind::declarations_of("text-sm").is_some());
+ assert!(crate::tailwind::declarations_of("font-thin").is_some());
+ set_tailwind_css("");
+ }
+}
diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs
index 3f3ea2732..59ed32bac 100644
--- a/libs/sheet/src/lib.rs
+++ b/libs/sheet/src/lib.rs
@@ -10,7 +10,8 @@ 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},
+ tailwind_definitions::set_tailwind_css,
+ theme_tokens::{set_color_tokens, set_theme_token_levels, set_typography_keys},
utils::compile_regex,
write_merge_selector,
};
@@ -481,6 +482,8 @@ impl StyleSheet {
theme.get_shadow_token_levels(),
);
set_typography_keys(theme.typography.keys().cloned().collect());
+ set_color_tokens(theme.get_color_token_names());
+ set_tailwind_css(&theme.tailwind_css);
self.theme = theme;
}
@@ -3631,6 +3634,25 @@ mod tests {
}
}
+ #[test]
+ #[serial]
+ fn test_set_theme_reads_the_tailwind_css() {
+ let theme: Theme =
+ serde_json::from_str(r#"{"tailwindCss": "@theme { --color-brand: #0af; }"}"#).unwrap();
+ let mut sheet = StyleSheet::default();
+
+ sheet.set_theme(theme);
+ assert_eq!(
+ css::tailwind_definitions::theme_value("color", "brand").as_deref(),
+ Some("#0af")
+ );
+
+ sheet.set_theme(Theme::default());
+ assert_eq!(
+ css::tailwind_definitions::theme_value("color", "brand"),
+ None
+ );
+ }
#[test]
#[serial]
fn test_global_css_reads_theme_tokens() {
diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap
index bc58b6e61..d255b5caa 100644
--- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap
@@ -38,5 +38,6 @@ StyleSheet {
typography: {},
length: {},
shadows: {},
+ tailwind_css: "",
},
}
diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs
index 2aeb60873..a5fc5424f 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)
@@ -653,6 +653,10 @@ pub struct Theme {
deserialize_with = "deserialize_shadow_themes"
)]
pub shadows: BTreeMap,
+ /// The text of the project's Tailwind CSS file, whose `@theme`, `@utility`
+ /// and `@custom-variant` definitions Tailwind classes use
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub tailwind_css: String,
}
/// Deserialize the color variants, naming the variant a token error is in
@@ -726,6 +730,7 @@ impl Default for Theme {
typography: BTreeMap::new(),
length: BTreeMap::new(),
shadows: BTreeMap::new(),
+ tailwind_css: String::new(),
}
}
}
@@ -818,6 +823,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 +1408,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
diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts
index 5fc4b01c9..226f98be4 100644
--- a/packages/bun-plugin/src/plugin.ts
+++ b/packages/bun-plugin/src/plugin.ts
@@ -9,6 +9,7 @@ import {
type CustomShorthands,
loadDevupConfig,
mergeImportAliases,
+ withTailwindCss,
} from '@devup-ui/plugin-utils'
import {
codeExtract,
@@ -52,7 +53,7 @@ async function writeDataFiles() {
let theme = {}
try {
const config = await loadDevupConfig(devupFile)
- theme = config.theme ?? {}
+ theme = withTailwindCss(config.theme ?? {}, config)
} catch {
// Error reading devup.json, use empty theme
}
diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts
index 3f4df0644..667e7e50e 100644
--- a/packages/next-plugin/src/__tests__/loader.test.ts
+++ b/packages/next-plugin/src/__tests__/loader.test.ts
@@ -156,8 +156,10 @@ describe('devupUILoader', () => {
// Test WATCH mode init (lines 55-67) + CSS writing (lines 94-111)
it('should initialize watch mode and write css files', async () => {
existsSyncSpy.mockReturnValue(true)
- readFileSyncSpy.mockReturnValue(
- '{"theme": {"colors": {"primary": "#fff"}}}',
+ readFileSyncSpy.mockImplementation((path: unknown) =>
+ String(path).endsWith('tw.css')
+ ? '@theme { --color-brand: #0af; }'
+ : '{"theme": {"colors": {"primary": "#fff"}}, "tailwind": {"css": "tw.css"}}',
)
exportSheetSpy.mockReturnValue('sheet')
exportClassMapSpy.mockReturnValue('classMap')
@@ -207,7 +209,9 @@ describe('devupUILoader', () => {
expect(existsSyncSpy).toHaveBeenCalledWith('themeFile')
expect(registerThemeSpy).toHaveBeenCalledWith({
colors: { primary: '#fff' },
+ tailwindCss: '@theme { --color-brand: #0af; }',
})
+ expect(t.addDependency).toHaveBeenCalledWith(resolve('tw.css'))
// Verify updatedBaseStyle && watch branch (lines 94-99)
expect(writeFileSpy).toHaveBeenCalledWith(
diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts
index e11c789b4..7a9b8dc50 100644
--- a/packages/next-plugin/src/loader.ts
+++ b/packages/next-plugin/src/loader.ts
@@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises'
import { Agent, request } from 'node:http'
import { basename, dirname, join, relative, resolve } from 'node:path'
+import { tailwindCssFiles, withTailwindCss } from '@devup-ui/plugin-utils'
import type { RawLoaderDefinitionFunction } from 'webpack'
import { loadWasm } from './wasm'
@@ -221,10 +222,11 @@ const devupUILoader: RawLoaderDefinitionFunction =
importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8')))
if (existsSync(fileMapFile))
importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8')))
- if (existsSync(themeFile))
- registerTheme(
- JSON.parse(readFileSync(themeFile, 'utf-8'))?.theme ?? {},
- )
+ if (existsSync(themeFile)) {
+ const config = JSON.parse(readFileSync(themeFile, 'utf-8')) ?? {}
+ registerTheme(withTailwindCss(config.theme ?? {}, config))
+ for (const file of tailwindCssFiles(config)) this.addDependency(file)
+ }
} else {
importFileMap(defaultFileMap)
importClassMap(defaultClassMap)
diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts
index 7905cc7d0..8873752db 100644
--- a/packages/next-plugin/src/plugin.ts
+++ b/packages/next-plugin/src/plugin.ts
@@ -21,6 +21,7 @@ import {
mergeImportAliases,
planAtomHoist,
type StaticImportGraph,
+ withTailwindCss,
} from '@devup-ui/plugin-utils'
import { type NextConfig } from 'next'
@@ -251,7 +252,7 @@ export function DevupUI(
const devupConfig = loadDevupConfigSync(devupFile)
- const theme: any = devupConfig.theme ?? {}
+ const theme: any = withTailwindCss(devupConfig.theme ?? {}, devupConfig)
// Register current theme after importing previous state,
// since importSheet replaces the entire sheet including its theme.
registerTheme(theme)
diff --git a/packages/plugin-utils/src/__tests__/tailwind-css.test.ts b/packages/plugin-utils/src/__tests__/tailwind-css.test.ts
new file mode 100644
index 000000000..4be06c515
--- /dev/null
+++ b/packages/plugin-utils/src/__tests__/tailwind-css.test.ts
@@ -0,0 +1,127 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+import {
+ findTailwindCss,
+ TAILWIND_CSS_CANDIDATES,
+ tailwindCssFiles,
+ withTailwindCss,
+} from '../tailwind-css'
+
+let root: string
+
+function write(file: string, content: string) {
+ const path = join(root, file)
+ mkdirSync(join(path, '..'), { recursive: true })
+ writeFileSync(path, content)
+}
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'devup-tailwind-'))
+})
+
+afterEach(() => {
+ rmSync(root, { recursive: true, force: true })
+})
+
+describe('findTailwindCss', () => {
+ it('finds the conventional file that imports tailwindcss', () => {
+ write(
+ 'src/app/globals.css',
+ '@import "tailwindcss";\n@theme { --color-a: red; }',
+ )
+ const source = findTailwindCss({}, root)
+ expect(source?.file).toBe(resolve(root, 'src/app/globals.css'))
+ expect(source?.css).toContain('--color-a: red')
+ expect(source?.files).toEqual([resolve(root, 'src/app/globals.css')])
+ })
+
+ it('skips a conventional file that does not import tailwindcss', () => {
+ write('src/app/globals.css', 'body { margin: 0 }')
+ write('src/index.css', "@import 'tailwindcss/theme';")
+ expect(findTailwindCss({}, root)?.file).toBe(resolve(root, 'src/index.css'))
+ })
+
+ it('takes the file devup.json names, whatever it holds', () => {
+ write('src/app/globals.css', '@import "tailwindcss";')
+ write('styles/tw.css', '@theme { --color-b: blue }')
+ const source = findTailwindCss({ tailwind: { css: 'styles/tw.css' } }, root)
+ expect(source?.file).toBe(resolve(root, 'styles/tw.css'))
+ })
+
+ it('finds nothing when the named file is missing, when none imports tailwindcss, or when it is switched off', () => {
+ expect(findTailwindCss({}, root)).toBeUndefined()
+ expect(
+ findTailwindCss({ tailwind: { css: 'missing.css' } }, root),
+ ).toBeUndefined()
+ write('src/app/globals.css', '@import "tailwindcss";')
+ expect(findTailwindCss({ tailwind: { css: false } }, root)).toBeUndefined()
+ })
+
+ it('reads the local CSS files it imports, where they are imported', () => {
+ write(
+ 'src/app/globals.css',
+ '@import "tailwindcss";\n@import "./theme.css";\n@import "../shared/more.css" layer(base);\n@import "./missing.css";\n@import "https://x.test/a.css";\n.after {}',
+ )
+ write(
+ 'src/app/theme.css',
+ '@theme { --color-c: red }\n@import "./theme.css";',
+ )
+ write('src/shared/more.css', '@utility u { a: b }')
+ const source = findTailwindCss({}, root)
+ expect(source?.files).toEqual([
+ resolve(root, 'src/app/globals.css'),
+ resolve(root, 'src/app/theme.css'),
+ resolve(root, 'src/shared/more.css'),
+ ])
+ expect(source?.css).toBe(
+ '@import "tailwindcss";\n@theme { --color-c: red }\n@import "./theme.css";\n@utility u { a: b }\n@import "./missing.css";\n@import "https://x.test/a.css";\n.after {}',
+ )
+ })
+
+ it('stops following imports at a depth limit', () => {
+ write('src/app/globals.css', '@import "tailwindcss";\n@import "./a0.css";')
+ for (let index = 0; index < 12; index++) {
+ write(
+ `src/app/a${index}.css`,
+ `.a${index} {}\n@import "./a${index + 1}.css";`,
+ )
+ }
+ const source = findTailwindCss({}, root)
+ expect(source?.files.length).toBe(9)
+ })
+
+ it('lists the conventional files', () => {
+ expect(TAILWIND_CSS_CANDIDATES[0]).toBe('src/app/globals.css')
+ })
+})
+
+describe('withTailwindCss', () => {
+ it('adds the text of the Tailwind CSS to the theme', () => {
+ write('src/index.css', '@import "tailwindcss";')
+ expect(withTailwindCss({ colors: {} }, {}, root)).toEqual({
+ colors: {},
+ tailwindCss: '@import "tailwindcss";',
+ })
+ })
+
+ it('leaves the theme alone without a Tailwind CSS', () => {
+ const theme = { colors: {} }
+ expect(withTailwindCss(theme, {}, root)).toBe(theme)
+ })
+})
+
+describe('tailwindCssFiles', () => {
+ it('lists every file read, none without a Tailwind CSS', () => {
+ expect(tailwindCssFiles({}, root)).toEqual([])
+ write('src/index.css', '@import "tailwindcss";\n@import "./t.css";')
+ write('src/t.css', '')
+ expect(tailwindCssFiles({}, root)).toEqual([
+ resolve(root, 'src/index.css'),
+ resolve(root, 'src/t.css'),
+ ])
+ })
+})
diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts
index 385a2dc2b..56290a8b6 100644
--- a/packages/plugin-utils/src/index.ts
+++ b/packages/plugin-utils/src/index.ts
@@ -27,6 +27,13 @@ export {
type DevupUIBasePluginOptions,
getFileNumByFilename,
} from './shared'
+export {
+ findTailwindCss,
+ TAILWIND_CSS_CANDIDATES,
+ tailwindCssFiles,
+ type TailwindCssSource,
+ withTailwindCss,
+} from './tailwind-css'
export type {
CustomShorthands,
DevupConfig,
diff --git a/packages/plugin-utils/src/tailwind-css.ts b/packages/plugin-utils/src/tailwind-css.ts
new file mode 100644
index 000000000..c4a40d8ab
--- /dev/null
+++ b/packages/plugin-utils/src/tailwind-css.ts
@@ -0,0 +1,109 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+
+import type { DevupConfig } from './types'
+
+/**
+ * Where a project's Tailwind CSS file usually is, in the order they are tried
+ */
+export const TAILWIND_CSS_CANDIDATES = [
+ 'src/app/globals.css',
+ 'app/globals.css',
+ 'src/globals.css',
+ 'src/index.css',
+ 'src/styles/globals.css',
+ 'src/styles.css',
+ 'src/style.css',
+ 'src/app.css',
+ 'styles/globals.css',
+ 'app/app.css',
+] as const
+
+const TAILWIND_IMPORT = /@import\s+(?:url\()?["']tailwindcss(?:\/[^"']*)?["']/
+const LOCAL_IMPORT =
+ /@import\s+(?:url\()?["'](\.{1,2}\/[^"']+\.css)["']\)?[^;]*;/g
+const MAX_IMPORT_DEPTH = 8
+
+/**
+ * The Tailwind CSS a project defines its theme, utilities and variants in
+ */
+export interface TailwindCssSource {
+ /** The entry file, the one that imports `tailwindcss` */
+ file: string
+ /** Every file read: the entry and the local CSS files it imports */
+ files: string[]
+ /** The text of the files, each import where it is written */
+ css: string
+}
+
+function read(file: string): string | undefined {
+ try {
+ return readFileSync(file, 'utf-8')
+ } catch {
+ return undefined
+ }
+}
+
+/** The text of css, each local import replaced by what it imports */
+function expandImports(
+ file: string,
+ css: string,
+ files: string[],
+ depth: number,
+): string {
+ files.push(file)
+ if (depth >= MAX_IMPORT_DEPTH) return css
+ return css.replace(LOCAL_IMPORT, (statement, path: string) => {
+ const imported = resolve(dirname(file), path)
+ const importedCss = files.includes(imported) ? undefined : read(imported)
+ return importedCss === undefined
+ ? statement
+ : expandImports(imported, importedCss, files, depth + 1)
+ })
+}
+/**
+ * Finds the project's Tailwind CSS: `tailwind.css` of devup.json when it names
+ * a file, nothing when it is `false`, else the first conventional file that
+ * imports `tailwindcss`
+ */
+export function findTailwindCss(
+ config: DevupConfig,
+ cwd = process.cwd(),
+): TailwindCssSource | undefined {
+ const configured = config.tailwind?.css
+ if (configured === false) return undefined
+ const candidates =
+ typeof configured === 'string' ? [configured] : TAILWIND_CSS_CANDIDATES
+ for (const candidate of candidates) {
+ const file = resolve(cwd, candidate)
+ const entry = read(file)
+ if (entry === undefined) continue
+ if (typeof configured !== 'string' && !TAILWIND_IMPORT.test(entry)) continue
+ const files: string[] = []
+ return { file, files, css: expandImports(file, entry, files, 0) }
+ }
+ return undefined
+}
+
+/**
+ * `theme` with the text of the project's Tailwind CSS, which the build
+ * reads the definitions of `@theme`, `@utility` and `@custom-variant` from
+ */
+export function withTailwindCss(
+ theme: T,
+ config: DevupConfig,
+ cwd = process.cwd(),
+): T & { tailwindCss?: string } {
+ const source = findTailwindCss(config, cwd)
+ return source ? { ...theme, tailwindCss: source.css } : theme
+}
+
+/**
+ * The files the project's Tailwind CSS is read from, which a rebuild watches
+ */
+export function tailwindCssFiles(
+ config: DevupConfig,
+ cwd = process.cwd(),
+): string[] {
+ return findTailwindCss(config, cwd)?.files ?? []
+}
diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts
index 1a1365c2d..2a8f90a3a 100644
--- a/packages/plugin-utils/src/types.ts
+++ b/packages/plugin-utils/src/types.ts
@@ -79,6 +79,18 @@ export interface DevupConfig {
* Theme configuration
*/
theme?: DevupTheme
+
+ /**
+ * The project's Tailwind CSS, which the build reads the definitions of
+ * `@theme`, `@utility` and `@custom-variant` from
+ */
+ tailwind?: {
+ /**
+ * The CSS file that imports `tailwindcss`. Found among the conventional
+ * files when omitted; `false` reads none
+ */
+ css?: string | false
+ }
}
/**
diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts
index 1a4393a97..303e6f386 100644
--- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts
+++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts
@@ -390,6 +390,23 @@ const App = () => `,
map: undefined,
})
})
+ it('registers the text of the Tailwind CSS with the theme', async () => {
+ const readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+ '@theme { --color-brand: #0af; }',
+ )
+ readFileSpy.mockResolvedValue(
+ JSON.stringify({ tailwind: { css: 'tailwind.css' } }),
+ )
+ existsSyncSpy.mockImplementation((path: string) => path === 'devup.json')
+
+ await DevupUI({}).setup(createSetupContext())
+
+ expect(registerThemeSpy).toHaveBeenCalledWith({
+ tailwindCss: '@theme { --color-brand: #0af; }',
+ })
+ readFileSyncSpy.mockRestore()
+ readFileSpy.mockResolvedValue('{}')
+ })
it.each(
createTestMatrix({
watch: [true, false],
diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts
index 47d4f2f3f..f8ac82ad4 100644
--- a/packages/rsbuild-plugin/src/plugin.ts
+++ b/packages/rsbuild-plugin/src/plugin.ts
@@ -16,6 +16,7 @@ import {
loadDevupConfig,
mergeImportAliases,
planAtomHoist,
+ withTailwindCss,
} from '@devup-ui/plugin-utils'
import {
codeExtract,
@@ -78,7 +79,7 @@ async function writeDataFiles(
const config = await loadDevupConfig(options.devupFile)
const theme = config.theme ?? {}
- registerTheme(theme)
+ registerTheme(withTailwindCss(theme, config))
const interfaceCode = getThemeInterface(
...createThemeInterfaceArgs(options.package),
)
diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts
index 4e14b5fd8..102c3d901 100644
--- a/packages/vite-plugin/src/__tests__/plugin.test.ts
+++ b/packages/vite-plugin/src/__tests__/plugin.test.ts
@@ -1,5 +1,6 @@
import * as fs from 'node:fs'
import * as fsPromises from 'node:fs/promises'
+import * as os from 'node:os'
import * as nodePath from 'node:path'
import * as pluginUtils from '@devup-ui/plugin-utils'
@@ -1088,6 +1089,118 @@ describe('devupUIVitePlugin', () => {
expect(send).not.toHaveBeenCalled()
})
+ describe('Tailwind CSS definitions', () => {
+ let directory: string
+ let tailwindFile: string
+
+ beforeEach(() => {
+ directory = fs.mkdtempSync(join(os.tmpdir(), 'devup-vite-tailwind-'))
+ tailwindFile = join(directory, 'tailwind.css')
+ fs.writeFileSync(tailwindFile, '@theme { --color-brand: #0af; }')
+ existsSyncSpy.mockReturnValue(true)
+ readFileSpy.mockResolvedValue(
+ JSON.stringify({ tailwind: { css: tailwindFile } }),
+ )
+ })
+
+ afterEach(() => {
+ fs.rmSync(directory, { recursive: true, force: true })
+ })
+
+ it('registers the text of the Tailwind CSS with the theme', async () => {
+ const plugin = createPlugin({})
+ await plugin.configResolved()
+ expect(registerThemeSpy).toHaveBeenCalledWith({
+ tailwindCss: '@theme { --color-brand: #0af; }',
+ })
+ })
+
+ it('reads it again when the Tailwind CSS changes', async () => {
+ const plugin = createPlugin({})
+ await plugin.configResolved()
+ registerThemeSpy.mockClear()
+ fs.writeFileSync(tailwindFile, '@theme { --color-brand: #f00; }')
+
+ await plugin.watchChange(tailwindFile)
+
+ expect(registerThemeSpy).toHaveBeenCalledWith({
+ tailwindCss: '@theme { --color-brand: #f00; }',
+ })
+ })
+
+ it('compiles every module again when the Tailwind CSS changes', async () => {
+ const plugin = createPlugin({})
+ await plugin.configResolved()
+ const first = {}
+ const second = {}
+ const environment = {
+ ...createHotUpdateEnvironment('client'),
+ moduleGraph: {
+ invalidateModule: mock(),
+ idToModuleMap: new Map([
+ ['a', first],
+ ['b', second],
+ ]),
+ },
+ }
+
+ const result = await plugin.hotUpdate.call(
+ { environment },
+ { file: tailwindFile, modules: [{}], timestamp: 1 },
+ )
+
+ expect(environment.moduleGraph.invalidateModule).toHaveBeenCalledTimes(2)
+ expect(environment.moduleGraph.invalidateModule).toHaveBeenCalledWith(
+ first,
+ expect.any(Set),
+ 1,
+ true,
+ )
+ expect(environment.hot.send).toHaveBeenCalledWith({ type: 'full-reload' })
+ expect(result).toEqual([])
+ })
+
+ it('compiles every module again on a Vite 5 hot update', async () => {
+ const plugin = createPlugin({})
+ await plugin.configResolved()
+ const invalidateModule = mock()
+ const send = mock()
+ const module = {}
+
+ const result = await plugin.handleHotUpdate({
+ file: tailwindFile,
+ server: {
+ moduleGraph: {
+ invalidateModule,
+ idToModuleMap: new Map([['a', module]]),
+ },
+ ws: { send },
+ },
+ modules: [],
+ timestamp: 2,
+ })
+
+ expect(invalidateModule).toHaveBeenCalledWith(
+ module,
+ expect.any(Set),
+ 2,
+ true,
+ )
+ expect(send).toHaveBeenCalledWith({ type: 'full-reload' })
+ expect(result).toEqual([])
+ })
+
+ it('leaves a Tailwind CSS that is gone alone', async () => {
+ const plugin = createPlugin({})
+ await plugin.configResolved()
+ registerThemeSpy.mockClear()
+ existsSyncSpy.mockReturnValue(false)
+
+ await plugin.watchChange(tailwindFile)
+
+ expect(registerThemeSpy).not.toHaveBeenCalled()
+ })
+ })
function createHotUpdateEnvironment(consumer: 'client' | 'server') {
return {
config: { consumer },
diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts
index 91e585045..f6d50ad48 100644
--- a/packages/vite-plugin/src/plugin.ts
+++ b/packages/vite-plugin/src/plugin.ts
@@ -16,6 +16,8 @@ import {
loadDevupConfig,
mergeImportAliases,
planAtomHoist,
+ tailwindCssFiles,
+ withTailwindCss,
} from '@devup-ui/plugin-utils'
import {
codeExtract,
@@ -292,12 +294,14 @@ export interface DevupUIPluginOptions {
async function writeDataFiles(
options: Omit,
-) {
+): Promise {
+ let tailwindFiles: string[] = []
try {
const config = await loadDevupConfig(options.devupFile)
const theme = config.theme ?? {}
- registerTheme(theme)
+ registerTheme(withTailwindCss(theme, config))
+ tailwindFiles = tailwindCssFiles(config)
const interfaceCode = getThemeInterface(
...createThemeInterfaceArgs(options.package),
)
@@ -321,6 +325,7 @@ async function writeDataFiles(
if (!options.singleCss) {
await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false))
}
+ return tailwindFiles
}
export function DevupUI({
@@ -354,6 +359,13 @@ export function DevupUI({
// module transformed again writes its sheet again, and the reload that
// signal causes transforms it once more: signal only a changed sheet.
const writtenCss = new Map()
+ // The files the project's Tailwind CSS is read from, which change what a
+ // Tailwind class compiles to
+ let tailwindFiles: string[] = []
+ const isTailwindFile = (file: string) =>
+ tailwindFiles.some(
+ (tailwindFile) => resolve(tailwindFile) === resolve(file),
+ )
function writeCssFile(fileName: string, css: string): Promise {
if (writtenCss.get(fileName) === css) return Promise.resolve()
writtenCss.set(fileName, css)
@@ -389,7 +401,7 @@ export function DevupUI({
createCompatTypes(importAliases),
'utf-8',
)
- await writeDataFiles({
+ tailwindFiles = await writeDataFiles({
package: libPackage,
cssDir,
devupFile,
@@ -485,9 +497,12 @@ export function DevupUI({
return true
},
async watchChange(id) {
- if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) {
+ if (
+ (resolve(id) === resolve(devupFile) && existsSync(devupFile)) ||
+ (isTailwindFile(id) && existsSync(id))
+ ) {
try {
- await writeDataFiles({
+ tailwindFiles = await writeDataFiles({
package: libPackage,
cssDir,
devupFile,
@@ -514,11 +529,14 @@ export function DevupUI({
? []
: undefined
}
- if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) {
+ const isDevupFile =
+ resolve(file) === resolve(devupFile) && existsSync(devupFile)
+ const isTailwind = isTailwindFile(file) && existsSync(file)
+ if (!isDevupFile && !isTailwind) {
return
}
- await writeDataFiles({
+ tailwindFiles = await writeDataFiles({
package: libPackage,
cssDir,
devupFile,
@@ -527,7 +545,11 @@ export function DevupUI({
})
const invalidatedModules = new Set()
- for (const mod of modules) {
+ // A Tailwind class compiles from the definitions of the Tailwind CSS, so
+ // every module is compiled again
+ for (const mod of isTailwind
+ ? environment.moduleGraph.idToModuleMap.values()
+ : modules) {
environment.moduleGraph.invalidateModule(
mod,
invalidatedModules,
@@ -540,11 +562,14 @@ export function DevupUI({
},
// Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists.
async handleHotUpdate({ file, server, modules, timestamp }) {
- if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) {
+ const isDevupFile =
+ resolve(file) === resolve(devupFile) && existsSync(devupFile)
+ const isTailwind = isTailwindFile(file) && existsSync(file)
+ if (!isDevupFile && !isTailwind) {
return
}
- await writeDataFiles({
+ tailwindFiles = await writeDataFiles({
package: libPackage,
cssDir,
devupFile,
@@ -553,7 +578,9 @@ export function DevupUI({
})
const invalidatedModules = new Set()
- for (const mod of modules) {
+ for (const mod of isTailwind
+ ? server.moduleGraph.idToModuleMap.values()
+ : modules) {
server.moduleGraph.invalidateModule(
mod,
invalidatedModules,
diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts
index 524e755bd..ab3a1f617 100644
--- a/packages/webpack-plugin/src/__tests__/plugin.test.ts
+++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts
@@ -323,6 +323,79 @@ describe('devupUIWebpackPlugin', () => {
expect(importFileMapSpy).toHaveBeenCalledWith({})
})
+ describe('Tailwind CSS definitions', () => {
+ const tailwindFile = resolve('styles/tailwind.css')
+
+ function mockTailwindCss() {
+ existsSyncSpy.mockImplementation(
+ (path: string) => path === 'devup.json' || path === tailwindFile,
+ )
+ loadDevupConfigSyncSpy.mockReturnValue({
+ tailwind: { css: 'styles/tailwind.css' },
+ })
+ readFileSyncSpy.mockImplementation((path: string) =>
+ path === tailwindFile ? '@theme { --color-brand: #0af; }' : '{}',
+ )
+ }
+
+ it('registers the text of the Tailwind CSS and depends on its files', () => {
+ mockTailwindCss()
+ const plugin = new DevupUIWebpackPlugin({})
+ const compiler = createCompiler()
+
+ plugin.apply(asCompiler(compiler))
+
+ expect(registerThemeSpy).toHaveBeenCalledWith({
+ tailwindCss: '@theme { --color-brand: #0af; }',
+ })
+ const add = mock()
+ compiler.hooks.afterCompile.tap.mock.calls[0][1]({
+ fileDependencies: { add },
+ })
+ expect(add).toHaveBeenCalledWith(resolve('devup.json'))
+ expect(add).toHaveBeenCalledWith(tailwindFile)
+ })
+
+ it('reads it again when a file changes', async () => {
+ mockTailwindCss()
+ const plugin = new DevupUIWebpackPlugin({ watch: true })
+ const compiler = createCompiler()
+ statSpy.mockResolvedValueOnce(createStats(1))
+ statSpy.mockResolvedValueOnce(createStats(1))
+ statSpy.mockResolvedValueOnce(createStats(1))
+ statSpy.mockResolvedValueOnce(createStats(2))
+ plugin.apply(asCompiler(compiler))
+ registerThemeSpy.mockClear()
+
+ await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]()
+ await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]()
+
+ expect(registerThemeSpy).toHaveBeenCalledWith({
+ tailwindCss: '@theme { --color-brand: #0af; }',
+ })
+ })
+
+ it('takes a Tailwind CSS that is gone as unchanged', async () => {
+ existsSyncSpy.mockImplementation((path: string) => path === tailwindFile)
+ loadDevupConfigSyncSpy.mockReturnValue({
+ tailwind: { css: 'styles/tailwind.css' },
+ })
+ readFileSyncSpy.mockImplementation((path: string) =>
+ path === tailwindFile ? '@import "tailwindcss";' : '{}',
+ )
+ const plugin = new DevupUIWebpackPlugin({
+ watch: true,
+ devupFile: 'missing.json',
+ })
+ const compiler = createCompiler()
+ statSpy.mockRejectedValue(new Error('gone'))
+ plugin.apply(asCompiler(compiler))
+
+ await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]()
+
+ expect(plugin.tailwindFiles).toEqual([tailwindFile])
+ })
+ })
it.each(
createTestMatrix({
watch: [true, false],
diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts
index 83b2c2264..b96f2e176 100644
--- a/packages/webpack-plugin/src/plugin.ts
+++ b/packages/webpack-plugin/src/plugin.ts
@@ -17,7 +17,9 @@ import {
loadDevupConfigSync,
mergeImportAliases,
planAtomHoist,
+ tailwindCssFiles,
type WasmImportAliases,
+ withTailwindCss,
} from '@devup-ui/plugin-utils'
import {
codeExtract,
@@ -118,12 +120,16 @@ export class DevupUIWebpackPlugin {
this.fileMapFile = join(this.options.distDir, 'fileMap.json')
}
+ /** The files the project's Tailwind CSS is read from */
+ tailwindFiles: string[] = []
+
writeDataFiles() {
try {
const config = loadDevupConfigSync(this.options.devupFile)
const theme = config.theme ?? {}
- registerTheme(theme)
+ registerTheme(withTailwindCss(theme, config))
+ this.tailwindFiles = tailwindCssFiles(config)
const interfaceCode = getThemeInterface(
...createThemeInterfaceArgs(this.options.package),
)
@@ -297,12 +303,21 @@ export class DevupUIWebpackPlugin {
}
if (this.options.watch) {
- let lastModifiedTime: number | null = null
+ let lastModifiedTime: string | null = null
compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => {
- if (existsDevup) {
- const stats = await stat(this.options.devupFile)
-
- const modifiedTime = stats.mtimeMs
+ const stamps: number[] = []
+ if (existsDevup)
+ stamps.push((await stat(this.options.devupFile)).mtimeMs)
+ for (const file of this.tailwindFiles) {
+ stamps.push(
+ await stat(file).then(
+ (stats) => stats.mtimeMs,
+ () => 0,
+ ),
+ )
+ }
+ if (stamps.length > 0) {
+ const modifiedTime = stamps.join()
if (lastModifiedTime && lastModifiedTime !== modifiedTime)
this.writeDataFiles()
@@ -310,11 +325,13 @@ export class DevupUIWebpackPlugin {
}
})
}
- if (existsDevup)
+ if (existsDevup || this.tailwindFiles.length > 0)
compiler.hooks.afterCompile.tap('DevupUIWebpackPlugin', (compilation) => {
- compilation.fileDependencies.add(resolve(this.options.devupFile))
+ if (existsDevup)
+ compilation.fileDependencies.add(resolve(this.options.devupFile))
+ for (const file of this.tailwindFiles)
+ compilation.fileDependencies.add(resolve(file))
})
-
compiler.options.plugins.push(
new compiler.webpack.DefinePlugin({
'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()),