diff --git a/.changepacks/changepack_log_tailwind_hover_active_order.json b/.changepacks/changepack_log_tailwind_hover_active_order.json
new file mode 100644
index 000000000..6d748495f
--- /dev/null
+++ b/.changepacks/changepack_log_tailwind_hover_active_order.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "Order standalone nonresponsive hover-capability guards within their selector group so active, focus and disabled styles can override hover. Keep responsive Tailwind hover and Devup selector prop ordering unchanged. No new build errors.",
+ "date": "2026-10-09T00:00:00.000Z"
+}
diff --git a/e2e/tailwind-hover-order.spec.ts b/e2e/tailwind-hover-order.spec.ts
new file mode 100644
index 000000000..803106f74
--- /dev/null
+++ b/e2e/tailwind-hover-order.spec.ts
@@ -0,0 +1,259 @@
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+import { expect, test } from '@playwright/test'
+
+import {
+ codeExtractWithoutSourceMap,
+ getCss,
+ importClassMap,
+ importFileMap,
+ importSheet,
+ registerTheme,
+} from '../bindings/devup-ui-wasm/pkg/index.js'
+
+const tailwindRequire = createRequire(
+ new URL('../benchmark/next-tailwind/package.json', import.meta.url),
+)
+const {
+ compile,
+}: typeof import('../benchmark/next-tailwind/node_modules/tailwindcss/dist/lib.mjs') =
+ tailwindRequire('tailwindcss')
+const tailwindTheme = readFileSync(
+ tailwindRequire.resolve('tailwindcss/theme.css'),
+ 'utf8',
+)
+const cases = [
+ {
+ classes: 'hover:bg-red-500 active:bg-blue-500',
+ winners: ['blue', 'blue'],
+ },
+ {
+ classes: 'md:hover:bg-red-500 active:bg-blue-500',
+ winners: ['blue', 'red'],
+ },
+ {
+ classes: 'hover:bg-red-500 focus:bg-green-500 active:bg-blue-500',
+ winners: ['blue', 'blue'],
+ },
+ {
+ classes: 'hover:bg-red-500 disabled:bg-gray-500',
+ winners: ['gray', 'gray'],
+ },
+ {
+ classes: 'dark:hover:bg-red-500 active:bg-blue-500',
+ winners: ['red', 'red'],
+ },
+ {
+ classes: 'hover:bg-red-500 active:bg-blue-500',
+ props:
+ '_media={{"(hover: hover)": {_hover: {bgColor:"#EF4444"}}}} _active={{bgColor:"#3B82F6"}}',
+ winners: ['blue', 'blue'],
+ },
+] as const
+const colors = {
+ red: ['oklch(0.637 0.237 25.331)', 'rgb(239, 68, 68)'],
+ blue: ['oklch(0.623 0.214 259.815)', 'rgb(59, 130, 246)'],
+ green: ['oklch(0.723 0.219 149.579)', 'rgb(34, 197, 94)'],
+ gray: ['oklch(0.551 0.027 264.364)', 'rgb(107, 114, 128)'],
+}
+
+for (const singleCss of [true, false]) {
+ for (const fixture of [
+ {
+ props:
+ '_hover={{bg:"red"}} _focus={{bg:"green"}} _active={{bg:"blue"}} _disabled={{bg:"gray"}}',
+ css: '.a:hover{background:red}.b:focus{background:green}.c:active{background:blue}.d:disabled{background:gray}',
+ },
+ {
+ props:
+ 'bg={["white","black"]} _hover={{bg:["red",null,"orange"]}} _focus={{bg:"green"}} _active={{bg:"blue"}} _disabled={{bg:"gray"}}',
+ css: '.a{background:white}@media(min-width:480px){.b{background:black}}.c:hover{background:red}@media(min-width:768px){.d:hover{background:orange}}.e:focus{background:green}.f:active{background:blue}.g:disabled{background:gray}',
+ },
+ ]) {
+ test(`Devup selector props unchanged, ${fixture.props}, singleCss=${singleCss}`, () => {
+ importSheet({})
+ importClassMap({})
+ importFileMap({})
+ registerTheme({})
+ const output = codeExtractWithoutSourceMap(
+ 'selector-props.tsx',
+ `import { Box } from '@devup-ui/react';\n`,
+ '@devup-ui/react',
+ 'df',
+ singleCss,
+ false,
+ false,
+ {},
+ )
+ try {
+ const actual = singleCss ? getCss(null, false) : output.css
+ const expected = singleCss
+ ? fixture.css
+ : fixture.css.replace(/\.([a-g])/g, '.a-$1')
+ expect(actual?.replace(/^\/\*[\s\S]*?\*\//, '')).toBe(expected)
+ } finally {
+ output.free()
+ }
+ })
+ }
+}
+
+test.describe('Hover capability guard', () => {
+ test.use({ hasTouch: true, isMobile: true })
+ for (const singleCss of [true, false]) {
+ test(`touch input keeps the base background, singleCss=${singleCss}`, async ({
+ page,
+ }) => {
+ const classes = 'bg-white hover:bg-red-500'
+ const native = await compile(`${tailwindTheme}\n@tailwind utilities;`)
+ const oracleCss = native.build(classes.split(' '))
+ importSheet({})
+ registerTheme({})
+ const output = codeExtractWithoutSourceMap(
+ 'hover-capability.tsx',
+ `import { Box } from '@devup-ui/react';\n`,
+ '@devup-ui/react',
+ 'df',
+ singleCss,
+ false,
+ false,
+ {},
+ )
+ try {
+ const matched = output.code.match(/className="([^"]+)"/)
+ expect(matched).not.toBeNull()
+ const devupCss = singleCss
+ ? getCss(null, false)
+ : getCss(null, false) + output.css
+ expect(devupCss).toContain('(hover:hover)')
+ for (const [className, css] of [
+ [classes, oracleCss],
+ [matched?.[1], devupCss],
+ ]) {
+ await page.setContent(
+ `
tap
`,
+ )
+ expect(
+ await page.evaluate(() => matchMedia('(hover:hover)').matches),
+ ).toBe(false)
+ await page.locator('#target').tap()
+ await expect(page.locator('#target')).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 255, 255)',
+ )
+ }
+ } finally {
+ output.free()
+ }
+ })
+ }
+})
+
+for (const singleCss of [true, false]) {
+ for (const fixture of cases) {
+ for (const [index, width] of [640, 1024].entries()) {
+ const label = 'props' in fixture ? 'explicit Devup guard: ' : ''
+ test(`${label}${fixture.classes}, ${width}px, singleCss=${singleCss}`, async ({
+ page,
+ }, testInfo) => {
+ const disabled = fixture.classes.includes('disabled:')
+ const native = await compile(`${tailwindTheme}\n@tailwind utilities;`)
+ const oracleCss = native.build(fixture.classes.split(' '))
+ importSheet({})
+ registerTheme({})
+ const props =
+ 'props' in fixture
+ ? fixture.props
+ : `className="${fixture.classes}" ${disabled ? 'disabled' : ''}`
+ const source = `import { Box } from '@devup-ui/react';\n`
+ const output = codeExtractWithoutSourceMap(
+ 'hover-order.tsx',
+ source,
+ '@devup-ui/react',
+ 'df',
+ singleCss,
+ false,
+ false,
+ {},
+ )
+ try {
+ const matched = output.code.match(/className="([^"]+)"/)
+ expect(matched).not.toBeNull()
+ if (!singleCss) expect(output.cssFile).toBeDefined()
+ const devupCss = singleCss
+ ? getCss(null, false)
+ : getCss(null, false) + output.css
+ expect(devupCss).toContain('(hover:hover)')
+ const expected = colors[fixture.winners[index]]
+ await page.setViewportSize({ width, height: 400 })
+ await page.emulateMedia({ colorScheme: 'dark' })
+ const observed = []
+ for (const [name, classes, css, color] of [
+ ['Tailwind', fixture.classes, oracleCss, expected[0]],
+ ['Devup', matched?.[1], devupCss, expected[1]],
+ ]) {
+ await page.setContent(
+ ``,
+ )
+ await page.mouse.move(width - 1, 399)
+ const target = page.locator('#target')
+ const baseline = await target.evaluate(
+ (element) => getComputedStyle(element).backgroundColor,
+ )
+ await target.hover()
+ if (fixture.classes.startsWith('md:') && width < 768) {
+ await expect(target).toHaveCSS('background-color', baseline)
+ } else if (!disabled) {
+ await expect(target).toHaveCSS(
+ 'background-color',
+ colors.red[name === 'Tailwind' ? 0 : 1],
+ )
+ }
+ if (fixture.classes.includes('focus:')) {
+ await target.focus()
+ await expect(target).toHaveCSS(
+ 'background-color',
+ colors.green[name === 'Tailwind' ? 0 : 1],
+ )
+ }
+ await page.mouse.down()
+ try {
+ const held = await target.evaluate((element) => ({
+ color: getComputedStyle(element).backgroundColor,
+ hover: element.matches(':hover'),
+ active: element.matches(':active'),
+ focus: element.matches(':focus'),
+ disabled: element.matches(':disabled'),
+ hoverMedia: matchMedia('(hover:hover)').matches,
+ }))
+ expect(held.hover, name).toBe(true)
+ expect(held.active, name).toBe(true)
+ expect(held.hoverMedia, name).toBe(true)
+ expect(held.disabled, name).toBe(disabled)
+ expect(held.focus, name).toBe(!disabled)
+ observed.push({ name, held })
+ expect(held.color, name).toBe(color)
+ if (name === 'Devup') {
+ const screenshot = testInfo.outputPath('held.png')
+ await page.screenshot({ path: screenshot })
+ await testInfo.attach('held-Devup', {
+ path: screenshot,
+ contentType: 'image/png',
+ })
+ }
+ } finally {
+ await page.mouse.up()
+ }
+ }
+ await testInfo.attach('cascade-evidence', {
+ body: JSON.stringify({ source, oracleCss, devupCss, observed }),
+ contentType: 'application/json',
+ })
+ } finally {
+ output.free()
+ }
+ })
+ }
+ }
+}
diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs
index 3f3ea2732..df2ac1e4f 100644
--- a/libs/sheet/src/lib.rs
+++ b/libs/sheet/src/lib.rs
@@ -871,7 +871,7 @@ impl StyleSheet {
// Estimate ~64 bytes per property for pre-allocation
let prop_count: usize = map.values().map(FxHashSet::len).sum();
let mut current_css = String::with_capacity(prop_count * 64);
- let mut class_rules: Vec<(RuleOrder<'_>, &StyleSheetProperty)> =
+ let mut class_rules: Vec<(RuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> =
Vec::with_capacity(prop_count);
let mut at_rules: Vec<(AtRuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::new();
let mut global_props: Vec> = Vec::new();
@@ -890,15 +890,33 @@ impl StyleSheet {
query,
selector,
outer,
- ..
+ file,
}) => {
+ let wrapper = Wrapper {
+ level: *level,
+ at_rule: Some((outer.as_slice(), *kind, query.as_str())),
+ };
+ let selector = selector.as_deref();
+ if *level == 0
+ && *kind == AtRuleKind::Media
+ && query == "(hover:hover)"
+ && outer.is_empty()
+ && file.is_none()
+ && selector.is_some()
+ {
+ class_rules.push((
+ (selector_group(selector), *level, selector.unwrap_or("")),
+ wrapper,
+ prop,
+ ));
+ continue;
+ }
let chain = outer
.iter()
.map(|rule| (rule.kind, rule.query.as_str()))
.chain(std::iter::once((*kind, query.as_str())))
.map(|(kind, query)| (kind as u8, query_order(kind, query), query))
.collect();
- let selector = selector.as_deref();
at_rules.push((
(
chain,
@@ -906,18 +924,26 @@ impl StyleSheet {
*level,
selector.unwrap_or(""),
),
- Wrapper {
- level: *level,
- at_rule: Some((outer.as_slice(), *kind, query.as_str())),
- },
+ wrapper,
prop,
));
}
Some(StyleSelector::Selector(selector)) => class_rules.push((
(selector_group(Some(selector)), *level, selector.as_str()),
+ Wrapper {
+ level: *level,
+ at_rule: None,
+ },
+ prop,
+ )),
+ None => class_rules.push((
+ (selector_group(None), *level, ""),
+ Wrapper {
+ level: *level,
+ at_rule: None,
+ },
prop,
)),
- None => class_rules.push(((selector_group(None), *level, ""), prop)),
}
}
}
@@ -927,25 +953,22 @@ impl StyleSheet {
// Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint
// level, so `:active` still follows a `:hover` set at a wider breakpoint while
- // one selector's responsive values keep ascending. At-rules follow every plain
- // rule so a condition beats the breakpoint values it overrides; among
- // themselves they order by condition, then selector group, then level.
- class_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.1, b.1)));
+ // one selector's responsive values keep ascending. Standalone level-0 hover
+ // guards share that order without losing their media wrapper. Other at-rules,
+ // including responsive hover guards, follow these rules and keep ordering by
+ // condition, then selector group, then level.
+ class_rules.sort_by(|a, b| {
+ a.0.cmp(&b.0)
+ .then_with(|| prop_cmp(a.2, b.2))
+ .then_with(|| a.2.selector.cmp(&b.2.selector))
+ });
at_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.2, b.2)));
let mut open: Option<(Wrapper<'_>, Option)> = None;
let mut open_rule: Option<&StyleSheetProperty> = None;
for (wrapper, prop) in class_rules
.iter()
- .map(|((_, level, _), prop)| {
- (
- Wrapper {
- level: *level,
- at_rule: None,
- },
- *prop,
- )
- })
+ .map(|(_, wrapper, prop)| (*wrapper, *prop))
.chain(at_rules.iter().map(|(_, wrapper, prop)| (*wrapper, *prop)))
{
if open.as_ref().is_none_or(|(current, _)| *current != wrapper) {
@@ -3333,6 +3356,10 @@ mod tests {
}
fn pipeline_css(theme: Theme, source: &str) -> String {
+ pipeline_css_in_mode(theme, source, true)
+ }
+
+ fn pipeline_css_in_mode(theme: Theme, source: &str, single_css: bool) -> String {
css::debug::set_debug(false);
css::set_prefix(None);
css::atom_hoist::set_atom_hoist(None);
@@ -3346,19 +3373,28 @@ mod tests {
ExtractOption {
package: "@devup-ui/core".to_string(),
css_dir: "@devup-ui/core".to_string(),
- single_css: true,
+ single_css,
import_main_css: false,
import_aliases: std::collections::HashMap::new(),
},
)
.unwrap();
- sheet.update_styles(&output.styles, "test.tsx", true);
+ sheet.update_styles(&output.styles, "test.tsx", single_css);
+ let filename = if single_css { None } else { Some("test.tsx") };
+ let css = sheet.create_css(filename, false);
+ if !single_css {
+ assert_eq!(sheet.create_css(None, false), StyleSheet::create_header());
+ assert_eq!(
+ sheet.create_css(Some("other.tsx"), false),
+ StyleSheet::create_header()
+ );
+ }
// Class names come from a process-wide counter; number them by first
// appearance so the expected CSS only pins down structure and order.
let mut names: Vec = vec![];
compile_regex(r"\.([A-Za-z_][\w-]*)")
.replace_all(
- sheet.create_css(None, false).split("*/").nth(1).unwrap(),
+ css.split("*/").nth(1).unwrap(),
|caps: ®ex_lite::Captures| {
let index = names.iter().position(|n| *n == caps[1]).unwrap_or_else(|| {
names.push(caps[1].to_string());
@@ -3370,6 +3406,256 @@ mod tests {
.into_owned()
}
+ #[rstest]
+ #[case(
+ "hover:bg-red-500 active:bg-blue-500",
+ "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}"
+ )]
+ #[case(
+ "md:hover:bg-red-500 active:bg-blue-500",
+ ".c0:active{background-color:#3B82F6}@media(min-width:768px)and (hover:hover){.c1:hover{background-color:#EF4444}}"
+ )]
+ #[case(
+ "hover:bg-red-500 focus:bg-green-500 active:bg-blue-500",
+ "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:focus{background-color:#22C55E}.c2:active{background-color:#3B82F6}"
+ )]
+ #[case(
+ "hover:bg-red-500 disabled:bg-gray-500",
+ "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:disabled{background-color:#6B7280}"
+ )]
+ #[case(
+ "dark:hover:bg-red-500 active:bg-blue-500",
+ "@media(hover:hover){:root[data-theme=dark] .c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}"
+ )]
+ #[serial]
+ fn tailwind_states_keep_css_order_when_hover_is_guarded(
+ #[case] classes: &str,
+ #[case] expected: &str,
+ #[values(true, false)] single_css: bool,
+ ) {
+ // Given the same utilities in either authored token order and CSS mode.
+ for tokens in [
+ classes.to_string(),
+ classes
+ .split_whitespace()
+ .rev()
+ .collect::>()
+ .join(" "),
+ ] {
+ let source = format!("");
+ // When extraction and sheet emission run together.
+ let css = pipeline_css_in_mode(Theme::default(), &source, single_css);
+ // Then the exact cascade order retains each original guard and selector.
+ assert_eq!(css, expected, "{source}, single_css={single_css}");
+ }
+ }
+
+ #[rstest]
+ #[case(
+ r"",
+ "@media(hover:hover){.c0:hover{background:red}}.c1:active{background:blue}"
+ )]
+ #[case(
+ r"",
+ ".c0:hover{background:red}.c1:focus{background:green}.c2:active{background:blue}.c3:disabled{background:gray}"
+ )]
+ #[case(
+ r"",
+ ".c0{background:white}@media(min-width:480px){.c1{background:black}}.c2:hover{background:red}@media(min-width:768px){.c3:hover{background:orange}}.c4:focus{background:green}.c5:active{background:blue}.c6:disabled{background:gray}"
+ )]
+ #[case(
+ r"",
+ ".c0:hover{background:red}@media(min-width:768px){.c1:hover{background:blue}}.c2:active{background:green}"
+ )]
+ #[serial]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn devup_states_keep_css_order_when_guard_exception_is_applied(
+ #[case] source: &str,
+ #[case] expected: &str,
+ #[values(true, false)] single_css: bool,
+ ) {
+ // Given literal Devup state props, including the explicitly authored guard.
+ // When extraction emits the selected CSS mode.
+ let css = pipeline_css_in_mode(Theme::default(), source, single_css);
+ // Then only the standalone level-0 guard adopts ordinary state ordering.
+ assert_eq!(css, expected, "{source}, single_css={single_css}");
+ }
+
+ #[rstest]
+ #[case(
+ (2, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)").unwrap()),
+ ".active:active{color:blue}@media(min-width:768px)and (hover:hover){.guard:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:none)").unwrap()),
+ ".active:active{color:blue}@media(hover:none){.guard:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(prefers-reduced-motion:reduce)").unwrap()),
+ ".active:active{color:blue}@media(prefers-reduced-motion:reduce){.guard:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)and (min-width:900px)").unwrap()),
+ ".active:active{color:blue}@media(hover:hover)and (min-width:900px){.guard:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Supports, "(hover:hover)").unwrap()),
+ ".active:active{color:blue}@supports(hover:hover){.guard:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Container, "(hover:hover)").unwrap()),
+ ".active:active{color:blue}@container(hover:hover){.guard:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::At {
+ kind: AtRuleKind::Media,
+ query: "(hover:hover)".to_string(),
+ selector: Some("&:hover".to_string()),
+ outer: vec![AtRule { kind: AtRuleKind::Supports, query: "(display:grid)".to_string() }],
+ file: None,
+ }),
+ ".active:active{color:blue}@supports(display:grid){@media(hover:hover){.guard:hover{color:red}}}"
+ )]
+ #[case(
+ (0, StyleSelector::At {
+ kind: AtRuleKind::Media,
+ query: "(hover:hover)".to_string(),
+ selector: Some("body:hover".to_string()),
+ outer: vec![],
+ file: Some("global.tsx".to_string()),
+ }),
+ ".active:active{color:blue}@media(hover:hover){body:hover{color:red}}"
+ )]
+ #[case(
+ (0, StyleSelector::nest_at_rule(None, AtRuleKind::Media, "(hover:hover)").unwrap()),
+ ".active:active{color:blue}@media(hover:hover){.guard{color:red}}"
+ )]
+ #[serial]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn conditions_keep_css_order_when_guard_has_other_constraints(
+ #[case] condition: (u8, StyleSelector),
+ #[case] expected: &str,
+ #[values(true, false)] single_css: bool,
+ ) {
+ // Given an ordinary active state and one excluded conditional shape.
+ let mut sheet = StyleSheet::default();
+ let filename = if single_css { None } else { Some("test.tsx") };
+ sheet.add_property(
+ "active",
+ "color",
+ 0,
+ "blue",
+ Some(&"active".into()),
+ None,
+ filename,
+ );
+ sheet.add_property(
+ "guard",
+ "color",
+ condition.0,
+ "red",
+ Some(&condition.1),
+ None,
+ filename,
+ );
+ // When the sheet emits its original condition-ordered tail.
+ let css = sheet.create_css(filename, false);
+ // Then the exact wrapper and its position after active remain unchanged.
+ assert_eq!(css.split("*/").nth(1).unwrap(), expected);
+ }
+
+ #[rstest]
+ #[serial]
+ fn guarded_rules_merge_when_wrappers_and_selectors_match(
+ #[values(None, Some("ui"))] layer: Option<&str>,
+ #[values(true, false)] single_css: bool,
+ ) {
+ // Given multiple declarations, guarded classes, and wrapper transitions in o2.
+ let mut sheet = StyleSheet::default();
+ let filename = if single_css { None } else { Some("test.tsx") };
+ let guard =
+ StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)")
+ .unwrap();
+ let motion =
+ StyleSelector::nest_at_rule(None, AtRuleKind::Media, "(prefers-reduced-motion:reduce)")
+ .unwrap();
+ let focus = StyleSelector::from("focus");
+ let active = StyleSelector::from("active");
+ for (name, property, value, selector) in [
+ ("base", "color", "black", None),
+ ("guard", "background", "red", Some(&guard)),
+ ("guard", "color", "white", Some(&guard)),
+ ("other", "color", "yellow", Some(&guard)),
+ ("focus", "color", "green", Some(&focus)),
+ ("active", "color", "blue", Some(&active)),
+ ("motion", "color", "gray", Some(&motion)),
+ ] {
+ sheet.add_property_with_layer(
+ name,
+ property,
+ 0,
+ value,
+ selector,
+ Some(2),
+ filename,
+ layer,
+ );
+ }
+ // When emission crosses base, guard, state, and conditional wrappers.
+ let css = sheet.create_css(filename, false);
+ // Then declarations merge only within a matching rule and layer.
+ #[allow(clippy::literal_string_with_formatting_args)]
+ let body = ".base{color:black}@media(hover:hover){.guard:hover{background:red;color:white}.other:hover{color:yellow}}.focus:focus{color:green}.active:active{color:blue}@media(prefers-reduced-motion:reduce){.motion{color:gray}}";
+ let expected = match layer {
+ Some(layer) => format!("@layer o2{{@layer {layer}{{{body}}}}}"),
+ None => format!("@layer o2{{{body}}}"),
+ };
+ let declaration = if single_css { "@layer o2;" } else { "" };
+ assert_eq!(
+ css.split("*/").nth(1).unwrap(),
+ format!("{declaration}{expected}")
+ );
+ }
+
+ #[rstest]
+ #[case(
+ "red",
+ ".same:hover{color:red}@media(hover:hover){.same:hover{color:red}}"
+ )]
+ #[case(
+ "blue",
+ "@media(hover:hover){.same:hover{color:blue}}.same:hover{color:red}"
+ )]
+ #[case(
+ "white",
+ ".same:hover{color:red}@media(hover:hover){.same:hover{color:white}}"
+ )]
+ #[serial]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn guarded_and_ordinary_rules_stay_distinct_when_order_keys_tie(
+ #[case] guarded_value: &str,
+ #[case] expected: &str,
+ #[values(true, false)] reverse: bool,
+ ) {
+ // Given equal order keys, with either tied or distinct declaration values.
+ let mut sheet = StyleSheet::default();
+ let guard =
+ StyleSelector::nest_at_rule(Some(&"hover".into()), AtRuleKind::Media, "(hover:hover)")
+ .unwrap();
+ let ordinary = StyleSelector::from("hover");
+ let mut rules = [(&guard, guarded_value), (&ordinary, "red")];
+ if reverse {
+ rules.reverse();
+ }
+ for (selector, value) in rules {
+ sheet.add_property("same", "color", 0, value, Some(selector), None, None);
+ }
+ // When sorting must resolve an otherwise identical rule key.
+ let css = sheet.create_css(None, false);
+ // Then the selector tie is deterministic without dropping either wrapper.
+ assert_eq!(css.split("*/").nth(1).unwrap(), expected);
+ }
+
#[test]
#[serial]
#[allow(clippy::literal_string_with_formatting_args)]