diff --git a/.changepacks/changepack_log_global_css_fixes.json b/.changepacks/changepack_log_global_css_fixes.json new file mode 100644 index 00000000..6f7ea5cc --- /dev/null +++ b/.changepacks/changepack_log_global_css_fixes.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "globalCss: theme tokens ($text) resolve to var(--text) in global rules instead of being written as invalid $text; imports given as { url, query } objects with unquoted keys are emitted instead of dropped; an '@layer name' key puts the rules it holds in that cascade layer (layers nest as base.reset) instead of flattening them into invalid declarations, and a layer name that is not one identifier is a build error", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/features/page.mdx b/apps/landing/src/app/(detail)/docs/features/page.mdx index afb7227d..da8b9771 100644 --- a/apps/landing/src/app/(detail)/docs/features/page.mdx +++ b/apps/landing/src/app/(detail)/docs/features/page.mdx @@ -265,6 +265,15 @@ globalCss({ }) ``` +Theme tokens work as in any style (`body: { color: '$text' }`). `imports` takes URLs or `{ url, query }` objects, emitted as `@import` rules; an `'@layer name'` key puts the rules it holds in that cascade layer, and layers nest (`'@layer base': { '@layer reset': { ... } }` is `base.reset`): + +```tsx +globalCss({ + imports: ['reset.css', { url: 'print.css', query: 'print' }], + '@layer utilities': { '.hidden': { display: 'none' } }, +}) +``` + ## keyframes() API Define CSS animations. diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index e82d4a73..a4484b90 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -90,6 +90,30 @@ fn collect_global_styles<'a>( ); } } + } else if let Some(layer) = name + .strip_prefix("@layer") + .filter(|rest| rest.starts_with(char::is_whitespace)) + .map(str::trim) + { + if layer.is_empty() + || !layer.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') + }) + { + styles.push(unreadable_key(&o.key, false)); + continue; + } + // `'@layer name': { selector: rules }` puts the rules it holds in the layer + let mut layered = vec![]; + collect_global_styles( + ast_builder, + &mut o.value, + file, + at_rules, + &mut layered, + ); + place_in_layer(&mut layered, layer); + styles.extend(layered); } else if let Some(at_rule) = global_at_rule_key(&name) { let mut nested = at_rules.to_vec(); nested.push(at_rule); @@ -106,9 +130,7 @@ fn collect_global_styles<'a>( let mut query = None; for p in &obj.properties { if let ObjectPropertyKind::ObjectProperty(o) = p - && let Some(ident) = o.key.as_expression() - && let Some(ident) = - get_string_by_literal_expression(ident) + && let Some(ident) = get_str_by_property_key(&o.key) { if ident == "url" { url = diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 62018c30..68981f36 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -10854,6 +10854,42 @@ const className = myCss({ bg: "red" }) )); } + #[test] + #[serial] + fn test_global_css_layer_blocks_and_import_objects() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import {globalCss} from '@devup-ui/react' +globalCss({ + imports: [{ url: 'a.css', query: 'screen' }, { 'url': 'b.css' }], + '@layer utilities': { '.x': { color: 'blue' }, '.y': { color: 'red', _print: { color: 'black' } } }, + '@layer base': { '@layer reset': { html: { m: 0 } } }, +})", + ExtractOption::default(), + ) + .unwrap() + )); + for key in ["'@layer a, b'", "'@layer a b'"] { + reset_class_map(); + reset_file_map(); + let message = extract( + "test.tsx", + &format!("import {{globalCss}} from '@devup-ui/react'\nglobalCss({{ {key}: {{ '.x': {{ color: 'red' }} }} }})"), + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + message.contains("`globalCss()` cannot use"), + "{key}: {message}" + ); + } + } + #[test] #[serial] fn test_global_css_with_imports() { diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_layer_blocks_and_import_objects.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_layer_blocks_and_import_objects.snap new file mode 100644 index 00000000..f039363f --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_layer_blocks_and_import_objects.snap @@ -0,0 +1,104 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {globalCss} from '@devup-ui/react'\nglobalCss({\n imports: [{ url: 'a.css', query: 'screen' }, { 'url': 'b.css' }],\n '@layer utilities': { '.x': { color: 'blue' }, '.y': { color: 'red', _print: { color: 'black' } } },\n '@layer base': { '@layer reset': { html: { m: 0 } } },\n})\",\nExtractOption::default(),).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "black", + level: 0, + selector: Some( + At { + kind: Media, + query: "print", + selector: Some( + ".y", + ), + outer: [], + file: Some( + "test.tsx", + ), + }, + ), + style_order: Some( + 0, + ), + layer: Some( + "utilities", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: Some( + Global( + ".x", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "utilities", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: Some( + Global( + ".y", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "utilities", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "0", + level: 0, + selector: Some( + Global( + "html", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "base.reset", + ), + }, + ), + Import( + ExtractImport { + url: "\"a.css\" screen", + file: "test.tsx", + }, + ), + Import( + ExtractImport { + url: "\"b.css\"", + file: "test.tsx", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\n;\n", +} diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 511b4af2..115b62f3 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -1066,9 +1066,7 @@ impl StyleSheet { css.push('{'); open_selector = Some(selector); } - css.push_str(&prop.property); - css.push(':'); - css.push_str(&prop.value); + prop.write_declaration(css); } if open_selector.is_some() { css.push('}'); @@ -3589,6 +3587,30 @@ mod tests { } } + #[test] + #[serial] + fn test_global_css_reads_theme_tokens() { + let mut sheet = StyleSheet::default(); + let output = extract( + "global.tsx", + "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: '$text', border: '1px solid $line.100' } })", + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "global.tsx", true); + let css = sheet.create_css(None, false); + assert!( + css.contains("body{border:1px solid var(--line-100);color:var(--text)}"), + "{css}" + ); + } + #[test] #[serial] fn test_rm_global_css_drops_global_at_rules() {