themed_styler/
lib.rs

1use indexmap::{IndexMap, IndexSet};
2use once_cell::sync::Lazy;
3use serde::{de::Deserializer, Deserialize, Serialize};
4use serde_json::json;
5#[cfg(target_arch = "wasm32")]
6use wasm_bindgen::prelude::*;
7mod default_state;
8mod color;
9use default_state::bundled_state;
10
11// Default display density (1.0 = mdpi baseline)
12fn default_display_density() -> f32 { 1.0 }
13fn default_scaled_density() -> f32 { 1.0 }
14
15pub type CssProps = IndexMap<String, serde_json::Value>;
16pub type SelectorStyles = IndexMap<String, CssProps>; // selector -> props
17
18/// Convert dp to pixels using display density
19fn dp_to_px(dp: f32, density: f32) -> i32 {
20    (dp * density).round() as i32
21}
22
23/// Convert sp to pixels using scaled density  
24fn sp_to_px(sp: f32, scaled_density: f32) -> f32 {
25    sp * scaled_density
26}
27
28/// Parse a CSS value and convert to Android pixels if needed
29fn parse_and_convert_to_px(value: &serde_json::Value, density: f32) -> Option<serde_json::Value> {
30    match value {
31        serde_json::Value::Number(n) => {
32            // Bare number treated as dp
33            let dp = n.as_f64()? as f32;
34            Some(serde_json::json!(dp_to_px(dp, density)))
35        }
36        serde_json::Value::String(s) => {
37            // Parse string with units
38            let trimmed = s.trim();
39            if trimmed.ends_with("px") {
40                // Treat px as density-independent pixels (dp) for cross-platform parity
41                let px = trimmed.trim_end_matches("px").trim().parse::<f32>().ok()?;
42                Some(serde_json::json!(dp_to_px(px, density)))
43            } else if trimmed.ends_with("dp") {
44                let dp = trimmed.trim_end_matches("dp").trim().parse::<f32>().ok()?;
45                Some(serde_json::json!(dp_to_px(dp, density)))
46            } else if let Ok(num) = trimmed.parse::<f32>() {
47                // Bare number as string, treat as dp
48                Some(serde_json::json!(dp_to_px(num, density)))
49            } else {
50                // Keep as-is (e.g., "wrap_content", "match_parent")
51                None
52            }
53        }
54        _ => None
55    }
56}
57
58fn deserialize_variables<'de, D>(deserializer: D) -> Result<IndexMap<String, String>, D::Error>
59where
60    D: Deserializer<'de>,
61{
62    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
63    let mut out: IndexMap<String, String> = IndexMap::new();
64    if let Some(v) = value {
65        flatten_variables(None, &v, &mut out);
66    }
67    Ok(out)
68}
69
70fn flatten_variables(prefix: Option<&str>, value: &serde_json::Value, out: &mut IndexMap<String, String>) {
71    match value {
72        serde_json::Value::Object(map) => {
73            for (k, v) in map {
74                let key = if let Some(p) = prefix {
75                    format!("{}.{}", p, k)
76                } else {
77                    k.to_string()
78                };
79                flatten_variables(Some(&key), v, out);
80            }
81        }
82        serde_json::Value::Array(arr) => {
83            for (idx, v) in arr.iter().enumerate() {
84                let key = if let Some(p) = prefix {
85                    format!("{}.{}", p, idx)
86                } else {
87                    idx.to_string()
88                };
89                flatten_variables(Some(&key), v, out);
90            }
91        }
92        serde_json::Value::Null => {}
93        serde_json::Value::Bool(b) => {
94            if let Some(p) = prefix {
95                out.insert(p.to_string(), b.to_string());
96            }
97        }
98        serde_json::Value::Number(n) => {
99            if let Some(p) = prefix {
100                out.insert(p.to_string(), n.to_string());
101            }
102        }
103        serde_json::Value::String(s) => {
104            if let Some(p) = prefix {
105                out.insert(p.to_string(), s.clone());
106            }
107        }
108    }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112pub struct ThemeEntry {
113    #[serde(default)]
114    pub name: Option<String>,
115    #[serde(default)]
116    pub inherits: Option<String>,
117    #[serde(default)]
118    pub selectors: SelectorStyles,
119    #[serde(default, deserialize_with = "deserialize_variables")]
120    pub variables: IndexMap<String, String>,
121    #[serde(default, deserialize_with = "deserialize_variables")]
122    pub breakpoints: IndexMap<String, String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct State {
127    // New format: each theme has selectors, variables, breakpoints, and optional inherits
128    pub themes: IndexMap<String, ThemeEntry>,
129    pub default_theme: String,
130    pub current_theme: String,
131    // Platform-specific metadata for unit conversions
132    #[serde(default = "default_display_density")]
133    pub display_density: f32, // Android displayMetrics.density (1.0 for mdpi, 2.0 for xhdpi, etc.)
134    #[serde(default = "default_scaled_density")]
135    pub scaled_density: f32,  // Android displayMetrics.scaledDensity for SP conversions
136    
137    #[serde(default)]
138    pub used_classes: IndexSet<String>,   // observed classes on elements
139    #[serde(default)]
140    pub used_tags: IndexSet<String>,      // observed tags on elements
141    /// Observed (tag, class) pairs. Encoded as "tag|class" for JSON simplicity.
142    #[serde(default)]
143    pub used_tag_classes: IndexSet<String>,
144}
145
146#[derive(thiserror::Error, Debug)]
147pub enum Error {
148    #[error("theme not found: {0}")]
149    ThemeNotFound(String),
150}
151
152impl State {
153    pub fn new_default() -> Self {
154        // Prefer embedded Rust bundled defaults
155        return bundled_state();
156    }
157
158    /// Public helper to access the embedded default state.
159    pub fn default_state() -> Self {
160        bundled_state()
161    }
162
163    pub fn set_theme(&mut self, theme: impl Into<String>) -> Result<(), Error> {
164        let name = theme.into();
165        if !self.themes.contains_key(&name) {
166            return Err(Error::ThemeNotFound(name));
167        }
168        self.current_theme = name;
169        Ok(())
170    }
171
172    pub fn add_theme(&mut self, name: impl Into<String>, styles: SelectorStyles) {
173        let name = name.into();
174        let entry = self.themes.entry(name).or_default();
175        for (sel, props) in styles.into_iter() {
176            let e = entry.selectors.entry(sel).or_default();
177            merge_props(e, &props);
178        }
179    }
180
181    pub fn set_variables(&mut self, vars: IndexMap<String, String>) {
182        // Back-compat: set on current theme entry
183        let cur = self.current_theme.clone();
184        let entry = self.themes.entry(cur).or_default();
185        entry.variables = vars;
186    }
187
188    pub fn set_breakpoints(&mut self, map: IndexMap<String, String>) {
189        let cur = self.current_theme.clone();
190        let entry = self.themes.entry(cur).or_default();
191        entry.breakpoints = map;
192    }
193
194    pub fn process_styles(&self, mut styles: IndexMap<String, serde_json::Value>) -> IndexMap<String, serde_json::Value> {
195        let density = self.display_density;
196        
197        // Expand shorthands
198        // Order matters: Horizontal/Vertical should be expanded before general shorthands
199        // so that specific ones win if they were already present.
200        if let Some(ph) = styles.get("paddingHorizontal").cloned() {
201            styles.entry("paddingLeft".into()).or_insert(ph.clone());
202            styles.entry("paddingRight".into()).or_insert(ph.clone());
203        }
204        if let Some(pv) = styles.get("paddingVertical").cloned() {
205            styles.entry("paddingTop".into()).or_insert(pv.clone());
206            styles.entry("paddingBottom".into()).or_insert(pv.clone());
207        }
208        if let Some(p) = styles.get("padding").cloned() {
209            styles.entry("paddingTop".into()).or_insert(p.clone());
210            styles.entry("paddingBottom".into()).or_insert(p.clone());
211            styles.entry("paddingLeft".into()).or_insert(p.clone());
212            styles.entry("paddingRight".into()).or_insert(p.clone());
213        }
214        if let Some(mh) = styles.get("marginHorizontal").cloned() {
215            styles.entry("marginLeft".into()).or_insert(mh.clone());
216            styles.entry("marginRight".into()).or_insert(mh.clone());
217        }
218        if let Some(mv) = styles.get("marginVertical").cloned() {
219            styles.entry("marginTop".into()).or_insert(mv.clone());
220            styles.entry("marginBottom".into()).or_insert(mv.clone());
221        }
222        if let Some(m) = styles.get("margin").cloned() {
223            styles.entry("marginTop".into()).or_insert(m.clone());
224            styles.entry("marginBottom".into()).or_insert(m.clone());
225            styles.entry("marginLeft".into()).or_insert(m.clone());
226            styles.entry("marginRight".into()).or_insert(m.clone());
227        }
228        if let Some(r) = styles.get("borderRadius").cloned() {
229            styles.entry("borderTopLeftRadius".into()).or_insert(r.clone());
230            styles.entry("borderTopRightRadius".into()).or_insert(r.clone());
231            styles.entry("borderBottomLeftRadius".into()).or_insert(r.clone());
232            styles.entry("borderBottomRightRadius".into()).or_insert(r.clone());
233        }
234
235        // Convert only dimension properties to pixels
236        let dimension_props = [
237            "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
238            "padding", "paddingTop", "paddingBottom", "paddingLeft", "paddingRight",
239            "paddingHorizontal", "paddingVertical",
240            "margin", "marginTop", "marginBottom", "marginLeft", "marginRight",
241            "marginHorizontal", "marginVertical",
242            "borderRadius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius",
243            "borderWidth", "borderTopWidth", "borderBottomWidth", "borderLeftWidth", "borderRightWidth",
244            "gap", "rowGap", "columnGap", "elevation", "fontSize", "lineHeight", "letterSpacing"
245        ];
246        
247        for prop in &dimension_props {
248            if let Some(value) = styles.get(*prop).cloned() {
249                if let Some(converted) = parse_and_convert_to_px(&value, density) {
250                    styles.insert(prop.to_string(), converted);
251                }
252            }
253        }
254        
255        styles
256    }
257
258    pub fn set_default_theme(&mut self, name: impl Into<String>) {
259        self.default_theme = name.into();
260    }
261
262    pub fn register_selectors<I: IntoIterator<Item = String>>(&mut self, _selectors: I) {
263        // Deprecated: selectors are now part of the theme entry
264    }
265
266    pub fn register_tailwind_classes<I: IntoIterator<Item = String>>(&mut self, classes: I) {
267        for c in classes {
268            self.used_classes.insert(c);
269        }
270    }
271
272    pub fn register_tags<I: IntoIterator<Item = String>>(&mut self, tags: I) {
273        for t in tags {
274            self.used_tags.insert(t);
275        }
276    }
277
278    pub fn register_tag_class(&mut self, tag: impl Into<String>, class_: impl Into<String>) {
279        let key = format!("{}|{}", tag.into(), class_.into());
280        self.used_tag_classes.insert(key);
281    }
282
283
284    pub fn clear_usage(&mut self) {
285        self.used_classes.clear();
286        self.used_tags.clear();
287        self.used_tag_classes.clear();
288    }
289
290    pub fn to_json(&self) -> serde_json::Value {
291        json!({
292            "themes": self.themes,
293            "default_theme": self.default_theme,
294            "current_theme": self.current_theme,
295            "display_density": self.display_density,
296            "scaled_density": self.scaled_density,
297            "used_classes": self.used_classes,
298            "used_tags": self.used_tags,
299            "used_tag_classes": self.used_tag_classes,
300        })
301    }
302
303    pub fn from_json(value: serde_json::Value) -> anyhow::Result<Self> {
304        let state: State = serde_json::from_value(value)?;
305        Ok(state)
306    }
307
308    pub fn css_for_web(&self) -> String {
309        // Compute CSS resolved from the effective theme (with inheritance)
310        let (eff, vars) = self.effective_theme_all();
311        let bps = self.effective_breakpoints();
312        let mut rules: Vec<(String, CssProps)> = Vec::new();
313        
314        // Build closure: if a (tag,class) pair is observed, consider both the tag and the class as used too
315        let mut used_tags: IndexSet<String> = self.used_tags.clone();
316        let mut used_classes: IndexSet<String> = self.used_classes.clone();
317        for key in &self.used_tag_classes {
318            if let Some((t, c)) = split_tag_class_key(key) {
319                used_tags.insert(t);
320                used_classes.insert(c);
321            }
322        }
323
324        // Helper to decide if a themed selector should be emitted based on observed usage.
325        // Supported selector forms:
326        //  - tag           (e.g., "h1")
327        //  - .class        (e.g., ".text-sm"), optional pseudo ":hover"
328        //  - tag.class     (e.g., "h1.text-sm"), optional pseudo ":hover"
329        for (sel, props) in eff.iter() {
330            if should_emit_selector(sel, &used_tags, &used_classes, &self.used_tag_classes) {
331                rules.push((sel.clone(), props.clone()));
332            }
333        }
334
335        // Also emit dynamic utility properties for used classes
336        for class in &used_classes {
337            let (bp_key, hover, base) = parse_prefixed_class(class);
338            let selector = if hover { format!(".{}:hover", css_escape_class(&base)) } else { format!(".{}", css_escape_class(&base)) };
339
340            // 1) Exact selector in effective theme (e.g. ".x:hover")
341            if let Some(props) = eff.get(&selector) {
342                let final_sel = wrap_with_media(&selector, bp_key.as_deref(), &bps);
343                rules.push((final_sel, props.clone()));
344                continue;
345            }
346            // 2) Dynamic generation for the base class (ignoring hover/breakpoint for props)
347            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
348                let sel = if hover { format!(".{}:hover", css_escape_class(&base)) } else { format!(".{}", css_escape_class(&base)) };
349                let final_sel = wrap_with_media(&sel, bp_key.as_deref(), &bps);
350                rules.push((final_sel, dynamic_props));
351                continue;
352            }
353            // 3) Fallback: class key itself in theme (rare)
354            if let Some(props) = eff.get(&base) {
355                let final_sel = wrap_with_media(&selector, bp_key.as_deref(), &bps);
356                rules.push((final_sel, props.clone()));
357            }
358        }
359
360        post_process_css(&rules, &vars)
361    }
362
363    pub fn android_base_styles(&self, selector: &str, classes: &[String]) -> IndexMap<String, serde_json::Value> {
364        let (eff, vars) = self.effective_theme_all();
365        let mut out: IndexMap<String, serde_json::Value> = IndexMap::new();
366
367        // Pre-insert androidOrientation to ensure it's early in the map for gap processing
368        out.insert("androidOrientation".to_string(), serde_json::json!("vertical"));
369
370        let mut combined_props = CssProps::new();
371
372        // 1. Apply hardcoded platform defaults (lowest priority)
373        match selector.to_lowercase().as_str() {
374            "div" => {
375                combined_props.insert("width".into(), json!("match_parent"));
376            }
377            "p" => {
378                combined_props.insert("width".into(), json!("match_parent"));
379                combined_props.insert("margin-vertical".into(), json!("16px"));
380            }
381            "h1" => {
382                combined_props.insert("width".into(), json!("match_parent"));
383                combined_props.insert("font-size".into(), json!("32px"));
384                combined_props.insert("font-weight".into(), json!("bold"));
385                combined_props.insert("margin-vertical".into(), json!("21.44px"));
386            }
387            "h2" => {
388                combined_props.insert("width".into(), json!("match_parent"));
389                combined_props.insert("font-size".into(), json!("24px"));
390                combined_props.insert("font-weight".into(), json!("bold"));
391                combined_props.insert("margin-vertical".into(), json!("19.92px"));
392            }
393            "h3" => {
394                combined_props.insert("width".into(), json!("match_parent"));
395                combined_props.insert("font-size".into(), json!("18.72px"));
396                combined_props.insert("font-weight".into(), json!("bold"));
397                combined_props.insert("margin-vertical".into(), json!("18.72px"));
398            }
399            "h4" => {
400                combined_props.insert("width".into(), json!("match_parent"));
401                combined_props.insert("font-size".into(), json!("16px"));
402                combined_props.insert("font-weight".into(), json!("bold"));
403                combined_props.insert("margin-vertical".into(), json!("21.28px"));
404            }
405            "h5" => {
406                combined_props.insert("width".into(), json!("match_parent"));
407                combined_props.insert("font-size".into(), json!("13.28px"));
408                combined_props.insert("font-weight".into(), json!("bold"));
409                combined_props.insert("margin-vertical".into(), json!("22.17px"));
410            }
411            "h6" => {
412                combined_props.insert("width".into(), json!("match_parent"));
413                combined_props.insert("font-size".into(), json!("10.72px"));
414                combined_props.insert("font-weight".into(), json!("bold"));
415                combined_props.insert("margin-vertical".into(), json!("24.96px"));
416            }
417            "input" => {
418                combined_props.insert("padding-vertical".into(), json!("8px"));
419                combined_props.insert("padding-horizontal".into(), json!("12px"));
420                combined_props.insert("border-radius".into(), json!("4px"));
421                combined_props.insert("border-width".into(), json!("1px"));
422                combined_props.insert("border-color".into(), json!("#cccccc"));
423                combined_props.insert("background-color".into(), json!("#ffffff"));
424                combined_props.insert("color".into(), json!("#000000"));
425                combined_props.insert("placeholder-color".into(), json!("#88888870"));
426                combined_props.insert("min-height".into(), json!("40px"));
427                combined_props.insert("android-gravity".into(), json!("center_vertical"));
428            }
429            "select" => {
430                combined_props.insert("padding-vertical".into(), json!("8px"));
431                combined_props.insert("padding-horizontal".into(), json!("12px"));
432                combined_props.insert("border-radius".into(), json!("4px"));
433                combined_props.insert("border-width".into(), json!("1px"));
434                combined_props.insert("border-color".into(), json!("#cccccc"));
435                combined_props.insert("background-color".into(), json!("#ffffff"));
436                combined_props.insert("color".into(), json!("#000000"));
437                combined_props.insert("min-height".into(), json!("40px"));
438                combined_props.insert("android-gravity".into(), json!("center_vertical"));
439            }
440            "textarea" => {
441                combined_props.insert("padding".into(), json!("12px"));
442                combined_props.insert("border-radius".into(), json!("4px"));
443                combined_props.insert("border-width".into(), json!("1px"));
444                combined_props.insert("border-color".into(), json!("#cccccc"));
445                combined_props.insert("background-color".into(), json!("#ffffff"));
446                combined_props.insert("color".into(), json!("#000000"));
447                combined_props.insert("placeholder-color".into(), json!("color-mix(in srgb, currentColor 75%, grey)"));
448                combined_props.insert("min-height".into(), json!("80px"));
449                combined_props.insert("android-gravity".into(), json!("top"));
450            }
451            "button" => {
452                combined_props.insert("padding-vertical".into(), json!("8px"));
453                combined_props.insert("padding-horizontal".into(), json!("16px"));
454                combined_props.insert("border-radius".into(), json!("4px"));
455                combined_props.insert("background-color".into(), json!("#2196F3"));
456                combined_props.insert("color".into(), json!("#ffffff"));
457                combined_props.insert("android-gravity".into(), json!("center"));
458            }
459            _ => {}
460        }
461
462        if selector == "button" || selector == "input" || selector == "textarea" || classes.iter().any(|c| c.contains("bg-")) {
463            log::debug!("[android_base_styles] selector={} classes={:?}", selector, classes);
464        }
465
466        // 2. Apply theme selector styles (overwrites defaults)
467        if let Some(props) = eff.get(selector) {
468            merge_props(&mut combined_props, props);
469        }
470
471        // 3. Apply class styles (overwrites selector)
472        for class in classes {
473            // Normalize input: strip leading dot if present (Android may pass ".bg-primary" as selector format)
474            let normalized_class = if class.starts_with('.') {
475                class[1..].to_string()
476            } else {
477                class.clone()
478            };
479            
480            let (_bp, _hover, base) = parse_prefixed_class(&normalized_class);
481            // Prefer base selector match from theme
482            let sel = class_to_selector(&base);
483            if let Some(props) = eff.get(&sel) {
484                merge_props(&mut combined_props, props);
485                continue;
486            }
487            // Dynamic mapping for base class
488            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
489                merge_props(&mut combined_props, &dynamic_props);
490                continue;
491            }
492            if let Some(props) = eff.get(&base) {
493                merge_props(&mut combined_props, props);
494            }
495        }
496
497        if selector == "input" || selector == "textarea" {
498            log::debug!("[android_base_styles] combined_props for {}: {:?}", selector, combined_props);
499        }
500
501        merge_android_props(&mut out, &combined_props, &vars);
502        
503        // CSS semantics: display: flex defaults to flexDirection: row
504        if let Some(display) = out.get("display") {
505            if display.as_str() == Some("flex") && !out.contains_key("flexDirection") {
506                out.insert("flexDirection".to_string(), serde_json::json!("row"));
507                out.insert("androidOrientation".to_string(), serde_json::json!("horizontal"));
508            }
509        }
510
511        // Fallback for block elements to be column if not specified
512        if !out.contains_key("flexDirection") {
513            match selector.to_lowercase().as_str() {
514                "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" => {
515                    out.insert("flexDirection".to_string(), serde_json::json!("column"));
516                    out.insert("androidOrientation".to_string(), serde_json::json!("vertical"));
517                }
518                _ => {}
519            }
520        }
521        
522        // Ensure androidOrientation is in sync with flexDirection if it was set but orientation wasn't
523        if let Some(fd) = out.get("flexDirection").and_then(|v| v.as_str()) {
524            if !out.contains_key("androidOrientation") {
525                let orientation = if fd == "column" || fd == "column-reverse" {
526                    "vertical"
527                } else {
528                    "horizontal"
529                };
530                out.insert("androidOrientation".to_string(), serde_json::json!(orientation));
531            }
532        }
533
534        out
535    }
536
537    /// Android-specific style transformations
538    /// Converts CSS properties to Android-compatible values with platform-specific defaults
539    /// Handles unit conversions (dp/sp to px) using display density
540    pub fn android_styles_for(&self, selector: &str, classes: &[String]) -> IndexMap<String, serde_json::Value> {
541        let mut styles = self.android_base_styles(selector, classes);
542        
543        let density = self.display_density;
544        let scaled_density = self.scaled_density;
545
546        // Convert flexDirection to Android orientation EARLY so layout-dependent props (like gap) can use it
547        if let Some(flex_dir) = styles.get("flexDirection") {
548            let orientation = if flex_dir.as_str() == Some("row") { "horizontal" } else { "vertical" };
549            styles.shift_insert(0, "androidOrientation".to_string(), serde_json::json!(orientation));
550        }
551        
552        // Convert all dimension properties to pixels
553        let dimension_props = [
554            "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
555            "padding", "paddingTop", "paddingBottom", "paddingLeft", "paddingRight",
556            "paddingHorizontal", "paddingVertical",
557            "margin", "marginTop", "marginBottom", "marginLeft", "marginRight",
558            "marginHorizontal", "marginVertical",
559            "borderRadius", "borderWidth", "borderTopWidth", "borderBottomWidth",
560            "borderLeftWidth", "borderRightWidth",
561            "gap", "rowGap", "columnGap", "elevation", "lineHeight", "letterSpacing"
562        ];
563        
564        for prop in &dimension_props {
565            if let Some(value) = styles.get(*prop).cloned() {
566                if let Some(converted) = parse_and_convert_to_px(&value, density) {
567                    styles.insert(prop.to_string(), converted);
568                }
569            }
570        }
571        
572        // Convert font sizes (use scaled density for accessibility)
573        if let Some(font_size) = styles.get("fontSize").cloned() {
574            if let Some(serde_json::Value::Number(n)) = parse_and_convert_to_px(&font_size, density).as_ref() {
575                // For text, use scaled density for accessibility
576                let sp_value = n.as_f64().unwrap_or(14.0) as f32 / density * scaled_density;
577                styles.insert("fontSize".to_string(), serde_json::json!(sp_value));
578            }
579        }
580        
581        // Convert flexWrap to Android-friendly format
582        if let Some(flex_wrap) = styles.get("flexWrap") {
583            if flex_wrap.as_str() == Some("wrap") {
584                styles.insert("androidFlexWrap".to_string(), serde_json::json!(true));
585            }
586        }
587
588        // Map opacity to androidAlpha
589        if let Some(opacity) = styles.get("opacity").cloned() {
590            styles.insert("androidAlpha".to_string(), opacity);
591        }
592
593        let is_horizontal = styles.get("androidOrientation").and_then(|v| v.as_str()) == Some("horizontal");
594        let mut gravity_parts = Vec::new();
595
596        // Convert alignItems (cross-axis) to Android gravity equivalents
597        if let Some(align_items) = styles.get("alignItems") {
598            let part = match align_items.as_str() {
599                Some("center") => if is_horizontal { "center_vertical" } else { "center_horizontal" },
600                Some("flex-start") | Some("start") => if is_horizontal { "top" } else { "start" },
601                Some("flex-end") | Some("end") => if is_horizontal { "bottom" } else { "end" },
602                Some("stretch") => if is_horizontal { "fill_vertical" } else { "fill_horizontal" },
603                _ => ""
604            };
605            if !part.is_empty() {
606                gravity_parts.push(part);
607            }
608        }
609        
610        // Convert justifyContent (main-axis) to Android gravity equivalents
611        if let Some(justify) = styles.get("justifyContent") {
612            let part = match justify.as_str() {
613                Some("center") => if is_horizontal { "center_horizontal" } else { "center_vertical" },
614                Some("flex-start") | Some("start") => if is_horizontal { "start" } else { "top" },
615                Some("flex-end") | Some("end") => if is_horizontal { "end" } else { "bottom" },
616                _ => ""
617            };
618            if !part.is_empty() {
619                gravity_parts.push(part);
620            }
621
622            // Also keep layout gravity for compatibility or non-LinearLayout parents
623            let layout_gravity = match justify.as_str() {
624                Some("center") => "center_horizontal",
625                Some("flex-start") | Some("start") => "start",
626                Some("flex-end") | Some("end") => "end",
627                Some("space-between") | Some("between") => "space_between",
628                Some("space-around") | Some("around") => "space_around",
629                _ => ""
630            };
631            if !layout_gravity.is_empty() {
632                styles.insert("androidLayoutGravity".to_string(), serde_json::json!(layout_gravity));
633            }
634        }
635
636        if !gravity_parts.is_empty() {
637            let gravity = if gravity_parts.contains(&"center_vertical") && gravity_parts.contains(&"center_horizontal") {
638                "center".to_string()
639            } else {
640                gravity_parts.join("|")
641            };
642            styles.insert("androidGravity".to_string(), serde_json::json!(gravity));
643        }
644
645        // Handle border shorthand: "1px solid #color"
646        if let Some(serde_json::Value::String(border)) = styles.get("border").cloned() {
647            let parts: Vec<&str> = border.split_whitespace().collect();
648            for part in parts {
649                if part.ends_with("px") {
650                    if let Ok(w) = part.trim_end_matches("px").parse::<f32>() {
651                        styles.insert("borderWidth".to_string(), serde_json::json!(dp_to_px(w, density)));
652                    }
653                } else if part.starts_with('#') {
654                    styles.insert("borderColor".to_string(), serde_json::json!(part));
655                }
656            }
657        }
658
659        // Map boxShadow to elevation
660        if let Some(serde_json::Value::String(shadow)) = styles.get("boxShadow").cloned() {
661            if !shadow.is_empty() {
662                let elevation = if shadow.contains("20px") { 24 }
663                               else if shadow.contains("15px") { 16 }
664                               else if shadow.contains("10px") { 8 }
665                               else { 4 };
666                styles.insert("elevation".to_string(), serde_json::json!(dp_to_px(elevation as f32, density)));
667            }
668        }
669
670        // Convert overflow-x/overflow-y to Android scrolling hints
671        if let Some(overflow_x) = styles.get("overflowX") {
672            if overflow_x.as_str() == Some("auto") || overflow_x.as_str() == Some("scroll") {
673                styles.insert("androidScrollHorizontal".to_string(), serde_json::json!(true));
674            }
675        }
676        if let Some(overflow_y) = styles.get("overflowY") {
677            if overflow_y.as_str() == Some("auto") || overflow_y.as_str() == Some("scroll") {
678                styles.insert("androidScrollVertical".to_string(), serde_json::json!(true));
679            }
680        }
681        
682        // Convert textAlign to Android gravity
683        if let Some(text_align) = styles.get("textAlign") {
684            let gravity = match text_align.as_str() {
685                Some("center") => "center_horizontal",
686                Some("right") | Some("end") => "end",
687                Some("left") | Some("start") => "start",
688                _ => ""
689            };
690            if !gravity.is_empty() {
691                styles.insert("androidTextGravity".to_string(), serde_json::json!(gravity));
692            }
693        }
694
695        // Convert objectFit to Android scaleType
696        if let Some(object_fit) = styles.get("objectFit") {
697            let scale_type = match object_fit.as_str() {
698                Some("cover") => "center_crop",
699                Some("contain") => "fit_center",
700                Some("fill") => "fit_xy",
701                Some("none") => "center",
702                Some("scale-down") => "center_inside",
703                _ => ""
704            };
705            if !scale_type.is_empty() {
706                styles.insert("androidScaleType".to_string(), serde_json::json!(scale_type));
707            }
708        }
709
710        // Handle full width/height
711        if let Some(h) = styles.get("height").cloned() {
712            if h.as_str() == Some("100%") {
713                styles.insert("height".to_string(), serde_json::json!("match_parent"));
714            }
715        }
716        if let Some(w) = styles.get("width").cloned() {
717            if w.as_str() == Some("100%") {
718                styles.insert("width".to_string(), serde_json::json!("match_parent"));
719            }
720        }
721
722        // Handle flex/weight: if flex is present, set the dimension in the orientation direction to 0
723        // Note: We don't set it to 0 here anymore because we don't know if the parent is a LinearLayout.
724        // The NativeRenderer will handle setting it to 0 if it's inside a LinearLayout.
725        if styles.contains_key("flex") || styles.contains_key("flexGrow") {
726            // Just ensure we have some dimension if not specified
727            if !styles.contains_key("width") {
728                styles.insert("width".to_string(), serde_json::json!("wrap_content"));
729            }
730            if !styles.contains_key("height") {
731                styles.insert("height".to_string(), serde_json::json!("wrap_content"));
732            }
733        }
734        
735        // Convert fontWeight to Android typeface style
736        if let Some(font_weight) = styles.get("fontWeight") {
737            let is_bold = match font_weight {
738                serde_json::Value::String(s) => s.contains("bold") || s == "600" || s == "700" || s == "500",
739                serde_json::Value::Number(n) => {
740                    let weight = n.as_i64().unwrap_or(400);
741                    weight >= 500
742                }
743                _ => false
744            };
745            if is_bold {
746                styles.insert("androidTypefaceStyle".to_string(), serde_json::json!("bold"));
747            }
748        }
749        
750        // Convert boxShadow to elevation
751        if let Some(box_shadow) = styles.get("boxShadow") {
752            if let Some(shadow_str) = box_shadow.as_str() {
753                if !shadow_str.is_empty() {
754                    let elevation_dp = if shadow_str.contains("20px") { 24.0 }
755                    else if shadow_str.contains("15px") { 16.0 }
756                    else if shadow_str.contains("10px") { 8.0 }
757                    else if shadow_str.contains("5px") { 4.0 }
758                    else { 4.0 };
759                    styles.insert("elevation".to_string(), serde_json::json!(dp_to_px(elevation_dp, density)));
760                }
761            }
762        }
763        
764        styles
765    }
766
767    // Previously supported loading YAML at runtime; now defaults are embedded.
768
769    // Build the inheritance chain from current theme upward via `inherits` and default fallback
770    fn theme_chain(&self) -> Vec<String> {
771        let mut chain = Vec::new();
772        // Resolve base names
773        let default_name = if self.themes.contains_key(&self.default_theme) {
774            self.default_theme.clone()
775        } else if let Some((k, _)) = self.themes.first() { k.clone() } else { return chain };
776        let mut current_name = if self.themes.contains_key(&self.current_theme) {
777            self.current_theme.clone()
778        } else { default_name.clone() };
779        // push child first
780        let mut seen: IndexSet<String> = IndexSet::new();
781        while !seen.contains(&current_name) {
782            seen.insert(current_name.clone());
783            chain.push(current_name.clone());
784            // next parent via inherits, else stop
785            let inherits = self.themes.get(&current_name).and_then(|t| t.inherits.clone());
786            if let Some(p) = inherits {
787                current_name = p;
788            } else {
789                break;
790            }
791        }
792        if !chain.iter().any(|n| n == &default_name) {
793            chain.push(default_name);
794        }
795        chain
796    }
797
798    // Compute effective selectors + variables + breakpoints with inheritance.
799    // Child overrides parent/default on conflicts (expected for "inherits").
800    fn effective_theme_all(&self) -> (SelectorStyles, IndexMap<String, String>) {
801        let mut selectors: SelectorStyles = SelectorStyles::new();
802        let mut vars: IndexMap<String, String> = IndexMap::new();
803        // Merge default -> parents -> child so child wins on conflicts
804        let chain = self.theme_chain();
805        for name in chain.into_iter().rev() {
806            if let Some(entry) = self.themes.get(&name) {
807                // merge selectors: later (child) overrides earlier (parent/default)
808                for (sel, props) in entry.selectors.iter() {
809                    // Support multiple selectors separated by commas (e.g., "h1, h2, h3")
810                    if sel.contains(',') {
811                        for s in sel.split(',') {
812                            let s = s.trim();
813                            if s.is_empty() { continue; }
814                            let e = selectors.entry(s.to_string()).or_default();
815                            merge_props(e, props);
816                        }
817                    } else {
818                        let e = selectors.entry(sel.clone()).or_default();
819                        merge_props(e, props);
820                    }
821                }
822                // merge variables
823                for (k, v) in entry.variables.iter() {
824                    vars.insert(k.clone(), v.clone());
825                }
826            }
827        }
828        (selectors, vars)
829    }
830
831    // Effective breakpoints with inheritance; child overrides parent/default.
832    pub fn effective_breakpoints(&self) -> IndexMap<String, String> {
833        let mut bps: IndexMap<String, String> = IndexMap::new();
834        let chain = self.theme_chain();
835        for name in chain.into_iter().rev() {
836            if let Some(entry) = self.themes.get(&name) {
837                for (k, v) in entry.breakpoints.iter() {
838                    bps.insert(k.clone(), v.clone());
839                }
840            }
841        }
842        bps
843    }
844}
845
846fn split_tag_class_key(key: &str) -> Option<(String, String)> {
847    let mut it = key.splitn(2, '|');
848    let t = it.next()?.to_string();
849    let c = it.next()?.to_string();
850    if t.is_empty() || c.is_empty() { return None; }
851    Some((t, c))
852}
853
854fn strip_hover_suffix(selector: &str) -> (&str, bool) {
855    if let Some(stripped) = selector.strip_suffix(":hover") { (stripped, true) } else { (selector, false) }
856}
857
858fn should_emit_selector(sel: &str, used_tags: &IndexSet<String>, used_classes: &IndexSet<String>, used_tag_classes: &IndexSet<String>) -> bool {
859    // Optionally handle :hover suffix
860    let (base, _hover) = strip_hover_suffix(sel);
861
862    // tag-only
863    if is_simple_tag(base) {
864        return used_tags.contains(base) || used_tag_classes.iter().any(|k| k.split('|').next() == Some(base));
865    }
866
867    // .class-only
868    if let Some(class_name) = base.strip_prefix('.') {
869        // Normalize potential escaped class names as-is
870        return used_classes.contains(class_name) || used_tag_classes.iter().any(|k| k.ends_with(&format!("|{}", class_name)));
871    }
872
873    // tag.class
874    if let Some((tag, class_name)) = split_tag_class_selector(base) {
875        let key = format!("{}|{}", tag, class_name);
876        return used_tag_classes.contains(&key) || (used_tags.contains(&tag) && used_classes.contains(&class_name));
877    }
878
879    // Other complex selectors are currently ignored
880    false
881}
882
883fn is_simple_tag(s: &str) -> bool {
884    // Match simple HTML tag-ish identifiers
885    let mut chars = s.chars();
886    match chars.next() { Some(c) if c.is_ascii_alphabetic() => {}, _ => return false }
887    chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
888}
889
890fn split_tag_class_selector(s: &str) -> Option<(String, String)> {
891    // "tag.class" -> (tag, class)
892    let mut parts = s.splitn(2, '.');
893    let tag = parts.next()?.to_string();
894    let class_name = parts.next()?.to_string();
895    if tag.is_empty() || class_name.is_empty() { return None; }
896    Some((tag, class_name))
897}
898
899// wasm-bindgen exports (only when compiling to wasm32)
900#[cfg(target_arch = "wasm32")]
901#[wasm_bindgen]
902pub fn render_css_for_web(state_json: &str) -> String {
903    match serde_json::from_str::<State>(state_json) {
904        Ok(s) => s.css_for_web(),
905        Err(_) => "".into(),
906    }
907}
908
909#[cfg(target_arch = "wasm32")]
910#[wasm_bindgen]
911pub fn get_android_styles(state_json: &str, selector: &str, classes_json: &str) -> String {
912    let classes: Vec<String> = serde_json::from_str(classes_json).unwrap_or_default();
913    match serde_json::from_str::<State>(state_json) {
914        Ok(s) => serde_json::to_string(&s.android_styles_for(selector, &classes)).unwrap_or_else(|_| "{}".into()),
915        Err(_) => "{}".into(),
916    }
917}
918
919#[cfg(target_arch = "wasm32")]
920#[wasm_bindgen]
921pub fn get_android_styles(state_json: &str, selector: &str, classes_json: &str) -> String {
922    get_android_styles(state_json, selector, classes_json)
923}
924
925// Expose crate version to JS via wasm-bindgen
926#[cfg(target_arch = "wasm32")]
927#[wasm_bindgen]
928pub fn get_version() -> String {
929    // CARGO_PKG_VERSION is provided at compile time
930    env!("CARGO_PKG_VERSION").to_string()
931}
932
933// Plain Rust accessor for crate version used by Android JNI glue
934pub fn version() -> &'static str {
935    env!("CARGO_PKG_VERSION")
936}
937
938/// Return the embedded default state as a JSON string.
939#[cfg(target_arch = "wasm32")]
940#[wasm_bindgen]
941pub fn get_default_state_json() -> String {
942    let st = bundled_state();
943    match serde_json::to_string(&st.to_json()) {
944        Ok(s) => s,
945        Err(_) => "{}".to_string(),
946    }
947}
948
949/// Register a theme from JSON. On duplicate, replace the theme's selectors, inheritance, and variables.
950/// Expected JSON format: `{ "name": "theme-name", "theme": { "inherits": "parent", "selectors": {...}, "variables": {...}, "breakpoints": {...} } }`
951/// Returns the updated state as JSON, or "{}" on error.
952#[cfg(target_arch = "wasm32")]
953#[wasm_bindgen]
954pub fn register_theme_json(state_json: &str, theme_json: &str) -> String {
955    match (serde_json::from_str::<State>(state_json), serde_json::from_str::<serde_json::Value>(theme_json)) {
956        (Ok(mut state), Ok(theme_obj)) => {
957            if let (Some(name), Some(theme_entry)) = (theme_obj.get("name"), theme_obj.get("theme")) {
958                if let Ok(entry) = serde_json::from_value::<ThemeEntry>(theme_entry.clone()) {
959                    let theme_name = name.as_str().unwrap_or("").to_string();
960                    if !theme_name.is_empty() {
961                        state.themes.insert(theme_name, entry);
962                    }
963                }
964            }
965            match serde_json::to_string(&state.to_json()) {
966                Ok(s) => s,
967                Err(_) => "{}".to_string(),
968            }
969        }
970        _ => "{}".to_string(),
971    }
972}
973
974/// Set the default and current theme. Returns the updated state as JSON.
975#[cfg(target_arch = "wasm32")]
976#[wasm_bindgen]
977pub fn set_theme_json(state_json: &str, theme_name: &str) -> String {
978    match serde_json::from_str::<State>(state_json) {
979        Ok(mut state) => {
980            if state.themes.contains_key(theme_name) {
981                state.default_theme = theme_name.to_string();
982                state.current_theme = theme_name.to_string();
983            }
984            match serde_json::to_string(&state.to_json()) {
985                Ok(s) => s,
986                Err(_) => "{}".to_string(),
987            }
988        }
989        _ => "{}".to_string(),
990    }
991}
992
993/// Get all theme keys and names as JSON array: [{ "key": "default", "name": "Default Theme" }, ...]
994/// Returns array of themes from the state JSON.
995#[cfg(target_arch = "wasm32")]
996#[wasm_bindgen]
997pub fn get_theme_list_json(state_json: &str) -> String {
998    match serde_json::from_str::<State>(state_json) {
999        Ok(state) => {
1000            let themes: Vec<serde_json::Value> = state.themes.iter().map(|(key, entry)| {
1001                json!({
1002                    "key": key,
1003                    "name": entry.name.as_ref().unwrap_or(key)
1004                })
1005            }).collect();
1006            serde_json::to_string(&themes).unwrap_or_else(|_| "[]".to_string())
1007        }
1008        _ => "[]".to_string(),
1009    }
1010}
1011
1012fn merge_props(into: &mut CssProps, from: &CssProps) {
1013    for (k, v) in from.iter() {
1014        into.insert(k.clone(), v.clone());
1015    }
1016}
1017
1018// merge_indexmap removed — unused
1019
1020fn css_props_string(props: &CssProps, vars: &IndexMap<String, String>) -> String {
1021    let mut buf = String::new();
1022    for (k, v) in props.iter() {
1023        buf.push_str(k);
1024        buf.push(':');
1025        let val = if v.is_string() {
1026            let s = v.as_str().unwrap();
1027            resolve_vars(s, vars)
1028        } else {
1029            v.to_string()
1030        };
1031        buf.push_str(&val);
1032        if !val.ends_with(';') {
1033            buf.push(';');
1034        }
1035    }
1036    buf
1037}
1038
1039/// Parse var() references manually (replaces regex dependency)
1040/// Matches: var(--name), var(name), with optional whitespace
1041/// Supports alphanumeric, underscore, dot, and dash in variable names
1042fn parse_var_references(input: &str) -> Vec<(usize, usize, String)> {
1043    let mut results = Vec::new();
1044    let bytes = input.as_bytes();
1045    let mut i = 0;
1046    
1047    while i < bytes.len() {
1048        // Look for "var("
1049        if i + 4 <= bytes.len() && &bytes[i..i+4] == b"var(" {
1050            let start = i;
1051            i += 4;
1052            
1053            // Skip whitespace
1054            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\n' || bytes[i] == b'\r') {
1055                i += 1;
1056            }
1057            
1058            // Check for optional -- prefix
1059            let has_prefix = i + 2 <= bytes.len() && &bytes[i..i+2] == b"--";
1060            if has_prefix {
1061                i += 2;
1062            }
1063            
1064            // Collect variable name: [a-zA-Z0-9_.-]+
1065            let name_start = i;
1066            while i < bytes.len() {
1067                let c = bytes[i];
1068                if (c >= b'a' && c <= b'z') || (c >= b'A' && c <= b'Z') || 
1069                   (c >= b'0' && c <= b'9') || c == b'_' || c == b'.' || c == b'-' {
1070                    i += 1;
1071                } else {
1072                    break;
1073                }
1074            }
1075            
1076            let name_end = i;
1077            if name_start < name_end {
1078                // Skip trailing whitespace
1079                while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\n' || bytes[i] == b'\r') {
1080                    i += 1;
1081                }
1082                
1083                // Check for closing )
1084                if i < bytes.len() && bytes[i] == b')' {
1085                    let end = i + 1;
1086                    let var_name = std::str::from_utf8(&bytes[name_start..name_end])
1087                        .unwrap_or("").to_string();
1088                    results.push((start, end, var_name));
1089                    i = end;
1090                    continue;
1091                }
1092            }
1093        }
1094        i += 1;
1095    }
1096    
1097    results
1098}
1099
1100// Tailwind color palette - embedded from tailwind-colors.html
1101static TAILWIND_COLORS: Lazy<IndexMap<&'static str, IndexMap<&'static str, &'static str>>> = Lazy::new(|| {
1102    let mut colors = IndexMap::new();
1103    
1104    let mut slate = IndexMap::new();
1105    slate.insert("50", "#f8fafc"); slate.insert("100", "#f1f5f9"); slate.insert("200", "#e2e8f0");
1106    slate.insert("300", "#cbd5e1"); slate.insert("400", "#94a3b8"); slate.insert("500", "#64748b");
1107    slate.insert("600", "#475569"); slate.insert("700", "#334155"); slate.insert("800", "#1e293b");
1108    slate.insert("900", "#0f172a"); slate.insert("950", "#020617");
1109    colors.insert("slate", slate);
1110    
1111    let mut gray = IndexMap::new();
1112    gray.insert("50", "#f9fafb"); gray.insert("100", "#f3f4f6"); gray.insert("200", "#e5e7eb");
1113    gray.insert("300", "#d1d5db"); gray.insert("400", "#9ca3af"); gray.insert("500", "#6b7280");
1114    gray.insert("600", "#4b5563"); gray.insert("700", "#374151"); gray.insert("800", "#1f2937");
1115    gray.insert("900", "#111827"); gray.insert("950", "#030712");
1116    colors.insert("gray", gray);
1117    
1118    let mut zinc = IndexMap::new();
1119    zinc.insert("50", "#fafafa"); zinc.insert("100", "#f4f4f5"); zinc.insert("200", "#e4e4e7");
1120    zinc.insert("300", "#d4d4d8"); zinc.insert("400", "#a1a1aa"); zinc.insert("500", "#71717a");
1121    zinc.insert("600", "#52525b"); zinc.insert("700", "#3f3f46"); zinc.insert("800", "#27272a");
1122    zinc.insert("900", "#18181b"); zinc.insert("950", "#09090b");
1123    colors.insert("zinc", zinc);
1124    
1125    let mut neutral = IndexMap::new();
1126    neutral.insert("50", "#fafafa"); neutral.insert("100", "#f5f5f5"); neutral.insert("200", "#e5e5e5");
1127    neutral.insert("300", "#d4d4d4"); neutral.insert("400", "#a3a3a3"); neutral.insert("500", "#737373");
1128    neutral.insert("600", "#525252"); neutral.insert("700", "#404040"); neutral.insert("800", "#262626");
1129    neutral.insert("900", "#171717"); neutral.insert("950", "#0a0a0a");
1130    colors.insert("neutral", neutral);
1131    
1132    let mut stone = IndexMap::new();
1133    stone.insert("50", "#fafaf9"); stone.insert("100", "#f5f5f4"); stone.insert("200", "#e7e5e4");
1134    stone.insert("300", "#d6d3d1"); stone.insert("400", "#a8a29e"); stone.insert("500", "#78716c");
1135    stone.insert("600", "#57534e"); stone.insert("700", "#44403c"); stone.insert("800", "#292524");
1136    stone.insert("900", "#1c1917"); stone.insert("950", "#0c0a09");
1137    colors.insert("stone", stone);
1138    
1139    let mut red = IndexMap::new();
1140    red.insert("50", "#fef2f2"); red.insert("100", "#fee2e2"); red.insert("200", "#fecaca");
1141    red.insert("300", "#fca5a5"); red.insert("400", "#f87171"); red.insert("500", "#ef4444");
1142    red.insert("600", "#dc2626"); red.insert("700", "#b91c1c"); red.insert("800", "#991b1b");
1143    red.insert("900", "#7f1d1d"); red.insert("950", "#450a0a");
1144    colors.insert("red", red);
1145    
1146    let mut orange = IndexMap::new();
1147    orange.insert("50", "#fff7ed"); orange.insert("100", "#ffedd5"); orange.insert("200", "#fed7aa");
1148    orange.insert("300", "#fdba74"); orange.insert("400", "#fb923c"); orange.insert("500", "#f97316");
1149    orange.insert("600", "#ea580c"); orange.insert("700", "#c2410c"); orange.insert("800", "#9a3412");
1150    orange.insert("900", "#7c2d12"); orange.insert("950", "#431407");
1151    colors.insert("orange", orange);
1152    
1153    let mut amber = IndexMap::new();
1154    amber.insert("50", "#fffbeb"); amber.insert("100", "#fef3c7"); amber.insert("200", "#fde68a");
1155    amber.insert("300", "#fcd34d"); amber.insert("400", "#fbbf24"); amber.insert("500", "#f59e0b");
1156    amber.insert("600", "#d97706"); amber.insert("700", "#b45309"); amber.insert("800", "#92400e");
1157    amber.insert("900", "#78350f"); amber.insert("950", "#451a03");
1158    colors.insert("amber", amber);
1159    
1160    let mut blue = IndexMap::new();
1161    blue.insert("50", "#eff6ff"); blue.insert("100", "#dbeafe"); blue.insert("200", "#bfdbfe");
1162    blue.insert("300", "#93c5fd"); blue.insert("400", "#60a5fa"); blue.insert("500", "#3b82f6");
1163    blue.insert("600", "#2563eb"); blue.insert("700", "#1d4ed8"); blue.insert("800", "#1e40af");
1164    blue.insert("900", "#1e3a8a"); blue.insert("950", "#0b1c52");
1165    colors.insert("blue", blue);
1166    
1167    let mut lime = IndexMap::new();
1168    lime.insert("50", "#f7fee7"); lime.insert("100", "#ecfccb"); lime.insert("200", "#d9f99d");
1169    lime.insert("300", "#bef264"); lime.insert("400", "#a3e635"); lime.insert("500", "#84cc16");
1170    lime.insert("600", "#65a30d"); lime.insert("700", "#4d7c0f"); lime.insert("800", "#3f6212");
1171    lime.insert("900", "#365314"); lime.insert("950", "#1a2e05");
1172    colors.insert("lime", lime);
1173    
1174    let mut green = IndexMap::new();
1175    green.insert("50", "#f0fdf4"); green.insert("100", "#dcfce7"); green.insert("200", "#bbf7d0");
1176    green.insert("300", "#86efac"); green.insert("400", "#4ade80"); green.insert("500", "#22c55e");
1177    green.insert("600", "#16a34a"); green.insert("700", "#15803d"); green.insert("800", "#166534");
1178    green.insert("900", "#14532d"); green.insert("950", "#052e16");
1179    colors.insert("green", green);
1180    
1181    let mut emerald = IndexMap::new();
1182    emerald.insert("50", "#ecfdf5"); emerald.insert("100", "#d1fae5"); emerald.insert("200", "#a7f3d0");
1183    emerald.insert("300", "#6ee7b7"); emerald.insert("400", "#34d399"); emerald.insert("500", "#10b981");
1184    emerald.insert("600", "#059669"); emerald.insert("700", "#047857"); emerald.insert("800", "#065f46");
1185    emerald.insert("900", "#064e3b"); emerald.insert("950", "#022c22");
1186    colors.insert("emerald", emerald);
1187    
1188    let mut teal = IndexMap::new();
1189    teal.insert("50", "#f0fdfa"); teal.insert("100", "#ccfbf1"); teal.insert("200", "#99f6e4");
1190    teal.insert("300", "#5eead4"); teal.insert("400", "#2dd4bf"); teal.insert("500", "#14b8a6");
1191    teal.insert("600", "#0d9488"); teal.insert("700", "#0f766e"); teal.insert("800", "#115e59");
1192    teal.insert("900", "#134e4a"); teal.insert("950", "#042f2e");
1193    colors.insert("teal", teal);
1194    
1195    let mut cyan = IndexMap::new();
1196    cyan.insert("50", "#ecfeff"); cyan.insert("100", "#cffafe"); cyan.insert("200", "#a5f3fc");
1197    cyan.insert("300", "#67e8f9"); cyan.insert("400", "#22d3ee"); cyan.insert("500", "#06b6d4");
1198    cyan.insert("600", "#0891b2"); cyan.insert("700", "#0e7490"); cyan.insert("800", "#155e75");
1199    cyan.insert("900", "#164e63"); cyan.insert("950", "#083344");
1200    colors.insert("cyan", cyan);
1201    
1202    let mut sky = IndexMap::new();
1203    sky.insert("50", "#f0f9ff"); sky.insert("100", "#e0f2fe"); sky.insert("200", "#bae6fd");
1204    sky.insert("300", "#7dd3fc"); sky.insert("400", "#38bdf8"); sky.insert("500", "#0ea5e9");
1205    sky.insert("600", "#0284c7"); sky.insert("700", "#0369a1"); sky.insert("800", "#075985");
1206    sky.insert("900", "#0c4a6e"); sky.insert("950", "#082f49");
1207    colors.insert("sky", sky);
1208    
1209    let mut blue = IndexMap::new();
1210    blue.insert("50", "#eff6ff"); blue.insert("100", "#dbeafe"); blue.insert("200", "#bfdbfe");
1211    blue.insert("300", "#93c5fd"); blue.insert("400", "#60a5fa"); blue.insert("500", "#3b82f6");
1212    blue.insert("600", "#2563eb"); blue.insert("700", "#1d4ed8"); blue.insert("800", "#1e40af");
1213    blue.insert("900", "#1e3a8a"); blue.insert("950", "#172554");
1214    colors.insert("blue", blue);
1215    
1216    let mut indigo = IndexMap::new();
1217    indigo.insert("50", "#eef2ff"); indigo.insert("100", "#e0e7ff"); indigo.insert("200", "#c7d2fe");
1218    indigo.insert("300", "#a5b4fc"); indigo.insert("400", "#818cf8"); indigo.insert("500", "#6366f1");
1219    indigo.insert("600", "#4f46e5"); indigo.insert("700", "#4338ca"); indigo.insert("800", "#3730a3");
1220    indigo.insert("900", "#312e81"); indigo.insert("950", "#1e1b4b");
1221    colors.insert("indigo", indigo);
1222    
1223    let mut violet = IndexMap::new();
1224    violet.insert("50", "#f5f3ff"); violet.insert("100", "#ede9fe"); violet.insert("200", "#ddd6fe");
1225    violet.insert("300", "#c4b5fd"); violet.insert("400", "#a78bfa"); violet.insert("500", "#8b5cf6");
1226    violet.insert("600", "#7c3aed"); violet.insert("700", "#6d28d9"); violet.insert("800", "#5b21b6");
1227    violet.insert("900", "#4c1d95"); violet.insert("950", "#2e1065");
1228    colors.insert("violet", violet);
1229    
1230    let mut purple = IndexMap::new();
1231    purple.insert("50", "#faf5ff"); purple.insert("100", "#f3e8ff"); purple.insert("200", "#e9d5ff");
1232    purple.insert("300", "#d8b4fe"); purple.insert("400", "#c084fc"); purple.insert("500", "#a855f7");
1233    purple.insert("600", "#9333ea"); purple.insert("700", "#7e22ce"); purple.insert("800", "#6b21a8");
1234    purple.insert("900", "#581c87"); purple.insert("950", "#3b0764");
1235    colors.insert("purple", purple);
1236    
1237    let mut fuchsia = IndexMap::new();
1238    fuchsia.insert("50", "#fdf4ff"); fuchsia.insert("100", "#fae8ff"); fuchsia.insert("200", "#f5d0fe");
1239    fuchsia.insert("300", "#f0abfc"); fuchsia.insert("400", "#e879f9"); fuchsia.insert("500", "#d946ef");
1240    fuchsia.insert("600", "#c026d3"); fuchsia.insert("700", "#a21caf"); fuchsia.insert("800", "#86198f");
1241    fuchsia.insert("900", "#701a75"); fuchsia.insert("950", "#4a044e");
1242    colors.insert("fuchsia", fuchsia);
1243    
1244    let mut pink = IndexMap::new();
1245    pink.insert("50", "#fdf2f8"); pink.insert("100", "#fce7f3"); pink.insert("200", "#fbcfe8");
1246    pink.insert("300", "#f9a8d4"); pink.insert("400", "#f472b6"); pink.insert("500", "#ec4899");
1247    pink.insert("600", "#db2777"); pink.insert("700", "#be185d"); pink.insert("800", "#9d174d");
1248    pink.insert("900", "#831843"); pink.insert("950", "#500724");
1249    colors.insert("pink", pink);
1250    
1251    let mut rose = IndexMap::new();
1252    rose.insert("50", "#fff1f2"); rose.insert("100", "#ffe4e6"); rose.insert("200", "#fecdd3");
1253    rose.insert("300", "#fda4af"); rose.insert("400", "#fb7185"); rose.insert("500", "#f43f5e");
1254    rose.insert("600", "#e11d48"); rose.insert("700", "#be123c"); rose.insert("800", "#9f1239");
1255    rose.insert("900", "#881337"); rose.insert("950", "#4c0519");
1256    colors.insert("rose", rose);
1257    
1258    colors
1259});
1260
1261fn resolve_vars(input: &str, vars: &IndexMap<String, String>) -> String {
1262    let var_refs = parse_var_references(input);
1263    
1264    if var_refs.is_empty() {
1265        // Fast path: no var() references, just check for $ prefix
1266        if input.starts_with('$') {
1267            if let Some(val) = vars.get(&input[1..]) {
1268                return val.clone();
1269            }
1270        }
1271        return input.to_string();
1272    }
1273    
1274    // Replace var() references from right to left to preserve indices
1275    let mut out = input.to_string();
1276    for (start, end, var_name) in var_refs.iter().rev() {
1277        if let Some(val) = vars.get(var_name) {
1278            out.replace_range(*start..*end, val);
1279        }
1280    }
1281    
1282    // Also handle $ prefix for direct variable references
1283    if out.starts_with('$') {
1284        if let Some(val) = vars.get(&out[1..]) {
1285            return val.clone();
1286        }
1287    }
1288    
1289    out
1290}
1291
1292fn camel_case(name: &str) -> String {
1293    let mut out = String::new();
1294    let mut upper = false;
1295    for ch in name.chars() {
1296        if ch == '-' {
1297            upper = true;
1298            continue;
1299        }
1300        if upper {
1301            out.extend(ch.to_uppercase());
1302            upper = false;
1303        } else {
1304            out.push(ch);
1305        }
1306    }
1307    out
1308}
1309
1310fn css_value_to_android(
1311    value: &serde_json::Value,
1312    vars: &IndexMap<String, String>,
1313    current_color: Option<&str>,
1314) -> serde_json::Value {
1315    if let Some(s) = value.as_str() {
1316        if s.contains("color-mix") {
1317            log::debug!("[css_value_to_android] color-mix detected: {} current_color={:?}", s, current_color);
1318        }
1319    }
1320    match value {
1321        serde_json::Value::String(s) => {
1322            let s2 = resolve_vars(s, vars);
1323            let s3 = color::resolve_color(&s2, current_color, vars);
1324            if let Some(n) = s3.strip_suffix("px") {
1325                if let Ok(parsed) = n.trim().parse::<f64>() {
1326                    return json!(parsed);
1327                }
1328            }
1329            json!(s3)
1330        }
1331        _ => value.clone(),
1332    }
1333}
1334
1335fn merge_android_props(
1336    into: &mut IndexMap<String, serde_json::Value>,
1337    css_props: &CssProps,
1338    vars: &IndexMap<String, String>,
1339) {
1340    log::debug!("[merge_android_props] START props_count={}", css_props.len());
1341    // 1. Find current color for currentColor resolution
1342    let mut current_color = css_props.get("color")
1343        .and_then(|v| v.as_str())
1344        .map(|s| resolve_vars(s, vars));
1345    
1346    if current_color.is_none() {
1347        current_color = into.get("color").and_then(|v| v.as_str()).map(|s| s.to_string());
1348    }
1349
1350    if let Some(ref c) = current_color {
1351        log::debug!("[merge_android_props] current_color resolved to: {}", c);
1352    }
1353
1354    for (k, v) in css_props.iter() {
1355        let val = css_value_to_android(v, vars, current_color.as_deref());
1356        
1357        if k == "placeholder-color" || k == "placeholderColor" {
1358            log::debug!("[merge_android_props] placeholder-color: input={:?} output={:?}", v, val);
1359        }
1360
1361        match k.as_str() {
1362            "padding" => {
1363                into.insert("paddingTop".to_string(), val.clone());
1364                into.insert("paddingBottom".to_string(), val.clone());
1365                into.insert("paddingLeft".to_string(), val.clone());
1366                into.insert("paddingRight".to_string(), val.clone());
1367                into.insert("paddingHorizontal".to_string(), val.clone());
1368                into.insert("paddingVertical".to_string(), val.clone());
1369                into.insert("padding".to_string(), val);
1370            }
1371            "padding-horizontal" | "paddingHorizontal" => {
1372                into.insert("paddingLeft".to_string(), val.clone());
1373                into.insert("paddingRight".to_string(), val.clone());
1374                into.insert("paddingHorizontal".to_string(), val);
1375            }
1376            "padding-vertical" | "paddingVertical" => {
1377                into.insert("paddingTop".to_string(), val.clone());
1378                into.insert("paddingBottom".to_string(), val.clone());
1379                into.insert("paddingVertical".to_string(), val);
1380            }
1381            "margin" => {
1382                into.insert("marginTop".to_string(), val.clone());
1383                into.insert("marginBottom".to_string(), val.clone());
1384                into.insert("marginLeft".to_string(), val.clone());
1385                into.insert("marginRight".to_string(), val.clone());
1386                into.insert("marginHorizontal".to_string(), val.clone());
1387                into.insert("marginVertical".to_string(), val.clone());
1388                into.insert("margin".to_string(), val);
1389            }
1390            "margin-horizontal" | "marginHorizontal" => {
1391                into.insert("marginLeft".to_string(), val.clone());
1392                into.insert("marginRight".to_string(), val.clone());
1393                into.insert("marginHorizontal".to_string(), val);
1394            }
1395            "margin-vertical" | "marginVertical" => {
1396                into.insert("marginTop".to_string(), val.clone());
1397                into.insert("marginBottom".to_string(), val.clone());
1398                into.insert("marginVertical".to_string(), val);
1399            }
1400            "border-radius" | "borderRadius" => {
1401                into.insert("borderTopLeftRadius".to_string(), val.clone());
1402                into.insert("borderTopRightRadius".to_string(), val.clone());
1403                into.insert("borderBottomLeftRadius".to_string(), val.clone());
1404                into.insert("borderBottomRightRadius".to_string(), val.clone());
1405                into.insert("borderRadius".to_string(), val);
1406            }
1407            "background-color" => { into.insert("backgroundColor".to_string(), val); }
1408            "text-align" => { into.insert("textAlign".to_string(), val); }
1409            "flex-direction" | "flexDirection" => {
1410                let orientation = if val.as_str() == Some("column") || val.as_str() == Some("column-reverse") {
1411                    "vertical"
1412                } else {
1413                    "horizontal"
1414                };
1415                into.insert("androidOrientation".to_string(), serde_json::json!(orientation));
1416                into.insert("flexDirection".to_string(), val);
1417            }
1418            _ => {
1419                into.insert(camel_case(k), val);
1420            }
1421        }
1422    }
1423}
1424
1425fn dynamic_css_properties_for_class(class: &str, vars: &IndexMap<String, String>) -> Option<CssProps> {
1426    // Display utilities
1427    match class {
1428        "block" => { let mut p = CssProps::new(); p.insert("display".into(), json!("block")); return Some(p); }
1429        "inline-block" => { let mut p = CssProps::new(); p.insert("display".into(), json!("inline-block")); return Some(p); }
1430        "inline" => { let mut p = CssProps::new(); p.insert("display".into(), json!("inline")); return Some(p); }
1431        "inline-flex" => { let mut p = CssProps::new(); p.insert("display".into(), json!("inline-flex")); return Some(p); }
1432        "grid" => { let mut p = CssProps::new(); p.insert("display".into(), json!("grid")); return Some(p); }
1433        "hidden" => { let mut p = CssProps::new(); p.insert("display".into(), json!("none")); return Some(p); }
1434        _ => {}
1435    }
1436    // Flexbox shorthands
1437    match class {
1438        "flex" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); return Some(p); }
1439        "flex-row" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flexDirection".into(), json!("row")); return Some(p); }
1440        "flex-col" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flexDirection".into(), json!("column")); return Some(p); }
1441        "flex-wrap" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-wrap".into(), json!("wrap")); return Some(p); }
1442        "flex-nowrap" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-wrap".into(), json!("nowrap")); return Some(p); }
1443        "flex-wrap-reverse" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-wrap".into(), json!("wrap-reverse")); return Some(p); }
1444        "flex-1" => { let mut p = CssProps::new(); p.insert("flex".into(), json!(1)); return Some(p); }
1445        "w-full" => { let mut p = CssProps::new(); p.insert("width".into(), json!("match_parent")); return Some(p); }
1446        "h-full" => { let mut p = CssProps::new(); p.insert("height".into(), json!("match_parent")); return Some(p); }
1447        _ => {}
1448    }
1449    if let Some(value) = class.strip_prefix("z-") {
1450        if let Ok(z) = value.parse::<i32>() {
1451            let mut p = CssProps::new();
1452            p.insert("elevation".into(), json!(z));
1453            return Some(p);
1454        }
1455    }
1456    if let Some(rest) = class.strip_prefix("items-") {
1457        let mut p = CssProps::new();
1458        let v = match rest { "start" => "flex-start", "end" => "flex-end", "center" => "center", "stretch" => "stretch", other => other };
1459        p.insert("align-items".into(), json!(v));
1460        return Some(p);
1461    }
1462    if let Some(rest) = class.strip_prefix("justify-") {
1463        let mut p = CssProps::new();
1464        let v = match rest { "start" => "flex-start", "end" => "flex-end", "center" => "center", "between" => "space-between", "around" => "space-around", "evenly" => "space-evenly", other => other };
1465        p.insert("justify-content".into(), json!(v));
1466        return Some(p);
1467    }
1468    if let Some(value) = class.strip_prefix("p-") {
1469        return parse_tailwind_spacing(value, &|px| padding_props(&["padding"], px));
1470    }
1471    if let Some(value) = class.strip_prefix("px-") {
1472        return parse_tailwind_spacing(value, &|px| padding_props(&["padding-left", "padding-right"], px));
1473    }
1474    if let Some(value) = class.strip_prefix("py-") {
1475        return parse_tailwind_spacing(value, &|px| padding_props(&["padding-top", "padding-bottom"], px));
1476    }
1477    for &(prefix, prop) in &[("pt-", "padding-top"), ("pr-", "padding-right"), ("pb-", "padding-bottom"), ("pl-", "padding-left")] {
1478        if let Some(value) = class.strip_prefix(prefix) {
1479            return parse_tailwind_spacing(value, &|px| padding_props(&[prop], px));
1480        }
1481    }
1482    // Margin utilities
1483    if let Some(value) = class.strip_prefix("m-") {
1484        if value == "auto" {
1485            let mut p = CssProps::new();
1486            p.insert("margin".into(), json!("auto"));
1487            return Some(p);
1488        }
1489        return parse_tailwind_spacing(value, &|px| margin_props(&["margin"], px));
1490    }
1491    if let Some(value) = class.strip_prefix("mx-") {
1492        if value == "auto" {
1493            let mut p = CssProps::new();
1494            p.insert("margin-left".into(), json!("auto"));
1495            p.insert("margin-right".into(), json!("auto"));
1496            return Some(p);
1497        }
1498        return parse_tailwind_spacing(value, &|px| margin_props(&["margin-left", "margin-right"], px));
1499    }
1500    if let Some(value) = class.strip_prefix("my-") {
1501        if value == "auto" {
1502            let mut p = CssProps::new();
1503            p.insert("margin-top".into(), json!("auto"));
1504            p.insert("margin-bottom".into(), json!("auto"));
1505            return Some(p);
1506        }
1507        return parse_tailwind_spacing(value, &|px| margin_props(&["margin-top", "margin-bottom"], px));
1508    }
1509    for &(prefix, prop) in &[("mt-", "margin-top"), ("mr-", "margin-right"), ("mb-", "margin-bottom"), ("ml-", "margin-left")] {
1510        if let Some(value) = class.strip_prefix(prefix) {
1511            if value == "auto" {
1512                let mut p = CssProps::new();
1513                p.insert(prop.into(), json!("auto"));
1514                return Some(p);
1515            }
1516            return parse_tailwind_spacing(value, &|px| margin_props(&[prop], px));
1517        }
1518    }
1519    // Gap utilities (works in Android with Flexbox)
1520    if let Some(value) = class.strip_prefix("gap-") {
1521        if !value.starts_with("x-") && !value.starts_with("y-") {
1522            return parse_tailwind_spacing(value, &|px| {
1523                let mut props = CssProps::new();
1524                props.insert("gap".into(), json!(format!("{}px", px)));
1525                props
1526            });
1527        }
1528    }
1529    if let Some(value) = class.strip_prefix("gap-x-") {
1530        return parse_tailwind_spacing(value, &|px| {
1531            let mut props = CssProps::new();
1532            props.insert("column-gap".into(), json!(format!("{}px", px)));
1533            props
1534        });
1535    }
1536    if let Some(value) = class.strip_prefix("gap-y-") {
1537        return parse_tailwind_spacing(value, &|px| {
1538            let mut props = CssProps::new();
1539            props.insert("row-gap".into(), json!(format!("{}px", px)));
1540            props
1541        });
1542    }
1543    // Space utilities (space-x-*, space-y-*)
1544    if let Some(value) = class.strip_prefix("space-x-") {
1545        return parse_tailwind_spacing(value, &|px| {
1546            let mut props = CssProps::new();
1547            // In CSS, this is typically done with :not(:last-child) selector
1548            // For now, we'll set it as a custom property that can be used
1549            props.insert("--space-x".into(), json!(format!("{}px", px)));
1550            props
1551        });
1552    }
1553    if let Some(value) = class.strip_prefix("space-y-") {
1554        return parse_tailwind_spacing(value, &|px| {
1555            let mut props = CssProps::new();
1556            props.insert("--space-y".into(), json!(format!("{}px", px)));
1557            props
1558        });
1559    }
1560    // Font weight utilities
1561    match class {
1562        "font-thin" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("100")); return Some(p); }
1563        "font-extralight" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("200")); return Some(p); }
1564        "font-light" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("300")); return Some(p); }
1565        "font-normal" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("400")); return Some(p); }
1566        "font-medium" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("500")); return Some(p); }
1567        "font-semibold" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("600")); return Some(p); }
1568        "font-bold" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("700")); return Some(p); }
1569        "font-extrabold" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("800")); return Some(p); }
1570        "font-black" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("900")); return Some(p); }
1571        _ => {}
1572    }
1573    // Font family utilities
1574    match class {
1575        "font-sans" => { let mut p = CssProps::new(); p.insert("font-family".into(), json!("system-ui, -apple-system, sans-serif")); return Some(p); }
1576        "font-serif" => { let mut p = CssProps::new(); p.insert("font-family".into(), json!("Georgia, serif")); return Some(p); }
1577        "font-mono" => { let mut p = CssProps::new(); p.insert("font-family".into(), json!("ui-monospace, monospace")); return Some(p); }
1578        _ => {}
1579    }
1580    // Text size utilities
1581    match class {
1582        "text-xs" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("12px")); p.insert("line-height".into(), json!("16px")); return Some(p); }
1583        "text-sm" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("14px")); p.insert("line-height".into(), json!("20px")); return Some(p); }
1584        "text-base" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("16px")); p.insert("line-height".into(), json!("24px")); return Some(p); }
1585        "text-lg" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("18px")); p.insert("line-height".into(), json!("28px")); return Some(p); }
1586        "text-xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("20px")); p.insert("line-height".into(), json!("28px")); return Some(p); }
1587        "text-2xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("24px")); p.insert("line-height".into(), json!("32px")); return Some(p); }
1588        "text-3xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("30px")); p.insert("line-height".into(), json!("36px")); return Some(p); }
1589        "text-4xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("36px")); p.insert("line-height".into(), json!("40px")); return Some(p); }
1590        "text-5xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("48px")); p.insert("line-height".into(), json!("1")); return Some(p); }
1591        "text-6xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("60px")); p.insert("line-height".into(), json!("1")); return Some(p); }
1592        _ => {}
1593    }
1594    // Text alignment
1595    match class {
1596        "text-left" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("left")); return Some(p); }
1597        "text-center" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("center")); return Some(p); }
1598        "text-right" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("right")); return Some(p); }
1599        "text-justify" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("justify")); return Some(p); }
1600        _ => {}
1601    }
1602    // Overflow utilities
1603    match class {
1604        "overflow-auto" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("auto")); return Some(p); }
1605        "overflow-hidden" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("hidden")); return Some(p); }
1606        "overflow-visible" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("visible")); return Some(p); }
1607        "overflow-scroll" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("scroll")); return Some(p); }
1608        "overflow-x-auto" => { let mut p = CssProps::new(); p.insert("overflow-x".into(), json!("auto")); return Some(p); }
1609        "overflow-x-hidden" => { let mut p = CssProps::new(); p.insert("overflow-x".into(), json!("hidden")); return Some(p); }
1610        "overflow-x-scroll" => { let mut p = CssProps::new(); p.insert("overflow-x".into(), json!("scroll")); return Some(p); }
1611        "overflow-y-auto" => { let mut p = CssProps::new(); p.insert("overflow-y".into(), json!("auto")); return Some(p); }
1612        "overflow-y-hidden" => { let mut p = CssProps::new(); p.insert("overflow-y".into(), json!("hidden")); return Some(p); }
1613        "overflow-y-scroll" => { let mut p = CssProps::new(); p.insert("overflow-y".into(), json!("scroll")); return Some(p); }
1614        _ => {}
1615    }
1616    // Opacity utilities
1617    if let Some(value) = class.strip_prefix("opacity-") {
1618        if let Ok(opacity) = value.parse::<f32>() {
1619            let mut p = CssProps::new();
1620            p.insert("opacity".into(), json!(opacity / 100.0));
1621            return Some(p);
1622        }
1623    }
1624    // Shadow utilities (basic cross-platform support)
1625    match class {
1626        "shadow-sm" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 1px 2px 0 rgba(0, 0, 0, 0.05)")); return Some(p); }
1627        "shadow" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)")); return Some(p); }
1628        "shadow-md" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)")); return Some(p); }
1629        "shadow-lg" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)")); return Some(p); }
1630        "shadow-xl" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)")); return Some(p); }
1631        "shadow-2xl" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 25px 50px -12px rgba(0, 0, 0, 0.25)")); return Some(p); }
1632        "shadow-none" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("none")); return Some(p); }
1633        _ => {}
1634    }
1635    // Parse arbitrary values like bg-[var(--primary)], text-[#ff0000], etc.
1636    if let Some(arb_value) = parse_arbitrary_value(class) {
1637        return Some(arb_value);
1638    }
1639    // text-{color}-{shade}
1640    if let Some(rest) = class.strip_prefix("text-") {
1641        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
1642            let mut props = CssProps::new();
1643            props.insert("color".into(), json!(hex));
1644            return Some(props);
1645        }
1646    }
1647    // bg-{color}-{shade}
1648    if let Some(rest) = class.strip_prefix("bg-") {
1649        match rest {
1650            "white" => { let mut p = CssProps::new(); p.insert("background-color".into(), json!("#ffffff")); return Some(p); }
1651            "black" => { let mut p = CssProps::new(); p.insert("background-color".into(), json!("#000000")); return Some(p); }
1652            "transparent" => { let mut p = CssProps::new(); p.insert("background-color".into(), json!("#00000000")); return Some(p); }
1653            _ => {}
1654        }
1655        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
1656            let mut props = CssProps::new();
1657            props.insert("background-color".into(), json!(hex));
1658            return Some(props);
1659        }
1660    }
1661    // divide-{color}-{shade} (sets border-color for child dividers)
1662    if let Some(rest) = class.strip_prefix("divide-") {
1663        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
1664            let mut props = CssProps::new();
1665            props.insert("border-color".into(), json!(hex));
1666            return Some(props);
1667        }
1668    }
1669    if class == "border" {
1670        return Some(border_props(None, 1, vars));
1671    }
1672    if let Some(rest) = class.strip_prefix("border-") {
1673        // Parse border-* classes
1674        // Possible patterns:
1675        // - border-{color}-{shade} → border-color
1676        // - border-{side}-{color}-{shade} → border-{side}-color
1677        // - border-{width} → border-width
1678        // - border-{side}-{width} → border-{side}-width
1679        
1680        let parts: Vec<&str> = rest.split('-').collect();
1681        
1682        // Check if first part is a directional side (t, b, l, r, x, y)
1683        let valid_sides = ["t", "b", "l", "r", "x", "y"];
1684        let (side, color_or_width_parts) = if parts.len() > 1 && valid_sides.contains(&parts[0]) {
1685            (Some(parts[0]), &parts[1..])
1686        } else {
1687            (None, &parts[..])
1688        };
1689        
1690        // Now check if remaining parts form a color-shade pattern
1691        if color_or_width_parts.len() == 2 {
1692            // Could be color-shade like "blue-500"
1693            let color_shade = color_or_width_parts.join("-");
1694            if let Some(hex) = get_tailwind_color_with_vars(&color_shade, vars) {
1695                let mut props = CssProps::new();
1696                let prop_name = if let Some(s) = side {
1697                    format!("border-{}-color", s)
1698                } else {
1699                    "border-color".to_string()
1700                };
1701                props.insert(prop_name, json!(hex));
1702                return Some(props);
1703            }
1704        }
1705        
1706        // Check for simple color without shade (single word color like "black", "white")
1707        if color_or_width_parts.len() == 1 {
1708            let potential_color = format!("{}-500", color_or_width_parts[0]);
1709            if let Some(hex) = get_tailwind_color_with_vars(&potential_color, vars) {
1710                let mut props = CssProps::new();
1711                let prop_name = if let Some(s) = side {
1712                    format!("border-{}-color", s)
1713                } else {
1714                    "border-color".to_string()
1715                };
1716                props.insert(prop_name, json!(hex));
1717                return Some(props);
1718            }
1719        }
1720        
1721        // Otherwise, check for width (e.g., border-2, border-t-4)
1722        if color_or_width_parts.len() == 1 {
1723            if let Ok(width) = color_or_width_parts[0].parse::<i32>() {
1724                return Some(border_props(side, width, vars));
1725            }
1726        }
1727    }
1728    // rounded* (border-radius)
1729    if class == "rounded" { return Some(rounded_props(None, Some("md"))); }
1730    if let Some(sz) = class.strip_prefix("rounded-") {
1731        return Some(rounded_props(None, Some(sz)));
1732    }
1733    for &(pref, side) in &[("rounded-t", "t"), ("rounded-b", "b"), ("rounded-l", "l"), ("rounded-r", "r")] {
1734        if class == pref { return Some(rounded_props(Some(side), Some("md"))); }
1735        if let Some(sz) = class.strip_prefix(&(pref.to_string() + "-")) {
1736            return Some(rounded_props(Some(side), Some(sz)));
1737        }
1738    }
1739    // cursor-*
1740    if let Some(cur) = class.strip_prefix("cursor-") {
1741        let mut props = CssProps::new();
1742        props.insert("cursor".into(), json!(match cur {
1743            "pointer" => "pointer",
1744            "default" => "default",
1745            "text" => "text",
1746            "move" => "move",
1747            "wait" => "wait",
1748            "not-allowed" => "not-allowed",
1749            other => other,
1750        }));
1751        return Some(props);
1752    }
1753    // transition*
1754    if class == "transition" || class == "transition-all" {
1755        let mut props = CssProps::new();
1756        props.insert("transition-property".into(), json!("all"));
1757        props.insert("transition-duration".into(), json!("150ms"));
1758        props.insert("transition-timing-function".into(), json!("ease-in-out"));
1759        return Some(props);
1760    }
1761    if class == "transition-none" {
1762        let mut props = CssProps::new();
1763        props.insert("transition-property".into(), json!("none"));
1764        props.insert("transition-duration".into(), json!("0ms"));
1765        return Some(props);
1766    }
1767    if let Some(rest) = class.strip_prefix("transition-") {
1768        // e.g., transition-colors → limit property; keep default duration/ease
1769        let mut props = CssProps::new();
1770        let property = match rest {
1771            "colors" => "color, background-color, border-color, fill, stroke",
1772            "opacity" => "opacity",
1773            "transform" => "transform",
1774            "shadow" => "box-shadow",
1775            other => other,
1776        };
1777        props.insert("transition-property".into(), json!(property));
1778        props.insert("transition-duration".into(), json!("150ms"));
1779        props.insert("transition-timing-function".into(), json!("ease-in-out"));
1780        return Some(props);
1781    }
1782    // width utilities: w-*, w-full, w-screen, w-min, w-max (treat min/max as auto), w-px
1783    if let Some(val) = class.strip_prefix("w-") {
1784        return width_like_props("width", val);
1785    }
1786    if let Some(val) = class.strip_prefix("min-w-") {
1787        return width_like_props("min-width", val);
1788    }
1789    if let Some(val) = class.strip_prefix("max-w-") {
1790        return width_like_props("max-width", val);
1791    }
1792    // Height utilities
1793    if let Some(val) = class.strip_prefix("h-") {
1794        return width_like_props("height", val);
1795    }
1796    if let Some(val) = class.strip_prefix("min-h-") {
1797        return width_like_props("min-height", val);
1798    }
1799    if let Some(val) = class.strip_prefix("max-h-") {
1800        return width_like_props("max-height", val);
1801    }
1802    None
1803}
1804
1805fn parse_tailwind_spacing<F>(value: &str, builder: &F) -> Option<CssProps>
1806where
1807    F: Fn(i32) -> CssProps,
1808{
1809    if let Ok(n) = value.parse::<i32>() {
1810        let px = n * 4;
1811        return Some(builder(px));
1812    }
1813    None
1814}
1815
1816fn padding_props(keys: &[&str], px_value: i32) -> CssProps {
1817    let mut props = CssProps::new();
1818    let val = format!("{}px", px_value);
1819    for key in keys {
1820        props.insert((*key).into(), json!(&val));
1821    }
1822    props
1823}
1824
1825fn margin_props(keys: &[&str], px_value: i32) -> CssProps {
1826    let mut props = CssProps::new();
1827    let val = format!("{}px", px_value);
1828    for key in keys {
1829        props.insert((*key).into(), json!(&val));
1830    }
1831    props
1832}
1833
1834fn border_props(side: Option<&str>, width: i32, _vars: &IndexMap<String, String>) -> CssProps {
1835    let mut props = CssProps::new();
1836    let width_str = format!("{}px", width);
1837    match side {
1838        None => {
1839            props.insert("border-width".into(), json!(&width_str));
1840        }
1841        Some("t") => {
1842            props.insert("border-top-width".into(), json!(&width_str));
1843        }
1844        Some("b") => {
1845            props.insert("border-bottom-width".into(), json!(&width_str));
1846        }
1847        Some("l") => {
1848            props.insert("border-left-width".into(), json!(&width_str));
1849        }
1850        Some("r") => {
1851            props.insert("border-right-width".into(), json!(&width_str));
1852        }
1853        Some("x") => {
1854            props.insert("border-left-width".into(), json!(&width_str));
1855            props.insert("border-right-width".into(), json!(&width_str));
1856        }
1857        Some("y") => {
1858            props.insert("border-top-width".into(), json!(&width_str));
1859            props.insert("border-bottom-width".into(), json!(&width_str));
1860        }
1861        _ => {
1862            props.insert("border-width".into(), json!(&width_str));
1863        }
1864    };
1865    props.insert("border-color".into(), json!("var(border)"));
1866    props.insert("border-style".into(), json!("solid"));
1867    props
1868}
1869
1870fn rounded_props(side: Option<&str>, size: Option<&str>) -> CssProps {
1871    let mut props = CssProps::new();
1872    let px = match size.unwrap_or("md") {
1873        "none" => 0,
1874        "sm" => 2,
1875        "md" => 4,
1876        "lg" => 8,
1877        "xl" => 12,
1878        "2xl" => 16,
1879        "3xl" => 24,
1880        "full" => 9999,
1881        s => s.parse::<i32>().unwrap_or(4),
1882    };
1883    let v = json!(format!("{}px", px));
1884    match side {
1885        None => { props.insert("border-radius".into(), v); }
1886        Some("t") => {
1887            props.insert("border-top-left-radius".into(), v.clone());
1888            props.insert("border-top-right-radius".into(), v);
1889        }
1890        Some("b") => {
1891            props.insert("border-bottom-left-radius".into(), v.clone());
1892            props.insert("border-bottom-right-radius".into(), v);
1893        }
1894        Some("l") => { props.insert("border-top-left-radius".into(), v.clone()); props.insert("border-bottom-left-radius".into(), v); }
1895        Some("r") => { props.insert("border-top-right-radius".into(), v.clone()); props.insert("border-bottom-right-radius".into(), v); }
1896        _ => { props.insert("border-radius".into(), v); }
1897    }
1898    props
1899}
1900
1901fn width_like_props(prop: &str, token: &str) -> Option<CssProps> {
1902    let mut props = CssProps::new();
1903    let value = match token {
1904        "full" => Some("100%".to_string()),
1905        "screen" => Some(if prop == "width" { "100vw" } else { "100vh" }.to_string()),
1906        "min" => Some("min-content".to_string()),
1907        "max" => Some("max-content".to_string()),
1908        "fit" => Some("fit-content".to_string()),
1909        "auto" => Some("auto".to_string()),
1910        "px" => Some("1px".to_string()),
1911        other => {
1912            // numeric scale n => n*4px, fraction e.g., 1/2 => 50%
1913            if let Some((a, b)) = other.split_once('/') {
1914                if let (Ok(na), Ok(nb)) = (a.parse::<f64>(), b.parse::<f64>()) {
1915                    let pct = (na / nb) * 100.0;
1916                    Some(format!("{}%", trim_trailing_zeros(pct)))
1917                } else { None }
1918            } else if let Ok(n) = other.parse::<i32>() {
1919                Some(format!("{}px", n * 4))
1920            } else {
1921                None
1922            }
1923        }
1924    }?;
1925    props.insert(prop.into(), json!(value));
1926    Some(props)
1927}
1928
1929fn trim_trailing_zeros(num: f64) -> String {
1930    let mut s = format!("{:.6}", num);
1931    while s.contains('.') && s.ends_with('0') { s.pop(); }
1932    if s.ends_with('.') { s.pop(); }
1933    s
1934}
1935
1936// ---------------- Tailwind subset ----------------
1937
1938// static RE_NUM: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?P<prefix>(hover:)?(xs:|sm:|md:|lg:|xl:)*)?(?P<base>.+)$").unwrap());
1939
1940fn css_escape_class(class: &str) -> String { class.replace(':', "\\:") }
1941
1942fn class_to_selector(class: &str) -> String {
1943    let (_bp, hover, base) = parse_prefixed_class(class);
1944    if hover {
1945        format!(".{}:hover", css_escape_class(&base))
1946    } else {
1947        format!(".{}", css_escape_class(&base))
1948    }
1949}
1950
1951// ------------- helpers for CSS output of media selectors -------------
1952
1953/// Flatten CSS with potential selectors that include media prelude.
1954/// This simple post-processor merges entries that use the special selector format
1955/// "@media (min-width: X) {<sel>" where we will close the block at the end.
1956/// We group by media and inside concatenate selectors.
1957pub fn post_process_css(
1958    raw_rules: &[(String, CssProps)],
1959    vars: &IndexMap<String, String>,
1960) -> String {
1961    // Group into normal rules and media rules
1962    let mut normal = vec![];
1963    let mut media_map: IndexMap<String, Vec<(String, CssProps)>> = IndexMap::new();
1964    for (sel, props) in raw_rules.iter() {
1965        if let Some((media, inner)) = sel.split_once('{') {
1966            if media.trim_start().starts_with("@media ") && inner.ends_with('}') {
1967                let inner_sel = inner.trim_end_matches('}').to_string();
1968                media_map
1969                    .entry(media.trim().to_string())
1970                    .or_default()
1971                    .push((inner_sel, props.clone()));
1972                continue;
1973            }
1974        }
1975        normal.push((sel.clone(), props.clone()));
1976    }
1977    let mut out = String::new();
1978    for (sel, props) in normal {
1979        out.push_str(&sel);
1980        out.push('{');
1981        out.push_str(&css_props_string(&props, vars));
1982        out.push_str("}\n");
1983    }
1984    for (media, entries) in media_map {
1985        out.push_str(&media);
1986        out.push('{');
1987        for (sel, props) in entries {
1988            out.push_str(&sel);
1989            out.push('{');
1990            out.push_str(&css_props_string(&props, vars));
1991            out.push_str("}");
1992        }
1993        out.push_str("}\n");
1994    }
1995    out
1996}
1997
1998// -------- Prefix parsing (hover:, breakpoint:) --------
1999
2000fn parse_prefixed_class(class: &str) -> (Option<String>, bool, String) {
2001    // Split by ':' to find prefixes like md:hover:block
2002    let parts: Vec<&str> = class.split(':').collect();
2003    if parts.len() == 1 {
2004        return (None, false, class.to_string());
2005    }
2006    let mut bp: Option<String> = None;
2007    let mut hover = false;
2008    for &p in &parts[..parts.len() - 1] {
2009        match p {
2010            "hover" => hover = true,
2011            "xs" | "sm" | "md" | "lg" | "xl" => bp = Some(p.to_string()),
2012            _ => {}
2013        }
2014    }
2015    let base = parts.last().unwrap().to_string();
2016    (bp, hover, base)
2017}
2018
2019fn wrap_with_media(selector: &str, bp_key: Option<&str>, bps: &IndexMap<String, String>) -> String {
2020    if let Some(k) = bp_key {
2021        if let Some(val) = bps.get(k) {
2022            return format!("@media (min-width: {}) {{{}}}", val, selector);
2023        }
2024    }
2025    selector.to_string()
2026}
2027
2028/// Get a Tailwind color hex value from a string like "slate-200" or "blue-500"
2029fn get_tailwind_color(color_shade: &str) -> Option<String> {
2030    let parts: Vec<&str> = color_shade.split('-').collect();
2031    if parts.len() != 2 {
2032        return None;
2033    }
2034    let color_name = parts[0];
2035    let shade = parts[1];
2036    
2037    // First try standard Tailwind colors
2038    if let Some(hex) = TAILWIND_COLORS
2039        .get(color_name)
2040        .and_then(|shades| shades.get(shade))
2041    {
2042        return Some(hex.to_string());
2043    }
2044    
2045    None
2046}
2047
2048fn get_tailwind_color_with_vars(color_shade: &str, vars: &IndexMap<String, String>) -> Option<String> {
2049    // First try standard Tailwind colors
2050    if let Some(hex) = get_tailwind_color(color_shade) {
2051        return Some(hex);
2052    }
2053    
2054    // If not found, check if color_shade matches a variable
2055    // Theme variables are flattened with "." separators, e.g., "colors.primary"
2056    // So we need to check:
2057    // 1. Direct match: "primary" → look for "primary" in vars
2058    // 2. Color namespace: "primary" → look for "colors.primary" in vars (plural)
2059    // 3. Color namespace: "primary" → look for "color.primary" in vars (singular)
2060    // 4. With shade: "primary-500" → look for "colors.primary" or "colors.primary-500" in vars
2061    
2062    if let Some(val) = vars.get(color_shade) {
2063        return Some(val.clone());
2064    }
2065    
2066    // Try with "colors." namespace prefix (plural - HookRenderer uses this)
2067    if let Some(val) = vars.get(&format!("colors.{}", color_shade)) {
2068        return Some(val.clone());
2069    }
2070    
2071    // Try with "color." namespace prefix (singular - fallback)
2072    if let Some(val) = vars.get(&format!("color.{}", color_shade)) {
2073        return Some(val.clone());
2074    }
2075    
2076    // Handle cases where the color name doesn't have a shade but we need to look for a variable
2077    // e.g., "primary" (from bg-primary) → look for "color.primary"
2078    let parts: Vec<&str> = color_shade.split('-').collect();
2079    if parts.len() >= 1 {
2080        let color_name = parts[0];
2081        
2082        // Try direct variable
2083        if let Some(val) = vars.get(color_name) {
2084            return Some(val.clone());
2085        }
2086        
2087        // Try with "color." namespace
2088        if let Some(val) = vars.get(&format!("color.{}", color_name)) {
2089            return Some(val.clone());
2090        }
2091    }
2092    
2093    None
2094}
2095
2096/// Parse arbitrary values like bg-[var(--primary)], text-[#ff0000], border-[hsl(200,50%,50%)]
2097fn parse_arbitrary_value(class: &str) -> Option<CssProps> {
2098    // Match pattern: prefix-[value]
2099    if let Some(bracket_start) = class.find('[') {
2100        if !class.ends_with(']') {
2101            return None;
2102        }
2103        let prefix = &class[..bracket_start];
2104        let value = &class[bracket_start + 1..class.len() - 1];
2105        
2106        let mut props = CssProps::new();
2107        match prefix {
2108            "bg" => {
2109                props.insert("background-color".into(), json!(value));
2110                return Some(props);
2111            }
2112            "text" => {
2113                props.insert("color".into(), json!(value));
2114                return Some(props);
2115            }
2116            "border" => {
2117                props.insert("border-color".into(), json!(value));
2118                return Some(props);
2119            }
2120            "divide" => {
2121                props.insert("border-color".into(), json!(value));
2122                return Some(props);
2123            }
2124            _ => return None,
2125        }
2126    }
2127    None
2128}
2129
2130// re-export minimal API for CLI
2131pub mod api {
2132    pub use super::{SelectorStyles, State};
2133}
2134
2135#[cfg(test)]
2136mod tests {
2137    use super::*;
2138
2139    #[test]
2140    fn default_theme_has_p2() {
2141        let mut st = State::new_default();
2142        st.register_tailwind_classes(["p-2".to_string()]);
2143        let css = st.css_for_web();
2144        assert!(css.contains(".p-2{"));
2145        assert!(css.contains("padding:8px"));
2146    }
2147
2148    #[test]
2149    fn android_conversion() {
2150        let mut st = State::new_default();
2151        // Add a theme with button styles
2152        let mut styles = IndexMap::new();
2153        let mut button_props = IndexMap::new();
2154        button_props.insert("backgroundColor".to_string(), json!("#007bff"));
2155        styles.insert("button".to_string(), button_props);
2156        st.add_theme("default", styles);
2157        st.set_theme("default").ok();
2158        
2159        let out = st.android_styles_for("button", &[]);
2160        assert!(out.get("backgroundColor").is_some());
2161    }
2162
2163    #[test]
2164    fn embedded_defaults_and_version() {
2165        // Test that we can create a state and add a theme with variables
2166        let mut st = State::default_state();
2167        st.add_theme("default", IndexMap::new());
2168        st.set_theme("default").ok();
2169        
2170        let mut vars = IndexMap::new();
2171        vars.insert("primary".to_string(), "#007bff".to_string());
2172        st.set_variables(vars);
2173        
2174        assert!(st.themes.contains_key("default"));
2175        let def = st.themes.get("default").unwrap();
2176        assert!(def.variables.contains_key("primary"));
2177
2178        // Version should compile and be non-empty (env! evaluated at compile-time)
2179        // Note: get_version() is only available for wasm32 target
2180        #[cfg(target_arch = "wasm32")]
2181        {
2182            let v = get_version();
2183            assert!(!v.is_empty());
2184        }
2185    }
2186
2187    #[test]
2188    fn border_color_with_direction() {
2189        let mut st = State::new_default();
2190        
2191        // Test border-b-blue-500 (border-bottom with blue color shade 500)
2192        st.register_tailwind_classes(["border-b-blue-500".to_string()]);
2193        let css = st.css_for_web();
2194        assert!(css.contains(".border-b-blue-500{"));
2195        assert!(css.contains("border-bottom-color:#3b82f6") || css.contains("border-b-color:#3b82f6"));
2196        
2197        // Test border-t-red-500
2198        st.register_tailwind_classes(["border-t-red-500".to_string()]);
2199        let css = st.css_for_web();
2200        assert!(css.contains(".border-t-red-500{"));
2201        
2202        // Test border-blue-500 (all borders)
2203        st.register_tailwind_classes(["border-blue-500".to_string()]);
2204        let css = st.css_for_web();
2205        assert!(css.contains(".border-blue-500{"));
2206        assert!(css.contains("border-color:#3b82f6"));
2207    }
2208
2209    #[test]
2210    fn multiple_selectors_support() {
2211        let mut st = State::new_default();
2212        let mut selectors = SelectorStyles::new();
2213        let mut props = CssProps::new();
2214        props.insert("color".to_string(), serde_json::json!("#ff0000"));
2215        selectors.insert("h1, h2, h3".to_string(), props);
2216        
2217        st.add_theme("test", selectors);
2218        st.set_theme("test").ok();
2219        
2220        // Test h1
2221        let android = st.android_styles_for("h1", &[]);
2222        assert_eq!(android.get("color").and_then(|v| v.as_str()), Some("#ff0000"), "h1 should have red color");
2223        
2224        // Test h2
2225        let android = st.android_styles_for("h2", &[]);
2226        assert_eq!(android.get("color").and_then(|v| v.as_str()), Some("#ff0000"), "h2 should have red color");
2227        
2228        // Test h3
2229        let android = st.android_styles_for("h3", &[]);
2230        assert_eq!(android.get("color").and_then(|v| v.as_str()), Some("#ff0000"), "h3 should have red color");
2231    }
2232
2233    #[test]
2234    fn multiple_selectors_classes() {
2235        let mut st = State::new_default();
2236        let mut selectors = SelectorStyles::new();
2237        let mut props = CssProps::new();
2238        props.insert("padding".to_string(), serde_json::json!("10px"));
2239        selectors.insert(".btn, .link".to_string(), props);
2240        
2241        st.add_theme("test", selectors);
2242        st.set_theme("test").ok();
2243        
2244        // Test .btn
2245        let android = st.android_styles_for("div", &["btn".to_string()]);
2246        assert_eq!(android.get("padding").and_then(|v| v.as_f64()), Some(10.0), ".btn should have 10px padding");
2247        
2248        // Test .link
2249        let android = st.android_styles_for("div", &["link".to_string()]);
2250        assert_eq!(android.get("padding").and_then(|v| v.as_f64()), Some(10.0), ".link should have 10px padding");
2251    }
2252
2253    #[test]
2254    fn border_width_with_direction() {
2255        let mut st = State::new_default();
2256        
2257        // Test border-b-2 (border-bottom width 2px)
2258        st.register_tailwind_classes(["border-b-2".to_string()]);
2259        let css = st.css_for_web();
2260        assert!(css.contains(".border-b-2{"));
2261        assert!(css.contains("border-bottom-width:2px"));
2262        
2263        // Test border-2 (all borders width 2px)
2264        st.register_tailwind_classes(["border-2".to_string()]);
2265        let css = st.css_for_web();
2266        assert!(css.contains(".border-2{"));
2267        assert!(css.contains("border-width:2px"));
2268    }
2269
2270    #[test]
2271    fn display_flex_hover_breakpoint() {
2272        let mut st = State::new_default();
2273        
2274        // Set up theme with breakpoints
2275        st.add_theme("default", IndexMap::new());
2276        st.set_theme("default").ok();
2277        
2278        let mut breakpoints = IndexMap::new();
2279        breakpoints.insert("md".to_string(), "768px".to_string());
2280        st.set_breakpoints(breakpoints);
2281        
2282        st.register_tailwind_classes([
2283            "block".into(),
2284            "inline-flex".into(),
2285            "hidden".into(),
2286            "md:flex".into(),
2287            "md:hover:block".into(),
2288        ]);
2289        let css = st.css_for_web();
2290        assert!(css.contains(".block{"));
2291        assert!(css.contains("display:block"));
2292        assert!(css.contains(".inline-flex{"));
2293        assert!(css.contains("display:inline-flex"));
2294        assert!(css.contains(".hidden{"));
2295        assert!(css.contains("display:none"));
2296        // breakpoint rule
2297        assert!(css.contains("@media (min-width: 768px)"));
2298        assert!(css.contains(".flex{display:flex"));
2299        // hover inside media (substring check)
2300        assert!(css.contains(":hover{display:block"));
2301
2302        // Android resolves base class styles ignoring prefixes
2303        let android = st.android_styles_for("div", &["md:flex".into()]);
2304        assert_eq!(android.get("display").and_then(|v| v.as_str()), Some("flex"));
2305    }
2306
2307    #[test]
2308    fn parse_var_references_basic() {
2309        // Test basic var() parsing
2310        let refs = parse_var_references("var(color)");
2311        assert_eq!(refs.len(), 1);
2312        assert_eq!(refs[0].2, "color");
2313        assert_eq!(refs[0].0, 0); // start
2314        assert_eq!(refs[0].1, 10); // end (exclusive, so "var(color)" is 0..10)
2315
2316        // Test var() with -- prefix
2317        let refs = parse_var_references("var(--primary)");
2318        assert_eq!(refs.len(), 1);
2319        assert_eq!(refs[0].2, "primary");
2320
2321        // Test multiple var() references
2322        let refs = parse_var_references("var(--color) and var(size)");
2323        assert_eq!(refs.len(), 2);
2324        assert_eq!(refs[0].2, "color");
2325        assert_eq!(refs[1].2, "size");
2326
2327        // Test with whitespace
2328        let refs = parse_var_references("var( --spacing )");
2329        assert_eq!(refs.len(), 1);
2330        assert_eq!(refs[0].2, "spacing");
2331
2332        // Test with dots and dashes
2333        let refs = parse_var_references("var(color.primary-500)");
2334        assert_eq!(refs.len(), 1);
2335        assert_eq!(refs[0].2, "color.primary-500");
2336
2337        // Test no matches
2338        let refs = parse_var_references("no variables here");
2339        assert_eq!(refs.len(), 0);
2340
2341        // Test incomplete var(
2342        let refs = parse_var_references("var(");
2343        assert_eq!(refs.len(), 0);
2344
2345        // Test var without closing
2346        let refs = parse_var_references("var(color");
2347        assert_eq!(refs.len(), 0);
2348    }
2349
2350    #[test]
2351    fn resolve_vars_basic() {
2352        let mut vars = IndexMap::new();
2353        vars.insert("primary".to_string(), "#ff0000".to_string());
2354        vars.insert("spacing".to_string(), "8px".to_string());
2355        vars.insert("color.blue".to_string(), "#0000ff".to_string());
2356
2357        // Test basic resolution
2358        assert_eq!(resolve_vars("var(--primary)", &vars), "#ff0000");
2359        assert_eq!(resolve_vars("var(primary)", &vars), "#ff0000");
2360        assert_eq!(resolve_vars("var( --primary )", &vars), "#ff0000");
2361
2362        // Test multiple vars
2363        assert_eq!(
2364            resolve_vars("var(--primary) var(--spacing)", &vars),
2365            "#ff0000 8px"
2366        );
2367
2368        // Test dotted variable names
2369        assert_eq!(resolve_vars("var(--color.blue)", &vars), "#0000ff");
2370
2371        // Test undefined variable (should not replace)
2372        assert_eq!(resolve_vars("var(--undefined)", &vars), "var(--undefined)");
2373
2374        // Test $ prefix syntax
2375        assert_eq!(resolve_vars("$primary", &vars), "#ff0000");
2376
2377        // Test no variables
2378        assert_eq!(resolve_vars("plain text", &vars), "plain text");
2379    }
2380
2381    #[test]
2382    fn resolve_vars_edge_cases() {
2383        let mut vars = IndexMap::new();
2384        vars.insert("a".to_string(), "1".to_string());
2385        vars.insert("b".to_string(), "2".to_string());
2386
2387        // Test adjacent vars
2388        assert_eq!(resolve_vars("var(a)var(b)", &vars), "12");
2389
2390        // Test var in middle of text
2391        assert_eq!(resolve_vars("prefix var(a) suffix", &vars), "prefix 1 suffix");
2392
2393        // Test empty input
2394        assert_eq!(resolve_vars("", &vars), "");
2395
2396        // Test var with numbers
2397        vars.insert("var123".to_string(), "value".to_string());
2398        assert_eq!(resolve_vars("var(var123)", &vars), "value");
2399
2400        // Test var with underscores
2401        vars.insert("my_var".to_string(), "test".to_string());
2402        assert_eq!(resolve_vars("var(my_var)", &vars), "test");
2403    }
2404
2405    #[test]
2406    fn test_android_scrolling_mapping() {
2407        let mut state = State::default();
2408        state.display_density = 2.0;
2409        state.scaled_density = 2.0;
2410        state.current_theme = "default".to_string();
2411        
2412        let mut themes = IndexMap::new();
2413        let mut default_theme = crate::ThemeEntry::default();
2414        default_theme.name = Some("Default".to_string());
2415        
2416        let mut overflow_styles = IndexMap::new();
2417        overflow_styles.insert("overflowX".to_string(), serde_json::json!("auto"));
2418        overflow_styles.insert("overflowY".to_string(), serde_json::json!("scroll"));
2419        
2420        default_theme.selectors.insert(".scroller".to_string(), overflow_styles);
2421        themes.insert("default".to_string(), default_theme);
2422        state.themes = themes;
2423        
2424        let styles = state.android_styles_for("div", &vec![".scroller".to_string()]);
2425        
2426        assert_eq!(styles.get("androidScrollHorizontal"), Some(&serde_json::json!(true)));
2427        assert_eq!(styles.get("androidScrollVertical"), Some(&serde_json::json!(true)));
2428    }
2429
2430    #[test]
2431    fn android_flex_row_default() {
2432        let st = State::new_default();
2433        // div with flex class should be horizontal (row) on Android
2434        let styles = st.android_styles_for("div", &["flex".to_string()]);
2435        assert_eq!(styles.get("androidOrientation").and_then(|v| v.as_str()), Some("horizontal"));
2436        assert_eq!(styles.get("flexDirection").and_then(|v| v.as_str()), Some("row"));
2437        
2438        // div without flex class should be vertical (column) on Android
2439        let styles = st.android_styles_for("div", &[]);
2440        assert_eq!(styles.get("androidOrientation").and_then(|v| v.as_str()), Some("vertical"));
2441        assert_eq!(styles.get("flexDirection").and_then(|v| v.as_str()), Some("column"));
2442    }
2443
2444    #[test]
2445    fn android_gap_orientation_order() {
2446        let st = State::new_default();
2447        let styles = st.android_styles_for("div", &["flex".to_string(), "gap-4".to_string()]);
2448        
2449        // Check that androidOrientation comes BEFORE gap in the map
2450        let keys: Vec<&String> = styles.keys().collect();
2451        let orientation_idx = keys.iter().position(|&k| k == "androidOrientation").unwrap();
2452        let gap_idx = keys.iter().position(|&k| k == "gap").unwrap();
2453        
2454        assert!(orientation_idx < gap_idx, "androidOrientation should come before gap for correct layout processing");
2455    }
2456
2457    #[test]
2458    fn margin_auto_support() {
2459        let mut st = State::new_default();
2460        st.register_tailwind_classes(["ml-auto".to_string(), "mr-auto".to_string(), "mx-auto".to_string()]);
2461        
2462        // Web CSS check
2463        let css = st.css_for_web();
2464        assert!(css.contains("margin-left:auto"));
2465        assert!(css.contains("margin-right:auto"));
2466        
2467        // Android check
2468        let styles = st.android_styles_for("div", &["ml-auto".to_string()]);
2469        assert_eq!(styles.get("marginLeft").and_then(|v| v.as_str()), Some("auto"));
2470        
2471        let styles = st.android_styles_for("div", &["mx-auto".to_string()]);
2472        assert_eq!(styles.get("marginLeft").and_then(|v| v.as_str()), Some("auto"));
2473        assert_eq!(styles.get("marginRight").and_then(|v| v.as_str()), Some("auto"));
2474    }
2475
2476    #[test]
2477    fn alignment_mapping() {
2478        let st = State::new_default();
2479        
2480        // Test Row (default)
2481        let row_styles = st.android_styles_for("div", &["flex".to_string(), "justify-center".to_string(), "items-center".to_string()]);
2482        assert_eq!(row_styles.get("androidOrientation").and_then(|v| v.as_str()), Some("horizontal"));
2483        // Row: justify-center (horizontal) + items-center (vertical) -> center
2484        assert_eq!(row_styles.get("androidGravity").and_then(|v| v.as_str()), Some("center"));
2485        
2486        // Test Column
2487        let col_styles = st.android_styles_for("div", &["flex".to_string(), "flex-col".to_string(), "justify-center".to_string(), "items-center".to_string()]);
2488        assert_eq!(col_styles.get("androidOrientation").and_then(|v| v.as_str()), Some("vertical"));
2489        // Column: justify-center (vertical) + items-center (horizontal) -> center
2490        assert_eq!(col_styles.get("androidGravity").and_then(|v| v.as_str()), Some("center"));
2491
2492        // Test Row Start/End
2493        let row_start_styles = st.android_styles_for("div", &["flex".to_string(), "justify-start".to_string(), "items-end".to_string()]);
2494        assert_eq!(row_start_styles.get("androidGravity").and_then(|v| v.as_str()), Some("bottom|start"));
2495    }
2496
2497    #[test]
2498    fn test_button_bg_override() {
2499        let mut themes = IndexMap::new();
2500        
2501        let mut variables = IndexMap::new();
2502        variables.insert("color.bg".to_string(), "#ffffff".to_string());
2503        
2504        let mut selectors = IndexMap::new();
2505        let mut button_props = IndexMap::new();
2506        button_props.insert("background-color".to_string(), json!("#2563eb"));
2507        selectors.insert("button".to_string(), button_props);
2508        
2509        let default_theme = ThemeEntry {
2510            name: Some("default".to_string()),
2511            inherits: None,
2512            selectors,
2513            variables,
2514            breakpoints: IndexMap::new(),
2515        };
2516        
2517        themes.insert("default".to_string(), default_theme);
2518        
2519        let mut state = State::new_default();
2520        state.themes = themes;
2521        state.current_theme = "default".to_string();
2522        
2523        // Test button with bg-bg class and p-4
2524        let classes = vec!["bg-bg".to_string(), "p-4".to_string()];
2525        let styles = state.android_styles_for("button", &classes);
2526        
2527        println!("[test_button_bg_override] styles: {:?}", styles);
2528        
2529        // Should have white background from bg-bg, not blue from button selector
2530        assert_eq!(styles.get("backgroundColor").and_then(|v: &serde_json::Value| v.as_str()), Some("#ffffff"));
2531        
2532        // Should have 16px padding from p-4, not 8px from button selector
2533        // p-4 = 1rem = 16px (default)
2534        assert_eq!(styles.get("paddingTop"), Some(&serde_json::json!(16)));
2535        assert_eq!(styles.get("paddingVertical"), Some(&serde_json::json!(16)));
2536    }
2537
2538    #[test]
2539    fn test_class_selector_matching() {
2540        let mut themes = IndexMap::new();
2541        let mut selectors = IndexMap::new();
2542        
2543        let mut bg_primary = IndexMap::new();
2544        bg_primary.insert("background-color".to_string(), json!("#3b82f6"));
2545        selectors.insert(".bg-primary".to_string(), bg_primary);
2546        
2547        let default_theme = ThemeEntry {
2548            name: Some("default".to_string()),
2549            inherits: None,
2550            selectors,
2551            variables: IndexMap::new(),
2552            breakpoints: IndexMap::new(),
2553        };
2554        
2555        themes.insert("default".to_string(), default_theme);
2556        
2557        let mut state = State::new_default();
2558        state.themes = themes;
2559        state.current_theme = "default".to_string();
2560        
2561        let classes = vec!["bg-primary".to_string()];
2562        let styles = state.android_styles_for("div", &classes);
2563        
2564        assert_eq!(styles.get("backgroundColor").and_then(|v| v.as_str()), Some("#3b82f6"));
2565    }
2566}
2567
2568#[cfg(all(target_os = "android", feature = "android"))]
2569#[cfg(feature = "android")]
2570mod android_jni;
2571
2572mod bridge_common;
2573mod ffi;
2574
2575pub use ffi::*;
2576
2577#[cfg(target_vendor = "apple")]
2578mod ios_ffi;