diff --git a/e2e/css-composition.spec.ts b/e2e/css-composition.spec.ts
new file mode 100644
index 000000000..54463931b
--- /dev/null
+++ b/e2e/css-composition.spec.ts
@@ -0,0 +1,223 @@
+import { expect, test } from '@playwright/test'
+
+import {
+ codeExtract,
+ exportCanonicalMap,
+ exportClassMap,
+ exportFileMap,
+ exportSheet,
+ getCss,
+ getPrefix,
+ importCanonicalMap,
+ importClassMap,
+ importFileMap,
+ importSheet,
+ isDebug,
+ setDebug,
+ setModuleResolver,
+ setPrefix,
+} from '../bindings/devup-ui-wasm/pkg/index.js'
+
+const api = "import { css } from '@devup-ui/react';"
+const leafRules = `{
+ color: ['orange', 'red', 'purple'], p: 2,
+ _hover: { color: ['tomato', 'red', 'purple'] }
+}`
+const laterRules = `{
+ color: ['blue', null, 'green', null, 'pink'], margin: 3,
+ _hover: { color: ['navy', null, 'lime'] }
+}`
+
+function exportClasses(code: string, name: string): string {
+ const literal = code.match(
+ new RegExp(`export const ${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*;`),
+ )?.[1]
+ if (literal === undefined) {
+ throw new TypeError(`Expected a compiled literal for ${name}: ${code}`)
+ }
+ const classes: unknown = JSON.parse(literal)
+ if (typeof classes !== 'string') {
+ throw new TypeError(`Expected class string for ${name}`)
+ }
+ expect(classes).toMatch(/^[a-zA-Z0-9_-]+(?: [a-zA-Z0-9_-]+)*$/)
+ return classes
+}
+
+function compile(filename: string, source: string, singleCss: boolean) {
+ const output = codeExtract(
+ filename,
+ source,
+ '@devup-ui/react',
+ 'df',
+ singleCss,
+ false,
+ false,
+ {},
+ )
+ try {
+ const css = singleCss ? '' : output.css
+ if (css === undefined) {
+ throw new TypeError(`Expected per-file CSS for ${filename}`)
+ }
+ return { code: output.code, css, dependencies: output.dependencies }
+ } finally {
+ output.free()
+ }
+}
+
+function artifacts(
+ singleCss: boolean,
+ parentSource: string,
+ childSource?: string,
+) {
+ const saved = {
+ sheet: exportSheet(),
+ classes: exportClassMap(),
+ files: exportFileMap(),
+ canonical: exportCanonicalMap(),
+ debug: isDebug(),
+ prefix: getPrefix(),
+ }
+ try {
+ importClassMap({})
+ importFileMap({})
+ importCanonicalMap({})
+ setDebug(false)
+ setPrefix(null)
+ setModuleResolver((specifier: string, importer: string) =>
+ childSource !== undefined &&
+ specifier === './child' &&
+ importer === '/src/parent.ts'
+ ? { path: '/src/child.ts', code: childSource }
+ : undefined,
+ )
+ const child =
+ childSource === undefined
+ ? undefined
+ : compile('/src/child.ts', childSource, singleCss)
+ const parent = compile('/src/parent.ts', parentSource, singleCss)
+ if (child !== undefined) {
+ expect(parent.dependencies).toContain('/src/child.ts')
+ }
+ return {
+ css: singleCss
+ ? getCss(undefined, false)
+ : `${child?.css ?? ''}\n${parent.css}`,
+ parent: exportClasses(parent.code, 'base'),
+ child: exportClasses(
+ child?.code ?? parent.code,
+ child === undefined ? 'leaf' : 'base',
+ ),
+ }
+ } finally {
+ setModuleResolver(null)
+ importSheet(JSON.parse(saved.sheet))
+ importClassMap(JSON.parse(saved.classes))
+ importFileMap(JSON.parse(saved.files))
+ importCanonicalMap(JSON.parse(saved.canonical))
+ setDebug(saved.debug)
+ setPrefix(saved.prefix)
+ }
+}
+
+for (const singleCss of [true, false]) {
+ test.describe(`css composition (${singleCss ? 'single' : 'per-file'} CSS)`, () => {
+ test('later argument wins across the imported W35i leaf', async ({
+ page,
+ }) => {
+ const emitted = artifacts(
+ singleCss,
+ `${api} import { base as child } from './child'; export const base = css(child, { color: 'blue', margin: 3 });`,
+ `${api} export const base = css({ color: 'orange', p: 2 });`,
+ )
+ await page.setContent(`
+
Parent
+ Child
`)
+ await expect(page.locator('#parent')).toHaveCSS('color', 'rgb(0, 0, 255)')
+ await expect(page.locator('#parent')).toHaveCSS('padding', '8px')
+ await expect(page.locator('#parent')).toHaveCSS('margin', '12px')
+ await expect(page.locator('#child')).toHaveCSS(
+ 'color',
+ 'rgb(255, 165, 0)',
+ )
+ await expect(page.locator('#child')).toHaveCSS('padding', '8px')
+ })
+
+ for (const chain of [false, true]) {
+ test(`later hover/responsive arguments win across ${chain ? 'a same-module three-level chain' : 'an imported leaf'}`, async ({
+ page,
+ }) => {
+ const emitted = chain
+ ? artifacts(
+ singleCss,
+ `${api}
+ export const leaf = css(${leafRules});
+ const middle = css(leaf, ${laterRules});
+ export const base = css(middle, {
+ color: ['cyan', null, null, null, 'gold'],
+ _hover: { color: ['aqua', null, null, null, 'yellow'] }
+ });`,
+ )
+ : artifacts(
+ singleCss,
+ `${api} import { base as child } from './child'; export const base = css(child, ${laterRules});`,
+ `${api} export const base = css(${leafRules});`,
+ )
+ await page.setContent(`
+ Parent
+ Child
`)
+ const widths = [
+ [
+ 320,
+ chain ? 'rgb(0, 255, 255)' : 'rgb(0, 0, 255)',
+ chain ? 'rgb(0, 255, 255)' : 'rgb(0, 0, 128)',
+ 'rgb(255, 165, 0)',
+ 'rgb(255, 99, 71)',
+ ],
+ [
+ 600,
+ 'rgb(255, 0, 0)',
+ 'rgb(255, 0, 0)',
+ 'rgb(255, 0, 0)',
+ 'rgb(255, 0, 0)',
+ ],
+ [
+ 900,
+ 'rgb(0, 128, 0)',
+ 'rgb(0, 255, 0)',
+ 'rgb(128, 0, 128)',
+ 'rgb(128, 0, 128)',
+ ],
+ [
+ 1400,
+ chain ? 'rgb(255, 215, 0)' : 'rgb(255, 192, 203)',
+ chain ? 'rgb(255, 255, 0)' : 'rgb(0, 255, 0)',
+ 'rgb(128, 0, 128)',
+ 'rgb(128, 0, 128)',
+ ],
+ ] as const
+ for (const [
+ width,
+ parentColor,
+ hoverColor,
+ childColor,
+ childHoverColor,
+ ] of widths) {
+ await page.setViewportSize({ width, height: 600 })
+ await page.mouse.move(width - 1, 599)
+ await expect(page.locator('#parent')).toHaveCSS('color', parentColor)
+ await expect(page.locator('#parent')).toHaveCSS('padding', '8px')
+ await expect(page.locator('#parent')).toHaveCSS('margin', '12px')
+ await expect(page.locator('#child')).toHaveCSS('color', childColor)
+ await page.locator('#parent').hover()
+ await expect(page.locator('#parent')).toHaveCSS('color', hoverColor)
+ await page.locator('#child').hover()
+ await expect(page.locator('#child')).toHaveCSS(
+ 'color',
+ childHoverColor,
+ )
+ }
+ })
+ }
+ })
+}
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index a1b30ced1..ce7e3e3aa 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -15299,6 +15299,130 @@ export const d = css(ordered, danger, called);",
}
}
+ fn assert_composed_export_atoms(code: &str, export: &str, expected: &[&str]) {
+ let declaration = format!("export const {export} = \"");
+ let classes = code
+ .split_once(&declaration)
+ .expect("compiled export must be a static class string")
+ .1
+ .split_once('"')
+ .unwrap()
+ .0;
+ let mut actual: Vec<_> = classes
+ .split_whitespace()
+ .map(|class| class.trim_end_matches("-a"))
+ .collect();
+ actual.sort_unstable();
+ let mut expected = expected.to_vec();
+ expected.sort_unstable();
+ assert_eq!(actual, expected, "{code}");
+ }
+
+ #[rstest]
+ #[case(
+ "{ color: 'orange', p: 2 }",
+ "{ color: 'blue', margin: 3 }",
+ &["color-0-blue--255", "padding-0-8px--255", "margin-0-12px--255"]
+ )]
+ #[case(
+ "{ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] } }",
+ "{ color: ['blue', null, 'green', null, 'pink'], margin: 3, _hover: { color: ['blue', null, 'green'] } }",
+ &[
+ "color-0-blue--255", "color-1-red--255", "color-2-green--255",
+ "color-4-pink--255", "padding-0-8px--255", "margin-0-12px--255",
+ "color-0-blue-_a__c_hover-255", "color-1-red-_a__c_hover-255",
+ "color-2-green-_a__c_hover-255"
+ ]
+ )]
+ #[serial]
+ fn test_css_later_argument_wins_across_leaf(
+ #[case] earlier: &'static str,
+ #[case] later: &str,
+ #[case] expected: &[&str],
+ #[values(false, true)] imported: bool,
+ #[values(false, true)] single_css: bool,
+ ) {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[
+ (
+ "/src/child.ts",
+ "import { css } from '@devup-ui/react'; export const base = css({ color: 'orange', p: 2 });",
+ ),
+ (
+ "/src/responsive.ts",
+ "import { css } from '@devup-ui/react'; export const base = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] } });",
+ ),
+ ];
+ let leaf = if earlier.contains("_hover") {
+ "responsive"
+ } else {
+ "child"
+ };
+ let binding = if imported {
+ format!("import {{ base as child }} from './{leaf}';")
+ } else {
+ format!("const child = css({earlier});")
+ };
+ let source = format!(
+ "import {{ css }} from '@devup-ui/react'; {binding} export const base = css(child, {later});"
+ );
+ let resolver = memory_resolver(modules);
+ css::debug::set_debug(true);
+ let output = extract_with_modules(
+ "/src/parent.ts",
+ &source,
+ ExtractOption {
+ single_css,
+ ..ExtractOption::default()
+ },
+ false,
+ &resolver,
+ );
+ css::debug::set_debug(false);
+ let output = output.unwrap();
+ assert_composed_export_atoms(&output.code, "base", expected);
+ if imported {
+ assert!(output.dependencies.contains(&format!("/src/{leaf}.ts")));
+ }
+ }
+
+ #[rstest]
+ #[serial]
+ fn test_css_later_argument_wins_across_three_levels(#[values(false, true)] single_css: bool) {
+ reset_class_map();
+ reset_file_map();
+ let resolver = memory_resolver(&[]);
+ css::debug::set_debug(true);
+ let output = extract_with_modules(
+ "/src/chain.ts",
+ "import { css } from '@devup-ui/react';
+const leaf = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] }, styleOrder: 3 });
+const middle = css(leaf, { color: ['blue', null, 'green'], margin: 3, _hover: { color: ['blue', null, 'green'] } });
+export const base = css(middle, { color: ['cyan', null, null, null, 'pink'], _hover: { color: 'cyan' } });",
+ ExtractOption { single_css, ..ExtractOption::default() },
+ false,
+ &resolver,
+ );
+ css::debug::set_debug(false);
+ let output = output.unwrap();
+ assert_composed_export_atoms(
+ &output.code,
+ "base",
+ &[
+ "color-0-cyan--255",
+ "color-1-red--3",
+ "color-2-green--255",
+ "color-4-pink--255",
+ "padding-0-8px--3",
+ "margin-0-12px--255",
+ "color-0-cyan-_a__c_hover-255",
+ "color-1-red-_a__c_hover-3",
+ "color-2-green-_a__c_hover-255",
+ ],
+ );
+ }
+
// Each Tailwind class becomes the classes of its styles; every other class,
// and every class that runs into an interpolation, stays as written
#[rstest]