Skip to main content

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 color;
8mod default_state;
9use default_state::bundled_state;
10
11// Default display density (1.0 = mdpi baseline)
12fn default_display_density() -> f32 {
13    1.0
14}
15fn default_scaled_density() -> f32 {
16    1.0
17}
18
19pub type CssProps = IndexMap<String, serde_json::Value>;
20pub type SelectorStyles = IndexMap<String, CssProps>; // selector -> props
21
22/// Convert dp to pixels using display density
23fn dp_to_px(dp: f32, density: f32) -> i32 {
24    (dp * density).round() as i32
25}
26
27/// Convert sp to pixels using scaled density  
28fn sp_to_px(sp: f32, scaled_density: f32) -> f32 {
29    sp * scaled_density
30}
31
32/// Parse a CSS value and convert to Android pixels if needed
33fn parse_and_convert_to_px(value: &serde_json::Value, density: f32) -> Option<serde_json::Value> {
34    match value {
35        serde_json::Value::Number(n) => {
36            // Bare number treated as dp
37            let dp = n.as_f64()? as f32;
38            Some(serde_json::json!(dp_to_px(dp, density)))
39        }
40        serde_json::Value::String(s) => {
41            // Parse string with units
42            let trimmed = s.trim();
43            if trimmed.ends_with("px") {
44                // Treat px as density-independent pixels (dp) for cross-platform parity
45                let px = trimmed.trim_end_matches("px").trim().parse::<f32>().ok()?;
46                Some(serde_json::json!(dp_to_px(px, density)))
47            } else if trimmed.ends_with("dp") {
48                let dp = trimmed.trim_end_matches("dp").trim().parse::<f32>().ok()?;
49                Some(serde_json::json!(dp_to_px(dp, density)))
50            } else if trimmed.ends_with("rem") {
51                // 1rem = 16px base (browser default)
52                let rem = trimmed.trim_end_matches("rem").trim().parse::<f32>().ok()?;
53                let dp = rem * 16.0;
54                Some(serde_json::json!(dp_to_px(dp, density)))
55            } else if trimmed.ends_with("em") {
56                // em same as rem for simplicity (no parent font context)
57                let em = trimmed.trim_end_matches("em").trim().parse::<f32>().ok()?;
58                let dp = em * 16.0;
59                Some(serde_json::json!(dp_to_px(dp, density)))
60            } else if let Ok(num) = trimmed.parse::<f32>() {
61                // Bare number as string, treat as dp
62                Some(serde_json::json!(dp_to_px(num, density)))
63            } else {
64                // Keep as-is (e.g., "wrap_content", "match_parent")
65                None
66            }
67        }
68        _ => None,
69    }
70}
71
72fn deserialize_variables<'de, D>(deserializer: D) -> Result<IndexMap<String, String>, D::Error>
73where
74    D: Deserializer<'de>,
75{
76    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
77    let mut out: IndexMap<String, String> = IndexMap::new();
78    if let Some(v) = value {
79        flatten_variables(None, &v, &mut out);
80    }
81    Ok(out)
82}
83
84fn flatten_variables(
85    prefix: Option<&str>,
86    value: &serde_json::Value,
87    out: &mut IndexMap<String, String>,
88) {
89    match value {
90        serde_json::Value::Object(map) => {
91            for (k, v) in map {
92                let key = if let Some(p) = prefix {
93                    format!("{}.{}", p, k)
94                } else {
95                    k.to_string()
96                };
97                flatten_variables(Some(&key), v, out);
98            }
99        }
100        serde_json::Value::Array(arr) => {
101            for (idx, v) in arr.iter().enumerate() {
102                let key = if let Some(p) = prefix {
103                    format!("{}.{}", p, idx)
104                } else {
105                    idx.to_string()
106                };
107                flatten_variables(Some(&key), v, out);
108            }
109        }
110        serde_json::Value::Null => {}
111        serde_json::Value::Bool(b) => {
112            if let Some(p) = prefix {
113                out.insert(p.to_string(), b.to_string());
114            }
115        }
116        serde_json::Value::Number(n) => {
117            if let Some(p) = prefix {
118                out.insert(p.to_string(), n.to_string());
119            }
120        }
121        serde_json::Value::String(s) => {
122            if let Some(p) = prefix {
123                out.insert(p.to_string(), s.clone());
124            }
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, Default)]
130pub struct ThemeEntry {
131    #[serde(default)]
132    pub name: Option<String>,
133    #[serde(default)]
134    pub inherits: Option<String>,
135    #[serde(default, rename = "inheritsDark", alias = "inherits_dark")]
136    pub inherits_dark: Option<String>,
137    #[serde(default)]
138    pub selectors: SelectorStyles,
139    #[serde(default, deserialize_with = "deserialize_variables")]
140    pub variables: IndexMap<String, String>,
141    #[serde(default, deserialize_with = "deserialize_variables")]
142    pub breakpoints: IndexMap<String, String>,
143}
144
145impl ThemeEntry {
146    fn parent_name(&self, prefers_dark: bool) -> Option<String> {
147        if prefers_dark {
148            self.inherits_dark.clone().or_else(|| self.inherits.clone())
149        } else {
150            self.inherits.clone()
151        }
152    }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156pub struct State {
157    // New format: each theme has selectors, variables, breakpoints, and optional inherits
158    pub themes: IndexMap<String, ThemeEntry>,
159    pub default_theme: String,
160    pub current_theme: String,
161    #[serde(default)]
162    pub prefers_color_scheme: Option<String>,
163    // Platform-specific metadata for unit conversions
164    #[serde(default = "default_display_density")]
165    pub display_density: f32, // Android displayMetrics.density (1.0 for mdpi, 2.0 for xhdpi, etc.)
166    #[serde(default = "default_scaled_density")]
167    pub scaled_density: f32, // Android displayMetrics.scaledDensity for SP conversions
168
169    #[serde(default)]
170    pub used_classes: IndexSet<String>, // observed classes on elements
171    #[serde(default)]
172    pub used_tags: IndexSet<String>, // observed tags on elements
173    /// Observed (tag, class) pairs. Encoded as "tag|class" for JSON simplicity.
174    #[serde(default)]
175    pub used_tag_classes: IndexSet<String>,
176}
177
178#[derive(thiserror::Error, Debug)]
179pub enum Error {
180    #[error("theme not found: {0}")]
181    ThemeNotFound(String),
182}
183
184impl State {
185    pub fn new_default() -> Self {
186        // Prefer embedded Rust bundled defaults
187        return bundled_state();
188    }
189
190    /// Public helper to access the embedded default state.
191    pub fn default_state() -> Self {
192        bundled_state()
193    }
194
195    pub fn set_theme(&mut self, theme: impl Into<String>) -> Result<(), Error> {
196        let name = theme.into();
197        if !self.themes.contains_key(&name) {
198            return Err(Error::ThemeNotFound(name));
199        }
200        self.current_theme = name;
201        Ok(())
202    }
203
204    pub fn add_theme(&mut self, name: impl Into<String>, styles: SelectorStyles) {
205        let name = name.into();
206        let entry = self.themes.entry(name).or_default();
207        for (sel, props) in styles.into_iter() {
208            let e = entry.selectors.entry(sel).or_default();
209            merge_props(e, &props);
210        }
211    }
212
213    pub fn set_variables(&mut self, vars: IndexMap<String, String>) {
214        // Back-compat: set on current theme entry
215        let cur = self.current_theme.clone();
216        let entry = self.themes.entry(cur).or_default();
217        entry.variables = vars;
218    }
219
220    pub fn set_breakpoints(&mut self, map: IndexMap<String, String>) {
221        let cur = self.current_theme.clone();
222        let entry = self.themes.entry(cur).or_default();
223        entry.breakpoints = map;
224    }
225
226    pub fn process_styles(
227        &self,
228        mut styles: IndexMap<String, serde_json::Value>,
229    ) -> IndexMap<String, serde_json::Value> {
230        let density = self.display_density;
231
232        // Expand shorthands
233        // Order matters: Horizontal/Vertical should be expanded before general shorthands
234        // so that specific ones win if they were already present.
235        if let Some(ph) = styles.get("paddingHorizontal").cloned() {
236            styles.entry("paddingLeft".into()).or_insert(ph.clone());
237            styles.entry("paddingRight".into()).or_insert(ph.clone());
238        }
239        if let Some(pv) = styles.get("paddingVertical").cloned() {
240            styles.entry("paddingTop".into()).or_insert(pv.clone());
241            styles.entry("paddingBottom".into()).or_insert(pv.clone());
242        }
243        if let Some(p) = styles.get("padding").cloned() {
244            styles.entry("paddingTop".into()).or_insert(p.clone());
245            styles.entry("paddingBottom".into()).or_insert(p.clone());
246            styles.entry("paddingLeft".into()).or_insert(p.clone());
247            styles.entry("paddingRight".into()).or_insert(p.clone());
248        }
249        if let Some(mh) = styles.get("marginHorizontal").cloned() {
250            styles.entry("marginLeft".into()).or_insert(mh.clone());
251            styles.entry("marginRight".into()).or_insert(mh.clone());
252        }
253        if let Some(mv) = styles.get("marginVertical").cloned() {
254            styles.entry("marginTop".into()).or_insert(mv.clone());
255            styles.entry("marginBottom".into()).or_insert(mv.clone());
256        }
257        if let Some(m) = styles.get("margin").cloned() {
258            styles.entry("marginTop".into()).or_insert(m.clone());
259            styles.entry("marginBottom".into()).or_insert(m.clone());
260            styles.entry("marginLeft".into()).or_insert(m.clone());
261            styles.entry("marginRight".into()).or_insert(m.clone());
262        }
263        if let Some(r) = styles.get("borderRadius").cloned() {
264            styles
265                .entry("borderTopLeftRadius".into())
266                .or_insert(r.clone());
267            styles
268                .entry("borderTopRightRadius".into())
269                .or_insert(r.clone());
270            styles
271                .entry("borderBottomLeftRadius".into())
272                .or_insert(r.clone());
273            styles
274                .entry("borderBottomRightRadius".into())
275                .or_insert(r.clone());
276        }
277
278        // Convert only dimension properties to pixels
279        let dimension_props = [
280            "width",
281            "height",
282            "minWidth",
283            "minHeight",
284            "maxWidth",
285            "maxHeight",
286            "padding",
287            "paddingTop",
288            "paddingBottom",
289            "paddingLeft",
290            "paddingRight",
291            "paddingHorizontal",
292            "paddingVertical",
293            "margin",
294            "marginTop",
295            "marginBottom",
296            "marginLeft",
297            "marginRight",
298            "marginHorizontal",
299            "marginVertical",
300            "borderRadius",
301            "borderTopLeftRadius",
302            "borderTopRightRadius",
303            "borderBottomLeftRadius",
304            "borderBottomRightRadius",
305            "borderWidth",
306            "borderTopWidth",
307            "borderBottomWidth",
308            "borderLeftWidth",
309            "borderRightWidth",
310            "gap",
311            "rowGap",
312            "columnGap",
313            "elevation",
314            "fontSize",
315            "lineHeight",
316            "letterSpacing",
317        ];
318
319        for prop in &dimension_props {
320            if let Some(value) = styles.get(*prop).cloned() {
321                if let Some(converted) = parse_and_convert_to_px(&value, density) {
322                    styles.insert(prop.to_string(), converted);
323                }
324            }
325        }
326
327        styles
328    }
329
330    pub fn set_default_theme(&mut self, name: impl Into<String>) {
331        self.default_theme = name.into();
332    }
333
334    pub fn register_selectors<I: IntoIterator<Item = String>>(&mut self, _selectors: I) {
335        // Deprecated: selectors are now part of the theme entry
336    }
337
338    pub fn register_tailwind_classes<I: IntoIterator<Item = String>>(&mut self, classes: I) {
339        for c in classes {
340            self.used_classes.insert(c);
341        }
342    }
343
344    pub fn register_tags<I: IntoIterator<Item = String>>(&mut self, tags: I) {
345        for t in tags {
346            self.used_tags.insert(t);
347        }
348    }
349
350    pub fn register_tag_class(&mut self, tag: impl Into<String>, class_: impl Into<String>) {
351        let key = format!("{}|{}", tag.into(), class_.into());
352        self.used_tag_classes.insert(key);
353    }
354
355    pub fn clear_usage(&mut self) {
356        self.used_classes.clear();
357        self.used_tags.clear();
358        self.used_tag_classes.clear();
359    }
360
361    pub fn to_json(&self) -> serde_json::Value {
362        json!({
363            "themes": self.themes,
364            "default_theme": self.default_theme,
365            "current_theme": self.current_theme,
366            "display_density": self.display_density,
367            "scaled_density": self.scaled_density,
368            "used_classes": self.used_classes,
369            "used_tags": self.used_tags,
370            "used_tag_classes": self.used_tag_classes,
371        })
372    }
373
374    pub fn from_json(value: serde_json::Value) -> anyhow::Result<Self> {
375        let state: State = serde_json::from_value(value)?;
376        Ok(state)
377    }
378
379    pub fn css_for_web(&self) -> String {
380        // Compute CSS resolved from the effective theme (with inheritance)
381        let (eff, vars) = self.effective_theme_all();
382        let bps = self.effective_breakpoints();
383        let mut rules: Vec<(String, CssProps)> = Vec::new();
384
385        // Build closure: if a (tag,class) pair is observed, consider both the tag and the class as used too
386        let mut used_tags: IndexSet<String> = self.used_tags.clone();
387        let mut used_classes: IndexSet<String> = self.used_classes.clone();
388        for key in &self.used_tag_classes {
389            if let Some((t, c)) = split_tag_class_key(key) {
390                used_tags.insert(t);
391                used_classes.insert(c);
392            }
393        }
394
395        // Helper to decide if a themed selector should be emitted based on observed usage.
396        // Supported selector forms:
397        //  - tag           (e.g., "h1")
398        //  - .class        (e.g., ".text-sm"), optional pseudo ":hover"
399        //  - tag.class     (e.g., "h1.text-sm"), optional pseudo ":hover"
400        for (sel, props) in eff.iter() {
401            if should_emit_selector(sel, &used_tags, &used_classes, &self.used_tag_classes) {
402                rules.push((sel.clone(), props.clone()));
403            }
404        }
405
406        // Also emit dynamic utility properties for used classes
407        for class in &used_classes {
408            let (bp_key, hover, base) = parse_prefixed_class(class);
409            let selector = if hover {
410                format!(".{}:hover", css_escape_class(&base))
411            } else {
412                format!(".{}", css_escape_class(&base))
413            };
414
415            // 1) Exact selector in effective theme (e.g. ".x:hover")
416            // Skip: already emitted in the first pass (theme selectors loop above)
417            if eff.get(&selector).is_some() {
418                continue;
419            }
420            // 2) Dynamic generation for the base class (ignoring hover/breakpoint for props)
421            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
422                let sel = if hover {
423                    format!(".{}:hover", css_escape_class(&base))
424                } else {
425                    format!(".{}", css_escape_class(&base))
426                };
427                let final_sel = wrap_with_media(&sel, bp_key.as_deref(), &bps);
428                rules.push((final_sel, dynamic_props));
429                continue;
430            }
431            // 3) Fallback: class key itself in theme (rare)
432            if let Some(props) = eff.get(&base) {
433                let final_sel = wrap_with_media(&selector, bp_key.as_deref(), &bps);
434                rules.push((final_sel, props.clone()));
435            }
436        }
437
438        post_process_css(&rules, &vars)
439    }
440
441    pub fn android_base_styles(
442        &self,
443        selector: &str,
444        classes: &[String],
445    ) -> IndexMap<String, serde_json::Value> {
446        let (eff, vars) = self.effective_theme_all();
447        let mut out: IndexMap<String, serde_json::Value> = IndexMap::new();
448
449        // Pre-insert androidOrientation to ensure it's early in the map for gap processing
450        out.insert(
451            "androidOrientation".to_string(),
452            serde_json::json!("vertical"),
453        );
454
455        let mut combined_props = CssProps::new();
456
457        // 1. Apply hardcoded platform defaults (lowest priority)
458        match selector.to_lowercase().as_str() {
459            "div" => {
460                combined_props.insert("width".into(), json!("match_parent"));
461            }
462            "p" => {
463                combined_props.insert("width".into(), json!("match_parent"));
464                combined_props.insert("margin-vertical".into(), json!("16px"));
465            }
466            "h1" => {
467                combined_props.insert("width".into(), json!("match_parent"));
468                combined_props.insert("font-size".into(), json!("32px"));
469                combined_props.insert("font-weight".into(), json!("bold"));
470                combined_props.insert("margin-vertical".into(), json!("21.44px"));
471            }
472            "h2" => {
473                combined_props.insert("width".into(), json!("match_parent"));
474                combined_props.insert("font-size".into(), json!("24px"));
475                combined_props.insert("font-weight".into(), json!("bold"));
476                combined_props.insert("margin-vertical".into(), json!("19.92px"));
477            }
478            "h3" => {
479                combined_props.insert("width".into(), json!("match_parent"));
480                combined_props.insert("font-size".into(), json!("18.72px"));
481                combined_props.insert("font-weight".into(), json!("bold"));
482                combined_props.insert("margin-vertical".into(), json!("18.72px"));
483            }
484            "h4" => {
485                combined_props.insert("width".into(), json!("match_parent"));
486                combined_props.insert("font-size".into(), json!("16px"));
487                combined_props.insert("font-weight".into(), json!("bold"));
488                combined_props.insert("margin-vertical".into(), json!("21.28px"));
489            }
490            "h5" => {
491                combined_props.insert("width".into(), json!("match_parent"));
492                combined_props.insert("font-size".into(), json!("13.28px"));
493                combined_props.insert("font-weight".into(), json!("bold"));
494                combined_props.insert("margin-vertical".into(), json!("22.17px"));
495            }
496            "h6" => {
497                combined_props.insert("width".into(), json!("match_parent"));
498                combined_props.insert("font-size".into(), json!("10.72px"));
499                combined_props.insert("font-weight".into(), json!("bold"));
500                combined_props.insert("margin-vertical".into(), json!("24.96px"));
501            }
502            "input" => {
503                combined_props.insert("padding-vertical".into(), json!("8px"));
504                combined_props.insert("padding-horizontal".into(), json!("12px"));
505                combined_props.insert("border-radius".into(), json!("4px"));
506                combined_props.insert("border-width".into(), json!("1px"));
507                combined_props.insert("border-color".into(), json!("#cccccc"));
508                combined_props.insert("background-color".into(), json!("#ffffff"));
509                combined_props.insert("color".into(), json!("#000000"));
510                combined_props.insert("placeholder-color".into(), json!("#88888870"));
511                combined_props.insert("min-height".into(), json!("40px"));
512                combined_props.insert("android-gravity".into(), json!("center_vertical"));
513            }
514            "select" => {
515                combined_props.insert("padding-vertical".into(), json!("8px"));
516                combined_props.insert("padding-horizontal".into(), json!("12px"));
517                combined_props.insert("border-radius".into(), json!("4px"));
518                combined_props.insert("border-width".into(), json!("1px"));
519                combined_props.insert("border-color".into(), json!("#cccccc"));
520                combined_props.insert("background-color".into(), json!("#ffffff"));
521                combined_props.insert("color".into(), json!("#000000"));
522                combined_props.insert("min-height".into(), json!("40px"));
523                combined_props.insert("android-gravity".into(), json!("center_vertical"));
524            }
525            "textarea" => {
526                combined_props.insert("padding".into(), json!("12px"));
527                combined_props.insert("border-radius".into(), json!("4px"));
528                combined_props.insert("border-width".into(), json!("1px"));
529                combined_props.insert("border-color".into(), json!("#cccccc"));
530                combined_props.insert("background-color".into(), json!("#ffffff"));
531                combined_props.insert("color".into(), json!("#000000"));
532                combined_props.insert(
533                    "placeholder-color".into(),
534                    json!("color-mix(in srgb, currentColor 75%, grey)"),
535                );
536                combined_props.insert("min-height".into(), json!("80px"));
537                combined_props.insert("android-gravity".into(), json!("top"));
538            }
539            "button" => {
540                combined_props.insert("padding-vertical".into(), json!("8px"));
541                combined_props.insert("padding-horizontal".into(), json!("16px"));
542                combined_props.insert("border-radius".into(), json!("4px"));
543                combined_props.insert("background-color".into(), json!("#2196F3"));
544                combined_props.insert("color".into(), json!("#ffffff"));
545                combined_props.insert("android-gravity".into(), json!("center"));
546            }
547            _ => {}
548        }
549
550        if selector == "button"
551            || selector == "input"
552            || selector == "textarea"
553            || classes.iter().any(|c| c.contains("bg-"))
554        {
555            log::debug!(
556                "[android_base_styles] selector={} classes={:?}",
557                selector,
558                classes
559            );
560        }
561
562        // 2. Apply theme selector styles (overwrites defaults)
563        if let Some(props) = eff.get(selector) {
564            merge_props(&mut combined_props, props);
565        }
566
567        // 3. Apply class styles (overwrites selector)
568        for class in classes {
569            // Normalize input: strip leading dot if present (Android may pass ".bg-primary" as selector format)
570            let normalized_class = if class.starts_with('.') {
571                class[1..].to_string()
572            } else {
573                class.clone()
574            };
575
576            let (_bp, _hover, base) = parse_prefixed_class(&normalized_class);
577            // Prefer base selector match from theme
578            let sel = class_to_selector(&base);
579            if let Some(props) = eff.get(&sel) {
580                merge_props(&mut combined_props, props);
581                continue;
582            }
583            // Dynamic mapping for base class
584            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
585                merge_props(&mut combined_props, &dynamic_props);
586                continue;
587            }
588            if let Some(props) = eff.get(&base) {
589                merge_props(&mut combined_props, props);
590            }
591        }
592
593        if selector == "input" || selector == "textarea" {
594            log::debug!(
595                "[android_base_styles] combined_props for {}: {:?}",
596                selector,
597                combined_props
598            );
599        }
600
601        merge_android_props(&mut out, &combined_props, &vars);
602
603        // CSS semantics: display: flex defaults to flexDirection: row
604        if let Some(display) = out.get("display") {
605            if display.as_str() == Some("flex") && !out.contains_key("flexDirection") {
606                out.insert("flexDirection".to_string(), serde_json::json!("row"));
607                out.insert(
608                    "androidOrientation".to_string(),
609                    serde_json::json!("horizontal"),
610                );
611            }
612        }
613
614        // Fallback for block elements to be column if not specified
615        if !out.contains_key("flexDirection") {
616            match selector.to_lowercase().as_str() {
617                "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" => {
618                    out.insert("flexDirection".to_string(), serde_json::json!("column"));
619                    out.insert(
620                        "androidOrientation".to_string(),
621                        serde_json::json!("vertical"),
622                    );
623                }
624                _ => {}
625            }
626        }
627
628        // Ensure androidOrientation is in sync with flexDirection if it was set but orientation wasn't
629        if let Some(fd) = out.get("flexDirection").and_then(|v| v.as_str()) {
630            if !out.contains_key("androidOrientation") {
631                let orientation = if fd == "column" || fd == "column-reverse" {
632                    "vertical"
633                } else {
634                    "horizontal"
635                };
636                out.insert(
637                    "androidOrientation".to_string(),
638                    serde_json::json!(orientation),
639                );
640            }
641        }
642
643        out
644    }
645
646    /// Web inline styles: flat style map, no cascading. Same resolution as Android but outputs
647    /// standard CSS property names/values for inline style (px/rem strings, no Android-specific keys).
648    pub fn web_styles_for(
649        &self,
650        selector: &str,
651        classes: &[String],
652    ) -> IndexMap<String, serde_json::Value> {
653        let (eff, vars) = self.effective_theme_all();
654        let mut out: IndexMap<String, serde_json::Value> = IndexMap::new();
655        let mut combined_props = CssProps::new();
656
657        // 1. Tag defaults (web-friendly: 100% not match_parent)
658        match selector.to_lowercase().as_str() {
659            "div" => {
660                combined_props.insert("width".into(), json!("100%"));
661            }
662            "p" => {
663                combined_props.insert("width".into(), json!("100%"));
664                combined_props.insert("margin-vertical".into(), json!("16px"));
665            }
666            "h1" => {
667                combined_props.insert("width".into(), json!("100%"));
668                combined_props.insert("font-size".into(), json!("32px"));
669                combined_props.insert("font-weight".into(), json!("bold"));
670                combined_props.insert("margin-vertical".into(), json!("21.44px"));
671            }
672            "h2" => {
673                combined_props.insert("width".into(), json!("100%"));
674                combined_props.insert("font-size".into(), json!("24px"));
675                combined_props.insert("font-weight".into(), json!("bold"));
676                combined_props.insert("margin-vertical".into(), json!("19.92px"));
677            }
678            "h3" => {
679                combined_props.insert("width".into(), json!("100%"));
680                combined_props.insert("font-size".into(), json!("18.72px"));
681                combined_props.insert("font-weight".into(), json!("bold"));
682                combined_props.insert("margin-vertical".into(), json!("18.72px"));
683            }
684            "h4" => {
685                combined_props.insert("width".into(), json!("100%"));
686                combined_props.insert("font-size".into(), json!("16px"));
687                combined_props.insert("font-weight".into(), json!("bold"));
688                combined_props.insert("margin-vertical".into(), json!("21.28px"));
689            }
690            "h5" => {
691                combined_props.insert("width".into(), json!("100%"));
692                combined_props.insert("font-size".into(), json!("13.28px"));
693                combined_props.insert("font-weight".into(), json!("bold"));
694                combined_props.insert("margin-vertical".into(), json!("22.17px"));
695            }
696            "h6" => {
697                combined_props.insert("width".into(), json!("100%"));
698                combined_props.insert("font-size".into(), json!("10.72px"));
699                combined_props.insert("font-weight".into(), json!("bold"));
700                combined_props.insert("margin-vertical".into(), json!("24.96px"));
701            }
702            "input" | "select" | "textarea" | "button" => {
703                combined_props.insert("width".into(), json!("100%"));
704            }
705            _ => {}
706        }
707
708        // 2. Theme selector styles
709        if let Some(props) = eff.get(selector) {
710            merge_props(&mut combined_props, props);
711        }
712
713        // 3. Class styles
714        for class in classes {
715            let normalized_class = if class.starts_with('.') {
716                class[1..].to_string()
717            } else {
718                class.clone()
719            };
720            let (_bp, _hover, base) = parse_prefixed_class(&normalized_class);
721            let sel = class_to_selector(&base);
722            if let Some(props) = eff.get(&sel) {
723                merge_props(&mut combined_props, props);
724                continue;
725            }
726            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
727                merge_props(&mut combined_props, &dynamic_props);
728                continue;
729            }
730            if let Some(props) = eff.get(&base) {
731                merge_props(&mut combined_props, props);
732            }
733        }
734
735        merge_web_props(&mut out, &combined_props, &vars);
736
737        // Flex defaults
738        if let Some(display) = out.get("display") {
739            if display.as_str() == Some("flex") && !out.contains_key("flexDirection") {
740                out.insert("flexDirection".to_string(), serde_json::json!("row"));
741            }
742        }
743        if !out.contains_key("flexDirection") {
744            match selector.to_lowercase().as_str() {
745                "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" => {
746                    out.insert("flexDirection".to_string(), serde_json::json!("column"));
747                }
748                _ => {}
749            }
750        }
751
752        out
753    }
754
755    /// Android-specific style transformations
756    /// Converts CSS properties to Android-compatible values with platform-specific defaults
757    /// Handles unit conversions (dp/sp to px) using display density
758    pub fn android_styles_for(
759        &self,
760        selector: &str,
761        classes: &[String],
762    ) -> IndexMap<String, serde_json::Value> {
763        let mut styles = self.android_base_styles(selector, classes);
764
765        let density = self.display_density;
766        let scaled_density = self.scaled_density;
767
768        // Convert flexDirection to Android orientation EARLY so layout-dependent props (like gap) can use it
769        if let Some(flex_dir) = styles.get("flexDirection") {
770            let orientation = if flex_dir.as_str() == Some("row") {
771                "horizontal"
772            } else {
773                "vertical"
774            };
775            styles.shift_insert(
776                0,
777                "androidOrientation".to_string(),
778                serde_json::json!(orientation),
779            );
780        }
781
782        // Convert all dimension properties to pixels
783        let dimension_props = [
784            "width",
785            "height",
786            "minWidth",
787            "minHeight",
788            "maxWidth",
789            "maxHeight",
790            "padding",
791            "paddingTop",
792            "paddingBottom",
793            "paddingLeft",
794            "paddingRight",
795            "paddingHorizontal",
796            "paddingVertical",
797            "margin",
798            "marginTop",
799            "marginBottom",
800            "marginLeft",
801            "marginRight",
802            "marginHorizontal",
803            "marginVertical",
804            "borderRadius",
805            "borderWidth",
806            "borderTopWidth",
807            "borderBottomWidth",
808            "borderLeftWidth",
809            "borderRightWidth",
810            "gap",
811            "rowGap",
812            "columnGap",
813            "spaceX",
814            "spaceY",
815            "elevation",
816            "lineHeight",
817            "letterSpacing",
818        ];
819
820        for prop in &dimension_props {
821            if let Some(value) = styles.get(*prop).cloned() {
822                if let Some(converted) = parse_and_convert_to_px(&value, density) {
823                    styles.insert(prop.to_string(), converted);
824                }
825            }
826        }
827
828        // Convert font sizes to sp (scaled pixels) for Android accessibility
829        if let Some(font_size) = styles.get("fontSize").cloned() {
830            if let Some(serde_json::Value::Number(n)) =
831                parse_and_convert_to_px(&font_size, density).as_ref()
832            {
833                // px to sp: sp = px / density (1rem=16px -> 16sp at 1x)
834                let sp_value = n.as_f64().unwrap_or(14.0) as f32 / density;
835                styles.insert("fontSize".to_string(), serde_json::json!(sp_value));
836            }
837        }
838
839        // Convert flexWrap to Android-friendly format
840        if let Some(flex_wrap) = styles.get("flexWrap") {
841            if flex_wrap.as_str() == Some("wrap") {
842                styles.insert("androidFlexWrap".to_string(), serde_json::json!(true));
843            }
844        }
845
846        // Map opacity to androidAlpha
847        if let Some(opacity) = styles.get("opacity").cloned() {
848            styles.insert("androidAlpha".to_string(), opacity);
849        }
850
851        let is_horizontal =
852            styles.get("androidOrientation").and_then(|v| v.as_str()) == Some("horizontal");
853        let mut gravity_parts = Vec::new();
854
855        // Convert alignItems (cross-axis) to Android gravity equivalents
856        if let Some(align_items) = styles.get("alignItems") {
857            let part = match align_items.as_str() {
858                Some("center") => {
859                    if is_horizontal {
860                        "center_vertical"
861                    } else {
862                        "center_horizontal"
863                    }
864                }
865                Some("flex-start") | Some("start") => {
866                    if is_horizontal {
867                        "top"
868                    } else {
869                        "start"
870                    }
871                }
872                Some("flex-end") | Some("end") => {
873                    if is_horizontal {
874                        "bottom"
875                    } else {
876                        "end"
877                    }
878                }
879                Some("stretch") => {
880                    if is_horizontal {
881                        "fill_vertical"
882                    } else {
883                        "fill_horizontal"
884                    }
885                }
886                _ => "",
887            };
888            if !part.is_empty() {
889                gravity_parts.push(part);
890            }
891        }
892
893        // Convert justifyContent (main-axis) to Android gravity equivalents
894        if let Some(justify) = styles.get("justifyContent") {
895            let part = match justify.as_str() {
896                Some("center") => {
897                    if is_horizontal {
898                        "center_horizontal"
899                    } else {
900                        "center_vertical"
901                    }
902                }
903                Some("flex-start") | Some("start") => {
904                    if is_horizontal {
905                        "start"
906                    } else {
907                        "top"
908                    }
909                }
910                Some("flex-end") | Some("end") => {
911                    if is_horizontal {
912                        "end"
913                    } else {
914                        "bottom"
915                    }
916                }
917                _ => "",
918            };
919            if !part.is_empty() {
920                gravity_parts.push(part);
921            }
922
923            // Also keep layout gravity for compatibility or non-LinearLayout parents
924            let layout_gravity = match justify.as_str() {
925                Some("center") => "center_horizontal",
926                Some("flex-start") | Some("start") => "start",
927                Some("flex-end") | Some("end") => "end",
928                Some("space-between") | Some("between") => "space_between",
929                Some("space-around") | Some("around") => "space_around",
930                _ => "",
931            };
932            if !layout_gravity.is_empty() {
933                styles.insert(
934                    "androidLayoutGravity".to_string(),
935                    serde_json::json!(layout_gravity),
936                );
937            }
938        }
939
940        if !gravity_parts.is_empty() {
941            let gravity = if gravity_parts.contains(&"center_vertical")
942                && gravity_parts.contains(&"center_horizontal")
943            {
944                "center".to_string()
945            } else {
946                gravity_parts.join("|")
947            };
948            styles.insert("androidGravity".to_string(), serde_json::json!(gravity));
949        }
950
951        // Handle border shorthand: "1px solid #color"
952        if let Some(serde_json::Value::String(border)) = styles.get("border").cloned() {
953            let parts: Vec<&str> = border.split_whitespace().collect();
954            for part in parts {
955                if part.ends_with("px") {
956                    if let Ok(w) = part.trim_end_matches("px").parse::<f32>() {
957                        styles.insert(
958                            "borderWidth".to_string(),
959                            serde_json::json!(dp_to_px(w, density)),
960                        );
961                    }
962                } else if part.starts_with('#') {
963                    styles.insert("borderColor".to_string(), serde_json::json!(part));
964                }
965            }
966        }
967
968        // Map boxShadow to elevation
969        if let Some(serde_json::Value::String(shadow)) = styles.get("boxShadow").cloned() {
970            if !shadow.is_empty() {
971                let elevation = if shadow.contains("20px") {
972                    24
973                } else if shadow.contains("15px") {
974                    16
975                } else if shadow.contains("10px") {
976                    8
977                } else {
978                    4
979                };
980                styles.insert(
981                    "elevation".to_string(),
982                    serde_json::json!(dp_to_px(elevation as f32, density)),
983                );
984            }
985        }
986
987        // Convert overflow-x/overflow-y to Android scrolling hints
988        if let Some(overflow_x) = styles.get("overflowX") {
989            if overflow_x.as_str() == Some("auto") || overflow_x.as_str() == Some("scroll") {
990                styles.insert(
991                    "androidScrollHorizontal".to_string(),
992                    serde_json::json!(true),
993                );
994            }
995        }
996        if let Some(overflow_y) = styles.get("overflowY") {
997            if overflow_y.as_str() == Some("auto") || overflow_y.as_str() == Some("scroll") {
998                styles.insert("androidScrollVertical".to_string(), serde_json::json!(true));
999            }
1000        }
1001
1002        // Convert textAlign to Android gravity
1003        if let Some(text_align) = styles.get("textAlign") {
1004            let gravity = match text_align.as_str() {
1005                Some("center") => "center_horizontal",
1006                Some("right") | Some("end") => "end",
1007                Some("left") | Some("start") => "start",
1008                _ => "",
1009            };
1010            if !gravity.is_empty() {
1011                styles.insert("androidTextGravity".to_string(), serde_json::json!(gravity));
1012            }
1013        }
1014
1015        // Convert objectFit to Android scaleType
1016        if let Some(object_fit) = styles.get("objectFit") {
1017            let scale_type = match object_fit.as_str() {
1018                Some("cover") => "center_crop",
1019                Some("contain") => "fit_center",
1020                Some("fill") => "fit_xy",
1021                Some("none") => "center",
1022                Some("scale-down") => "center_inside",
1023                _ => "",
1024            };
1025            if !scale_type.is_empty() {
1026                styles.insert(
1027                    "androidScaleType".to_string(),
1028                    serde_json::json!(scale_type),
1029                );
1030            }
1031        }
1032
1033        // Handle full width/height
1034        if let Some(h) = styles.get("height").cloned() {
1035            if h.as_str() == Some("100%") {
1036                styles.insert("height".to_string(), serde_json::json!("match_parent"));
1037            }
1038        }
1039        if let Some(w) = styles.get("width").cloned() {
1040            if w.as_str() == Some("100%") {
1041                styles.insert("width".to_string(), serde_json::json!("match_parent"));
1042            }
1043        }
1044
1045        // Handle flex/weight: if flex is present, set the dimension in the orientation direction to 0
1046        // Note: We don't set it to 0 here anymore because we don't know if the parent is a LinearLayout.
1047        // The NativeRenderer will handle setting it to 0 if it's inside a LinearLayout.
1048        if styles.contains_key("flex") || styles.contains_key("flexGrow") {
1049            // Just ensure we have some dimension if not specified
1050            if !styles.contains_key("width") {
1051                styles.insert("width".to_string(), serde_json::json!("wrap_content"));
1052            }
1053            if !styles.contains_key("height") {
1054                styles.insert("height".to_string(), serde_json::json!("wrap_content"));
1055            }
1056        }
1057
1058        // Convert fontWeight to Android typeface style
1059        if let Some(font_weight) = styles.get("fontWeight") {
1060            let is_bold = match font_weight {
1061                serde_json::Value::String(s) => {
1062                    s.contains("bold") || s == "600" || s == "700" || s == "500"
1063                }
1064                serde_json::Value::Number(n) => {
1065                    let weight = n.as_i64().unwrap_or(400);
1066                    weight >= 500
1067                }
1068                _ => false,
1069            };
1070            if is_bold {
1071                styles.insert(
1072                    "androidTypefaceStyle".to_string(),
1073                    serde_json::json!("bold"),
1074                );
1075            }
1076        }
1077
1078        // Convert boxShadow to elevation
1079        if let Some(box_shadow) = styles.get("boxShadow") {
1080            if let Some(shadow_str) = box_shadow.as_str() {
1081                if !shadow_str.is_empty() {
1082                    let elevation_dp = if shadow_str.contains("20px") {
1083                        24.0
1084                    } else if shadow_str.contains("15px") {
1085                        16.0
1086                    } else if shadow_str.contains("10px") {
1087                        8.0
1088                    } else if shadow_str.contains("5px") {
1089                        4.0
1090                    } else {
1091                        4.0
1092                    };
1093                    styles.insert(
1094                        "elevation".to_string(),
1095                        serde_json::json!(dp_to_px(elevation_dp, density)),
1096                    );
1097                }
1098            }
1099        }
1100
1101        styles
1102    }
1103
1104    // Previously supported loading YAML at runtime; now defaults are embedded.
1105
1106    // Build the inheritance chain from current theme upward via `inherits` and default fallback
1107    fn theme_chain(&self) -> Vec<String> {
1108        let mut chain = Vec::new();
1109        // Resolve base names
1110        let default_name = if self.themes.contains_key(&self.default_theme) {
1111            self.default_theme.clone()
1112        } else if let Some((k, _)) = self.themes.first() {
1113            k.clone()
1114        } else {
1115            return chain;
1116        };
1117        let mut current_name = if self.themes.contains_key(&self.current_theme) {
1118            self.current_theme.clone()
1119        } else {
1120            default_name.clone()
1121        };
1122        // push child first
1123        let mut seen: IndexSet<String> = IndexSet::new();
1124        let prefers_dark = self
1125            .prefers_color_scheme
1126            .as_deref()
1127            .map(|v| v.eq_ignore_ascii_case("dark"))
1128            .unwrap_or(false);
1129        while !seen.contains(&current_name) {
1130            seen.insert(current_name.clone());
1131            chain.push(current_name.clone());
1132            // next parent via inherits/inheritsDark, else stop
1133            let parent = self
1134                .themes
1135                .get(&current_name)
1136                .and_then(|t| t.parent_name(prefers_dark));
1137            if let Some(p) = parent {
1138                current_name = p;
1139            } else {
1140                break;
1141            }
1142        }
1143        if !chain.iter().any(|n| n == &default_name) {
1144            chain.push(default_name);
1145        }
1146        chain
1147    }
1148
1149    // Compute effective selectors + variables + breakpoints with inheritance.
1150    // Child overrides parent/default on conflicts (expected for "inherits").
1151    fn effective_theme_all(&self) -> (SelectorStyles, IndexMap<String, String>) {
1152        let mut selectors: SelectorStyles = SelectorStyles::new();
1153        let mut vars: IndexMap<String, String> = IndexMap::new();
1154        // Merge default -> parents -> child so child wins on conflicts
1155        let chain = self.theme_chain();
1156        for name in chain.into_iter().rev() {
1157            if let Some(entry) = self.themes.get(&name) {
1158                // merge selectors: later (child) overrides earlier (parent/default)
1159                for (sel, props) in entry.selectors.iter() {
1160                    // Support multiple selectors separated by commas (e.g., "h1, h2, h3")
1161                    if sel.contains(',') {
1162                        for s in sel.split(',') {
1163                            let s = s.trim();
1164                            if s.is_empty() {
1165                                continue;
1166                            }
1167                            let e = selectors.entry(s.to_string()).or_default();
1168                            merge_props(e, props);
1169                        }
1170                    } else {
1171                        let e = selectors.entry(sel.clone()).or_default();
1172                        merge_props(e, props);
1173                    }
1174                }
1175                // merge variables
1176                for (k, v) in entry.variables.iter() {
1177                    vars.insert(k.clone(), v.clone());
1178                }
1179            }
1180        }
1181        (selectors, vars)
1182    }
1183
1184    // Effective breakpoints with inheritance; child overrides parent/default.
1185    pub fn effective_breakpoints(&self) -> IndexMap<String, String> {
1186        let mut bps: IndexMap<String, String> = IndexMap::new();
1187        let chain = self.theme_chain();
1188        for name in chain.into_iter().rev() {
1189            if let Some(entry) = self.themes.get(&name) {
1190                for (k, v) in entry.breakpoints.iter() {
1191                    bps.insert(k.clone(), v.clone());
1192                }
1193            }
1194        }
1195        bps
1196    }
1197}
1198
1199fn split_tag_class_key(key: &str) -> Option<(String, String)> {
1200    let mut it = key.splitn(2, '|');
1201    let t = it.next()?.to_string();
1202    let c = it.next()?.to_string();
1203    if t.is_empty() || c.is_empty() {
1204        return None;
1205    }
1206    Some((t, c))
1207}
1208
1209fn strip_hover_suffix(selector: &str) -> (&str, bool) {
1210    if let Some(stripped) = selector.strip_suffix(":hover") {
1211        (stripped, true)
1212    } else {
1213        (selector, false)
1214    }
1215}
1216
1217fn should_emit_selector(
1218    sel: &str,
1219    used_tags: &IndexSet<String>,
1220    used_classes: &IndexSet<String>,
1221    used_tag_classes: &IndexSet<String>,
1222) -> bool {
1223    // Optionally handle :hover suffix
1224    let (base, _hover) = strip_hover_suffix(sel);
1225
1226    // tag-only
1227    if is_simple_tag(base) {
1228        return used_tags.contains(base)
1229            || used_tag_classes
1230                .iter()
1231                .any(|k| k.split('|').next() == Some(base));
1232    }
1233
1234    // .class-only
1235    if let Some(class_name) = base.strip_prefix('.') {
1236        // Normalize potential escaped class names as-is
1237        return used_classes.contains(class_name)
1238            || used_tag_classes
1239                .iter()
1240                .any(|k| k.ends_with(&format!("|{}", class_name)));
1241    }
1242
1243    // tag.class
1244    if let Some((tag, class_name)) = split_tag_class_selector(base) {
1245        let key = format!("{}|{}", tag, class_name);
1246        return used_tag_classes.contains(&key)
1247            || (used_tags.contains(&tag) && used_classes.contains(&class_name));
1248    }
1249
1250    // Other complex selectors are currently ignored
1251    false
1252}
1253
1254fn is_simple_tag(s: &str) -> bool {
1255    // Match simple HTML tag-ish identifiers
1256    let mut chars = s.chars();
1257    match chars.next() {
1258        Some(c) if c.is_ascii_alphabetic() => {}
1259        _ => return false,
1260    }
1261    chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1262}
1263
1264fn split_tag_class_selector(s: &str) -> Option<(String, String)> {
1265    // "tag.class" -> (tag, class)
1266    let mut parts = s.splitn(2, '.');
1267    let tag = parts.next()?.to_string();
1268    let class_name = parts.next()?.to_string();
1269    if tag.is_empty() || class_name.is_empty() {
1270        return None;
1271    }
1272    Some((tag, class_name))
1273}
1274
1275// wasm-bindgen exports (only when compiling to wasm32)
1276#[cfg(target_arch = "wasm32")]
1277#[wasm_bindgen]
1278pub fn render_css_for_web(state_json: &str) -> String {
1279    render_css_for_web_impl(state_json)
1280}
1281
1282/// Plain Rust implementation used by both WASM and native (e.g. Android JNI).
1283pub fn render_css_for_web_impl(state_json: &str) -> String {
1284    match serde_json::from_str::<State>(state_json) {
1285        Ok(s) => s.css_for_web(),
1286        Err(_) => "".into(),
1287    }
1288}
1289
1290#[cfg(not(target_arch = "wasm32"))]
1291pub fn render_css_for_web(state_json: &str) -> String {
1292    render_css_for_web_impl(state_json)
1293}
1294
1295#[cfg(target_arch = "wasm32")]
1296#[wasm_bindgen]
1297pub fn get_android_styles(state_json: &str, selector: &str, classes_json: &str) -> String {
1298    let classes: Vec<String> = serde_json::from_str(classes_json).unwrap_or_default();
1299    match serde_json::from_str::<State>(state_json) {
1300        Ok(s) => serde_json::to_string(&s.android_styles_for(selector, &classes))
1301            .unwrap_or_else(|_| "{}".into()),
1302        Err(_) => "{}".into(),
1303    }
1304}
1305
1306// Expose crate version to JS via wasm-bindgen
1307#[cfg(target_arch = "wasm32")]
1308#[wasm_bindgen]
1309pub fn get_version() -> String {
1310    // CARGO_PKG_VERSION is provided at compile time
1311    env!("CARGO_PKG_VERSION").to_string()
1312}
1313
1314// Plain Rust accessor for crate version used by Android JNI glue
1315pub fn version() -> &'static str {
1316    env!("CARGO_PKG_VERSION")
1317}
1318
1319/// Return the embedded default state as a JSON string.
1320pub fn get_default_state_json_impl() -> String {
1321    let st = bundled_state();
1322    match serde_json::to_string(&st.to_json()) {
1323        Ok(s) => s,
1324        Err(_) => "{}".to_string(),
1325    }
1326}
1327
1328#[cfg(target_arch = "wasm32")]
1329#[wasm_bindgen]
1330pub fn get_default_state_json() -> String {
1331    get_default_state_json_impl()
1332}
1333
1334#[cfg(not(target_arch = "wasm32"))]
1335pub fn get_default_state_json() -> String {
1336    get_default_state_json_impl()
1337}
1338
1339/// Register a theme from JSON. On duplicate, replace the theme's selectors, inheritance, and variables.
1340/// Expected JSON format: `{ "name": "theme-name", "theme": { "inherits": "parent", "selectors": {...}, "variables": {...}, "breakpoints": {...} } }`
1341/// Returns the updated state as JSON, or "{}" on error.
1342pub fn register_theme_json(state_json: &str, theme_json: &str) -> String {
1343    match (
1344        serde_json::from_str::<State>(state_json),
1345        serde_json::from_str::<serde_json::Value>(theme_json),
1346    ) {
1347        (Ok(mut state), Ok(theme_obj)) => {
1348            if let (Some(name), Some(theme_entry)) = (theme_obj.get("name"), theme_obj.get("theme"))
1349            {
1350                if let Ok(entry) = serde_json::from_value::<ThemeEntry>(theme_entry.clone()) {
1351                    let theme_name = name.as_str().unwrap_or("").to_string();
1352                    if !theme_name.is_empty() {
1353                        state.themes.insert(theme_name, entry);
1354                    }
1355                }
1356            }
1357            match serde_json::to_string(&state.to_json()) {
1358                Ok(s) => s,
1359                Err(_) => "{}".to_string(),
1360            }
1361        }
1362        _ => "{}".to_string(),
1363    }
1364}
1365
1366#[cfg(target_arch = "wasm32")]
1367#[wasm_bindgen]
1368pub fn set_theme_json(state_json: &str, theme_name: &str) -> String {
1369    set_theme_json_native(state_json, theme_name)
1370}
1371
1372#[cfg(not(target_arch = "wasm32"))]
1373pub fn set_theme_json(state_json: &str, theme_name: &str) -> String {
1374    set_theme_json_native(state_json, theme_name)
1375}
1376
1377fn set_theme_json_native(state_json: &str, theme_name: &str) -> String {
1378    match serde_json::from_str::<State>(state_json) {
1379        Ok(mut state) => {
1380            if state.themes.contains_key(theme_name) {
1381                state.default_theme = theme_name.to_string();
1382                state.current_theme = theme_name.to_string();
1383            }
1384            match serde_json::to_string(&state.to_json()) {
1385                Ok(s) => s,
1386                Err(_) => "{}".to_string(),
1387            }
1388        }
1389        _ => "{}".to_string(),
1390    }
1391}
1392
1393/// Get all theme keys and names as JSON array: [{ "key": "default", "name": "Default Theme" }, ...]
1394/// Returns array of themes from the state JSON.
1395#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
1396pub fn get_theme_list_json(state_json: &str) -> String {
1397    match serde_json::from_str::<State>(state_json) {
1398        Ok(state) => {
1399            let themes: Vec<serde_json::Value> = state
1400                .themes
1401                .iter()
1402                .map(|(key, entry)| {
1403                    json!({
1404                        "key": key,
1405                        "name": entry.name.as_ref().unwrap_or(key)
1406                    })
1407                })
1408                .collect();
1409            serde_json::to_string(&themes).unwrap_or_else(|_| "[]".to_string())
1410        }
1411        _ => "[]".to_string(),
1412    }
1413}
1414
1415fn merge_props(into: &mut CssProps, from: &CssProps) {
1416    for (k, v) in from.iter() {
1417        into.insert(k.clone(), v.clone());
1418    }
1419}
1420
1421// merge_indexmap removed — unused
1422
1423fn css_props_string(props: &CssProps, vars: &IndexMap<String, String>) -> String {
1424    let mut buf = String::new();
1425    for (k, v) in props.iter() {
1426        let key = crate::utils::kebab_case(k);
1427        buf.push_str(&key);
1428        buf.push(':');
1429        let val = if v.is_string() {
1430            let s = v.as_str().unwrap();
1431            resolve_vars(s, vars)
1432        } else {
1433            v.to_string()
1434        };
1435        buf.push_str(&val);
1436        if !val.ends_with(';') {
1437            buf.push(';');
1438        }
1439    }
1440    buf
1441}
1442
1443/// Parse var() references manually (replaces regex dependency)
1444/// Matches: var(--name), var(name), with optional whitespace
1445/// Supports alphanumeric, underscore, dot, and dash in variable names
1446fn parse_var_references(input: &str) -> Vec<(usize, usize, String)> {
1447    let mut results = Vec::new();
1448    let bytes = input.as_bytes();
1449    let mut i = 0;
1450
1451    while i < bytes.len() {
1452        // Look for "var("
1453        if i + 4 <= bytes.len() && &bytes[i..i + 4] == b"var(" {
1454            let start = i;
1455            i += 4;
1456
1457            // Skip whitespace
1458            while i < bytes.len()
1459                && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\n' || bytes[i] == b'\r')
1460            {
1461                i += 1;
1462            }
1463
1464            // Check for optional -- prefix
1465            let has_prefix = i + 2 <= bytes.len() && &bytes[i..i + 2] == b"--";
1466            if has_prefix {
1467                i += 2;
1468            }
1469
1470            // Collect variable name: [a-zA-Z0-9_.-]+
1471            let name_start = i;
1472            while i < bytes.len() {
1473                let c = bytes[i];
1474                if (c >= b'a' && c <= b'z')
1475                    || (c >= b'A' && c <= b'Z')
1476                    || (c >= b'0' && c <= b'9')
1477                    || c == b'_'
1478                    || c == b'.'
1479                    || c == b'-'
1480                {
1481                    i += 1;
1482                } else {
1483                    break;
1484                }
1485            }
1486
1487            let name_end = i;
1488            if name_start < name_end {
1489                // Skip trailing whitespace
1490                while i < bytes.len()
1491                    && (bytes[i] == b' '
1492                        || bytes[i] == b'\t'
1493                        || bytes[i] == b'\n'
1494                        || bytes[i] == b'\r')
1495                {
1496                    i += 1;
1497                }
1498
1499                // Check for closing )
1500                if i < bytes.len() && bytes[i] == b')' {
1501                    let end = i + 1;
1502                    let var_name = std::str::from_utf8(&bytes[name_start..name_end])
1503                        .unwrap_or("")
1504                        .to_string();
1505                    results.push((start, end, var_name));
1506                    i = end;
1507                    continue;
1508                }
1509            }
1510        }
1511        i += 1;
1512    }
1513
1514    results
1515}
1516
1517// Tailwind color palette - embedded from tailwind-colors.html
1518static TAILWIND_COLORS: Lazy<IndexMap<&'static str, IndexMap<&'static str, &'static str>>> =
1519    Lazy::new(|| {
1520        let mut colors = IndexMap::new();
1521
1522        let mut slate = IndexMap::new();
1523        slate.insert("50", "#f8fafc");
1524        slate.insert("100", "#f1f5f9");
1525        slate.insert("200", "#e2e8f0");
1526        slate.insert("300", "#cbd5e1");
1527        slate.insert("400", "#94a3b8");
1528        slate.insert("500", "#64748b");
1529        slate.insert("600", "#475569");
1530        slate.insert("700", "#334155");
1531        slate.insert("800", "#1e293b");
1532        slate.insert("900", "#0f172a");
1533        slate.insert("950", "#020617");
1534        colors.insert("slate", slate);
1535
1536        let mut gray = IndexMap::new();
1537        gray.insert("50", "#f9fafb");
1538        gray.insert("100", "#f3f4f6");
1539        gray.insert("200", "#e5e7eb");
1540        gray.insert("300", "#d1d5db");
1541        gray.insert("400", "#9ca3af");
1542        gray.insert("500", "#6b7280");
1543        gray.insert("600", "#4b5563");
1544        gray.insert("700", "#374151");
1545        gray.insert("800", "#1f2937");
1546        gray.insert("900", "#111827");
1547        gray.insert("950", "#030712");
1548        colors.insert("gray", gray);
1549
1550        let mut zinc = IndexMap::new();
1551        zinc.insert("50", "#fafafa");
1552        zinc.insert("100", "#f4f4f5");
1553        zinc.insert("200", "#e4e4e7");
1554        zinc.insert("300", "#d4d4d8");
1555        zinc.insert("400", "#a1a1aa");
1556        zinc.insert("500", "#71717a");
1557        zinc.insert("600", "#52525b");
1558        zinc.insert("700", "#3f3f46");
1559        zinc.insert("800", "#27272a");
1560        zinc.insert("900", "#18181b");
1561        zinc.insert("950", "#09090b");
1562        colors.insert("zinc", zinc);
1563
1564        let mut neutral = IndexMap::new();
1565        neutral.insert("50", "#fafafa");
1566        neutral.insert("100", "#f5f5f5");
1567        neutral.insert("200", "#e5e5e5");
1568        neutral.insert("300", "#d4d4d4");
1569        neutral.insert("400", "#a3a3a3");
1570        neutral.insert("500", "#737373");
1571        neutral.insert("600", "#525252");
1572        neutral.insert("700", "#404040");
1573        neutral.insert("800", "#262626");
1574        neutral.insert("900", "#171717");
1575        neutral.insert("950", "#0a0a0a");
1576        colors.insert("neutral", neutral);
1577
1578        let mut stone = IndexMap::new();
1579        stone.insert("50", "#fafaf9");
1580        stone.insert("100", "#f5f5f4");
1581        stone.insert("200", "#e7e5e4");
1582        stone.insert("300", "#d6d3d1");
1583        stone.insert("400", "#a8a29e");
1584        stone.insert("500", "#78716c");
1585        stone.insert("600", "#57534e");
1586        stone.insert("700", "#44403c");
1587        stone.insert("800", "#292524");
1588        stone.insert("900", "#1c1917");
1589        stone.insert("950", "#0c0a09");
1590        colors.insert("stone", stone);
1591
1592        let mut red = IndexMap::new();
1593        red.insert("50", "#fef2f2");
1594        red.insert("100", "#fee2e2");
1595        red.insert("200", "#fecaca");
1596        red.insert("300", "#fca5a5");
1597        red.insert("400", "#f87171");
1598        red.insert("500", "#ef4444");
1599        red.insert("600", "#dc2626");
1600        red.insert("700", "#b91c1c");
1601        red.insert("800", "#991b1b");
1602        red.insert("900", "#7f1d1d");
1603        red.insert("950", "#450a0a");
1604        colors.insert("red", red);
1605
1606        let mut orange = IndexMap::new();
1607        orange.insert("50", "#fff7ed");
1608        orange.insert("100", "#ffedd5");
1609        orange.insert("200", "#fed7aa");
1610        orange.insert("300", "#fdba74");
1611        orange.insert("400", "#fb923c");
1612        orange.insert("500", "#f97316");
1613        orange.insert("600", "#ea580c");
1614        orange.insert("700", "#c2410c");
1615        orange.insert("800", "#9a3412");
1616        orange.insert("900", "#7c2d12");
1617        orange.insert("950", "#431407");
1618        colors.insert("orange", orange);
1619
1620        let mut amber = IndexMap::new();
1621        amber.insert("50", "#fffbeb");
1622        amber.insert("100", "#fef3c7");
1623        amber.insert("200", "#fde68a");
1624        amber.insert("300", "#fcd34d");
1625        amber.insert("400", "#fbbf24");
1626        amber.insert("500", "#f59e0b");
1627        amber.insert("600", "#d97706");
1628        amber.insert("700", "#b45309");
1629        amber.insert("800", "#92400e");
1630        amber.insert("900", "#78350f");
1631        amber.insert("950", "#451a03");
1632        colors.insert("amber", amber);
1633
1634        let mut blue = IndexMap::new();
1635        blue.insert("50", "#eff6ff");
1636        blue.insert("100", "#dbeafe");
1637        blue.insert("200", "#bfdbfe");
1638        blue.insert("300", "#93c5fd");
1639        blue.insert("400", "#60a5fa");
1640        blue.insert("500", "#3b82f6");
1641        blue.insert("600", "#2563eb");
1642        blue.insert("700", "#1d4ed8");
1643        blue.insert("800", "#1e40af");
1644        blue.insert("900", "#1e3a8a");
1645        blue.insert("950", "#0b1c52");
1646        colors.insert("blue", blue);
1647
1648        let mut lime = IndexMap::new();
1649        lime.insert("50", "#f7fee7");
1650        lime.insert("100", "#ecfccb");
1651        lime.insert("200", "#d9f99d");
1652        lime.insert("300", "#bef264");
1653        lime.insert("400", "#a3e635");
1654        lime.insert("500", "#84cc16");
1655        lime.insert("600", "#65a30d");
1656        lime.insert("700", "#4d7c0f");
1657        lime.insert("800", "#3f6212");
1658        lime.insert("900", "#365314");
1659        lime.insert("950", "#1a2e05");
1660        colors.insert("lime", lime);
1661
1662        let mut green = IndexMap::new();
1663        green.insert("50", "#f0fdf4");
1664        green.insert("100", "#dcfce7");
1665        green.insert("200", "#bbf7d0");
1666        green.insert("300", "#86efac");
1667        green.insert("400", "#4ade80");
1668        green.insert("500", "#22c55e");
1669        green.insert("600", "#16a34a");
1670        green.insert("700", "#15803d");
1671        green.insert("800", "#166534");
1672        green.insert("900", "#14532d");
1673        green.insert("950", "#052e16");
1674        colors.insert("green", green);
1675
1676        let mut emerald = IndexMap::new();
1677        emerald.insert("50", "#ecfdf5");
1678        emerald.insert("100", "#d1fae5");
1679        emerald.insert("200", "#a7f3d0");
1680        emerald.insert("300", "#6ee7b7");
1681        emerald.insert("400", "#34d399");
1682        emerald.insert("500", "#10b981");
1683        emerald.insert("600", "#059669");
1684        emerald.insert("700", "#047857");
1685        emerald.insert("800", "#065f46");
1686        emerald.insert("900", "#064e3b");
1687        emerald.insert("950", "#022c22");
1688        colors.insert("emerald", emerald);
1689
1690        let mut teal = IndexMap::new();
1691        teal.insert("50", "#f0fdfa");
1692        teal.insert("100", "#ccfbf1");
1693        teal.insert("200", "#99f6e4");
1694        teal.insert("300", "#5eead4");
1695        teal.insert("400", "#2dd4bf");
1696        teal.insert("500", "#14b8a6");
1697        teal.insert("600", "#0d9488");
1698        teal.insert("700", "#0f766e");
1699        teal.insert("800", "#115e59");
1700        teal.insert("900", "#134e4a");
1701        teal.insert("950", "#042f2e");
1702        colors.insert("teal", teal);
1703
1704        let mut cyan = IndexMap::new();
1705        cyan.insert("50", "#ecfeff");
1706        cyan.insert("100", "#cffafe");
1707        cyan.insert("200", "#a5f3fc");
1708        cyan.insert("300", "#67e8f9");
1709        cyan.insert("400", "#22d3ee");
1710        cyan.insert("500", "#06b6d4");
1711        cyan.insert("600", "#0891b2");
1712        cyan.insert("700", "#0e7490");
1713        cyan.insert("800", "#155e75");
1714        cyan.insert("900", "#164e63");
1715        cyan.insert("950", "#083344");
1716        colors.insert("cyan", cyan);
1717
1718        let mut sky = IndexMap::new();
1719        sky.insert("50", "#f0f9ff");
1720        sky.insert("100", "#e0f2fe");
1721        sky.insert("200", "#bae6fd");
1722        sky.insert("300", "#7dd3fc");
1723        sky.insert("400", "#38bdf8");
1724        sky.insert("500", "#0ea5e9");
1725        sky.insert("600", "#0284c7");
1726        sky.insert("700", "#0369a1");
1727        sky.insert("800", "#075985");
1728        sky.insert("900", "#0c4a6e");
1729        sky.insert("950", "#082f49");
1730        colors.insert("sky", sky);
1731
1732        let mut blue = IndexMap::new();
1733        blue.insert("50", "#eff6ff");
1734        blue.insert("100", "#dbeafe");
1735        blue.insert("200", "#bfdbfe");
1736        blue.insert("300", "#93c5fd");
1737        blue.insert("400", "#60a5fa");
1738        blue.insert("500", "#3b82f6");
1739        blue.insert("600", "#2563eb");
1740        blue.insert("700", "#1d4ed8");
1741        blue.insert("800", "#1e40af");
1742        blue.insert("900", "#1e3a8a");
1743        blue.insert("950", "#172554");
1744        colors.insert("blue", blue);
1745
1746        let mut indigo = IndexMap::new();
1747        indigo.insert("50", "#eef2ff");
1748        indigo.insert("100", "#e0e7ff");
1749        indigo.insert("200", "#c7d2fe");
1750        indigo.insert("300", "#a5b4fc");
1751        indigo.insert("400", "#818cf8");
1752        indigo.insert("500", "#6366f1");
1753        indigo.insert("600", "#4f46e5");
1754        indigo.insert("700", "#4338ca");
1755        indigo.insert("800", "#3730a3");
1756        indigo.insert("900", "#312e81");
1757        indigo.insert("950", "#1e1b4b");
1758        colors.insert("indigo", indigo);
1759
1760        let mut violet = IndexMap::new();
1761        violet.insert("50", "#f5f3ff");
1762        violet.insert("100", "#ede9fe");
1763        violet.insert("200", "#ddd6fe");
1764        violet.insert("300", "#c4b5fd");
1765        violet.insert("400", "#a78bfa");
1766        violet.insert("500", "#8b5cf6");
1767        violet.insert("600", "#7c3aed");
1768        violet.insert("700", "#6d28d9");
1769        violet.insert("800", "#5b21b6");
1770        violet.insert("900", "#4c1d95");
1771        violet.insert("950", "#2e1065");
1772        colors.insert("violet", violet);
1773
1774        let mut purple = IndexMap::new();
1775        purple.insert("50", "#faf5ff");
1776        purple.insert("100", "#f3e8ff");
1777        purple.insert("200", "#e9d5ff");
1778        purple.insert("300", "#d8b4fe");
1779        purple.insert("400", "#c084fc");
1780        purple.insert("500", "#a855f7");
1781        purple.insert("600", "#9333ea");
1782        purple.insert("700", "#7e22ce");
1783        purple.insert("800", "#6b21a8");
1784        purple.insert("900", "#581c87");
1785        purple.insert("950", "#3b0764");
1786        colors.insert("purple", purple);
1787
1788        let mut fuchsia = IndexMap::new();
1789        fuchsia.insert("50", "#fdf4ff");
1790        fuchsia.insert("100", "#fae8ff");
1791        fuchsia.insert("200", "#f5d0fe");
1792        fuchsia.insert("300", "#f0abfc");
1793        fuchsia.insert("400", "#e879f9");
1794        fuchsia.insert("500", "#d946ef");
1795        fuchsia.insert("600", "#c026d3");
1796        fuchsia.insert("700", "#a21caf");
1797        fuchsia.insert("800", "#86198f");
1798        fuchsia.insert("900", "#701a75");
1799        fuchsia.insert("950", "#4a044e");
1800        colors.insert("fuchsia", fuchsia);
1801
1802        let mut pink = IndexMap::new();
1803        pink.insert("50", "#fdf2f8");
1804        pink.insert("100", "#fce7f3");
1805        pink.insert("200", "#fbcfe8");
1806        pink.insert("300", "#f9a8d4");
1807        pink.insert("400", "#f472b6");
1808        pink.insert("500", "#ec4899");
1809        pink.insert("600", "#db2777");
1810        pink.insert("700", "#be185d");
1811        pink.insert("800", "#9d174d");
1812        pink.insert("900", "#831843");
1813        pink.insert("950", "#500724");
1814        colors.insert("pink", pink);
1815
1816        let mut rose = IndexMap::new();
1817        rose.insert("50", "#fff1f2");
1818        rose.insert("100", "#ffe4e6");
1819        rose.insert("200", "#fecdd3");
1820        rose.insert("300", "#fda4af");
1821        rose.insert("400", "#fb7185");
1822        rose.insert("500", "#f43f5e");
1823        rose.insert("600", "#e11d48");
1824        rose.insert("700", "#be123c");
1825        rose.insert("800", "#9f1239");
1826        rose.insert("900", "#881337");
1827        rose.insert("950", "#4c0519");
1828        colors.insert("rose", rose);
1829
1830        colors
1831    });
1832
1833fn resolve_vars(input: &str, vars: &IndexMap<String, String>) -> String {
1834    let var_refs = parse_var_references(input);
1835
1836    if var_refs.is_empty() {
1837        // Fast path: no var() references, just check for $ prefix
1838        if input.starts_with('$') {
1839            if let Some(val) = vars.get(&input[1..]) {
1840                return val.clone();
1841            }
1842        }
1843        return input.to_string();
1844    }
1845
1846    // Replace var() references from right to left to preserve indices
1847    let mut out = input.to_string();
1848    for (start, end, var_name) in var_refs.iter().rev() {
1849        if let Some(val) = vars.get(var_name) {
1850            out.replace_range(*start..*end, val);
1851        }
1852    }
1853
1854    // Also handle $ prefix for direct variable references
1855    if out.starts_with('$') {
1856        if let Some(val) = vars.get(&out[1..]) {
1857            return val.clone();
1858        }
1859    }
1860
1861    out
1862}
1863
1864fn camel_case(name: &str) -> String {
1865    let mut out = String::new();
1866    let mut upper = false;
1867    for ch in name.chars() {
1868        if ch == '-' {
1869            upper = true;
1870            continue;
1871        }
1872        if upper {
1873            out.extend(ch.to_uppercase());
1874            upper = false;
1875        } else {
1876            out.push(ch);
1877        }
1878    }
1879    out
1880}
1881
1882fn css_value_to_android(
1883    value: &serde_json::Value,
1884    vars: &IndexMap<String, String>,
1885    current_color: Option<&str>,
1886) -> serde_json::Value {
1887    if let Some(s) = value.as_str() {
1888        if s.contains("color-mix") {
1889            log::debug!(
1890                "[css_value_to_android] color-mix detected: {} current_color={:?}",
1891                s,
1892                current_color
1893            );
1894        }
1895    }
1896    match value {
1897        serde_json::Value::String(s) => {
1898            let s2 = resolve_vars(s, vars);
1899            let s3 = color::resolve_color(&s2, current_color, vars);
1900            if let Some(n) = s3.strip_suffix("px") {
1901                if let Ok(parsed) = n.trim().parse::<f64>() {
1902                    return json!(parsed);
1903                }
1904            }
1905            json!(s3)
1906        }
1907        _ => value.clone(),
1908    }
1909}
1910
1911/// Web inline style: keep px/rem strings, map match_parent -> 100%, wrap_content -> auto.
1912fn css_value_to_web(
1913    value: &serde_json::Value,
1914    vars: &IndexMap<String, String>,
1915    current_color: Option<&str>,
1916) -> serde_json::Value {
1917    match value {
1918        serde_json::Value::String(s) => {
1919            let s2 = resolve_vars(s, vars);
1920            let s3 = color::resolve_color(&s2, current_color, vars);
1921            let s4 = match s3.as_str() {
1922                "match_parent" => "100%".to_string(),
1923                "wrap_content" | "fit-content" => "auto".to_string(),
1924                _ => s3,
1925            };
1926            json!(s4)
1927        }
1928        _ => value.clone(),
1929    }
1930}
1931
1932/// Merge resolved props into web-friendly format (camelCase, no Android-specific keys).
1933fn merge_web_props(
1934    into: &mut IndexMap<String, serde_json::Value>,
1935    css_props: &CssProps,
1936    vars: &IndexMap<String, String>,
1937) {
1938    let current_color = css_props
1939        .get("color")
1940        .and_then(|v| v.as_str())
1941        .map(|s| resolve_vars(s, vars))
1942        .or_else(|| into.get("color").and_then(|v| v.as_str()).map(|s| s.to_string()));
1943
1944    for (k, v) in css_props.iter() {
1945        let val = css_value_to_web(v, vars, current_color.as_deref());
1946
1947        match k.as_str() {
1948            "padding" => {
1949                into.insert("paddingTop".to_string(), val.clone());
1950                into.insert("paddingBottom".to_string(), val.clone());
1951                into.insert("paddingLeft".to_string(), val.clone());
1952                into.insert("paddingRight".to_string(), val.clone());
1953                into.insert("padding".to_string(), val);
1954            }
1955            "padding-horizontal" | "paddingHorizontal" => {
1956                into.insert("paddingLeft".to_string(), val.clone());
1957                into.insert("paddingRight".to_string(), val.clone());
1958            }
1959            "padding-vertical" | "paddingVertical" => {
1960                into.insert("paddingTop".to_string(), val.clone());
1961                into.insert("paddingBottom".to_string(), val.clone());
1962            }
1963            "margin" => {
1964                into.insert("marginTop".to_string(), val.clone());
1965                into.insert("marginBottom".to_string(), val.clone());
1966                into.insert("marginLeft".to_string(), val.clone());
1967                into.insert("marginRight".to_string(), val.clone());
1968                into.insert("margin".to_string(), val);
1969            }
1970            "margin-horizontal" | "marginHorizontal" => {
1971                into.insert("marginLeft".to_string(), val.clone());
1972                into.insert("marginRight".to_string(), val.clone());
1973            }
1974            "margin-vertical" | "marginVertical" => {
1975                into.insert("marginTop".to_string(), val.clone());
1976                into.insert("marginBottom".to_string(), val.clone());
1977            }
1978            "border-radius" | "borderRadius" => {
1979                into.insert("borderRadius".to_string(), val);
1980            }
1981            "background-color" => {
1982                into.insert("backgroundColor".to_string(), val);
1983            }
1984            "text-align" => {
1985                into.insert("textAlign".to_string(), val);
1986            }
1987            "flex-direction" | "flexDirection" => {
1988                into.insert("flexDirection".to_string(), val);
1989            }
1990            "android-gravity" | "androidOrientation" | "androidGravity" | "androidLayoutGravity"
1991            | "androidFlexWrap" | "androidAlpha" | "androidScrollHorizontal"
1992            | "androidScrollVertical" | "androidTextGravity" => {}
1993            "--space-x" => {
1994                into.insert("spaceX".to_string(), val);
1995            }
1996            "--space-y" => {
1997                into.insert("spaceY".to_string(), val);
1998            }
1999            _ => {
2000                into.insert(camel_case(k), val);
2001            }
2002        }
2003    }
2004}
2005
2006fn merge_android_props(
2007    into: &mut IndexMap<String, serde_json::Value>,
2008    css_props: &CssProps,
2009    vars: &IndexMap<String, String>,
2010) {
2011    log::debug!(
2012        "[merge_android_props] START props_count={}",
2013        css_props.len()
2014    );
2015    // 1. Find current color for currentColor resolution
2016    let mut current_color = css_props
2017        .get("color")
2018        .and_then(|v| v.as_str())
2019        .map(|s| resolve_vars(s, vars));
2020
2021    if current_color.is_none() {
2022        current_color = into
2023            .get("color")
2024            .and_then(|v| v.as_str())
2025            .map(|s| s.to_string());
2026    }
2027
2028    if let Some(ref c) = current_color {
2029        log::debug!("[merge_android_props] current_color resolved to: {}", c);
2030    }
2031
2032    for (k, v) in css_props.iter() {
2033        let val = css_value_to_android(v, vars, current_color.as_deref());
2034
2035        if k == "placeholder-color" || k == "placeholderColor" {
2036            log::debug!(
2037                "[merge_android_props] placeholder-color: input={:?} output={:?}",
2038                v,
2039                val
2040            );
2041        }
2042
2043        match k.as_str() {
2044            "padding" => {
2045                into.insert("paddingTop".to_string(), val.clone());
2046                into.insert("paddingBottom".to_string(), val.clone());
2047                into.insert("paddingLeft".to_string(), val.clone());
2048                into.insert("paddingRight".to_string(), val.clone());
2049                into.insert("paddingHorizontal".to_string(), val.clone());
2050                into.insert("paddingVertical".to_string(), val.clone());
2051                into.insert("padding".to_string(), val);
2052            }
2053            "padding-horizontal" | "paddingHorizontal" => {
2054                into.insert("paddingLeft".to_string(), val.clone());
2055                into.insert("paddingRight".to_string(), val.clone());
2056                into.insert("paddingHorizontal".to_string(), val);
2057            }
2058            "padding-vertical" | "paddingVertical" => {
2059                into.insert("paddingTop".to_string(), val.clone());
2060                into.insert("paddingBottom".to_string(), val.clone());
2061                into.insert("paddingVertical".to_string(), val);
2062            }
2063            "margin" => {
2064                into.insert("marginTop".to_string(), val.clone());
2065                into.insert("marginBottom".to_string(), val.clone());
2066                into.insert("marginLeft".to_string(), val.clone());
2067                into.insert("marginRight".to_string(), val.clone());
2068                into.insert("marginHorizontal".to_string(), val.clone());
2069                into.insert("marginVertical".to_string(), val.clone());
2070                into.insert("margin".to_string(), val);
2071            }
2072            "margin-horizontal" | "marginHorizontal" => {
2073                into.insert("marginLeft".to_string(), val.clone());
2074                into.insert("marginRight".to_string(), val.clone());
2075                into.insert("marginHorizontal".to_string(), val);
2076            }
2077            "margin-vertical" | "marginVertical" => {
2078                into.insert("marginTop".to_string(), val.clone());
2079                into.insert("marginBottom".to_string(), val.clone());
2080                into.insert("marginVertical".to_string(), val);
2081            }
2082            "border-radius" | "borderRadius" => {
2083                into.insert("borderTopLeftRadius".to_string(), val.clone());
2084                into.insert("borderTopRightRadius".to_string(), val.clone());
2085                into.insert("borderBottomLeftRadius".to_string(), val.clone());
2086                into.insert("borderBottomRightRadius".to_string(), val.clone());
2087                into.insert("borderRadius".to_string(), val);
2088            }
2089            "background-color" => {
2090                into.insert("backgroundColor".to_string(), val);
2091            }
2092            "text-align" => {
2093                into.insert("textAlign".to_string(), val);
2094            }
2095            "flex-direction" | "flexDirection" => {
2096                let orientation =
2097                    if val.as_str() == Some("column") || val.as_str() == Some("column-reverse") {
2098                        "vertical"
2099                    } else {
2100                        "horizontal"
2101                    };
2102                into.insert(
2103                    "androidOrientation".to_string(),
2104                    serde_json::json!(orientation),
2105                );
2106                into.insert("flexDirection".to_string(), val);
2107            }
2108            "--space-x" => {
2109                into.insert("spaceX".to_string(), val);
2110            }
2111            "--space-y" => {
2112                into.insert("spaceY".to_string(), val);
2113            }
2114            _ => {
2115                into.insert(camel_case(k), val);
2116            }
2117        }
2118    }
2119}
2120
2121fn dynamic_css_properties_for_class(
2122    class: &str,
2123    vars: &IndexMap<String, String>,
2124) -> Option<CssProps> {
2125    // Display utilities
2126    match class {
2127        "block" => {
2128            let mut p = CssProps::new();
2129            p.insert("display".into(), json!("block"));
2130            return Some(p);
2131        }
2132        "inline-block" => {
2133            let mut p = CssProps::new();
2134            p.insert("display".into(), json!("inline-block"));
2135            return Some(p);
2136        }
2137        "inline" => {
2138            let mut p = CssProps::new();
2139            p.insert("display".into(), json!("inline"));
2140            return Some(p);
2141        }
2142        "inline-flex" => {
2143            let mut p = CssProps::new();
2144            p.insert("display".into(), json!("inline-flex"));
2145            return Some(p);
2146        }
2147        "grid" => {
2148            let mut p = CssProps::new();
2149            p.insert("display".into(), json!("grid"));
2150            return Some(p);
2151        }
2152        "hidden" => {
2153            let mut p = CssProps::new();
2154            p.insert("display".into(), json!("none"));
2155            return Some(p);
2156        }
2157        _ => {}
2158    }
2159    // Flexbox shorthands
2160    match class {
2161        "flex" => {
2162            let mut p = CssProps::new();
2163            p.insert("display".into(), json!("flex"));
2164            return Some(p);
2165        }
2166        "flex-row" => {
2167            let mut p = CssProps::new();
2168            p.insert("display".into(), json!("flex"));
2169            p.insert("flexDirection".into(), json!("row"));
2170            return Some(p);
2171        }
2172        "flex-col" => {
2173            let mut p = CssProps::new();
2174            p.insert("display".into(), json!("flex"));
2175            p.insert("flexDirection".into(), json!("column"));
2176            return Some(p);
2177        }
2178        "flex-wrap" => {
2179            let mut p = CssProps::new();
2180            p.insert("display".into(), json!("flex"));
2181            p.insert("flex-wrap".into(), json!("wrap"));
2182            return Some(p);
2183        }
2184        "flex-nowrap" => {
2185            let mut p = CssProps::new();
2186            p.insert("display".into(), json!("flex"));
2187            p.insert("flex-wrap".into(), json!("nowrap"));
2188            return Some(p);
2189        }
2190        "flex-wrap-reverse" => {
2191            let mut p = CssProps::new();
2192            p.insert("display".into(), json!("flex"));
2193            p.insert("flex-wrap".into(), json!("wrap-reverse"));
2194            return Some(p);
2195        }
2196        "flex-1" => {
2197            let mut p = CssProps::new();
2198            p.insert("flex".into(), json!(1));
2199            return Some(p);
2200        }
2201        "w-full" => {
2202            let mut p = CssProps::new();
2203            p.insert("width".into(), json!("match_parent"));
2204            return Some(p);
2205        }
2206        "h-full" => {
2207            let mut p = CssProps::new();
2208            p.insert("height".into(), json!("match_parent"));
2209            return Some(p);
2210        }
2211        _ => {}
2212    }
2213    if let Some(value) = class.strip_prefix("z-") {
2214        if let Ok(z) = value.parse::<i32>() {
2215            let mut p = CssProps::new();
2216            p.insert("elevation".into(), json!(z));
2217            return Some(p);
2218        }
2219    }
2220    if let Some(rest) = class.strip_prefix("items-") {
2221        let mut p = CssProps::new();
2222        let v = match rest {
2223            "start" => "flex-start",
2224            "end" => "flex-end",
2225            "center" => "center",
2226            "stretch" => "stretch",
2227            other => other,
2228        };
2229        p.insert("align-items".into(), json!(v));
2230        return Some(p);
2231    }
2232    if let Some(rest) = class.strip_prefix("justify-") {
2233        let mut p = CssProps::new();
2234        let v = match rest {
2235            "start" => "flex-start",
2236            "end" => "flex-end",
2237            "center" => "center",
2238            "between" => "space-between",
2239            "around" => "space-around",
2240            "evenly" => "space-evenly",
2241            other => other,
2242        };
2243        p.insert("justify-content".into(), json!(v));
2244        return Some(p);
2245    }
2246    if let Some(value) = class.strip_prefix("p-") {
2247        return parse_tailwind_spacing(value, &|px| padding_props(&["padding"], px));
2248    }
2249    if let Some(value) = class.strip_prefix("px-") {
2250        return parse_tailwind_spacing(value, &|px| {
2251            padding_props(&["padding-left", "padding-right"], px)
2252        });
2253    }
2254    if let Some(value) = class.strip_prefix("py-") {
2255        return parse_tailwind_spacing(value, &|px| {
2256            padding_props(&["padding-top", "padding-bottom"], px)
2257        });
2258    }
2259    for &(prefix, prop) in &[
2260        ("pt-", "padding-top"),
2261        ("pr-", "padding-right"),
2262        ("pb-", "padding-bottom"),
2263        ("pl-", "padding-left"),
2264    ] {
2265        if let Some(value) = class.strip_prefix(prefix) {
2266            return parse_tailwind_spacing(value, &|px| padding_props(&[prop], px));
2267        }
2268    }
2269    // Margin utilities
2270    if let Some(value) = class.strip_prefix("m-") {
2271        if value == "auto" {
2272            let mut p = CssProps::new();
2273            p.insert("margin".into(), json!("auto"));
2274            return Some(p);
2275        }
2276        return parse_tailwind_spacing(value, &|px| margin_props(&["margin"], px));
2277    }
2278    if let Some(value) = class.strip_prefix("mx-") {
2279        if value == "auto" {
2280            let mut p = CssProps::new();
2281            p.insert("margin-left".into(), json!("auto"));
2282            p.insert("margin-right".into(), json!("auto"));
2283            return Some(p);
2284        }
2285        return parse_tailwind_spacing(value, &|px| {
2286            margin_props(&["margin-left", "margin-right"], px)
2287        });
2288    }
2289    if let Some(value) = class.strip_prefix("my-") {
2290        if value == "auto" {
2291            let mut p = CssProps::new();
2292            p.insert("margin-top".into(), json!("auto"));
2293            p.insert("margin-bottom".into(), json!("auto"));
2294            return Some(p);
2295        }
2296        return parse_tailwind_spacing(value, &|px| {
2297            margin_props(&["margin-top", "margin-bottom"], px)
2298        });
2299    }
2300    for &(prefix, prop) in &[
2301        ("mt-", "margin-top"),
2302        ("mr-", "margin-right"),
2303        ("mb-", "margin-bottom"),
2304        ("ml-", "margin-left"),
2305    ] {
2306        if let Some(value) = class.strip_prefix(prefix) {
2307            if value == "auto" {
2308                let mut p = CssProps::new();
2309                p.insert(prop.into(), json!("auto"));
2310                return Some(p);
2311            }
2312            return parse_tailwind_spacing(value, &|px| margin_props(&[prop], px));
2313        }
2314    }
2315    // Gap utilities (works in Android with Flexbox)
2316    if let Some(value) = class.strip_prefix("gap-") {
2317        if !value.starts_with("x-") && !value.starts_with("y-") {
2318            return parse_tailwind_spacing(value, &|px| {
2319                let mut props = CssProps::new();
2320                props.insert("gap".into(), json!(format!("{}px", px)));
2321                props
2322            });
2323        }
2324    }
2325    if let Some(value) = class.strip_prefix("gap-x-") {
2326        return parse_tailwind_spacing(value, &|px| {
2327            let mut props = CssProps::new();
2328            props.insert("column-gap".into(), json!(format!("{}px", px)));
2329            props
2330        });
2331    }
2332    if let Some(value) = class.strip_prefix("gap-y-") {
2333        return parse_tailwind_spacing(value, &|px| {
2334            let mut props = CssProps::new();
2335            props.insert("row-gap".into(), json!(format!("{}px", px)));
2336            props
2337        });
2338    }
2339    // Space utilities (space-x-*, space-y-*)
2340    if let Some(value) = class.strip_prefix("space-x-") {
2341        return parse_tailwind_spacing(value, &|px| {
2342            let mut props = CssProps::new();
2343            // In CSS, this is typically done with :not(:last-child) selector
2344            // For now, we'll set it as a custom property that can be used
2345            props.insert("--space-x".into(), json!(format!("{}px", px)));
2346            props
2347        });
2348    }
2349    if let Some(value) = class.strip_prefix("space-y-") {
2350        return parse_tailwind_spacing(value, &|px| {
2351            let mut props = CssProps::new();
2352            props.insert("--space-y".into(), json!(format!("{}px", px)));
2353            props
2354        });
2355    }
2356    // Font weight utilities
2357    match class {
2358        "font-thin" => {
2359            let mut p = CssProps::new();
2360            p.insert("font-weight".into(), json!("100"));
2361            return Some(p);
2362        }
2363        "font-extralight" => {
2364            let mut p = CssProps::new();
2365            p.insert("font-weight".into(), json!("200"));
2366            return Some(p);
2367        }
2368        "font-light" => {
2369            let mut p = CssProps::new();
2370            p.insert("font-weight".into(), json!("300"));
2371            return Some(p);
2372        }
2373        "font-normal" => {
2374            let mut p = CssProps::new();
2375            p.insert("font-weight".into(), json!("400"));
2376            return Some(p);
2377        }
2378        "font-medium" => {
2379            let mut p = CssProps::new();
2380            p.insert("font-weight".into(), json!("500"));
2381            return Some(p);
2382        }
2383        "font-semibold" => {
2384            let mut p = CssProps::new();
2385            p.insert("font-weight".into(), json!("600"));
2386            return Some(p);
2387        }
2388        "font-bold" => {
2389            let mut p = CssProps::new();
2390            p.insert("font-weight".into(), json!("700"));
2391            return Some(p);
2392        }
2393        "font-extrabold" => {
2394            let mut p = CssProps::new();
2395            p.insert("font-weight".into(), json!("800"));
2396            return Some(p);
2397        }
2398        "font-black" => {
2399            let mut p = CssProps::new();
2400            p.insert("font-weight".into(), json!("900"));
2401            return Some(p);
2402        }
2403        _ => {}
2404    }
2405    // Font family utilities
2406    match class {
2407        "font-sans" => {
2408            let mut p = CssProps::new();
2409            p.insert(
2410                "font-family".into(),
2411                json!("system-ui, -apple-system, sans-serif"),
2412            );
2413            return Some(p);
2414        }
2415        "font-serif" => {
2416            let mut p = CssProps::new();
2417            p.insert("font-family".into(), json!("Georgia, serif"));
2418            return Some(p);
2419        }
2420        "font-mono" => {
2421            let mut p = CssProps::new();
2422            p.insert("font-family".into(), json!("ui-monospace, monospace"));
2423            return Some(p);
2424        }
2425        _ => {}
2426    }
2427    // Text size utilities
2428    match class {
2429        "text-xs" => {
2430            let mut p = CssProps::new();
2431            p.insert("font-size".into(), json!("12px"));
2432            p.insert("line-height".into(), json!("16px"));
2433            return Some(p);
2434        }
2435        "text-sm" => {
2436            let mut p = CssProps::new();
2437            p.insert("font-size".into(), json!("14px"));
2438            p.insert("line-height".into(), json!("20px"));
2439            return Some(p);
2440        }
2441        "text-base" => {
2442            let mut p = CssProps::new();
2443            p.insert("font-size".into(), json!("16px"));
2444            p.insert("line-height".into(), json!("24px"));
2445            return Some(p);
2446        }
2447        "text-lg" => {
2448            let mut p = CssProps::new();
2449            p.insert("font-size".into(), json!("18px"));
2450            p.insert("line-height".into(), json!("28px"));
2451            return Some(p);
2452        }
2453        "text-xl" => {
2454            let mut p = CssProps::new();
2455            p.insert("font-size".into(), json!("20px"));
2456            p.insert("line-height".into(), json!("28px"));
2457            return Some(p);
2458        }
2459        "text-2xl" => {
2460            let mut p = CssProps::new();
2461            p.insert("font-size".into(), json!("24px"));
2462            p.insert("line-height".into(), json!("32px"));
2463            return Some(p);
2464        }
2465        "text-3xl" => {
2466            let mut p = CssProps::new();
2467            p.insert("font-size".into(), json!("30px"));
2468            p.insert("line-height".into(), json!("36px"));
2469            return Some(p);
2470        }
2471        "text-4xl" => {
2472            let mut p = CssProps::new();
2473            p.insert("font-size".into(), json!("36px"));
2474            p.insert("line-height".into(), json!("40px"));
2475            return Some(p);
2476        }
2477        "text-5xl" => {
2478            let mut p = CssProps::new();
2479            p.insert("font-size".into(), json!("48px"));
2480            p.insert("line-height".into(), json!("1"));
2481            return Some(p);
2482        }
2483        "text-6xl" => {
2484            let mut p = CssProps::new();
2485            p.insert("font-size".into(), json!("60px"));
2486            p.insert("line-height".into(), json!("1"));
2487            return Some(p);
2488        }
2489        _ => {}
2490    }
2491    // Text alignment
2492    match class {
2493        "text-left" => {
2494            let mut p = CssProps::new();
2495            p.insert("text-align".into(), json!("left"));
2496            return Some(p);
2497        }
2498        "text-center" => {
2499            let mut p = CssProps::new();
2500            p.insert("text-align".into(), json!("center"));
2501            return Some(p);
2502        }
2503        "text-right" => {
2504            let mut p = CssProps::new();
2505            p.insert("text-align".into(), json!("right"));
2506            return Some(p);
2507        }
2508        "text-justify" => {
2509            let mut p = CssProps::new();
2510            p.insert("text-align".into(), json!("justify"));
2511            return Some(p);
2512        }
2513        _ => {}
2514    }
2515    // Overflow utilities
2516    match class {
2517        "overflow-auto" => {
2518            let mut p = CssProps::new();
2519            p.insert("overflow".into(), json!("auto"));
2520            return Some(p);
2521        }
2522        "overflow-hidden" => {
2523            let mut p = CssProps::new();
2524            p.insert("overflow".into(), json!("hidden"));
2525            return Some(p);
2526        }
2527        "overflow-visible" => {
2528            let mut p = CssProps::new();
2529            p.insert("overflow".into(), json!("visible"));
2530            return Some(p);
2531        }
2532        "overflow-scroll" => {
2533            let mut p = CssProps::new();
2534            p.insert("overflow".into(), json!("scroll"));
2535            return Some(p);
2536        }
2537        "overflow-x-auto" => {
2538            let mut p = CssProps::new();
2539            p.insert("overflow-x".into(), json!("auto"));
2540            return Some(p);
2541        }
2542        "overflow-x-hidden" => {
2543            let mut p = CssProps::new();
2544            p.insert("overflow-x".into(), json!("hidden"));
2545            return Some(p);
2546        }
2547        "overflow-x-scroll" => {
2548            let mut p = CssProps::new();
2549            p.insert("overflow-x".into(), json!("scroll"));
2550            return Some(p);
2551        }
2552        "overflow-y-auto" => {
2553            let mut p = CssProps::new();
2554            p.insert("overflow-y".into(), json!("auto"));
2555            return Some(p);
2556        }
2557        "overflow-y-hidden" => {
2558            let mut p = CssProps::new();
2559            p.insert("overflow-y".into(), json!("hidden"));
2560            return Some(p);
2561        }
2562        "overflow-y-scroll" => {
2563            let mut p = CssProps::new();
2564            p.insert("overflow-y".into(), json!("scroll"));
2565            return Some(p);
2566        }
2567        _ => {}
2568    }
2569    // Opacity utilities
2570    if let Some(value) = class.strip_prefix("opacity-") {
2571        if let Ok(opacity) = value.parse::<f32>() {
2572            let mut p = CssProps::new();
2573            p.insert("opacity".into(), json!(opacity / 100.0));
2574            return Some(p);
2575        }
2576    }
2577    // Shadow utilities (basic cross-platform support)
2578    match class {
2579        "shadow-sm" => {
2580            let mut p = CssProps::new();
2581            p.insert(
2582                "box-shadow".into(),
2583                json!("0 1px 2px 0 rgba(0, 0, 0, 0.05)"),
2584            );
2585            return Some(p);
2586        }
2587        "shadow" => {
2588            let mut p = CssProps::new();
2589            p.insert(
2590                "box-shadow".into(),
2591                json!("0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)"),
2592            );
2593            return Some(p);
2594        }
2595        "shadow-md" => {
2596            let mut p = CssProps::new();
2597            p.insert(
2598                "box-shadow".into(),
2599                json!("0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)"),
2600            );
2601            return Some(p);
2602        }
2603        "shadow-lg" => {
2604            let mut p = CssProps::new();
2605            p.insert(
2606                "box-shadow".into(),
2607                json!("0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)"),
2608            );
2609            return Some(p);
2610        }
2611        "shadow-xl" => {
2612            let mut p = CssProps::new();
2613            p.insert(
2614                "box-shadow".into(),
2615                json!("0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)"),
2616            );
2617            return Some(p);
2618        }
2619        "shadow-2xl" => {
2620            let mut p = CssProps::new();
2621            p.insert(
2622                "box-shadow".into(),
2623                json!("0 25px 50px -12px rgba(0, 0, 0, 0.25)"),
2624            );
2625            return Some(p);
2626        }
2627        "shadow-none" => {
2628            let mut p = CssProps::new();
2629            p.insert("box-shadow".into(), json!("none"));
2630            return Some(p);
2631        }
2632        _ => {}
2633    }
2634    // Parse arbitrary values like bg-[var(--primary)], text-[#ff0000], etc.
2635    if let Some(arb_value) = parse_arbitrary_value(class) {
2636        return Some(arb_value);
2637    }
2638    // text-{color}-{shade}
2639    if let Some(rest) = class.strip_prefix("text-") {
2640        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
2641            let mut props = CssProps::new();
2642            props.insert("color".into(), json!(hex));
2643            return Some(props);
2644        }
2645    }
2646    // bg-{color}-{shade}
2647    if let Some(rest) = class.strip_prefix("bg-") {
2648        match rest {
2649            "white" => {
2650                let mut p = CssProps::new();
2651                p.insert("background-color".into(), json!("#ffffff"));
2652                return Some(p);
2653            }
2654            "black" => {
2655                let mut p = CssProps::new();
2656                p.insert("background-color".into(), json!("#000000"));
2657                return Some(p);
2658            }
2659            "transparent" => {
2660                let mut p = CssProps::new();
2661                p.insert("background-color".into(), json!("#00000000"));
2662                return Some(p);
2663            }
2664            _ => {}
2665        }
2666        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
2667            let mut props = CssProps::new();
2668            props.insert("background-color".into(), json!(hex));
2669            return Some(props);
2670        }
2671    }
2672    // divide-{color}-{shade} (sets border-color for child dividers)
2673    if let Some(rest) = class.strip_prefix("divide-") {
2674        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
2675            let mut props = CssProps::new();
2676            props.insert("border-color".into(), json!(hex));
2677            return Some(props);
2678        }
2679    }
2680    if class == "border" {
2681        return Some(border_props(None, 1, vars));
2682    }
2683    if let Some(rest) = class.strip_prefix("border-") {
2684        // Parse border-* classes
2685        // Possible patterns:
2686        // - border-{color}-{shade} → border-color
2687        // - border-{side}-{color}-{shade} → border-{side}-color
2688        // - border-{width} → border-width
2689        // - border-{side}-{width} → border-{side}-width
2690
2691        let parts: Vec<&str> = rest.split('-').collect();
2692
2693        // Check if first part is a directional side (t, b, l, r, x, y)
2694        let valid_sides = ["t", "b", "l", "r", "x", "y"];
2695        let (side, color_or_width_parts) = if parts.len() > 1 && valid_sides.contains(&parts[0]) {
2696            (Some(parts[0]), &parts[1..])
2697        } else {
2698            (None, &parts[..])
2699        };
2700
2701        // Now check if remaining parts form a color-shade pattern
2702        if color_or_width_parts.len() == 2 {
2703            // Could be color-shade like "blue-500"
2704            let color_shade = color_or_width_parts.join("-");
2705            if let Some(hex) = get_tailwind_color_with_vars(&color_shade, vars) {
2706                let mut props = CssProps::new();
2707                let prop_name = if let Some(s) = side {
2708                    format!("border-{}-color", s)
2709                } else {
2710                    "border-color".to_string()
2711                };
2712                props.insert(prop_name, json!(hex));
2713                return Some(props);
2714            }
2715        }
2716
2717        // Check for simple color without shade (single word color like "black", "white")
2718        if color_or_width_parts.len() == 1 {
2719            let potential_color = format!("{}-500", color_or_width_parts[0]);
2720            if let Some(hex) = get_tailwind_color_with_vars(&potential_color, vars) {
2721                let mut props = CssProps::new();
2722                let prop_name = if let Some(s) = side {
2723                    format!("border-{}-color", s)
2724                } else {
2725                    "border-color".to_string()
2726                };
2727                props.insert(prop_name, json!(hex));
2728                return Some(props);
2729            }
2730        }
2731
2732        // Otherwise, check for width (e.g., border-2, border-t-4)
2733        if color_or_width_parts.len() == 1 {
2734            if let Ok(width) = color_or_width_parts[0].parse::<i32>() {
2735                return Some(border_props(side, width, vars));
2736            }
2737        }
2738    }
2739    // rounded* (border-radius)
2740    if class == "rounded" {
2741        return Some(rounded_props(None, Some("md")));
2742    }
2743    if let Some(sz) = class.strip_prefix("rounded-") {
2744        return Some(rounded_props(None, Some(sz)));
2745    }
2746    for &(pref, side) in &[
2747        ("rounded-t", "t"),
2748        ("rounded-b", "b"),
2749        ("rounded-l", "l"),
2750        ("rounded-r", "r"),
2751    ] {
2752        if class == pref {
2753            return Some(rounded_props(Some(side), Some("md")));
2754        }
2755        if let Some(sz) = class.strip_prefix(&(pref.to_string() + "-")) {
2756            return Some(rounded_props(Some(side), Some(sz)));
2757        }
2758    }
2759    // cursor-*
2760    if let Some(cur) = class.strip_prefix("cursor-") {
2761        let mut props = CssProps::new();
2762        props.insert(
2763            "cursor".into(),
2764            json!(match cur {
2765                "pointer" => "pointer",
2766                "default" => "default",
2767                "text" => "text",
2768                "move" => "move",
2769                "wait" => "wait",
2770                "not-allowed" => "not-allowed",
2771                other => other,
2772            }),
2773        );
2774        return Some(props);
2775    }
2776    // transition*
2777    if class == "transition" || class == "transition-all" {
2778        let mut props = CssProps::new();
2779        props.insert("transition-property".into(), json!("all"));
2780        props.insert("transition-duration".into(), json!("150ms"));
2781        props.insert("transition-timing-function".into(), json!("ease-in-out"));
2782        return Some(props);
2783    }
2784    if class == "transition-none" {
2785        let mut props = CssProps::new();
2786        props.insert("transition-property".into(), json!("none"));
2787        props.insert("transition-duration".into(), json!("0ms"));
2788        return Some(props);
2789    }
2790    if let Some(rest) = class.strip_prefix("transition-") {
2791        // e.g., transition-colors → limit property; keep default duration/ease
2792        let mut props = CssProps::new();
2793        let property = match rest {
2794            "colors" => "color, background-color, border-color, fill, stroke",
2795            "opacity" => "opacity",
2796            "transform" => "transform",
2797            "shadow" => "box-shadow",
2798            other => other,
2799        };
2800        props.insert("transition-property".into(), json!(property));
2801        props.insert("transition-duration".into(), json!("150ms"));
2802        props.insert("transition-timing-function".into(), json!("ease-in-out"));
2803        return Some(props);
2804    }
2805    // width utilities: w-*, w-full, w-screen, w-min, w-max (treat min/max as auto), w-px
2806    if let Some(val) = class.strip_prefix("w-") {
2807        return width_like_props("width", val);
2808    }
2809    if let Some(val) = class.strip_prefix("min-w-") {
2810        return width_like_props("min-width", val);
2811    }
2812    if let Some(val) = class.strip_prefix("max-w-") {
2813        return width_like_props("max-width", val);
2814    }
2815    // Height utilities
2816    if let Some(val) = class.strip_prefix("h-") {
2817        return width_like_props("height", val);
2818    }
2819    if let Some(val) = class.strip_prefix("min-h-") {
2820        return width_like_props("min-height", val);
2821    }
2822    if let Some(val) = class.strip_prefix("max-h-") {
2823        return width_like_props("max-height", val);
2824    }
2825    None
2826}
2827
2828fn parse_tailwind_spacing<F>(value: &str, builder: &F) -> Option<CssProps>
2829where
2830    F: Fn(i32) -> CssProps,
2831{
2832    if let Ok(n) = value.parse::<i32>() {
2833        let px = n * 4;
2834        return Some(builder(px));
2835    }
2836    None
2837}
2838
2839fn padding_props(keys: &[&str], px_value: i32) -> CssProps {
2840    let mut props = CssProps::new();
2841    let val = format!("{}px", px_value);
2842    for key in keys {
2843        props.insert((*key).into(), json!(&val));
2844    }
2845    props
2846}
2847
2848fn margin_props(keys: &[&str], px_value: i32) -> CssProps {
2849    let mut props = CssProps::new();
2850    let val = format!("{}px", px_value);
2851    for key in keys {
2852        props.insert((*key).into(), json!(&val));
2853    }
2854    props
2855}
2856
2857fn border_props(side: Option<&str>, width: i32, _vars: &IndexMap<String, String>) -> CssProps {
2858    let mut props = CssProps::new();
2859    let width_str = format!("{}px", width);
2860    match side {
2861        None => {
2862            props.insert("border-width".into(), json!(&width_str));
2863        }
2864        Some("t") => {
2865            props.insert("border-top-width".into(), json!(&width_str));
2866        }
2867        Some("b") => {
2868            props.insert("border-bottom-width".into(), json!(&width_str));
2869        }
2870        Some("l") => {
2871            props.insert("border-left-width".into(), json!(&width_str));
2872        }
2873        Some("r") => {
2874            props.insert("border-right-width".into(), json!(&width_str));
2875        }
2876        Some("x") => {
2877            props.insert("border-left-width".into(), json!(&width_str));
2878            props.insert("border-right-width".into(), json!(&width_str));
2879        }
2880        Some("y") => {
2881            props.insert("border-top-width".into(), json!(&width_str));
2882            props.insert("border-bottom-width".into(), json!(&width_str));
2883        }
2884        _ => {
2885            props.insert("border-width".into(), json!(&width_str));
2886        }
2887    };
2888    props.insert("border-color".into(), json!("var(border)"));
2889    props.insert("border-style".into(), json!("solid"));
2890    props
2891}
2892
2893fn rounded_props(side: Option<&str>, size: Option<&str>) -> CssProps {
2894    let mut props = CssProps::new();
2895    let px = match size.unwrap_or("md") {
2896        "none" => 0,
2897        "sm" => 2,
2898        "md" => 4,
2899        "lg" => 8,
2900        "xl" => 12,
2901        "2xl" => 16,
2902        "3xl" => 24,
2903        "full" => 9999,
2904        s => s.parse::<i32>().unwrap_or(4),
2905    };
2906    let v = json!(format!("{}px", px));
2907    match side {
2908        None => {
2909            props.insert("border-radius".into(), v);
2910        }
2911        Some("t") => {
2912            props.insert("border-top-left-radius".into(), v.clone());
2913            props.insert("border-top-right-radius".into(), v);
2914        }
2915        Some("b") => {
2916            props.insert("border-bottom-left-radius".into(), v.clone());
2917            props.insert("border-bottom-right-radius".into(), v);
2918        }
2919        Some("l") => {
2920            props.insert("border-top-left-radius".into(), v.clone());
2921            props.insert("border-bottom-left-radius".into(), v);
2922        }
2923        Some("r") => {
2924            props.insert("border-top-right-radius".into(), v.clone());
2925            props.insert("border-bottom-right-radius".into(), v);
2926        }
2927        _ => {
2928            props.insert("border-radius".into(), v);
2929        }
2930    }
2931    props
2932}
2933
2934fn width_like_props(prop: &str, token: &str) -> Option<CssProps> {
2935    let mut props = CssProps::new();
2936    let value = match token {
2937        "full" => Some("100%".to_string()),
2938        "screen" => Some(if prop == "width" { "100vw" } else { "100vh" }.to_string()),
2939        "min" => Some("min-content".to_string()),
2940        "max" => Some("max-content".to_string()),
2941        "fit" => Some("fit-content".to_string()),
2942        "auto" => Some("auto".to_string()),
2943        "px" => Some("1px".to_string()),
2944        other => {
2945            // numeric scale n => n*4px, fraction e.g., 1/2 => 50%
2946            if let Some((a, b)) = other.split_once('/') {
2947                if let (Ok(na), Ok(nb)) = (a.parse::<f64>(), b.parse::<f64>()) {
2948                    let pct = (na / nb) * 100.0;
2949                    Some(format!("{}%", trim_trailing_zeros(pct)))
2950                } else {
2951                    None
2952                }
2953            } else if let Ok(n) = other.parse::<i32>() {
2954                Some(format!("{}px", n * 4))
2955            } else {
2956                None
2957            }
2958        }
2959    }?;
2960    props.insert(prop.into(), json!(value));
2961    Some(props)
2962}
2963
2964fn trim_trailing_zeros(num: f64) -> String {
2965    let mut s = format!("{:.6}", num);
2966    while s.contains('.') && s.ends_with('0') {
2967        s.pop();
2968    }
2969    if s.ends_with('.') {
2970        s.pop();
2971    }
2972    s
2973}
2974
2975// ---------------- Tailwind subset ----------------
2976
2977// static RE_NUM: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?P<prefix>(hover:)?(xs:|sm:|md:|lg:|xl:)*)?(?P<base>.+)$").unwrap());
2978
2979fn css_escape_class(class: &str) -> String {
2980    class.replace(':', "\\:")
2981}
2982
2983fn class_to_selector(class: &str) -> String {
2984    let (_bp, hover, base) = parse_prefixed_class(class);
2985    if hover {
2986        format!(".{}:hover", css_escape_class(&base))
2987    } else {
2988        format!(".{}", css_escape_class(&base))
2989    }
2990}
2991
2992// ------------- helpers for CSS output of media selectors -------------
2993
2994/// Flatten CSS with potential selectors that include media prelude.
2995/// This simple post-processor merges entries that use the special selector format
2996/// "@media (min-width: X) {<sel>" where we will close the block at the end.
2997/// We group by media and inside concatenate selectors.
2998pub fn post_process_css(
2999    raw_rules: &[(String, CssProps)],
3000    vars: &IndexMap<String, String>,
3001) -> String {
3002    // Group into normal rules and media rules
3003    let mut normal = vec![];
3004    let mut media_map: IndexMap<String, Vec<(String, CssProps)>> = IndexMap::new();
3005    for (sel, props) in raw_rules.iter() {
3006        if let Some((media, inner)) = sel.split_once('{') {
3007            if media.trim_start().starts_with("@media ") && inner.ends_with('}') {
3008                let inner_sel = inner.trim_end_matches('}').to_string();
3009                media_map
3010                    .entry(media.trim().to_string())
3011                    .or_default()
3012                    .push((inner_sel, props.clone()));
3013                continue;
3014            }
3015        }
3016        normal.push((sel.clone(), props.clone()));
3017    }
3018    let mut out = String::new();
3019    for (sel, props) in normal {
3020        out.push_str(&sel);
3021        out.push('{');
3022        out.push_str(&css_props_string(&props, vars));
3023        out.push_str("}\n");
3024    }
3025    for (media, entries) in media_map {
3026        out.push_str(&media);
3027        out.push('{');
3028        for (sel, props) in entries {
3029            out.push_str(&sel);
3030            out.push('{');
3031            out.push_str(&css_props_string(&props, vars));
3032            out.push_str("}");
3033        }
3034        out.push_str("}\n");
3035    }
3036    out
3037}
3038
3039// -------- Prefix parsing (hover:, breakpoint:) --------
3040
3041fn parse_prefixed_class(class: &str) -> (Option<String>, bool, String) {
3042    // Split by ':' to find prefixes like md:hover:block
3043    let parts: Vec<&str> = class.split(':').collect();
3044    if parts.len() == 1 {
3045        return (None, false, class.to_string());
3046    }
3047    let mut bp: Option<String> = None;
3048    let mut hover = false;
3049    for &p in &parts[..parts.len() - 1] {
3050        match p {
3051            "hover" => hover = true,
3052            "xs" | "sm" | "md" | "lg" | "xl" => bp = Some(p.to_string()),
3053            _ => {}
3054        }
3055    }
3056    let base = parts.last().unwrap().to_string();
3057    (bp, hover, base)
3058}
3059
3060fn wrap_with_media(selector: &str, bp_key: Option<&str>, bps: &IndexMap<String, String>) -> String {
3061    if let Some(k) = bp_key {
3062        if let Some(val) = bps.get(k) {
3063            return format!("@media (min-width: {}) {{{}}}", val, selector);
3064        }
3065    }
3066    selector.to_string()
3067}
3068
3069/// Get a Tailwind color hex value from a string like "slate-200" or "blue-500"
3070fn get_tailwind_color(color_shade: &str) -> Option<String> {
3071    let parts: Vec<&str> = color_shade.split('-').collect();
3072    if parts.len() != 2 {
3073        return None;
3074    }
3075    let color_name = parts[0];
3076    let shade = parts[1];
3077
3078    // First try standard Tailwind colors
3079    if let Some(hex) = TAILWIND_COLORS
3080        .get(color_name)
3081        .and_then(|shades| shades.get(shade))
3082    {
3083        return Some(hex.to_string());
3084    }
3085
3086    None
3087}
3088
3089fn get_tailwind_color_with_vars(
3090    color_shade: &str,
3091    vars: &IndexMap<String, String>,
3092) -> Option<String> {
3093    // First try standard Tailwind colors
3094    if let Some(hex) = get_tailwind_color(color_shade) {
3095        return Some(hex);
3096    }
3097
3098    // If not found, check if color_shade matches a variable
3099    // Theme variables are flattened with "." separators, e.g., "colors.primary"
3100    // So we need to check:
3101    // 1. Direct match: "primary" → look for "primary" in vars
3102    // 2. Color namespace: "primary" → look for "colors.primary" in vars (plural)
3103    // 3. Color namespace: "primary" → look for "color.primary" in vars (singular)
3104    // 4. With shade: "primary-500" → look for "colors.primary" or "colors.primary-500" in vars
3105
3106    if let Some(val) = vars.get(color_shade) {
3107        return Some(val.clone());
3108    }
3109
3110    // Try with "colors." namespace prefix (plural - HookRenderer uses this)
3111    if let Some(val) = vars.get(&format!("colors.{}", color_shade)) {
3112        return Some(val.clone());
3113    }
3114
3115    // Try with "color." namespace prefix (singular - fallback)
3116    if let Some(val) = vars.get(&format!("color.{}", color_shade)) {
3117        return Some(val.clone());
3118    }
3119
3120    // Handle cases where the color name doesn't have a shade but we need to look for a variable
3121    // e.g., "primary" (from bg-primary) → look for "color.primary"
3122    let parts: Vec<&str> = color_shade.split('-').collect();
3123    if parts.len() >= 1 {
3124        let color_name = parts[0];
3125
3126        // Try direct variable
3127        if let Some(val) = vars.get(color_name) {
3128            return Some(val.clone());
3129        }
3130
3131        // Try with "color." namespace
3132        if let Some(val) = vars.get(&format!("color.{}", color_name)) {
3133            return Some(val.clone());
3134        }
3135    }
3136
3137    None
3138}
3139
3140/// Parse arbitrary values like bg-[var(--primary)], text-[#ff0000], border-[hsl(200,50%,50%)]
3141fn parse_arbitrary_value(class: &str) -> Option<CssProps> {
3142    // Match pattern: prefix-[value]
3143    if let Some(bracket_start) = class.find('[') {
3144        if !class.ends_with(']') {
3145            return None;
3146        }
3147        let prefix = &class[..bracket_start];
3148        let value = &class[bracket_start + 1..class.len() - 1];
3149
3150        let mut props = CssProps::new();
3151        match prefix {
3152            "bg" => {
3153                props.insert("background-color".into(), json!(value));
3154                return Some(props);
3155            }
3156            "text" => {
3157                props.insert("color".into(), json!(value));
3158                return Some(props);
3159            }
3160            "border" => {
3161                props.insert("border-color".into(), json!(value));
3162                return Some(props);
3163            }
3164            "divide" => {
3165                props.insert("border-color".into(), json!(value));
3166                return Some(props);
3167            }
3168            _ => return None,
3169        }
3170    }
3171    None
3172}
3173
3174// re-export minimal API for CLI
3175pub mod api {
3176    pub use super::{SelectorStyles, State};
3177}
3178
3179#[cfg(test)]
3180mod tests {
3181    use super::*;
3182
3183    #[test]
3184    fn default_theme_has_p2() {
3185        let mut st = State::new_default();
3186        st.register_tailwind_classes(["p-2".to_string()]);
3187        let css = st.css_for_web();
3188        assert!(css.contains(".p-2{"));
3189        assert!(css.contains("padding:8px"));
3190    }
3191
3192    #[test]
3193    fn android_conversion() {
3194        let mut st = State::new_default();
3195        // Add a theme with button styles
3196        let mut styles = IndexMap::new();
3197        let mut button_props = IndexMap::new();
3198        button_props.insert("backgroundColor".to_string(), json!("#007bff"));
3199        styles.insert("button".to_string(), button_props);
3200        st.add_theme("default", styles);
3201        st.set_theme("default").ok();
3202
3203        let out = st.android_styles_for("button", &[]);
3204        assert!(out.get("backgroundColor").is_some());
3205    }
3206
3207    #[test]
3208    fn embedded_defaults_and_version() {
3209        // Test that we can create a state and add a theme with variables
3210        let mut st = State::default_state();
3211        st.add_theme("default", IndexMap::new());
3212        st.set_theme("default").ok();
3213
3214        let mut vars = IndexMap::new();
3215        vars.insert("primary".to_string(), "#007bff".to_string());
3216        st.set_variables(vars);
3217
3218        assert!(st.themes.contains_key("default"));
3219        let def = st.themes.get("default").unwrap();
3220        assert!(def.variables.contains_key("primary"));
3221
3222        // Version should compile and be non-empty (env! evaluated at compile-time)
3223        // Note: get_version() is only available for wasm32 target
3224        #[cfg(target_arch = "wasm32")]
3225        {
3226            let v = get_version();
3227            assert!(!v.is_empty());
3228        }
3229    }
3230
3231    #[test]
3232    fn border_color_with_direction() {
3233        let mut st = State::new_default();
3234
3235        // Test border-b-blue-500 (border-bottom with blue color shade 500)
3236        st.register_tailwind_classes(["border-b-blue-500".to_string()]);
3237        let css = st.css_for_web();
3238        assert!(css.contains(".border-b-blue-500{"));
3239        assert!(
3240            css.contains("border-bottom-color:#3b82f6") || css.contains("border-b-color:#3b82f6")
3241        );
3242
3243        // Test border-t-red-500
3244        st.register_tailwind_classes(["border-t-red-500".to_string()]);
3245        let css = st.css_for_web();
3246        assert!(css.contains(".border-t-red-500{"));
3247
3248        // Test border-blue-500 (all borders)
3249        st.register_tailwind_classes(["border-blue-500".to_string()]);
3250        let css = st.css_for_web();
3251        assert!(css.contains(".border-blue-500{"));
3252        assert!(css.contains("border-color:#3b82f6"));
3253    }
3254
3255    #[test]
3256    fn multiple_selectors_support() {
3257        let mut st = State::new_default();
3258        let mut selectors = SelectorStyles::new();
3259        let mut props = CssProps::new();
3260        props.insert("color".to_string(), serde_json::json!("#ff0000"));
3261        selectors.insert("h1, h2, h3".to_string(), props);
3262
3263        st.add_theme("test", selectors);
3264        st.set_theme("test").ok();
3265
3266        // Test h1
3267        let android = st.android_styles_for("h1", &[]);
3268        assert_eq!(
3269            android.get("color").and_then(|v| v.as_str()),
3270            Some("#ff0000"),
3271            "h1 should have red color"
3272        );
3273
3274        // Test h2
3275        let android = st.android_styles_for("h2", &[]);
3276        assert_eq!(
3277            android.get("color").and_then(|v| v.as_str()),
3278            Some("#ff0000"),
3279            "h2 should have red color"
3280        );
3281
3282        // Test h3
3283        let android = st.android_styles_for("h3", &[]);
3284        assert_eq!(
3285            android.get("color").and_then(|v| v.as_str()),
3286            Some("#ff0000"),
3287            "h3 should have red color"
3288        );
3289    }
3290
3291    #[test]
3292    fn multiple_selectors_classes() {
3293        let mut st = State::new_default();
3294        let mut selectors = SelectorStyles::new();
3295        let mut props = CssProps::new();
3296        props.insert("padding".to_string(), serde_json::json!("10px"));
3297        selectors.insert(".btn, .link".to_string(), props);
3298
3299        st.add_theme("test", selectors);
3300        st.set_theme("test").ok();
3301
3302        // Test .btn
3303        let android = st.android_styles_for("div", &["btn".to_string()]);
3304        assert_eq!(
3305            android.get("padding").and_then(|v| v.as_f64()),
3306            Some(10.0),
3307            ".btn should have 10px padding"
3308        );
3309
3310        // Test .link
3311        let android = st.android_styles_for("div", &["link".to_string()]);
3312        assert_eq!(
3313            android.get("padding").and_then(|v| v.as_f64()),
3314            Some(10.0),
3315            ".link should have 10px padding"
3316        );
3317    }
3318
3319    #[test]
3320    fn border_width_with_direction() {
3321        let mut st = State::new_default();
3322
3323        // Test border-b-2 (border-bottom width 2px)
3324        st.register_tailwind_classes(["border-b-2".to_string()]);
3325        let css = st.css_for_web();
3326        assert!(css.contains(".border-b-2{"));
3327        assert!(css.contains("border-bottom-width:2px"));
3328
3329        // Test border-2 (all borders width 2px)
3330        st.register_tailwind_classes(["border-2".to_string()]);
3331        let css = st.css_for_web();
3332        assert!(css.contains(".border-2{"));
3333        assert!(css.contains("border-width:2px"));
3334    }
3335
3336    #[test]
3337    fn display_flex_hover_breakpoint() {
3338        let mut st = State::new_default();
3339
3340        // Set up theme with breakpoints
3341        st.add_theme("default", IndexMap::new());
3342        st.set_theme("default").ok();
3343
3344        let mut breakpoints = IndexMap::new();
3345        breakpoints.insert("md".to_string(), "768px".to_string());
3346        st.set_breakpoints(breakpoints);
3347
3348        st.register_tailwind_classes([
3349            "block".into(),
3350            "inline-flex".into(),
3351            "hidden".into(),
3352            "md:flex".into(),
3353            "md:hover:block".into(),
3354        ]);
3355        let css = st.css_for_web();
3356        assert!(css.contains(".block{"));
3357        assert!(css.contains("display:block"));
3358        assert!(css.contains(".inline-flex{"));
3359        assert!(css.contains("display:inline-flex"));
3360        assert!(css.contains(".hidden{"));
3361        assert!(css.contains("display:none"));
3362        // breakpoint rule
3363        assert!(css.contains("@media (min-width: 768px)"));
3364        assert!(css.contains(".flex{display:flex"));
3365        // hover inside media (substring check)
3366        assert!(css.contains(":hover{display:block"));
3367
3368        // Android resolves base class styles ignoring prefixes
3369        let android = st.android_styles_for("div", &["md:flex".into()]);
3370        assert_eq!(
3371            android.get("display").and_then(|v| v.as_str()),
3372            Some("flex")
3373        );
3374    }
3375
3376    #[test]
3377    fn parse_var_references_basic() {
3378        // Test basic var() parsing
3379        let refs = parse_var_references("var(color)");
3380        assert_eq!(refs.len(), 1);
3381        assert_eq!(refs[0].2, "color");
3382        assert_eq!(refs[0].0, 0); // start
3383        assert_eq!(refs[0].1, 10); // end (exclusive, so "var(color)" is 0..10)
3384
3385        // Test var() with -- prefix
3386        let refs = parse_var_references("var(--primary)");
3387        assert_eq!(refs.len(), 1);
3388        assert_eq!(refs[0].2, "primary");
3389
3390        // Test multiple var() references
3391        let refs = parse_var_references("var(--color) and var(size)");
3392        assert_eq!(refs.len(), 2);
3393        assert_eq!(refs[0].2, "color");
3394        assert_eq!(refs[1].2, "size");
3395
3396        // Test with whitespace
3397        let refs = parse_var_references("var( --spacing )");
3398        assert_eq!(refs.len(), 1);
3399        assert_eq!(refs[0].2, "spacing");
3400
3401        // Test with dots and dashes
3402        let refs = parse_var_references("var(color.primary-500)");
3403        assert_eq!(refs.len(), 1);
3404        assert_eq!(refs[0].2, "color.primary-500");
3405
3406        // Test no matches
3407        let refs = parse_var_references("no variables here");
3408        assert_eq!(refs.len(), 0);
3409
3410        // Test incomplete var(
3411        let refs = parse_var_references("var(");
3412        assert_eq!(refs.len(), 0);
3413
3414        // Test var without closing
3415        let refs = parse_var_references("var(color");
3416        assert_eq!(refs.len(), 0);
3417    }
3418
3419    #[test]
3420    fn resolve_vars_basic() {
3421        let mut vars = IndexMap::new();
3422        vars.insert("primary".to_string(), "#ff0000".to_string());
3423        vars.insert("spacing".to_string(), "8px".to_string());
3424        vars.insert("color.blue".to_string(), "#0000ff".to_string());
3425
3426        // Test basic resolution
3427        assert_eq!(resolve_vars("var(--primary)", &vars), "#ff0000");
3428        assert_eq!(resolve_vars("var(primary)", &vars), "#ff0000");
3429        assert_eq!(resolve_vars("var( --primary )", &vars), "#ff0000");
3430
3431        // Test multiple vars
3432        assert_eq!(
3433            resolve_vars("var(--primary) var(--spacing)", &vars),
3434            "#ff0000 8px"
3435        );
3436
3437        // Test dotted variable names
3438        assert_eq!(resolve_vars("var(--color.blue)", &vars), "#0000ff");
3439
3440        // Test undefined variable (should not replace)
3441        assert_eq!(resolve_vars("var(--undefined)", &vars), "var(--undefined)");
3442
3443        // Test $ prefix syntax
3444        assert_eq!(resolve_vars("$primary", &vars), "#ff0000");
3445
3446        // Test no variables
3447        assert_eq!(resolve_vars("plain text", &vars), "plain text");
3448    }
3449
3450    #[test]
3451    fn resolve_vars_edge_cases() {
3452        let mut vars = IndexMap::new();
3453        vars.insert("a".to_string(), "1".to_string());
3454        vars.insert("b".to_string(), "2".to_string());
3455
3456        // Test adjacent vars
3457        assert_eq!(resolve_vars("var(a)var(b)", &vars), "12");
3458
3459        // Test var in middle of text
3460        assert_eq!(
3461            resolve_vars("prefix var(a) suffix", &vars),
3462            "prefix 1 suffix"
3463        );
3464
3465        // Test empty input
3466        assert_eq!(resolve_vars("", &vars), "");
3467
3468        // Test var with numbers
3469        vars.insert("var123".to_string(), "value".to_string());
3470        assert_eq!(resolve_vars("var(var123)", &vars), "value");
3471
3472        // Test var with underscores
3473        vars.insert("my_var".to_string(), "test".to_string());
3474        assert_eq!(resolve_vars("var(my_var)", &vars), "test");
3475    }
3476
3477    #[test]
3478    fn test_android_scrolling_mapping() {
3479        let mut state = State::default();
3480        state.display_density = 2.0;
3481        state.scaled_density = 2.0;
3482        state.current_theme = "default".to_string();
3483
3484        let mut themes = IndexMap::new();
3485        let mut default_theme = crate::ThemeEntry::default();
3486        default_theme.name = Some("Default".to_string());
3487
3488        let mut overflow_styles = IndexMap::new();
3489        overflow_styles.insert("overflowX".to_string(), serde_json::json!("auto"));
3490        overflow_styles.insert("overflowY".to_string(), serde_json::json!("scroll"));
3491
3492        default_theme
3493            .selectors
3494            .insert(".scroller".to_string(), overflow_styles);
3495        themes.insert("default".to_string(), default_theme);
3496        state.themes = themes;
3497
3498        let styles = state.android_styles_for("div", &vec![".scroller".to_string()]);
3499
3500        assert_eq!(
3501            styles.get("androidScrollHorizontal"),
3502            Some(&serde_json::json!(true))
3503        );
3504        assert_eq!(
3505            styles.get("androidScrollVertical"),
3506            Some(&serde_json::json!(true))
3507        );
3508    }
3509
3510    #[test]
3511    fn android_flex_row_default() {
3512        let st = State::new_default();
3513        // div with flex class should be horizontal (row) on Android
3514        let styles = st.android_styles_for("div", &["flex".to_string()]);
3515        assert_eq!(
3516            styles.get("androidOrientation").and_then(|v| v.as_str()),
3517            Some("horizontal")
3518        );
3519        assert_eq!(
3520            styles.get("flexDirection").and_then(|v| v.as_str()),
3521            Some("row")
3522        );
3523
3524        // div without flex class should be vertical (column) on Android
3525        let styles = st.android_styles_for("div", &[]);
3526        assert_eq!(
3527            styles.get("androidOrientation").and_then(|v| v.as_str()),
3528            Some("vertical")
3529        );
3530        assert_eq!(
3531            styles.get("flexDirection").and_then(|v| v.as_str()),
3532            Some("column")
3533        );
3534    }
3535
3536    #[test]
3537    fn android_gap_orientation_order() {
3538        let st = State::new_default();
3539        let styles = st.android_styles_for("div", &["flex".to_string(), "gap-4".to_string()]);
3540
3541        // Check that androidOrientation comes BEFORE gap in the map
3542        let keys: Vec<&String> = styles.keys().collect();
3543        let orientation_idx = keys
3544            .iter()
3545            .position(|&k| k == "androidOrientation")
3546            .unwrap();
3547        let gap_idx = keys.iter().position(|&k| k == "gap").unwrap();
3548
3549        assert!(
3550            orientation_idx < gap_idx,
3551            "androidOrientation should come before gap for correct layout processing"
3552        );
3553    }
3554
3555    #[test]
3556    fn margin_auto_support() {
3557        let mut st = State::new_default();
3558        st.register_tailwind_classes([
3559            "ml-auto".to_string(),
3560            "mr-auto".to_string(),
3561            "mx-auto".to_string(),
3562        ]);
3563
3564        // Web CSS check
3565        let css = st.css_for_web();
3566        assert!(css.contains("margin-left:auto"));
3567        assert!(css.contains("margin-right:auto"));
3568
3569        // Android check
3570        let styles = st.android_styles_for("div", &["ml-auto".to_string()]);
3571        assert_eq!(
3572            styles.get("marginLeft").and_then(|v| v.as_str()),
3573            Some("auto")
3574        );
3575
3576        let styles = st.android_styles_for("div", &["mx-auto".to_string()]);
3577        assert_eq!(
3578            styles.get("marginLeft").and_then(|v| v.as_str()),
3579            Some("auto")
3580        );
3581        assert_eq!(
3582            styles.get("marginRight").and_then(|v| v.as_str()),
3583            Some("auto")
3584        );
3585    }
3586
3587    #[test]
3588    fn alignment_mapping() {
3589        let st = State::new_default();
3590
3591        // Test Row (default)
3592        let row_styles = st.android_styles_for(
3593            "div",
3594            &[
3595                "flex".to_string(),
3596                "justify-center".to_string(),
3597                "items-center".to_string(),
3598            ],
3599        );
3600        assert_eq!(
3601            row_styles
3602                .get("androidOrientation")
3603                .and_then(|v| v.as_str()),
3604            Some("horizontal")
3605        );
3606        // Row: justify-center (horizontal) + items-center (vertical) -> center
3607        assert_eq!(
3608            row_styles.get("androidGravity").and_then(|v| v.as_str()),
3609            Some("center")
3610        );
3611
3612        // Test Column
3613        let col_styles = st.android_styles_for(
3614            "div",
3615            &[
3616                "flex".to_string(),
3617                "flex-col".to_string(),
3618                "justify-center".to_string(),
3619                "items-center".to_string(),
3620            ],
3621        );
3622        assert_eq!(
3623            col_styles
3624                .get("androidOrientation")
3625                .and_then(|v| v.as_str()),
3626            Some("vertical")
3627        );
3628        // Column: justify-center (vertical) + items-center (horizontal) -> center
3629        assert_eq!(
3630            col_styles.get("androidGravity").and_then(|v| v.as_str()),
3631            Some("center")
3632        );
3633
3634        // Test Row Start/End
3635        let row_start_styles = st.android_styles_for(
3636            "div",
3637            &[
3638                "flex".to_string(),
3639                "justify-start".to_string(),
3640                "items-end".to_string(),
3641            ],
3642        );
3643        assert_eq!(
3644            row_start_styles
3645                .get("androidGravity")
3646                .and_then(|v| v.as_str()),
3647            Some("bottom|start")
3648        );
3649    }
3650
3651    #[test]
3652    fn test_button_bg_override() {
3653        let mut themes = IndexMap::new();
3654
3655        let mut variables = IndexMap::new();
3656        variables.insert("color.bg".to_string(), "#ffffff".to_string());
3657
3658        let mut selectors = IndexMap::new();
3659        let mut button_props = IndexMap::new();
3660        button_props.insert("background-color".to_string(), json!("#2563eb"));
3661        selectors.insert("button".to_string(), button_props);
3662
3663        let default_theme = ThemeEntry {
3664            name: Some("default".to_string()),
3665            inherits: None,
3666            selectors,
3667            variables,
3668            breakpoints: IndexMap::new(),
3669        };
3670
3671        themes.insert("default".to_string(), default_theme);
3672
3673        let mut state = State::new_default();
3674        state.themes = themes;
3675        state.current_theme = "default".to_string();
3676
3677        // Test button with bg-bg class and p-4
3678        let classes = vec!["bg-bg".to_string(), "p-4".to_string()];
3679        let styles = state.android_styles_for("button", &classes);
3680
3681        println!("[test_button_bg_override] styles: {:?}", styles);
3682
3683        // Should have white background from bg-bg, not blue from button selector
3684        assert_eq!(
3685            styles
3686                .get("backgroundColor")
3687                .and_then(|v: &serde_json::Value| v.as_str()),
3688            Some("#ffffff")
3689        );
3690
3691        // Should have 16px padding from p-4, not 8px from button selector
3692        // p-4 = 1rem = 16px (default)
3693        assert_eq!(styles.get("paddingTop"), Some(&serde_json::json!(16)));
3694        assert_eq!(styles.get("paddingVertical"), Some(&serde_json::json!(16)));
3695    }
3696
3697    #[test]
3698    fn test_class_selector_matching() {
3699        let mut themes = IndexMap::new();
3700        let mut selectors = IndexMap::new();
3701
3702        let mut bg_primary = IndexMap::new();
3703        bg_primary.insert("background-color".to_string(), json!("#3b82f6"));
3704        selectors.insert(".bg-primary".to_string(), bg_primary);
3705
3706        let default_theme = ThemeEntry {
3707            name: Some("default".to_string()),
3708            inherits: None,
3709            selectors,
3710            variables: IndexMap::new(),
3711            breakpoints: IndexMap::new(),
3712        };
3713
3714        themes.insert("default".to_string(), default_theme);
3715
3716        let mut state = State::new_default();
3717        state.themes = themes;
3718        state.current_theme = "default".to_string();
3719
3720        let classes = vec!["bg-primary".to_string()];
3721        let styles = state.android_styles_for("div", &classes);
3722
3723        assert_eq!(
3724            styles.get("backgroundColor").and_then(|v| v.as_str()),
3725            Some("#3b82f6")
3726        );
3727    }
3728
3729    #[test]
3730    fn test_css_kebab_case_conversion() {
3731        let mut themes = IndexMap::new();
3732        let mut selectors = IndexMap::new();
3733
3734        let mut props = IndexMap::new();
3735        // Use camelCase property which should be converted to kebab-case for web
3736        props.insert("backgroundColor".to_string(), json!("#ffffff"));
3737        props.insert("borderTopWidth".to_string(), json!(1));
3738        selectors.insert("body".to_string(), props);
3739
3740        let default_theme = ThemeEntry {
3741            name: Some("default".to_string()),
3742            inherits: None,
3743            selectors,
3744            variables: IndexMap::new(),
3745            breakpoints: IndexMap::new(),
3746        };
3747
3748        themes.insert("default".to_string(), default_theme);
3749
3750        let mut state = State::new_default();
3751        state.themes = themes;
3752        state.current_theme = "default".to_string();
3753
3754        // Mark body as used so it's emitted
3755        state.used_tags.insert("body".to_string());
3756
3757        let css = state.css_for_web();
3758        println!("[test_css_kebab_case_conversion] css: {}", css);
3759
3760        assert!(css.contains("background-color:#ffffff;"));
3761        assert!(css.contains("border-top-width:1;"));
3762        assert!(!css.contains("backgroundColor:"));
3763        assert!(!css.contains("borderTopWidth:"));
3764    }
3765}
3766
3767#[cfg(all(target_os = "android", feature = "android"))]
3768#[cfg(feature = "android")]
3769mod android_jni;
3770
3771mod bridge_common;
3772mod ffi;
3773mod utils;
3774
3775pub use ffi::*;
3776
3777/// Build State from theme JSON for programmatic style resolution.
3778/// Used by relay-runtime to resolve className+tag→style on non-web platforms.
3779pub fn build_state_from_theme_json(json: &str) -> State {
3780    bridge_common::build_state_from_theme_json(json)
3781}
3782
3783#[cfg(target_vendor = "apple")]
3784mod ios_ffi;