diff --git a/.changepacks/changepack_log_PpYddWjTwrwPJmblUAlm_.json b/.changepacks/changepack_log_PpYddWjTwrwPJmblUAlm_.json new file mode 100644 index 00000000..59226e33 --- /dev/null +++ b/.changepacks/changepack_log_PpYddWjTwrwPJmblUAlm_.json @@ -0,0 +1,5 @@ +{ + "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, + "note": "Optimize theme interface support deep colors", + "date": "2025-12-26T12:30:04.669317200Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index de71d758..a73a0784 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -216,8 +216,6 @@ pub fn get_theme_interface( } #[cfg(test)] mod tests { - use std::collections::HashMap; - use super::*; use insta::assert_debug_snapshot; use rstest::rstest; @@ -341,7 +339,7 @@ mod tests { let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#000"); - assert_eq!(color_theme.0.keys().count(), 1); + assert_eq!(color_theme.css_keys().count(), 1); theme.add_color_theme("default", color_theme); let mut color_theme = ColorTheme::default(); @@ -391,151 +389,44 @@ mod tests { ); assert_eq!(theme.to_css(), ""); - let mut theme = Theme::default(); - theme.add_color_theme( - "default", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + // Helper to create a ColorTheme with a single color + fn make_color_theme(name: &str, value: &str) -> ColorTheme { + let mut ct = ColorTheme::default(); + ct.add_color(name, value); + ct + } - theme.add_color_theme( - "dark", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + let mut theme = Theme::default(); + theme.add_color_theme("default", make_color_theme("primary", "#000")); + theme.add_color_theme("dark", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "dark", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); + theme.add_color_theme("dark", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "a", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "b", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("a", make_color_theme("primary", "#000")); + theme.add_color_theme("b", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "b", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "a", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "c", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); + theme.add_color_theme("b", make_color_theme("primary", "#000")); + theme.add_color_theme("a", make_color_theme("primary", "#000")); + theme.add_color_theme("c", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "b", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#001".to_string()); - map - }), - ); - - theme.add_color_theme( - "a", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#002".to_string()); - map - }), - ); - - theme.add_color_theme( - "c", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); + theme.add_color_theme("b", make_color_theme("primary", "#001")); + theme.add_color_theme("a", make_color_theme("primary", "#002")); + theme.add_color_theme("c", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); } @@ -584,7 +475,7 @@ mod tests { "TypographyInterface", "ThemeInterface" ), - "import \"package\";declare module \"package\"{interface ColorInterface{$primary:null;}interface TypographyInterface{}interface ThemeInterface{dark:null;}}" + "import \"package\";declare module \"package\"{interface ColorInterface{$primary:null}interface TypographyInterface{}interface ThemeInterface{dark:null}}" ); // test wrong case @@ -612,7 +503,7 @@ mod tests { "TypographyInterface", "ThemeInterface" ), - "import \"package\";declare module \"package\"{interface ColorInterface{[`$(primary)`]:null;}interface TypographyInterface{[`prim\\`\\`ary`]:null;}interface ThemeInterface{dark:null;}}" + "import \"package\";declare module \"package\"{interface ColorInterface{[`$(primary)`]:null}interface TypographyInterface{[`prim\\`\\`ary`]:null}interface ThemeInterface{dark:null}}" ); } 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 ed192426..cc519ca9 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 @@ -4,16 +4,24 @@ expression: theme --- Theme { colors: { - "dark": ColorTheme( - { - "primary": "#fff", + "dark": ColorTheme { + entries: { + "primary": ColorEntry { + interface_key: "primary", + css_key: "primary", + value: "#fff", + }, }, - ), - "default": ColorTheme( - { - "primary": "#000", + }, + "default": ColorTheme { + entries: { + "primary": ColorEntry { + interface_key: "primary", + css_key: "primary", + value: "#000", + }, }, - ), + }, }, breakpoints: [ 0, diff --git a/libs/sheet/Cargo.toml b/libs/sheet/Cargo.toml index 1db27748..8e3ecfc1 100644 --- a/libs/sheet/Cargo.toml +++ b/libs/sheet/Cargo.toml @@ -6,13 +6,13 @@ edition = "2024" [dependencies] css = { path = "../css" } serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.146" regex = "1.12.2" once_cell = "1.21.3" extractor = { path = "../extractor" } [dev-dependencies] insta = "1.45.0" -serde_json = "1.0.146" criterion = { version = "0.8", features = ["html_reports"] } rstest = "0.26.1" diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index a75f994d..e774d00d 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -383,11 +383,11 @@ impl StyleSheet { typography_interface_name: &str, theme_interface_name: &str, ) -> String { - let mut color_keys = HashSet::new(); - let mut typography_keys = HashSet::new(); - let mut theme_keys = HashSet::new(); + let mut color_keys = BTreeSet::new(); + let mut typography_keys = BTreeSet::new(); + let mut theme_keys = BTreeSet::new(); for color_theme in self.theme.colors.values() { - color_theme.0.keys().for_each(|key| { + color_theme.interface_keys().for_each(|key| { color_keys.insert(key.clone()); }); } @@ -409,19 +409,21 @@ impl StyleSheet { color_interface_name, color_keys .into_iter() - .map(|key| format!("{}:null;", convert_interface_key(&format!("${key}")))) - .collect::(), + .map(|key| format!("{}:null", convert_interface_key(&format!("${key}")))) + .collect::>() + .join(";"), typography_interface_name, typography_keys .into_iter() - .map(|key| format!("{}:null;", convert_interface_key(&key))) - .collect::(), + .map(|key| format!("{}:null", convert_interface_key(&key))) + .collect::>() + .join(";"), theme_interface_name, theme_keys .into_iter() - // key to pascal - .map(|key| format!("{}:null;", convert_interface_key(&key))) - .collect::() + .map(|key| format!("{}:null", convert_interface_key(&key))) + .collect::>() + .join(";") ) } } @@ -1628,17 +1630,14 @@ mod tests { color_theme.add_color("primary", "#000"); theme.add_color_theme("dark", color_theme); sheet.set_theme(theme); - assert_eq!( - sheet.create_interface( - "package", - "ColorInterface", - "TypographyInterface", - "ThemeInterface" - ), - "import \"package\";declare module \"package\"{interface ColorInterface{$primary:null;}interface TypographyInterface{}interface ThemeInterface{dark:null;}}" - ); - - // test wrong case + assert_debug_snapshot!(sheet.create_interface( + "package", + "ColorInterface", + "TypographyInterface", + "ThemeInterface" + )); + + // test wrong case (backticks and special characters) let mut sheet = StyleSheet::default(); let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); @@ -1655,15 +1654,62 @@ mod tests { ))], ); sheet.set_theme(theme); - assert_eq!( - sheet.create_interface( - "package", - "ColorInterface", - "TypographyInterface", - "ThemeInterface" - ), - "import \"package\";declare module \"package\"{interface ColorInterface{[`$(primary)`]:null;}interface TypographyInterface{[`prim\\`\\`ary`]:null;}interface ThemeInterface{dark:null;}}" - ); + assert_debug_snapshot!(sheet.create_interface( + "package", + "ColorInterface", + "TypographyInterface", + "ThemeInterface" + )); + + // test nested colors - interface keys should use dots for TypeScript + let mut sheet = StyleSheet::default(); + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "gray": { + "100": "#f5f5f5", + "200": "#eee" + }, + "primary": "#000", + "secondary.light": "#ccc" + } + } + }"##, + ) + .unwrap(); + sheet.set_theme(theme); + assert_debug_snapshot!(sheet.create_interface( + "package", + "ColorInterface", + "TypographyInterface", + "ThemeInterface" + )); + + // test deep nested colors + let mut sheet = StyleSheet::default(); + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "dark": { + "brand": { + "primary": { + "light": "#f0f", + "dark": "#0f0" + } + } + } + } + }"##, + ) + .unwrap(); + sheet.set_theme(theme); + assert_debug_snapshot!(sheet.create_interface( + "package", + "ColorInterface", + "TypographyInterface", + "ThemeInterface" + )); } #[test] diff --git a/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-2.snap b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-2.snap new file mode 100644 index 00000000..7acebdc2 --- /dev/null +++ b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-2.snap @@ -0,0 +1,5 @@ +--- +source: libs/sheet/src/lib.rs +expression: "sheet.create_interface(\"package\", \"ColorInterface\", \"TypographyInterface\",\n\"ThemeInterface\")" +--- +"import \"package\";declare module \"package\"{interface ColorInterface{[`$(primary)`]:null}interface TypographyInterface{[`prim\\`\\`ary`]:null}interface ThemeInterface{dark:null}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-3.snap b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-3.snap new file mode 100644 index 00000000..f836854c --- /dev/null +++ b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-3.snap @@ -0,0 +1,5 @@ +--- +source: libs/sheet/src/lib.rs +expression: "sheet.create_interface(\"package\", \"ColorInterface\", \"TypographyInterface\",\n\"ThemeInterface\")" +--- +"import \"package\";declare module \"package\"{interface ColorInterface{[`$gray.100`]:null;[`$gray.200`]:null;$primary:null;[`$secondary.light`]:null}interface TypographyInterface{}interface ThemeInterface{light:null}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-4.snap b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-4.snap new file mode 100644 index 00000000..a1a46985 --- /dev/null +++ b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface-4.snap @@ -0,0 +1,5 @@ +--- +source: libs/sheet/src/lib.rs +expression: "sheet.create_interface(\"package\", \"ColorInterface\", \"TypographyInterface\",\n\"ThemeInterface\")" +--- +"import \"package\";declare module \"package\"{interface ColorInterface{[`$brand.primary.dark`]:null;[`$brand.primary.light`]:null}interface TypographyInterface{}interface ThemeInterface{dark:null}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__get_theme_interface.snap b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface.snap new file mode 100644 index 00000000..cca97ea2 --- /dev/null +++ b/libs/sheet/src/snapshots/sheet__tests__get_theme_interface.snap @@ -0,0 +1,5 @@ +--- +source: libs/sheet/src/lib.rs +expression: "sheet.create_interface(\"package\", \"ColorInterface\", \"TypographyInterface\",\n\"ThemeInterface\")" +--- +"import \"package\";declare module \"package\"{interface ColorInterface{$primary:null}interface TypographyInterface{}interface ThemeInterface{dark:null}}" diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index b6d77efa..32058406 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -1,13 +1,129 @@ use css::optimize_value::optimize_value; use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; use std::collections::{BTreeMap, HashMap}; -#[derive(Default, Serialize, Deserialize, Debug)] -pub struct ColorTheme(pub HashMap); +/// ColorEntry stores both the original key (for TypeScript interface) and CSS key (for CSS variables) +#[derive(Debug, Clone, Serialize)] +pub struct ColorEntry { + /// Original key with dots for TypeScript interface (e.g., "gray.100") + pub interface_key: String, + /// CSS variable key with dashes (e.g., "gray-100") + pub css_key: String, + /// Color value + pub value: String, +} + +/// ColorTheme stores flattened color entries +/// Supports: +/// - Simple: `primary: "#000"` -> interface_key: "primary", css_key: "primary" +/// - Dot notation: `"primary.100": "#000"` -> interface_key: "primary.100", css_key: "primary-100" +/// - Nested object: `hello: { 100: "#000" }` -> interface_key: "hello.100", css_key: "hello-100" +/// - Deep nested: `gray: { light: { 100: "#000" } }` -> interface_key: "gray.light.100", css_key: "gray-light-100" +#[derive(Default, Serialize, Debug)] +pub struct ColorTheme { + /// Map from css_key to ColorEntry for quick lookup + entries: HashMap, +} + +/// Recursively flatten a JSON value into ColorEntry list +/// interface_prefix uses dots, css_prefix uses dashes +fn flatten_color_value( + interface_prefix: &str, + css_prefix: &str, + value: &Value, + result: &mut HashMap, +) -> Result<(), String> { + match value { + Value::String(s) => { + result.insert( + css_prefix.to_string(), + ColorEntry { + interface_key: interface_prefix.to_string(), + css_key: css_prefix.to_string(), + value: s.clone(), + }, + ); + Ok(()) + } + Value::Object(obj) => { + for (key, val) in obj { + let new_interface_prefix = if interface_prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", interface_prefix, key) + }; + let new_css_prefix = if css_prefix.is_empty() { + key.replace('.', "-") + } else { + format!("{}-{}", css_prefix, key.replace('.', "-")) + }; + flatten_color_value(&new_interface_prefix, &new_css_prefix, val, result)?; + } + Ok(()) + } + _ => Err(format!( + "color value for key '{}' must be a string or an object, got {:?}", + interface_prefix, value + )), + } +} + +impl<'de> Deserialize<'de> for ColorTheme { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let raw: HashMap = HashMap::deserialize(deserializer)?; + let mut entries = HashMap::new(); + + for (key, value) in raw { + let css_key = key.replace('.', "-"); + flatten_color_value(&key, &css_key, &value, &mut entries).map_err(D::Error::custom)?; + } + + Ok(ColorTheme { entries }) + } +} impl ColorTheme { pub fn add_color(&mut self, name: &str, value: &str) { - self.0.insert(name.to_string(), value.to_string()); + let css_key = name.replace('.', "-"); + self.entries.insert( + css_key.clone(), + ColorEntry { + interface_key: name.to_string(), + css_key, + value: value.to_string(), + }, + ); + } + + /// Get all interface keys (for TypeScript interface generation, with dots) + pub fn interface_keys(&self) -> impl Iterator { + self.entries.values().map(|e| &e.interface_key) + } + + /// Get all CSS keys (for CSS variable generation, with dashes) + pub fn css_keys(&self) -> impl Iterator { + self.entries.keys() + } + + /// Get iterator over (css_key, value) pairs for CSS generation + pub fn css_entries(&self) -> impl Iterator { + self.entries.iter().map(|(k, e)| (k, &e.value)) + } + + /// Get value by CSS key + pub fn get(&self, css_key: &str) -> Option<&String> { + self.entries.get(css_key).map(|e| &e.value) + } + + /// Check if CSS key exists + pub fn contains_key(&self, css_key: &str) -> bool { + self.entries.contains_key(css_key) } } @@ -188,13 +304,13 @@ impl Theme { css_contents.push("color-scheme:light".to_string()); } } - for (prop, value) in theme_properties.0.iter() { + for (prop, value) in theme_properties.css_entries() { let optimized_value = optimize_value(value); if theme_key.is_some() { if other_theme_key.is_none() && let Some(default_value) = self.colors.get(&default_theme_key).and_then(|v| { - v.0.get(prop).and_then(|v| { + v.get(prop).and_then(|v| { if optimize_value(v) == optimized_value { None } else { @@ -209,7 +325,7 @@ impl Theme { let other_theme_value = other_theme_key.as_ref().and_then(|other_theme_key| { self.colors.get(other_theme_key).and_then(|v| { - v.0.get(prop).and_then(|v| { + v.get(prop).and_then(|v| { let other_theme_value = optimize_value(v.as_str()); if other_theme_value == optimized_value { None @@ -310,7 +426,7 @@ mod tests { let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#000"); - assert_eq!(color_theme.0.keys().count(), 1); + assert_eq!(color_theme.css_keys().count(), 1); theme.add_color_theme("default", color_theme); let mut color_theme = ColorTheme::default(); @@ -360,151 +476,44 @@ mod tests { ); assert_eq!(theme.to_css(), ""); - let mut theme = Theme::default(); - theme.add_color_theme( - "default", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + // Helper to create a ColorTheme with a single color + fn make_color_theme(name: &str, value: &str) -> ColorTheme { + let mut ct = ColorTheme::default(); + ct.add_color(name, value); + ct + } - theme.add_color_theme( - "dark", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + let mut theme = Theme::default(); + theme.add_color_theme("default", make_color_theme("primary", "#000")); + theme.add_color_theme("dark", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "dark", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); + theme.add_color_theme("dark", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "a", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "b", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("a", make_color_theme("primary", "#000")); + theme.add_color_theme("b", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "b", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "a", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "c", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); + theme.add_color_theme("b", make_color_theme("primary", "#000")); + theme.add_color_theme("a", make_color_theme("primary", "#000")); + theme.add_color_theme("c", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); let mut theme = Theme::default(); - theme.add_color_theme( - "light", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); - - theme.add_color_theme( - "b", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#001".to_string()); - map - }), - ); - - theme.add_color_theme( - "a", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#002".to_string()); - map - }), - ); - - theme.add_color_theme( - "c", - ColorTheme({ - let mut map = HashMap::new(); - map.insert("primary".to_string(), "#000".to_string()); - map - }), - ); + theme.add_color_theme("light", make_color_theme("primary", "#000")); + theme.add_color_theme("b", make_color_theme("primary", "#001")); + theme.add_color_theme("a", make_color_theme("primary", "#002")); + theme.add_color_theme("c", make_color_theme("primary", "#000")); assert_debug_snapshot!(theme.to_css()); } @@ -526,4 +535,263 @@ mod tests { theme.update_breakpoints(input); assert_eq!(theme.breakpoints, expected); } + + #[test] + fn test_nested_color_theme_deserialization() { + // Test simple string values + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "primary": "#000" + } + } + }"##, + ) + .unwrap(); + assert!(theme.colors.get("light").unwrap().contains_key("primary")); + assert_eq!( + theme.colors.get("light").unwrap().get("primary").unwrap(), + "#000" + ); + + // Test dot notation keys (e.g., "primary.100" -> "primary-100") + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "primary.100": "#100", + "primary.200": "#200" + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + assert!(light.contains_key("primary-100")); + assert!(light.contains_key("primary-200")); + assert_eq!(light.get("primary-100").unwrap(), "#100"); + assert_eq!(light.get("primary-200").unwrap(), "#200"); + + // Test nested object (e.g., "hello": { "100": "#000" } -> "hello-100") + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "hello": { + "100": "#100", + "200": "#200" + } + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + assert!(light.contains_key("hello-100")); + assert!(light.contains_key("hello-200")); + assert_eq!(light.get("hello-100").unwrap(), "#100"); + assert_eq!(light.get("hello-200").unwrap(), "#200"); + + // Test mixed: simple, dot notation, and nested + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "primary": "#000", + "secondary.100": "#sec100", + "gray": { + "50": "#gray50", + "100": "#gray100" + } + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + assert_eq!(light.get("primary").unwrap(), "#000"); + assert_eq!(light.get("secondary-100").unwrap(), "#sec100"); + assert_eq!(light.get("gray-50").unwrap(), "#gray50"); + assert_eq!(light.get("gray-100").unwrap(), "#gray100"); + } + + #[test] + fn test_nested_color_theme_to_css() { + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "primary": "#000", + "gray": { + "100": "#f5f5f5", + "200": "#eee" + } + }, + "dark": { + "primary": "#fff", + "gray": { + "100": "#333", + "200": "#444" + } + } + } + }"##, + ) + .unwrap(); + let css = theme.to_css(); + // Should contain CSS variables for flattened keys + assert!(css.contains("--primary:")); + assert!(css.contains("--gray-100:")); + assert!(css.contains("--gray-200:")); + // Check light-dark() function is used for color switching + assert!(css.contains("light-dark(#000,#FFF)") || css.contains("light-dark(#000,#fff)")); + assert!(css.contains("color-scheme:light")); + assert!(css.contains("color-scheme:dark")); + } + + #[test] + fn test_add_color_with_dot_notation() { + let mut color_theme = ColorTheme::default(); + color_theme.add_color("primary.100", "#100"); + color_theme.add_color("primary.200", "#200"); + + // CSS keys should have dashes instead of dots + assert!(color_theme.contains_key("primary-100")); + assert!(color_theme.contains_key("primary-200")); + assert!(!color_theme.contains_key("primary.100")); + } + + #[test] + fn test_deep_nested_color_should_succeed() { + // Deep nesting should be flattened with dashes + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "primary": { + "100": { + "light": "#f0f", + "dark": "#0f0" + }, + "200": "#200" + } + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + // primary -> 100 -> light = "primary-100-light" + assert!(light.contains_key("primary-100-light")); + assert!(light.contains_key("primary-100-dark")); + assert!(light.contains_key("primary-200")); + assert_eq!(light.get("primary-100-light").unwrap(), "#f0f"); + assert_eq!(light.get("primary-100-dark").unwrap(), "#0f0"); + assert_eq!(light.get("primary-200").unwrap(), "#200"); + } + + #[test] + fn test_very_deep_nested_color() { + // 4 levels deep: a -> b -> c -> d -> value + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "a": { + "b": { + "c": { + "d": "#deep" + } + } + } + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + assert!(light.contains_key("a-b-c-d")); + assert_eq!(light.get("a-b-c-d").unwrap(), "#deep"); + } + + #[test] + fn test_nested_with_number_value_should_fail() { + // Nested object with non-string value should fail + let result: Result = serde_json::from_str( + r##"{ + "colors": { + "light": { + "gray": { + "100": 123 + } + } + } + }"##, + ); + assert!(result.is_err()); + } + + #[test] + fn test_interface_keys_vs_css_keys() { + // interface_keys should preserve dots, css_keys should use dashes + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "gray": { + "100": "#f5f5f5", + "200": "#eee" + }, + "primary.light": "#000" + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + + // Collect interface keys + let interface_keys: Vec<_> = light.interface_keys().cloned().collect(); + // Collect CSS keys + let css_keys: Vec<_> = light.css_keys().cloned().collect(); + + // Interface keys should use dots for nested objects + assert!(interface_keys.contains(&"gray.100".to_string())); + assert!(interface_keys.contains(&"gray.200".to_string())); + // Dot notation in original key stays as is + assert!(interface_keys.contains(&"primary.light".to_string())); + + // CSS keys should use dashes + assert!(css_keys.contains(&"gray-100".to_string())); + assert!(css_keys.contains(&"gray-200".to_string())); + assert!(css_keys.contains(&"primary-light".to_string())); + } + + #[test] + fn test_deep_nested_interface_keys() { + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "light": { + "a": { + "b": { + "c": "#deep" + } + } + } + } + }"##, + ) + .unwrap(); + let light = theme.colors.get("light").unwrap(); + + let interface_keys: Vec<_> = light.interface_keys().cloned().collect(); + let css_keys: Vec<_> = light.css_keys().cloned().collect(); + + // Interface key uses dots + assert!(interface_keys.contains(&"a.b.c".to_string())); + // CSS key uses dashes + assert!(css_keys.contains(&"a-b-c".to_string())); + } }