themed_styler/
lib.rs

1use indexmap::{IndexMap, IndexSet};
2use once_cell::sync::Lazy;
3use serde::{de::Deserializer, Deserialize, Serialize};
4use serde_json::json;
5#[cfg(target_arch = "wasm32")]
6use wasm_bindgen::prelude::*;
7mod default_state;
8use default_state::bundled_state;
9
10// Default display density (1.0 = mdpi baseline)
11fn default_display_density() -> f32 { 1.0 }
12fn default_scaled_density() -> f32 { 1.0 }
13
14pub type CssProps = IndexMap<String, serde_json::Value>;
15pub type SelectorStyles = IndexMap<String, CssProps>; // selector -> props
16
17/// Convert dp to pixels using display density
18fn dp_to_px(dp: f32, density: f32) -> i32 {
19    (dp * density).round() as i32
20}
21
22/// Convert sp to pixels using scaled density  
23fn sp_to_px(sp: f32, scaled_density: f32) -> f32 {
24    sp * scaled_density
25}
26
27/// Parse a CSS value and convert to Android pixels if needed
28fn parse_and_convert_to_px(value: &serde_json::Value, density: f32) -> Option<serde_json::Value> {
29    match value {
30        serde_json::Value::Number(n) => {
31            // Bare number treated as dp
32            let dp = n.as_f64()? as f32;
33            Some(serde_json::json!(dp_to_px(dp, density)))
34        }
35        serde_json::Value::String(s) => {
36            // Parse string with units
37            let trimmed = s.trim();
38            if trimmed.ends_with("px") {
39                // Already in pixels
40                let px = trimmed.trim_end_matches("px").trim().parse::<f32>().ok()?;
41                Some(serde_json::json!(px as i32))
42            } else if trimmed.ends_with("dp") {
43                let dp = trimmed.trim_end_matches("dp").trim().parse::<f32>().ok()?;
44                Some(serde_json::json!(dp_to_px(dp, density)))
45            } else if let Ok(num) = trimmed.parse::<f32>() {
46                // Bare number as string, treat as dp
47                Some(serde_json::json!(dp_to_px(num, density)))
48            } else {
49                // Keep as-is (e.g., "wrap_content", "match_parent")
50                None
51            }
52        }
53        _ => None
54    }
55}
56
57fn deserialize_variables<'de, D>(deserializer: D) -> Result<IndexMap<String, String>, D::Error>
58where
59    D: Deserializer<'de>,
60{
61    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
62    let mut out: IndexMap<String, String> = IndexMap::new();
63    if let Some(v) = value {
64        flatten_variables(None, &v, &mut out);
65    }
66    Ok(out)
67}
68
69fn flatten_variables(prefix: Option<&str>, value: &serde_json::Value, out: &mut IndexMap<String, String>) {
70    match value {
71        serde_json::Value::Object(map) => {
72            for (k, v) in map {
73                let key = if let Some(p) = prefix {
74                    format!("{}.{}", p, k)
75                } else {
76                    k.to_string()
77                };
78                flatten_variables(Some(&key), v, out);
79            }
80        }
81        serde_json::Value::Array(arr) => {
82            for (idx, v) in arr.iter().enumerate() {
83                let key = if let Some(p) = prefix {
84                    format!("{}.{}", p, idx)
85                } else {
86                    idx.to_string()
87                };
88                flatten_variables(Some(&key), v, out);
89            }
90        }
91        serde_json::Value::Null => {}
92        serde_json::Value::Bool(b) => {
93            if let Some(p) = prefix {
94                out.insert(p.to_string(), b.to_string());
95            }
96        }
97        serde_json::Value::Number(n) => {
98            if let Some(p) = prefix {
99                out.insert(p.to_string(), n.to_string());
100            }
101        }
102        serde_json::Value::String(s) => {
103            if let Some(p) = prefix {
104                out.insert(p.to_string(), s.clone());
105            }
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, Default)]
111pub struct ThemeEntry {
112    #[serde(default)]
113    pub name: Option<String>,
114    #[serde(default)]
115    pub inherits: Option<String>,
116    #[serde(default)]
117    pub selectors: SelectorStyles,
118    #[serde(default, deserialize_with = "deserialize_variables")]
119    pub variables: IndexMap<String, String>,
120    #[serde(default)]
121    pub breakpoints: IndexMap<String, String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
125pub struct State {
126    // New format: each theme has selectors, variables, breakpoints, and optional inherits
127    pub themes: IndexMap<String, ThemeEntry>,
128    pub default_theme: String,
129    pub current_theme: String,
130    // Platform-specific metadata for unit conversions
131    #[serde(default = "default_display_density")]
132    pub display_density: f32, // Android displayMetrics.density (1.0 for mdpi, 2.0 for xhdpi, etc.)
133    #[serde(default = "default_scaled_density")]
134    pub scaled_density: f32,  // Android displayMetrics.scaledDensity for SP conversions
135    // Legacy fields (kept for backward-compat JSON). Not used if themes[] carry variables/bps.
136    #[serde(default)]
137    pub theme_variables: IndexMap<String, IndexMap<String, String>>, // deprecated
138    #[serde(default)]
139    pub variables: IndexMap<String, String>, // deprecated global
140    #[serde(default)]
141    pub breakpoints: IndexMap<String, String>, // deprecated global
142    #[serde(default)]
143    pub used_selectors: IndexSet<String>, // deprecated: exact selector strings (kept for back-compat)
144    #[serde(default)]
145    pub used_classes: IndexSet<String>,   // observed classes on elements
146    #[serde(default)]
147    pub used_tags: IndexSet<String>,      // observed tags on elements
148    /// Observed (tag, class) pairs. Encoded as "tag|class" for JSON simplicity.
149    #[serde(default)]
150    pub used_tag_classes: IndexSet<String>,
151}
152
153#[derive(thiserror::Error, Debug)]
154pub enum Error {
155    #[error("theme not found: {0}")]
156    ThemeNotFound(String),
157}
158
159impl State {
160    pub fn new_default() -> Self {
161        // Prefer embedded Rust bundled defaults
162        return bundled_state();
163    }
164
165    /// Public helper to access the embedded default state.
166    pub fn default_state() -> Self {
167        bundled_state()
168    }
169
170    pub fn set_theme(&mut self, theme: impl Into<String>) -> Result<(), Error> {
171        let name = theme.into();
172        if !self.themes.contains_key(&name) {
173            return Err(Error::ThemeNotFound(name));
174        }
175        self.current_theme = name;
176        Ok(())
177    }
178
179    pub fn add_theme(&mut self, name: impl Into<String>, styles: SelectorStyles) {
180        let name = name.into();
181        let entry = self.themes.entry(name).or_default();
182        for (sel, props) in styles.into_iter() {
183            let e = entry.selectors.entry(sel).or_default();
184            merge_props(e, &props);
185        }
186    }
187
188    pub fn set_variables(&mut self, vars: IndexMap<String, String>) {
189        // Back-compat: set on current theme entry
190        let cur = self.current_theme.clone();
191        let entry = self.themes.entry(cur).or_default();
192        entry.variables = vars;
193    }
194
195    pub fn set_breakpoints(&mut self, map: IndexMap<String, String>) {
196        let cur = self.current_theme.clone();
197        let entry = self.themes.entry(cur).or_default();
198        entry.breakpoints = map;
199    }
200
201    pub fn set_default_theme(&mut self, name: impl Into<String>) {
202        self.default_theme = name.into();
203    }
204
205    pub fn register_selectors<I: IntoIterator<Item = String>>(&mut self, selectors: I) {
206        for s in selectors {
207            self.used_selectors.insert(s);
208        }
209    }
210
211    pub fn register_tailwind_classes<I: IntoIterator<Item = String>>(&mut self, classes: I) {
212        for c in classes {
213            self.used_classes.insert(c);
214        }
215    }
216
217    pub fn register_tags<I: IntoIterator<Item = String>>(&mut self, tags: I) {
218        for t in tags {
219            self.used_tags.insert(t);
220        }
221    }
222
223    pub fn register_tag_class(&mut self, tag: impl Into<String>, class_: impl Into<String>) {
224        let key = format!("{}|{}", tag.into(), class_.into());
225        self.used_tag_classes.insert(key);
226    }
227
228
229    pub fn clear_usage(&mut self) {
230        self.used_selectors.clear();
231        self.used_classes.clear();
232        self.used_tags.clear();
233        self.used_tag_classes.clear();
234    }
235
236    pub fn to_json(&self) -> serde_json::Value {
237        json!({
238            "themes": self.themes,
239            "default_theme": self.default_theme,
240            "current_theme": self.current_theme,
241            // legacy fields are still serialized for back-compat but may be empty
242            "theme_variables": self.theme_variables,
243            "variables": self.variables,
244            "breakpoints": self.breakpoints,
245            "used_selectors": self.used_selectors,
246            "used_classes": self.used_classes,
247            "used_tags": self.used_tags,
248            "used_tag_classes": self.used_tag_classes,
249        })
250    }
251
252    pub fn from_json(value: serde_json::Value) -> anyhow::Result<Self> {
253        let state: State = serde_json::from_value(value)?;
254        Ok(state)
255    }
256
257    pub fn css_for_web(&self) -> String {
258        // Compute CSS resolved from the effective theme (with inheritance)
259        let (eff, vars) = self.effective_theme_all();
260        let bps = self.effective_breakpoints();
261        let mut rules: Vec<(String, CssProps)> = Vec::new();
262        
263        // Build closure: if a (tag,class) pair is observed, consider both the tag and the class as used too
264        let mut used_tags: IndexSet<String> = self.used_tags.clone();
265        let mut used_classes: IndexSet<String> = self.used_classes.clone();
266        for key in &self.used_tag_classes {
267            if let Some((t, c)) = split_tag_class_key(key) {
268                used_tags.insert(t);
269                used_classes.insert(c);
270            }
271        }
272
273        // Helper to decide if a themed selector should be emitted based on observed usage.
274        // Supported selector forms:
275        //  - tag           (e.g., "h1")
276        //  - .class        (e.g., ".text-sm"), optional pseudo ":hover"
277        //  - tag.class     (e.g., "h1.text-sm"), optional pseudo ":hover"
278        for (sel, props) in eff.iter() {
279            if should_emit_selector(sel, &used_tags, &used_classes, &self.used_tag_classes) {
280                rules.push((sel.clone(), props.clone()));
281            }
282        }
283
284        // Also emit dynamic utility properties for used classes
285        for class in &used_classes {
286            let (bp_key, hover, base) = parse_prefixed_class(class);
287            let selector = if hover { format!(".{}:hover", css_escape_class(&base)) } else { format!(".{}", css_escape_class(&base)) };
288
289            // 1) Exact selector in effective theme (e.g. ".x:hover")
290            if let Some(props) = eff.get(&selector) {
291                let final_sel = wrap_with_media(&selector, bp_key.as_deref(), &bps);
292                rules.push((final_sel, props.clone()));
293                continue;
294            }
295            // 2) Dynamic generation for the base class (ignoring hover/breakpoint for props)
296            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
297                let sel = if hover { format!(".{}:hover", css_escape_class(&base)) } else { format!(".{}", css_escape_class(&base)) };
298                let final_sel = wrap_with_media(&sel, bp_key.as_deref(), &bps);
299                rules.push((final_sel, dynamic_props));
300                continue;
301            }
302            // 3) Fallback: class key itself in theme (rare)
303            if let Some(props) = eff.get(&base) {
304                let final_sel = wrap_with_media(&selector, bp_key.as_deref(), &bps);
305                rules.push((final_sel, props.clone()));
306            }
307        }
308
309        post_process_css(&rules, &vars)
310    }
311
312    pub fn rn_styles_for(&self, selector: &str, classes: &[String]) -> IndexMap<String, serde_json::Value> {
313        let (eff, vars) = self.effective_theme_all();
314        let mut out: IndexMap<String, serde_json::Value> = IndexMap::new();
315        if let Some(props) = eff.get(selector) {
316            merge_rn_props(&mut out, props, &vars);
317        }
318        for class in classes {
319            // Normalize input: strip leading dot if present (Android may pass ".bg-primary" as selector format)
320            let normalized_class = if class.starts_with('.') {
321                class[1..].to_string()
322            } else {
323                class.clone()
324            };
325            
326            let (_bp, _hover, base) = parse_prefixed_class(&normalized_class);
327            // Prefer base selector match from theme
328            let sel = class_to_selector(&base);
329            if let Some(props) = eff.get(&sel) {
330                merge_rn_props(&mut out, props, &vars);
331                continue;
332            }
333            // Dynamic mapping for base class
334            if let Some(dynamic_props) = dynamic_css_properties_for_class(&base, &vars) {
335                merge_rn_props(&mut out, &dynamic_props, &vars);
336                continue;
337            }
338            if let Some(props) = eff.get(&base) {
339                merge_rn_props(&mut out, props, &vars);
340            }
341        }
342        
343        // CSS semantics: display: flex defaults to flex-direction: row
344        if let Some(display) = out.get("display") {
345                        eprintln!("[CSS Semantics] Found display={:?}, has flex-direction={}", display, out.contains_key("flex-direction"));
346            if display.as_str() == Some("flex") && !out.contains_key("flex-direction") {
347                                eprintln!("[CSS Semantics] Adding default flex-direction: row");
348                out.insert("flex-direction".to_string(), serde_json::json!("row"));
349            }
350        }
351        
352        out
353    }
354
355    /// Android-specific style transformations
356    /// Converts CSS properties to Android-compatible values with platform-specific defaults
357    /// Handles unit conversions (dp/sp to px) using display density
358    pub fn android_styles_for(&self, selector: &str, classes: &[String]) -> IndexMap<String, serde_json::Value> {
359        let mut styles = self.rn_styles_for(selector, classes);
360        let density = self.display_density;
361        let scaled_density = self.scaled_density;
362        
363        // Convert all dimension properties to pixels
364        let dimension_props = [
365            "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
366            "padding", "paddingTop", "paddingBottom", "paddingLeft", "paddingRight",
367            "paddingHorizontal", "paddingVertical",
368            "margin", "marginTop", "marginBottom", "marginLeft", "marginRight",
369            "marginHorizontal", "marginVertical",
370            "borderRadius", "borderWidth", "borderTopWidth", "borderBottomWidth",
371            "borderLeftWidth", "borderRightWidth",
372            "gap", "rowGap", "columnGap", "elevation"
373        ];
374        
375        for prop in &dimension_props {
376            if let Some(value) = styles.get(*prop).cloned() {
377                if let Some(converted) = parse_and_convert_to_px(&value, density) {
378                    styles.insert(prop.to_string(), converted);
379                }
380            }
381        }
382        
383        // Convert font sizes (use scaled density for accessibility)
384        if let Some(font_size) = styles.get("fontSize").cloned() {
385            if let Some(serde_json::Value::Number(n)) = parse_and_convert_to_px(&font_size, density).as_ref() {
386                // For text, use scaled density for accessibility
387                let sp_value = n.as_f64().unwrap_or(14.0) as f32 / density * scaled_density;
388                styles.insert("fontSize".to_string(), serde_json::json!(sp_value));
389            }
390        }
391        
392        // Android Layout Defaults
393        if selector == "div" || selector == "view" {
394            if styles.get("flex-direction").map_or(false, |v| v.as_str() == Some("row")) {
395                if !styles.contains_key("width") {
396                    styles.insert("width".to_string(), serde_json::json!("match_parent"));
397                }
398            } else if !styles.contains_key("width") {
399                styles.insert("width".to_string(), serde_json::json!("match_parent"));
400            }
401            if selector == "div" && !styles.contains_key("height") {
402                styles.insert("height".to_string(), serde_json::json!("wrap_content"));
403            }
404        }
405        
406        // Text elements default to wrap_content
407        if selector == "span" || selector == "text" {
408            if !styles.contains_key("width") {
409                styles.insert("width".to_string(), serde_json::json!("wrap_content"));
410            }
411            if !styles.contains_key("height") {
412                styles.insert("height".to_string(), serde_json::json!("wrap_content"));
413            }
414        }
415        
416        // Convert flexWrap to Android-friendly format
417        if let Some(flex_wrap) = styles.get("flex-wrap") {
418            if flex_wrap.as_str() == Some("wrap") {
419                styles.insert("androidFlexWrap".to_string(), serde_json::json!(true));
420            }
421        }
422        
423        // Convert alignItems to Android gravity equivalents
424        if let Some(align_items) = styles.get("align-items") {
425            let gravity = match align_items.as_str() {
426                Some("center") => "center_vertical",
427                Some("flex-start") | Some("start") => "top",
428                Some("flex-end") | Some("end") => "bottom",
429                _ => ""
430            };
431            if !gravity.is_empty() {
432                styles.insert("androidGravity".to_string(), serde_json::json!(gravity));
433            }
434        }
435        
436        // Convert justifyContent to Android layout gravity
437        if let Some(justify) = styles.get("justify-content") {
438            let layout_gravity = match justify.as_str() {
439                Some("center") => "center_horizontal",
440                Some("flex-start") | Some("start") => "start",
441                Some("flex-end") | Some("end") => "end",
442                Some("space-between") => "space_between",
443                Some("space-around") => "space_around",
444                _ => ""
445            };
446            if !layout_gravity.is_empty() {
447                styles.insert("androidLayoutGravity".to_string(), serde_json::json!(layout_gravity));
448            }
449        }
450        
451        // Convert overflow-x/overflow-y to Android scrolling hints
452        if let Some(overflow_x) = styles.get("overflowX") {
453            if overflow_x.as_str() == Some("auto") || overflow_x.as_str() == Some("scroll") {
454                styles.insert("androidScrollHorizontal".to_string(), serde_json::json!(true));
455            }
456        }
457        if let Some(overflow_y) = styles.get("overflowY") {
458            if overflow_y.as_str() == Some("auto") || overflow_y.as_str() == Some("scroll") {
459                styles.insert("androidScrollVertical".to_string(), serde_json::json!(true));
460            }
461        }
462        
463        // Convert textAlign to Android gravity
464        if let Some(text_align) = styles.get("textAlign") {
465            let gravity = match text_align.as_str() {
466                Some("center") => "center_horizontal",
467                Some("right") | Some("end") => "end",
468                Some("left") | Some("start") => "start",
469                _ => ""
470            };
471            if !gravity.is_empty() {
472                styles.insert("androidTextGravity".to_string(), serde_json::json!(gravity));
473            }
474        }
475        
476        // Convert fontWeight to Android typeface style
477        if let Some(font_weight) = styles.get("fontWeight") {
478            let is_bold = match font_weight {
479                serde_json::Value::String(s) => s.contains("bold"),
480                serde_json::Value::Number(n) => {
481                    let weight = n.as_i64().unwrap_or(400);
482                    weight >= 500
483                }
484                _ => false
485            };
486            styles.insert("androidFontBold".to_string(), serde_json::json!(is_bold));
487        }
488        
489        // Convert boxShadow to elevation
490        if let Some(box_shadow) = styles.get("boxShadow") {
491            if let Some(shadow_str) = box_shadow.as_str() {
492                if !shadow_str.is_empty() {
493                    let elevation_dp = if shadow_str.contains("20px") { 24.0 }
494                    else if shadow_str.contains("15px") { 16.0 }
495                    else if shadow_str.contains("10px") { 8.0 }
496                    else if shadow_str.contains("5px") { 4.0 }
497                    else { 4.0 };
498                    styles.insert("elevation".to_string(), serde_json::json!(dp_to_px(elevation_dp, density)));
499                }
500            }
501        }
502        
503        styles
504    }
505
506    // Previously supported loading YAML at runtime; now defaults are embedded.
507
508    // Build the inheritance chain from current theme upward via `inherits` and default fallback
509    fn theme_chain(&self) -> Vec<String> {
510        let mut chain = Vec::new();
511        // Resolve base names
512        let default_name = if self.themes.contains_key(&self.default_theme) {
513            self.default_theme.clone()
514        } else if let Some((k, _)) = self.themes.first() { k.clone() } else { return chain };
515        let mut current_name = if self.themes.contains_key(&self.current_theme) {
516            self.current_theme.clone()
517        } else { default_name.clone() };
518        // push child first
519        let mut seen: IndexSet<String> = IndexSet::new();
520        while !seen.contains(&current_name) {
521            seen.insert(current_name.clone());
522            chain.push(current_name.clone());
523            // next parent via inherits, else stop
524            let inherits = self.themes.get(&current_name).and_then(|t| t.inherits.clone());
525            if let Some(p) = inherits {
526                current_name = p;
527            } else {
528                break;
529            }
530        }
531        if !chain.iter().any(|n| n == &default_name) {
532            chain.push(default_name);
533        }
534        chain
535    }
536
537    // Compute effective selectors + variables + breakpoints with inheritance.
538    // Child overrides parent/default on conflicts (expected for "inherits").
539    fn effective_theme_all(&self) -> (SelectorStyles, IndexMap<String, String>) {
540        let mut selectors: SelectorStyles = SelectorStyles::new();
541        let mut vars: IndexMap<String, String> = IndexMap::new();
542        // Start with deprecated globals as the lowest base
543        for (k, v) in self.variables.iter() { vars.insert(k.clone(), v.clone()); }
544        // Merge default -> parents -> child so child wins on conflicts
545        let chain = self.theme_chain();
546        for name in chain.into_iter().rev() {
547            if let Some(entry) = self.themes.get(&name) {
548                // merge selectors: later (child) overrides earlier (parent/default)
549                for (sel, props) in entry.selectors.iter() {
550                    let e = selectors.entry(sel.clone()).or_default();
551                    merge_props(e, props);
552                }
553                // merge variables
554                for (k, v) in entry.variables.iter() {
555                    vars.insert(k.clone(), v.clone());
556                }
557            }
558        }
559        (selectors, vars)
560    }
561
562    // Effective breakpoints with inheritance; child overrides parent/default.
563    pub fn effective_breakpoints(&self) -> IndexMap<String, String> {
564        let mut bps: IndexMap<String, String> = IndexMap::new();
565        // Start with deprecated globals
566        for (k, v) in self.breakpoints.iter() { bps.insert(k.clone(), v.clone()); }
567        let chain = self.theme_chain();
568        for name in chain.into_iter().rev() {
569            if let Some(entry) = self.themes.get(&name) {
570                for (k, v) in entry.breakpoints.iter() {
571                    bps.insert(k.clone(), v.clone());
572                }
573            }
574        }
575        bps
576    }
577}
578
579fn split_tag_class_key(key: &str) -> Option<(String, String)> {
580    let mut it = key.splitn(2, '|');
581    let t = it.next()?.to_string();
582    let c = it.next()?.to_string();
583    if t.is_empty() || c.is_empty() { return None; }
584    Some((t, c))
585}
586
587fn strip_hover_suffix(selector: &str) -> (&str, bool) {
588    if let Some(stripped) = selector.strip_suffix(":hover") { (stripped, true) } else { (selector, false) }
589}
590
591fn should_emit_selector(sel: &str, used_tags: &IndexSet<String>, used_classes: &IndexSet<String>, used_tag_classes: &IndexSet<String>) -> bool {
592    // Optionally handle :hover suffix
593    let (base, _hover) = strip_hover_suffix(sel);
594
595    // tag-only
596    if is_simple_tag(base) {
597        return used_tags.contains(base) || used_tag_classes.iter().any(|k| k.split('|').next() == Some(base));
598    }
599
600    // .class-only
601    if let Some(class_name) = base.strip_prefix('.') {
602        // Normalize potential escaped class names as-is
603        return used_classes.contains(class_name) || used_tag_classes.iter().any(|k| k.ends_with(&format!("|{}", class_name)));
604    }
605
606    // tag.class
607    if let Some((tag, class_name)) = split_tag_class_selector(base) {
608        let key = format!("{}|{}", tag, class_name);
609        return used_tag_classes.contains(&key) || (used_tags.contains(&tag) && used_classes.contains(&class_name));
610    }
611
612    // Other complex selectors are currently ignored
613    false
614}
615
616fn is_simple_tag(s: &str) -> bool {
617    // Match simple HTML tag-ish identifiers
618    let mut chars = s.chars();
619    match chars.next() { Some(c) if c.is_ascii_alphabetic() => {}, _ => return false }
620    chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
621}
622
623fn split_tag_class_selector(s: &str) -> Option<(String, String)> {
624    // "tag.class" -> (tag, class)
625    let mut parts = s.splitn(2, '.');
626    let tag = parts.next()?.to_string();
627    let class_name = parts.next()?.to_string();
628    if tag.is_empty() || class_name.is_empty() { return None; }
629    Some((tag, class_name))
630}
631
632// wasm-bindgen exports (only when compiling to wasm32)
633#[cfg(target_arch = "wasm32")]
634#[wasm_bindgen]
635pub fn render_css_for_web(state_json: &str) -> String {
636    match serde_json::from_str::<State>(state_json) {
637        Ok(s) => s.css_for_web(),
638        Err(_) => "".into(),
639    }
640}
641
642#[cfg(target_arch = "wasm32")]
643#[wasm_bindgen]
644pub fn get_rn_styles(state_json: &str, selector: &str, classes_json: &str) -> String {
645    let classes: Vec<String> = serde_json::from_str(classes_json).unwrap_or_default();
646    match serde_json::from_str::<State>(state_json) {
647        Ok(s) => serde_json::to_string(&s.rn_styles_for(selector, &classes)).unwrap_or_else(|_| "{}".into()),
648        Err(_) => "{}".into(),
649    }
650}
651
652/// Android-specific accessor for styles; currently mirrors RN mapping.
653/// Kept distinct to allow future Android-only adjustments without changing RN/web.
654#[cfg(target_arch = "wasm32")]
655#[wasm_bindgen]
656pub fn get_android_styles(state_json: &str, selector: &str, classes_json: &str) -> String {
657    get_rn_styles(state_json, selector, classes_json)
658}
659
660// Expose crate version to JS via wasm-bindgen
661#[cfg(target_arch = "wasm32")]
662#[wasm_bindgen]
663pub fn get_version() -> String {
664    // CARGO_PKG_VERSION is provided at compile time
665    env!("CARGO_PKG_VERSION").to_string()
666}
667
668// Plain Rust accessor for crate version used by Android JNI glue
669pub fn version() -> &'static str {
670    env!("CARGO_PKG_VERSION")
671}
672
673/// Return the embedded default state as a JSON string.
674#[cfg(target_arch = "wasm32")]
675#[wasm_bindgen]
676pub fn get_default_state_json() -> String {
677    let st = bundled_state();
678    match serde_json::to_string(&st.to_json()) {
679        Ok(s) => s,
680        Err(_) => "{}".to_string(),
681    }
682}
683
684/// Register a theme from JSON. On duplicate, replace the theme's selectors, inheritance, and variables.
685/// Expected JSON format: `{ "name": "theme-name", "theme": { "inherits": "parent", "selectors": {...}, "variables": {...}, "breakpoints": {...} } }`
686/// Returns the updated state as JSON, or "{}" on error.
687#[cfg(target_arch = "wasm32")]
688#[wasm_bindgen]
689pub fn register_theme_json(state_json: &str, theme_json: &str) -> String {
690    match (serde_json::from_str::<State>(state_json), serde_json::from_str::<serde_json::Value>(theme_json)) {
691        (Ok(mut state), Ok(theme_obj)) => {
692            if let (Some(name), Some(theme_entry)) = (theme_obj.get("name"), theme_obj.get("theme")) {
693                if let Ok(entry) = serde_json::from_value::<ThemeEntry>(theme_entry.clone()) {
694                    let theme_name = name.as_str().unwrap_or("").to_string();
695                    if !theme_name.is_empty() {
696                        state.themes.insert(theme_name, entry);
697                    }
698                }
699            }
700            match serde_json::to_string(&state.to_json()) {
701                Ok(s) => s,
702                Err(_) => "{}".to_string(),
703            }
704        }
705        _ => "{}".to_string(),
706    }
707}
708
709/// Set the default and current theme. Returns the updated state as JSON.
710#[cfg(target_arch = "wasm32")]
711#[wasm_bindgen]
712pub fn set_theme_json(state_json: &str, theme_name: &str) -> String {
713    match serde_json::from_str::<State>(state_json) {
714        Ok(mut state) => {
715            if state.themes.contains_key(theme_name) {
716                state.default_theme = theme_name.to_string();
717                state.current_theme = theme_name.to_string();
718            }
719            match serde_json::to_string(&state.to_json()) {
720                Ok(s) => s,
721                Err(_) => "{}".to_string(),
722            }
723        }
724        _ => "{}".to_string(),
725    }
726}
727
728/// Get all theme keys and names as JSON array: [{ "key": "default", "name": "Default Theme" }, ...]
729/// Returns array of themes from the state JSON.
730#[cfg(target_arch = "wasm32")]
731#[wasm_bindgen]
732pub fn get_theme_list_json(state_json: &str) -> String {
733    match serde_json::from_str::<State>(state_json) {
734        Ok(state) => {
735            let themes: Vec<serde_json::Value> = state.themes.iter().map(|(key, entry)| {
736                json!({
737                    "key": key,
738                    "name": entry.name.as_ref().unwrap_or(key)
739                })
740            }).collect();
741            serde_json::to_string(&themes).unwrap_or_else(|_| "[]".to_string())
742        }
743        _ => "[]".to_string(),
744    }
745}
746
747fn merge_props(into: &mut CssProps, from: &CssProps) {
748    for (k, v) in from.iter() {
749        into.insert(k.clone(), v.clone());
750    }
751}
752
753// merge_indexmap removed — unused
754
755fn css_props_string(props: &CssProps, vars: &IndexMap<String, String>) -> String {
756    let mut buf = String::new();
757    for (k, v) in props.iter() {
758        buf.push_str(k);
759        buf.push(':');
760        let val = if v.is_string() {
761            let s = v.as_str().unwrap();
762            resolve_vars(s, vars)
763        } else {
764            v.to_string()
765        };
766        buf.push_str(&val);
767        if !val.ends_with(';') {
768            buf.push(';');
769        }
770    }
771    buf
772}
773
774/// Parse var() references manually (replaces regex dependency)
775/// Matches: var(--name), var(name), with optional whitespace
776/// Supports alphanumeric, underscore, dot, and dash in variable names
777fn parse_var_references(input: &str) -> Vec<(usize, usize, String)> {
778    let mut results = Vec::new();
779    let bytes = input.as_bytes();
780    let mut i = 0;
781    
782    while i < bytes.len() {
783        // Look for "var("
784        if i + 4 <= bytes.len() && &bytes[i..i+4] == b"var(" {
785            let start = i;
786            i += 4;
787            
788            // Skip whitespace
789            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\n' || bytes[i] == b'\r') {
790                i += 1;
791            }
792            
793            // Check for optional -- prefix
794            let has_prefix = i + 2 <= bytes.len() && &bytes[i..i+2] == b"--";
795            if has_prefix {
796                i += 2;
797            }
798            
799            // Collect variable name: [a-zA-Z0-9_.-]+
800            let name_start = i;
801            while i < bytes.len() {
802                let c = bytes[i];
803                if (c >= b'a' && c <= b'z') || (c >= b'A' && c <= b'Z') || 
804                   (c >= b'0' && c <= b'9') || c == b'_' || c == b'.' || c == b'-' {
805                    i += 1;
806                } else {
807                    break;
808                }
809            }
810            
811            let name_end = i;
812            if name_start < name_end {
813                // Skip trailing whitespace
814                while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\n' || bytes[i] == b'\r') {
815                    i += 1;
816                }
817                
818                // Check for closing )
819                if i < bytes.len() && bytes[i] == b')' {
820                    let end = i + 1;
821                    let var_name = std::str::from_utf8(&bytes[name_start..name_end])
822                        .unwrap_or("").to_string();
823                    results.push((start, end, var_name));
824                    i = end;
825                    continue;
826                }
827            }
828        }
829        i += 1;
830    }
831    
832    results
833}
834
835// Tailwind color palette - embedded from tailwind-colors.html
836static TAILWIND_COLORS: Lazy<IndexMap<&'static str, IndexMap<&'static str, &'static str>>> = Lazy::new(|| {
837    let mut colors = IndexMap::new();
838    
839    let mut slate = IndexMap::new();
840    slate.insert("50", "#f8fafc"); slate.insert("100", "#f1f5f9"); slate.insert("200", "#e2e8f0");
841    slate.insert("300", "#cbd5e1"); slate.insert("400", "#94a3b8"); slate.insert("500", "#64748b");
842    slate.insert("600", "#475569"); slate.insert("700", "#334155"); slate.insert("800", "#1e293b");
843    slate.insert("900", "#0f172a"); slate.insert("950", "#020617");
844    colors.insert("slate", slate);
845    
846    let mut gray = IndexMap::new();
847    gray.insert("50", "#f9fafb"); gray.insert("100", "#f3f4f6"); gray.insert("200", "#e5e7eb");
848    gray.insert("300", "#d1d5db"); gray.insert("400", "#9ca3af"); gray.insert("500", "#6b7280");
849    gray.insert("600", "#4b5563"); gray.insert("700", "#374151"); gray.insert("800", "#1f2937");
850    gray.insert("900", "#111827"); gray.insert("950", "#030712");
851    colors.insert("gray", gray);
852    
853    let mut zinc = IndexMap::new();
854    zinc.insert("50", "#fafafa"); zinc.insert("100", "#f4f4f5"); zinc.insert("200", "#e4e4e7");
855    zinc.insert("300", "#d4d4d8"); zinc.insert("400", "#a1a1aa"); zinc.insert("500", "#71717a");
856    zinc.insert("600", "#52525b"); zinc.insert("700", "#3f3f46"); zinc.insert("800", "#27272a");
857    zinc.insert("900", "#18181b"); zinc.insert("950", "#09090b");
858    colors.insert("zinc", zinc);
859    
860    let mut neutral = IndexMap::new();
861    neutral.insert("50", "#fafafa"); neutral.insert("100", "#f5f5f5"); neutral.insert("200", "#e5e5e5");
862    neutral.insert("300", "#d4d4d4"); neutral.insert("400", "#a3a3a3"); neutral.insert("500", "#737373");
863    neutral.insert("600", "#525252"); neutral.insert("700", "#404040"); neutral.insert("800", "#262626");
864    neutral.insert("900", "#171717"); neutral.insert("950", "#0a0a0a");
865    colors.insert("neutral", neutral);
866    
867    let mut stone = IndexMap::new();
868    stone.insert("50", "#fafaf9"); stone.insert("100", "#f5f5f4"); stone.insert("200", "#e7e5e4");
869    stone.insert("300", "#d6d3d1"); stone.insert("400", "#a8a29e"); stone.insert("500", "#78716c");
870    stone.insert("600", "#57534e"); stone.insert("700", "#44403c"); stone.insert("800", "#292524");
871    stone.insert("900", "#1c1917"); stone.insert("950", "#0c0a09");
872    colors.insert("stone", stone);
873    
874    let mut red = IndexMap::new();
875    red.insert("50", "#fef2f2"); red.insert("100", "#fee2e2"); red.insert("200", "#fecaca");
876    red.insert("300", "#fca5a5"); red.insert("400", "#f87171"); red.insert("500", "#ef4444");
877    red.insert("600", "#dc2626"); red.insert("700", "#b91c1c"); red.insert("800", "#991b1b");
878    red.insert("900", "#7f1d1d"); red.insert("950", "#450a0a");
879    colors.insert("red", red);
880    
881    let mut orange = IndexMap::new();
882    orange.insert("50", "#fff7ed"); orange.insert("100", "#ffedd5"); orange.insert("200", "#fed7aa");
883    orange.insert("300", "#fdba74"); orange.insert("400", "#fb923c"); orange.insert("500", "#f97316");
884    orange.insert("600", "#ea580c"); orange.insert("700", "#c2410c"); orange.insert("800", "#9a3412");
885    orange.insert("900", "#7c2d12"); orange.insert("950", "#431407");
886    colors.insert("orange", orange);
887    
888    let mut amber = IndexMap::new();
889    amber.insert("50", "#fffbeb"); amber.insert("100", "#fef3c7"); amber.insert("200", "#fde68a");
890    amber.insert("300", "#fcd34d"); amber.insert("400", "#fbbf24"); amber.insert("500", "#f59e0b");
891    amber.insert("600", "#d97706"); amber.insert("700", "#b45309"); amber.insert("800", "#92400e");
892    amber.insert("900", "#78350f"); amber.insert("950", "#451a03");
893    colors.insert("amber", amber);
894    
895    let mut blue = IndexMap::new();
896    blue.insert("50", "#eff6ff"); blue.insert("100", "#dbeafe"); blue.insert("200", "#bfdbfe");
897    blue.insert("300", "#93c5fd"); blue.insert("400", "#60a5fa"); blue.insert("500", "#3b82f6");
898    blue.insert("600", "#2563eb"); blue.insert("700", "#1d4ed8"); blue.insert("800", "#1e40af");
899    blue.insert("900", "#1e3a8a"); blue.insert("950", "#0b1c52");
900    colors.insert("blue", blue);
901    
902    let mut lime = IndexMap::new();
903    lime.insert("50", "#f7fee7"); lime.insert("100", "#ecfccb"); lime.insert("200", "#d9f99d");
904    lime.insert("300", "#bef264"); lime.insert("400", "#a3e635"); lime.insert("500", "#84cc16");
905    lime.insert("600", "#65a30d"); lime.insert("700", "#4d7c0f"); lime.insert("800", "#3f6212");
906    lime.insert("900", "#365314"); lime.insert("950", "#1a2e05");
907    colors.insert("lime", lime);
908    
909    let mut green = IndexMap::new();
910    green.insert("50", "#f0fdf4"); green.insert("100", "#dcfce7"); green.insert("200", "#bbf7d0");
911    green.insert("300", "#86efac"); green.insert("400", "#4ade80"); green.insert("500", "#22c55e");
912    green.insert("600", "#16a34a"); green.insert("700", "#15803d"); green.insert("800", "#166534");
913    green.insert("900", "#14532d"); green.insert("950", "#052e16");
914    colors.insert("green", green);
915    
916    let mut emerald = IndexMap::new();
917    emerald.insert("50", "#ecfdf5"); emerald.insert("100", "#d1fae5"); emerald.insert("200", "#a7f3d0");
918    emerald.insert("300", "#6ee7b7"); emerald.insert("400", "#34d399"); emerald.insert("500", "#10b981");
919    emerald.insert("600", "#059669"); emerald.insert("700", "#047857"); emerald.insert("800", "#065f46");
920    emerald.insert("900", "#064e3b"); emerald.insert("950", "#022c22");
921    colors.insert("emerald", emerald);
922    
923    let mut teal = IndexMap::new();
924    teal.insert("50", "#f0fdfa"); teal.insert("100", "#ccfbf1"); teal.insert("200", "#99f6e4");
925    teal.insert("300", "#5eead4"); teal.insert("400", "#2dd4bf"); teal.insert("500", "#14b8a6");
926    teal.insert("600", "#0d9488"); teal.insert("700", "#0f766e"); teal.insert("800", "#115e59");
927    teal.insert("900", "#134e4a"); teal.insert("950", "#042f2e");
928    colors.insert("teal", teal);
929    
930    let mut cyan = IndexMap::new();
931    cyan.insert("50", "#ecfeff"); cyan.insert("100", "#cffafe"); cyan.insert("200", "#a5f3fc");
932    cyan.insert("300", "#67e8f9"); cyan.insert("400", "#22d3ee"); cyan.insert("500", "#06b6d4");
933    cyan.insert("600", "#0891b2"); cyan.insert("700", "#0e7490"); cyan.insert("800", "#155e75");
934    cyan.insert("900", "#164e63"); cyan.insert("950", "#083344");
935    colors.insert("cyan", cyan);
936    
937    let mut sky = IndexMap::new();
938    sky.insert("50", "#f0f9ff"); sky.insert("100", "#e0f2fe"); sky.insert("200", "#bae6fd");
939    sky.insert("300", "#7dd3fc"); sky.insert("400", "#38bdf8"); sky.insert("500", "#0ea5e9");
940    sky.insert("600", "#0284c7"); sky.insert("700", "#0369a1"); sky.insert("800", "#075985");
941    sky.insert("900", "#0c4a6e"); sky.insert("950", "#082f49");
942    colors.insert("sky", sky);
943    
944    let mut blue = IndexMap::new();
945    blue.insert("50", "#eff6ff"); blue.insert("100", "#dbeafe"); blue.insert("200", "#bfdbfe");
946    blue.insert("300", "#93c5fd"); blue.insert("400", "#60a5fa"); blue.insert("500", "#3b82f6");
947    blue.insert("600", "#2563eb"); blue.insert("700", "#1d4ed8"); blue.insert("800", "#1e40af");
948    blue.insert("900", "#1e3a8a"); blue.insert("950", "#172554");
949    colors.insert("blue", blue);
950    
951    let mut indigo = IndexMap::new();
952    indigo.insert("50", "#eef2ff"); indigo.insert("100", "#e0e7ff"); indigo.insert("200", "#c7d2fe");
953    indigo.insert("300", "#a5b4fc"); indigo.insert("400", "#818cf8"); indigo.insert("500", "#6366f1");
954    indigo.insert("600", "#4f46e5"); indigo.insert("700", "#4338ca"); indigo.insert("800", "#3730a3");
955    indigo.insert("900", "#312e81"); indigo.insert("950", "#1e1b4b");
956    colors.insert("indigo", indigo);
957    
958    let mut violet = IndexMap::new();
959    violet.insert("50", "#f5f3ff"); violet.insert("100", "#ede9fe"); violet.insert("200", "#ddd6fe");
960    violet.insert("300", "#c4b5fd"); violet.insert("400", "#a78bfa"); violet.insert("500", "#8b5cf6");
961    violet.insert("600", "#7c3aed"); violet.insert("700", "#6d28d9"); violet.insert("800", "#5b21b6");
962    violet.insert("900", "#4c1d95"); violet.insert("950", "#2e1065");
963    colors.insert("violet", violet);
964    
965    let mut purple = IndexMap::new();
966    purple.insert("50", "#faf5ff"); purple.insert("100", "#f3e8ff"); purple.insert("200", "#e9d5ff");
967    purple.insert("300", "#d8b4fe"); purple.insert("400", "#c084fc"); purple.insert("500", "#a855f7");
968    purple.insert("600", "#9333ea"); purple.insert("700", "#7e22ce"); purple.insert("800", "#6b21a8");
969    purple.insert("900", "#581c87"); purple.insert("950", "#3b0764");
970    colors.insert("purple", purple);
971    
972    let mut fuchsia = IndexMap::new();
973    fuchsia.insert("50", "#fdf4ff"); fuchsia.insert("100", "#fae8ff"); fuchsia.insert("200", "#f5d0fe");
974    fuchsia.insert("300", "#f0abfc"); fuchsia.insert("400", "#e879f9"); fuchsia.insert("500", "#d946ef");
975    fuchsia.insert("600", "#c026d3"); fuchsia.insert("700", "#a21caf"); fuchsia.insert("800", "#86198f");
976    fuchsia.insert("900", "#701a75"); fuchsia.insert("950", "#4a044e");
977    colors.insert("fuchsia", fuchsia);
978    
979    let mut pink = IndexMap::new();
980    pink.insert("50", "#fdf2f8"); pink.insert("100", "#fce7f3"); pink.insert("200", "#fbcfe8");
981    pink.insert("300", "#f9a8d4"); pink.insert("400", "#f472b6"); pink.insert("500", "#ec4899");
982    pink.insert("600", "#db2777"); pink.insert("700", "#be185d"); pink.insert("800", "#9d174d");
983    pink.insert("900", "#831843"); pink.insert("950", "#500724");
984    colors.insert("pink", pink);
985    
986    let mut rose = IndexMap::new();
987    rose.insert("50", "#fff1f2"); rose.insert("100", "#ffe4e6"); rose.insert("200", "#fecdd3");
988    rose.insert("300", "#fda4af"); rose.insert("400", "#fb7185"); rose.insert("500", "#f43f5e");
989    rose.insert("600", "#e11d48"); rose.insert("700", "#be123c"); rose.insert("800", "#9f1239");
990    rose.insert("900", "#881337"); rose.insert("950", "#4c0519");
991    colors.insert("rose", rose);
992    
993    colors
994});
995
996fn resolve_vars(input: &str, vars: &IndexMap<String, String>) -> String {
997    let var_refs = parse_var_references(input);
998    
999    if var_refs.is_empty() {
1000        // Fast path: no var() references, just check for $ prefix
1001        if input.starts_with('$') {
1002            if let Some(val) = vars.get(&input[1..]) {
1003                return val.clone();
1004            }
1005        }
1006        return input.to_string();
1007    }
1008    
1009    // Replace var() references from right to left to preserve indices
1010    let mut out = input.to_string();
1011    for (start, end, var_name) in var_refs.iter().rev() {
1012        if let Some(val) = vars.get(var_name) {
1013            out.replace_range(*start..*end, val);
1014        }
1015    }
1016    
1017    // Also handle $ prefix for direct variable references
1018    if out.starts_with('$') {
1019        if let Some(val) = vars.get(&out[1..]) {
1020            return val.clone();
1021        }
1022    }
1023    
1024    out
1025}
1026
1027fn camel_case(name: &str) -> String {
1028    let mut out = String::new();
1029    let mut upper = false;
1030    for ch in name.chars() {
1031        if ch == '-' {
1032            upper = true;
1033            continue;
1034        }
1035        if upper {
1036            out.extend(ch.to_uppercase());
1037            upper = false;
1038        } else {
1039            out.push(ch);
1040        }
1041    }
1042    out
1043}
1044
1045fn css_value_to_rn(
1046    value: &serde_json::Value,
1047    vars: &IndexMap<String, String>,
1048) -> serde_json::Value {
1049    match value {
1050        serde_json::Value::String(s) => {
1051            let s2 = resolve_vars(s, vars);
1052            if let Some(n) = s2.strip_suffix("px") {
1053                if let Ok(parsed) = n.trim().parse::<f64>() {
1054                    return json!(parsed);
1055                }
1056            }
1057            json!(s2)
1058        }
1059        _ => value.clone(),
1060    }
1061}
1062
1063fn merge_rn_props(
1064    into: &mut IndexMap<String, serde_json::Value>,
1065    css_props: &CssProps,
1066    vars: &IndexMap<String, String>,
1067) {
1068    for (k, v) in css_props.iter() {
1069        let rn_key = match k.as_str() {
1070            // Minimal explicit mappings
1071            "background-color" => "backgroundColor".to_string(),
1072            "text-align" => "textAlign".to_string(),
1073            _ => camel_case(k),
1074        };
1075        let rn_val = css_value_to_rn(v, vars);
1076        into.insert(rn_key, rn_val);
1077    }
1078}
1079
1080fn dynamic_css_properties_for_class(class: &str, vars: &IndexMap<String, String>) -> Option<CssProps> {
1081    // Display utilities
1082    match class {
1083        "block" => { let mut p = CssProps::new(); p.insert("display".into(), json!("block")); return Some(p); }
1084        "inline-block" => { let mut p = CssProps::new(); p.insert("display".into(), json!("inline-block")); return Some(p); }
1085        "inline" => { let mut p = CssProps::new(); p.insert("display".into(), json!("inline")); return Some(p); }
1086        "inline-flex" => { let mut p = CssProps::new(); p.insert("display".into(), json!("inline-flex")); return Some(p); }
1087        "grid" => { let mut p = CssProps::new(); p.insert("display".into(), json!("grid")); return Some(p); }
1088        "hidden" => { let mut p = CssProps::new(); p.insert("display".into(), json!("none")); return Some(p); }
1089        _ => {}
1090    }
1091    // Flexbox shorthands
1092    match class {
1093        "flex" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); return Some(p); }
1094        "flex-row" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-direction".into(), json!("row")); return Some(p); }
1095        "flex-col" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-direction".into(), json!("column")); return Some(p); }
1096        "flex-wrap" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-wrap".into(), json!("wrap")); return Some(p); }
1097        "flex-nowrap" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-wrap".into(), json!("nowrap")); return Some(p); }
1098        "flex-wrap-reverse" => { let mut p = CssProps::new(); p.insert("display".into(), json!("flex")); p.insert("flex-wrap".into(), json!("wrap-reverse")); return Some(p); }
1099        "flex-1" => { let mut p = CssProps::new(); p.insert("flex".into(), json!(1)); return Some(p); }
1100        _ => {}
1101    }
1102    if let Some(rest) = class.strip_prefix("items-") {
1103        let mut p = CssProps::new();
1104        let v = match rest { "start" => "flex-start", "end" => "flex-end", "center" => "center", "stretch" => "stretch", other => other };
1105        p.insert("align-items".into(), json!(v));
1106        return Some(p);
1107    }
1108    if let Some(rest) = class.strip_prefix("justify-") {
1109        let mut p = CssProps::new();
1110        let v = match rest { "start" => "flex-start", "end" => "flex-end", "center" => "center", "between" => "space-between", "around" => "space-around", "evenly" => "space-evenly", other => other };
1111        p.insert("justify-content".into(), json!(v));
1112        return Some(p);
1113    }
1114    if let Some(value) = class.strip_prefix("p-") {
1115        return parse_tailwind_spacing(value, &|px| padding_props(&["padding"], px));
1116    }
1117    if let Some(value) = class.strip_prefix("px-") {
1118        return parse_tailwind_spacing(value, &|px| padding_props(&["padding-left", "padding-right"], px));
1119    }
1120    if let Some(value) = class.strip_prefix("py-") {
1121        return parse_tailwind_spacing(value, &|px| padding_props(&["padding-top", "padding-bottom"], px));
1122    }
1123    for &(prefix, prop) in &[("pt-", "padding-top"), ("pr-", "padding-right"), ("pb-", "padding-bottom"), ("pl-", "padding-left")] {
1124        if let Some(value) = class.strip_prefix(prefix) {
1125            return parse_tailwind_spacing(value, &|px| padding_props(&[prop], px));
1126        }
1127    }
1128    // Margin utilities
1129    if let Some(value) = class.strip_prefix("m-") {
1130        return parse_tailwind_spacing(value, &|px| margin_props(&["margin"], px));
1131    }
1132    if let Some(value) = class.strip_prefix("mx-") {
1133        return parse_tailwind_spacing(value, &|px| margin_props(&["margin-left", "margin-right"], px));
1134    }
1135    if let Some(value) = class.strip_prefix("my-") {
1136        return parse_tailwind_spacing(value, &|px| margin_props(&["margin-top", "margin-bottom"], px));
1137    }
1138    for &(prefix, prop) in &[("mt-", "margin-top"), ("mr-", "margin-right"), ("mb-", "margin-bottom"), ("ml-", "margin-left")] {
1139        if let Some(value) = class.strip_prefix(prefix) {
1140            return parse_tailwind_spacing(value, &|px| margin_props(&[prop], px));
1141        }
1142    }
1143    // Gap utilities (works in RN 0.71+ with Flexbox)
1144    if let Some(value) = class.strip_prefix("gap-") {
1145        if !value.starts_with("x-") && !value.starts_with("y-") {
1146            return parse_tailwind_spacing(value, &|px| {
1147                let mut props = CssProps::new();
1148                props.insert("gap".into(), json!(format!("{}px", px)));
1149                props
1150            });
1151        }
1152    }
1153    if let Some(value) = class.strip_prefix("gap-x-") {
1154        return parse_tailwind_spacing(value, &|px| {
1155            let mut props = CssProps::new();
1156            props.insert("column-gap".into(), json!(format!("{}px", px)));
1157            props
1158        });
1159    }
1160    if let Some(value) = class.strip_prefix("gap-y-") {
1161        return parse_tailwind_spacing(value, &|px| {
1162            let mut props = CssProps::new();
1163            props.insert("row-gap".into(), json!(format!("{}px", px)));
1164            props
1165        });
1166    }
1167    // Space utilities (space-x-*, space-y-*)
1168    if let Some(value) = class.strip_prefix("space-x-") {
1169        return parse_tailwind_spacing(value, &|px| {
1170            let mut props = CssProps::new();
1171            // In CSS, this is typically done with :not(:last-child) selector
1172            // For now, we'll set it as a custom property that can be used
1173            props.insert("--space-x".into(), json!(format!("{}px", px)));
1174            props
1175        });
1176    }
1177    if let Some(value) = class.strip_prefix("space-y-") {
1178        return parse_tailwind_spacing(value, &|px| {
1179            let mut props = CssProps::new();
1180            props.insert("--space-y".into(), json!(format!("{}px", px)));
1181            props
1182        });
1183    }
1184    // Font weight utilities
1185    match class {
1186        "font-thin" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("100")); return Some(p); }
1187        "font-extralight" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("200")); return Some(p); }
1188        "font-light" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("300")); return Some(p); }
1189        "font-normal" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("400")); return Some(p); }
1190        "font-medium" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("500")); return Some(p); }
1191        "font-semibold" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("600")); return Some(p); }
1192        "font-bold" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("700")); return Some(p); }
1193        "font-extrabold" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("800")); return Some(p); }
1194        "font-black" => { let mut p = CssProps::new(); p.insert("font-weight".into(), json!("900")); return Some(p); }
1195        _ => {}
1196    }
1197    // Font family utilities
1198    match class {
1199        "font-sans" => { let mut p = CssProps::new(); p.insert("font-family".into(), json!("system-ui, -apple-system, sans-serif")); return Some(p); }
1200        "font-serif" => { let mut p = CssProps::new(); p.insert("font-family".into(), json!("Georgia, serif")); return Some(p); }
1201        "font-mono" => { let mut p = CssProps::new(); p.insert("font-family".into(), json!("ui-monospace, monospace")); return Some(p); }
1202        _ => {}
1203    }
1204    // Text size utilities
1205    match class {
1206        "text-xs" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("12px")); p.insert("line-height".into(), json!("16px")); return Some(p); }
1207        "text-sm" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("14px")); p.insert("line-height".into(), json!("20px")); return Some(p); }
1208        "text-base" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("16px")); p.insert("line-height".into(), json!("24px")); return Some(p); }
1209        "text-lg" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("18px")); p.insert("line-height".into(), json!("28px")); return Some(p); }
1210        "text-xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("20px")); p.insert("line-height".into(), json!("28px")); return Some(p); }
1211        "text-2xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("24px")); p.insert("line-height".into(), json!("32px")); return Some(p); }
1212        "text-3xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("30px")); p.insert("line-height".into(), json!("36px")); return Some(p); }
1213        "text-4xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("36px")); p.insert("line-height".into(), json!("40px")); return Some(p); }
1214        "text-5xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("48px")); p.insert("line-height".into(), json!("1")); return Some(p); }
1215        "text-6xl" => { let mut p = CssProps::new(); p.insert("font-size".into(), json!("60px")); p.insert("line-height".into(), json!("1")); return Some(p); }
1216        _ => {}
1217    }
1218    // Text alignment
1219    match class {
1220        "text-left" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("left")); return Some(p); }
1221        "text-center" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("center")); return Some(p); }
1222        "text-right" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("right")); return Some(p); }
1223        "text-justify" => { let mut p = CssProps::new(); p.insert("text-align".into(), json!("justify")); return Some(p); }
1224        _ => {}
1225    }
1226    // Overflow utilities
1227    match class {
1228        "overflow-auto" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("auto")); return Some(p); }
1229        "overflow-hidden" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("hidden")); return Some(p); }
1230        "overflow-visible" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("visible")); return Some(p); }
1231        "overflow-scroll" => { let mut p = CssProps::new(); p.insert("overflow".into(), json!("scroll")); return Some(p); }
1232        "overflow-x-auto" => { let mut p = CssProps::new(); p.insert("overflow-x".into(), json!("auto")); return Some(p); }
1233        "overflow-x-hidden" => { let mut p = CssProps::new(); p.insert("overflow-x".into(), json!("hidden")); return Some(p); }
1234        "overflow-x-scroll" => { let mut p = CssProps::new(); p.insert("overflow-x".into(), json!("scroll")); return Some(p); }
1235        "overflow-y-auto" => { let mut p = CssProps::new(); p.insert("overflow-y".into(), json!("auto")); return Some(p); }
1236        "overflow-y-hidden" => { let mut p = CssProps::new(); p.insert("overflow-y".into(), json!("hidden")); return Some(p); }
1237        "overflow-y-scroll" => { let mut p = CssProps::new(); p.insert("overflow-y".into(), json!("scroll")); return Some(p); }
1238        _ => {}
1239    }
1240    // Shadow utilities (basic cross-platform support)
1241    match class {
1242        "shadow-sm" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 1px 2px 0 rgba(0, 0, 0, 0.05)")); return Some(p); }
1243        "shadow" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)")); return Some(p); }
1244        "shadow-md" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)")); return Some(p); }
1245        "shadow-lg" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)")); return Some(p); }
1246        "shadow-xl" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)")); return Some(p); }
1247        "shadow-2xl" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("0 25px 50px -12px rgba(0, 0, 0, 0.25)")); return Some(p); }
1248        "shadow-none" => { let mut p = CssProps::new(); p.insert("box-shadow".into(), json!("none")); return Some(p); }
1249        _ => {}
1250    }
1251    // Parse arbitrary values like bg-[var(--primary)], text-[#ff0000], etc.
1252    if let Some(arb_value) = parse_arbitrary_value(class) {
1253        return Some(arb_value);
1254    }
1255    // text-{color}-{shade}
1256    if let Some(rest) = class.strip_prefix("text-") {
1257        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
1258            let mut props = CssProps::new();
1259            props.insert("color".into(), json!(hex));
1260            return Some(props);
1261        }
1262    }
1263    // bg-{color}-{shade}
1264    if let Some(rest) = class.strip_prefix("bg-") {
1265        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
1266            let mut props = CssProps::new();
1267            props.insert("background-color".into(), json!(hex));
1268            return Some(props);
1269        }
1270    }
1271    // divide-{color}-{shade} (sets border-color for child dividers)
1272    if let Some(rest) = class.strip_prefix("divide-") {
1273        if let Some(hex) = get_tailwind_color_with_vars(rest, vars) {
1274            let mut props = CssProps::new();
1275            props.insert("border-color".into(), json!(hex));
1276            return Some(props);
1277        }
1278    }
1279    if class == "border" {
1280        return Some(border_props(None, 1, vars));
1281    }
1282    if let Some(rest) = class.strip_prefix("border-") {
1283        // Parse border-* classes
1284        // Possible patterns:
1285        // - border-{color}-{shade} → border-color
1286        // - border-{side}-{color}-{shade} → border-{side}-color
1287        // - border-{width} → border-width
1288        // - border-{side}-{width} → border-{side}-width
1289        
1290        let parts: Vec<&str> = rest.split('-').collect();
1291        
1292        // Check if first part is a directional side (t, b, l, r, x, y)
1293        let valid_sides = ["t", "b", "l", "r", "x", "y"];
1294        let (side, color_or_width_parts) = if parts.len() > 1 && valid_sides.contains(&parts[0]) {
1295            (Some(parts[0]), &parts[1..])
1296        } else {
1297            (None, &parts[..])
1298        };
1299        
1300        // Now check if remaining parts form a color-shade pattern
1301        if color_or_width_parts.len() == 2 {
1302            // Could be color-shade like "blue-500"
1303            let color_shade = color_or_width_parts.join("-");
1304            if let Some(hex) = get_tailwind_color_with_vars(&color_shade, vars) {
1305                let mut props = CssProps::new();
1306                let prop_name = if let Some(s) = side {
1307                    format!("border-{}-color", s)
1308                } else {
1309                    "border-color".to_string()
1310                };
1311                props.insert(prop_name, json!(hex));
1312                return Some(props);
1313            }
1314        }
1315        
1316        // Check for simple color without shade (single word color like "black", "white")
1317        if color_or_width_parts.len() == 1 {
1318            let potential_color = format!("{}-500", color_or_width_parts[0]);
1319            if let Some(hex) = get_tailwind_color_with_vars(&potential_color, vars) {
1320                let mut props = CssProps::new();
1321                let prop_name = if let Some(s) = side {
1322                    format!("border-{}-color", s)
1323                } else {
1324                    "border-color".to_string()
1325                };
1326                props.insert(prop_name, json!(hex));
1327                return Some(props);
1328            }
1329        }
1330        
1331        // Otherwise, check for width (e.g., border-2, border-t-4)
1332        if color_or_width_parts.len() == 1 {
1333            if let Ok(width) = color_or_width_parts[0].parse::<i32>() {
1334                return Some(border_props(side, width, vars));
1335            }
1336        }
1337    }
1338    // rounded* (border-radius)
1339    if class == "rounded" { return Some(rounded_props(None, Some("md"))); }
1340    if let Some(sz) = class.strip_prefix("rounded-") {
1341        return Some(rounded_props(None, Some(sz)));
1342    }
1343    for &(pref, side) in &[("rounded-t", "t"), ("rounded-b", "b"), ("rounded-l", "l"), ("rounded-r", "r")] {
1344        if class == pref { return Some(rounded_props(Some(side), Some("md"))); }
1345        if let Some(sz) = class.strip_prefix(&(pref.to_string() + "-")) {
1346            return Some(rounded_props(Some(side), Some(sz)));
1347        }
1348    }
1349    // cursor-*
1350    if let Some(cur) = class.strip_prefix("cursor-") {
1351        let mut props = CssProps::new();
1352        props.insert("cursor".into(), json!(match cur {
1353            "pointer" => "pointer",
1354            "default" => "default",
1355            "text" => "text",
1356            "move" => "move",
1357            "wait" => "wait",
1358            "not-allowed" => "not-allowed",
1359            other => other,
1360        }));
1361        return Some(props);
1362    }
1363    // transition*
1364    if class == "transition" || class == "transition-all" {
1365        let mut props = CssProps::new();
1366        props.insert("transition-property".into(), json!("all"));
1367        props.insert("transition-duration".into(), json!("150ms"));
1368        props.insert("transition-timing-function".into(), json!("ease-in-out"));
1369        return Some(props);
1370    }
1371    if class == "transition-none" {
1372        let mut props = CssProps::new();
1373        props.insert("transition-property".into(), json!("none"));
1374        props.insert("transition-duration".into(), json!("0ms"));
1375        return Some(props);
1376    }
1377    if let Some(rest) = class.strip_prefix("transition-") {
1378        // e.g., transition-colors → limit property; keep default duration/ease
1379        let mut props = CssProps::new();
1380        let property = match rest {
1381            "colors" => "color, background-color, border-color, fill, stroke",
1382            "opacity" => "opacity",
1383            "transform" => "transform",
1384            "shadow" => "box-shadow",
1385            other => other,
1386        };
1387        props.insert("transition-property".into(), json!(property));
1388        props.insert("transition-duration".into(), json!("150ms"));
1389        props.insert("transition-timing-function".into(), json!("ease-in-out"));
1390        return Some(props);
1391    }
1392    // width utilities: w-*, w-full, w-screen, w-min, w-max (treat min/max as auto), w-px
1393    if let Some(val) = class.strip_prefix("w-") {
1394        return width_like_props("width", val);
1395    }
1396    if let Some(val) = class.strip_prefix("min-w-") {
1397        return width_like_props("min-width", val);
1398    }
1399    if let Some(val) = class.strip_prefix("max-w-") {
1400        return width_like_props("max-width", val);
1401    }
1402    // Height utilities
1403    if let Some(val) = class.strip_prefix("h-") {
1404        return width_like_props("height", val);
1405    }
1406    if let Some(val) = class.strip_prefix("min-h-") {
1407        return width_like_props("min-height", val);
1408    }
1409    if let Some(val) = class.strip_prefix("max-h-") {
1410        return width_like_props("max-height", val);
1411    }
1412    None
1413}
1414
1415fn parse_tailwind_spacing<F>(value: &str, builder: &F) -> Option<CssProps>
1416where
1417    F: Fn(i32) -> CssProps,
1418{
1419    if let Ok(n) = value.parse::<i32>() {
1420        let px = n * 4;
1421        return Some(builder(px));
1422    }
1423    None
1424}
1425
1426fn padding_props(keys: &[&str], px_value: i32) -> CssProps {
1427    let mut props = CssProps::new();
1428    let val = format!("{}px", px_value);
1429    for key in keys {
1430        props.insert((*key).into(), json!(&val));
1431    }
1432    props
1433}
1434
1435fn margin_props(keys: &[&str], px_value: i32) -> CssProps {
1436    let mut props = CssProps::new();
1437    let val = format!("{}px", px_value);
1438    for key in keys {
1439        props.insert((*key).into(), json!(&val));
1440    }
1441    props
1442}
1443
1444fn border_props(side: Option<&str>, width: i32, _vars: &IndexMap<String, String>) -> CssProps {
1445    let mut props = CssProps::new();
1446    let width_str = format!("{}px", width);
1447    match side {
1448        None => {
1449            props.insert("border-width".into(), json!(&width_str));
1450        }
1451        Some("t") => {
1452            props.insert("border-top-width".into(), json!(&width_str));
1453        }
1454        Some("b") => {
1455            props.insert("border-bottom-width".into(), json!(&width_str));
1456        }
1457        Some("l") => {
1458            props.insert("border-left-width".into(), json!(&width_str));
1459        }
1460        Some("r") => {
1461            props.insert("border-right-width".into(), json!(&width_str));
1462        }
1463        Some("x") => {
1464            props.insert("border-left-width".into(), json!(&width_str));
1465            props.insert("border-right-width".into(), json!(&width_str));
1466        }
1467        Some("y") => {
1468            props.insert("border-top-width".into(), json!(&width_str));
1469            props.insert("border-bottom-width".into(), json!(&width_str));
1470        }
1471        _ => {
1472            props.insert("border-width".into(), json!(&width_str));
1473        }
1474    };
1475    props.insert("border-color".into(), json!("var(border)"));
1476    props.insert("border-style".into(), json!("solid"));
1477    props
1478}
1479
1480fn rounded_props(side: Option<&str>, size: Option<&str>) -> CssProps {
1481    let mut props = CssProps::new();
1482    let px = match size.unwrap_or("md") {
1483        "none" => 0,
1484        "sm" => 2,
1485        "md" => 4,
1486        "lg" => 8,
1487        "xl" => 12,
1488        "2xl" => 16,
1489        "3xl" => 24,
1490        "full" => 9999,
1491        s => s.parse::<i32>().unwrap_or(4),
1492    };
1493    let v = json!(format!("{}px", px));
1494    match side {
1495        None => { props.insert("border-radius".into(), v); }
1496        Some("t") => {
1497            props.insert("border-top-left-radius".into(), v.clone());
1498            props.insert("border-top-right-radius".into(), v);
1499        }
1500        Some("b") => {
1501            props.insert("border-bottom-left-radius".into(), v.clone());
1502            props.insert("border-bottom-right-radius".into(), v);
1503        }
1504        Some("l") => { props.insert("border-top-left-radius".into(), v.clone()); props.insert("border-bottom-left-radius".into(), v); }
1505        Some("r") => { props.insert("border-top-right-radius".into(), v.clone()); props.insert("border-bottom-right-radius".into(), v); }
1506        _ => { props.insert("border-radius".into(), v); }
1507    }
1508    props
1509}
1510
1511fn width_like_props(prop: &str, token: &str) -> Option<CssProps> {
1512    let mut props = CssProps::new();
1513    let value = match token {
1514        "full" => Some("100%".to_string()),
1515        "screen" => Some(if prop == "width" { "100vw" } else { "100vh" }.to_string()),
1516        "min" => Some("min-content".to_string()),
1517        "max" => Some("max-content".to_string()),
1518        "fit" => Some("fit-content".to_string()),
1519        "auto" => Some("auto".to_string()),
1520        "px" => Some("1px".to_string()),
1521        other => {
1522            // numeric scale n => n*4px, fraction e.g., 1/2 => 50%
1523            if let Some((a, b)) = other.split_once('/') {
1524                if let (Ok(na), Ok(nb)) = (a.parse::<f64>(), b.parse::<f64>()) {
1525                    let pct = (na / nb) * 100.0;
1526                    Some(format!("{}%", trim_trailing_zeros(pct)))
1527                } else { None }
1528            } else if let Ok(n) = other.parse::<i32>() {
1529                Some(format!("{}px", n * 4))
1530            } else {
1531                None
1532            }
1533        }
1534    }?;
1535    props.insert(prop.into(), json!(value));
1536    Some(props)
1537}
1538
1539fn trim_trailing_zeros(num: f64) -> String {
1540    let mut s = format!("{:.6}", num);
1541    while s.contains('.') && s.ends_with('0') { s.pop(); }
1542    if s.ends_with('.') { s.pop(); }
1543    s
1544}
1545
1546// ---------------- Tailwind subset ----------------
1547
1548// static RE_NUM: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?P<prefix>(hover:)?(xs:|sm:|md:|lg:|xl:)*)?(?P<base>.+)$").unwrap());
1549
1550fn css_escape_class(class: &str) -> String { class.replace(':', "\\:") }
1551
1552fn class_to_selector(class: &str) -> String {
1553    let (_bp, hover, base) = parse_prefixed_class(class);
1554    if hover {
1555        format!(".{}:hover", css_escape_class(&base))
1556    } else {
1557        format!(".{}", css_escape_class(&base))
1558    }
1559}
1560
1561// ------------- helpers for CSS output of media selectors -------------
1562
1563/// Flatten CSS with potential selectors that include media prelude.
1564/// This simple post-processor merges entries that use the special selector format
1565/// "@media (min-width: X) {<sel>" where we will close the block at the end.
1566/// We group by media and inside concatenate selectors.
1567pub fn post_process_css(
1568    raw_rules: &[(String, CssProps)],
1569    vars: &IndexMap<String, String>,
1570) -> String {
1571    // Group into normal rules and media rules
1572    let mut normal = vec![];
1573    let mut media_map: IndexMap<String, Vec<(String, CssProps)>> = IndexMap::new();
1574    for (sel, props) in raw_rules.iter() {
1575        if let Some((media, inner)) = sel.split_once('{') {
1576            if media.trim_start().starts_with("@media ") && inner.ends_with('}') {
1577                let inner_sel = inner.trim_end_matches('}').to_string();
1578                media_map
1579                    .entry(media.trim().to_string())
1580                    .or_default()
1581                    .push((inner_sel, props.clone()));
1582                continue;
1583            }
1584        }
1585        normal.push((sel.clone(), props.clone()));
1586    }
1587    let mut out = String::new();
1588    for (sel, props) in normal {
1589        out.push_str(&sel);
1590        out.push('{');
1591        out.push_str(&css_props_string(&props, vars));
1592        out.push_str("}\n");
1593    }
1594    for (media, entries) in media_map {
1595        out.push_str(&media);
1596        out.push('{');
1597        for (sel, props) in entries {
1598            out.push_str(&sel);
1599            out.push('{');
1600            out.push_str(&css_props_string(&props, vars));
1601            out.push_str("}");
1602        }
1603        out.push_str("}\n");
1604    }
1605    out
1606}
1607
1608// -------- Prefix parsing (hover:, breakpoint:) --------
1609
1610fn parse_prefixed_class(class: &str) -> (Option<String>, bool, String) {
1611    // Split by ':' to find prefixes like md:hover:block
1612    let parts: Vec<&str> = class.split(':').collect();
1613    if parts.len() == 1 {
1614        return (None, false, class.to_string());
1615    }
1616    let mut bp: Option<String> = None;
1617    let mut hover = false;
1618    for &p in &parts[..parts.len() - 1] {
1619        match p {
1620            "hover" => hover = true,
1621            "xs" | "sm" | "md" | "lg" | "xl" => bp = Some(p.to_string()),
1622            _ => {}
1623        }
1624    }
1625    let base = parts.last().unwrap().to_string();
1626    (bp, hover, base)
1627}
1628
1629fn wrap_with_media(selector: &str, bp_key: Option<&str>, bps: &IndexMap<String, String>) -> String {
1630    if let Some(k) = bp_key {
1631        if let Some(val) = bps.get(k) {
1632            return format!("@media (min-width: {}) {{{}}}", val, selector);
1633        }
1634    }
1635    selector.to_string()
1636}
1637
1638/// Get a Tailwind color hex value from a string like "slate-200" or "blue-500"
1639fn get_tailwind_color(color_shade: &str) -> Option<String> {
1640    let parts: Vec<&str> = color_shade.split('-').collect();
1641    if parts.len() != 2 {
1642        return None;
1643    }
1644    let color_name = parts[0];
1645    let shade = parts[1];
1646    
1647    // First try standard Tailwind colors
1648    if let Some(hex) = TAILWIND_COLORS
1649        .get(color_name)
1650        .and_then(|shades| shades.get(shade))
1651    {
1652        return Some(hex.to_string());
1653    }
1654    
1655    None
1656}
1657
1658fn get_tailwind_color_with_vars(color_shade: &str, vars: &IndexMap<String, String>) -> Option<String> {
1659    // First try standard Tailwind colors
1660    if let Some(hex) = get_tailwind_color(color_shade) {
1661        return Some(hex);
1662    }
1663    
1664    // If not found, check if color_shade matches a variable
1665    // Theme variables are flattened with "." separators, e.g., "colors.primary"
1666    // So we need to check:
1667    // 1. Direct match: "primary" → look for "primary" in vars
1668    // 2. Color namespace: "primary" → look for "colors.primary" in vars (plural)
1669    // 3. Color namespace: "primary" → look for "color.primary" in vars (singular)
1670    // 4. With shade: "primary-500" → look for "colors.primary" or "colors.primary-500" in vars
1671    
1672    if let Some(val) = vars.get(color_shade) {
1673        return Some(val.clone());
1674    }
1675    
1676    // Try with "colors." namespace prefix (plural - HookRenderer uses this)
1677    if let Some(val) = vars.get(&format!("colors.{}", color_shade)) {
1678        return Some(val.clone());
1679    }
1680    
1681    // Try with "color." namespace prefix (singular - fallback)
1682    if let Some(val) = vars.get(&format!("color.{}", color_shade)) {
1683        return Some(val.clone());
1684    }
1685    
1686    // Handle cases where the color name doesn't have a shade but we need to look for a variable
1687    // e.g., "primary" (from bg-primary) → look for "color.primary"
1688    let parts: Vec<&str> = color_shade.split('-').collect();
1689    if parts.len() >= 1 {
1690        let color_name = parts[0];
1691        
1692        // Try direct variable
1693        if let Some(val) = vars.get(color_name) {
1694            return Some(val.clone());
1695        }
1696        
1697        // Try with "color." namespace
1698        if let Some(val) = vars.get(&format!("color.{}", color_name)) {
1699            return Some(val.clone());
1700        }
1701    }
1702    
1703    None
1704}
1705
1706/// Parse arbitrary values like bg-[var(--primary)], text-[#ff0000], border-[hsl(200,50%,50%)]
1707fn parse_arbitrary_value(class: &str) -> Option<CssProps> {
1708    // Match pattern: prefix-[value]
1709    if let Some(bracket_start) = class.find('[') {
1710        if !class.ends_with(']') {
1711            return None;
1712        }
1713        let prefix = &class[..bracket_start];
1714        let value = &class[bracket_start + 1..class.len() - 1];
1715        
1716        let mut props = CssProps::new();
1717        match prefix {
1718            "bg" => {
1719                props.insert("background-color".into(), json!(value));
1720                return Some(props);
1721            }
1722            "text" => {
1723                props.insert("color".into(), json!(value));
1724                return Some(props);
1725            }
1726            "border" => {
1727                props.insert("border-color".into(), json!(value));
1728                return Some(props);
1729            }
1730            "divide" => {
1731                props.insert("border-color".into(), json!(value));
1732                return Some(props);
1733            }
1734            _ => return None,
1735        }
1736    }
1737    None
1738}
1739
1740// re-export minimal API for CLI
1741pub mod api {
1742    pub use super::{SelectorStyles, State};
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748
1749    #[test]
1750    fn default_theme_has_p2() {
1751        let mut st = State::new_default();
1752        st.register_tailwind_classes(["p-2".to_string()]);
1753        let css = st.css_for_web();
1754        assert!(css.contains(".p-2{"));
1755        assert!(css.contains("padding:8px"));
1756    }
1757
1758    #[test]
1759    fn rn_conversion() {
1760        let mut st = State::new_default();
1761        // Add a theme with button styles
1762        let mut styles = IndexMap::new();
1763        let mut button_props = IndexMap::new();
1764        button_props.insert("backgroundColor".to_string(), json!("#007bff"));
1765        styles.insert("button".to_string(), button_props);
1766        st.add_theme("default", styles);
1767        st.set_theme("default").ok();
1768        
1769        let out = st.rn_styles_for("button", &[]);
1770        assert!(out.get("backgroundColor").is_some());
1771    }
1772
1773    #[test]
1774    fn embedded_defaults_and_version() {
1775        // Test that we can create a state and add a theme with variables
1776        let mut st = State::default_state();
1777        st.add_theme("default", IndexMap::new());
1778        st.set_theme("default").ok();
1779        
1780        let mut vars = IndexMap::new();
1781        vars.insert("primary".to_string(), "#007bff".to_string());
1782        st.set_variables(vars);
1783        
1784        assert!(st.themes.contains_key("default"));
1785        let def = st.themes.get("default").unwrap();
1786        assert!(def.variables.contains_key("primary"));
1787
1788        // Version should compile and be non-empty (env! evaluated at compile-time)
1789        // Note: get_version() is only available for wasm32 target
1790        #[cfg(target_arch = "wasm32")]
1791        {
1792            let v = get_version();
1793            assert!(!v.is_empty());
1794        }
1795    }
1796
1797    #[test]
1798    fn border_color_with_direction() {
1799        let mut st = State::new_default();
1800        
1801        // Test border-b-blue-500 (border-bottom with blue color shade 500)
1802        st.register_tailwind_classes(["border-b-blue-500".to_string()]);
1803        let css = st.css_for_web();
1804        assert!(css.contains(".border-b-blue-500{"));
1805        assert!(css.contains("border-bottom-color:#3b82f6") || css.contains("border-b-color:#3b82f6"));
1806        
1807        // Test border-t-red-500
1808        st.register_tailwind_classes(["border-t-red-500".to_string()]);
1809        let css = st.css_for_web();
1810        assert!(css.contains(".border-t-red-500{"));
1811        
1812        // Test border-blue-500 (all borders)
1813        st.register_tailwind_classes(["border-blue-500".to_string()]);
1814        let css = st.css_for_web();
1815        assert!(css.contains(".border-blue-500{"));
1816        assert!(css.contains("border-color:#3b82f6"));
1817    }
1818
1819    #[test]
1820    fn border_width_with_direction() {
1821        let mut st = State::new_default();
1822        
1823        // Test border-b-2 (border-bottom width 2px)
1824        st.register_tailwind_classes(["border-b-2".to_string()]);
1825        let css = st.css_for_web();
1826        assert!(css.contains(".border-b-2{"));
1827        assert!(css.contains("border-bottom-width:2px"));
1828        
1829        // Test border-2 (all borders width 2px)
1830        st.register_tailwind_classes(["border-2".to_string()]);
1831        let css = st.css_for_web();
1832        assert!(css.contains(".border-2{"));
1833        assert!(css.contains("border-width:2px"));
1834    }
1835
1836    #[test]
1837    fn display_flex_hover_breakpoint() {
1838        let mut st = State::new_default();
1839        
1840        // Set up theme with breakpoints
1841        st.add_theme("default", IndexMap::new());
1842        st.set_theme("default").ok();
1843        
1844        let mut breakpoints = IndexMap::new();
1845        breakpoints.insert("md".to_string(), "768px".to_string());
1846        st.set_breakpoints(breakpoints);
1847        
1848        st.register_tailwind_classes([
1849            "block".into(),
1850            "inline-flex".into(),
1851            "hidden".into(),
1852            "md:flex".into(),
1853            "md:hover:block".into(),
1854        ]);
1855        let css = st.css_for_web();
1856        assert!(css.contains(".block{"));
1857        assert!(css.contains("display:block"));
1858        assert!(css.contains(".inline-flex{"));
1859        assert!(css.contains("display:inline-flex"));
1860        assert!(css.contains(".hidden{"));
1861        assert!(css.contains("display:none"));
1862        // breakpoint rule
1863        assert!(css.contains("@media (min-width: 768px)"));
1864        assert!(css.contains(".flex{display:flex"));
1865        // hover inside media (substring check)
1866        assert!(css.contains(":hover{display:block"));
1867
1868        // RN resolves base class styles ignoring prefixes
1869        let rn = st.rn_styles_for("div", &["md:flex".into()]);
1870        assert_eq!(rn.get("display").and_then(|v| v.as_str()), Some("flex"));
1871    }
1872
1873    #[test]
1874    fn parse_var_references_basic() {
1875        // Test basic var() parsing
1876        let refs = parse_var_references("var(color)");
1877        assert_eq!(refs.len(), 1);
1878        assert_eq!(refs[0].2, "color");
1879        assert_eq!(refs[0].0, 0); // start
1880        assert_eq!(refs[0].1, 10); // end (exclusive, so "var(color)" is 0..10)
1881
1882        // Test var() with -- prefix
1883        let refs = parse_var_references("var(--primary)");
1884        assert_eq!(refs.len(), 1);
1885        assert_eq!(refs[0].2, "primary");
1886
1887        // Test multiple var() references
1888        let refs = parse_var_references("var(--color) and var(size)");
1889        assert_eq!(refs.len(), 2);
1890        assert_eq!(refs[0].2, "color");
1891        assert_eq!(refs[1].2, "size");
1892
1893        // Test with whitespace
1894        let refs = parse_var_references("var( --spacing )");
1895        assert_eq!(refs.len(), 1);
1896        assert_eq!(refs[0].2, "spacing");
1897
1898        // Test with dots and dashes
1899        let refs = parse_var_references("var(color.primary-500)");
1900        assert_eq!(refs.len(), 1);
1901        assert_eq!(refs[0].2, "color.primary-500");
1902
1903        // Test no matches
1904        let refs = parse_var_references("no variables here");
1905        assert_eq!(refs.len(), 0);
1906
1907        // Test incomplete var(
1908        let refs = parse_var_references("var(");
1909        assert_eq!(refs.len(), 0);
1910
1911        // Test var without closing
1912        let refs = parse_var_references("var(color");
1913        assert_eq!(refs.len(), 0);
1914    }
1915
1916    #[test]
1917    fn resolve_vars_basic() {
1918        let mut vars = IndexMap::new();
1919        vars.insert("primary".to_string(), "#ff0000".to_string());
1920        vars.insert("spacing".to_string(), "8px".to_string());
1921        vars.insert("color.blue".to_string(), "#0000ff".to_string());
1922
1923        // Test basic resolution
1924        assert_eq!(resolve_vars("var(--primary)", &vars), "#ff0000");
1925        assert_eq!(resolve_vars("var(primary)", &vars), "#ff0000");
1926        assert_eq!(resolve_vars("var( --primary )", &vars), "#ff0000");
1927
1928        // Test multiple vars
1929        assert_eq!(
1930            resolve_vars("var(--primary) var(--spacing)", &vars),
1931            "#ff0000 8px"
1932        );
1933
1934        // Test dotted variable names
1935        assert_eq!(resolve_vars("var(--color.blue)", &vars), "#0000ff");
1936
1937        // Test undefined variable (should not replace)
1938        assert_eq!(resolve_vars("var(--undefined)", &vars), "var(--undefined)");
1939
1940        // Test $ prefix syntax
1941        assert_eq!(resolve_vars("$primary", &vars), "#ff0000");
1942
1943        // Test no variables
1944        assert_eq!(resolve_vars("plain text", &vars), "plain text");
1945    }
1946
1947    #[test]
1948    fn resolve_vars_edge_cases() {
1949        let mut vars = IndexMap::new();
1950        vars.insert("a".to_string(), "1".to_string());
1951        vars.insert("b".to_string(), "2".to_string());
1952
1953        // Test adjacent vars
1954        assert_eq!(resolve_vars("var(a)var(b)", &vars), "12");
1955
1956        // Test var in middle of text
1957        assert_eq!(resolve_vars("prefix var(a) suffix", &vars), "prefix 1 suffix");
1958
1959        // Test empty input
1960        assert_eq!(resolve_vars("", &vars), "");
1961
1962        // Test var with numbers
1963        vars.insert("var123".to_string(), "value".to_string());
1964        assert_eq!(resolve_vars("var(var123)", &vars), "value");
1965
1966        // Test var with underscores
1967        vars.insert("my_var".to_string(), "test".to_string());
1968        assert_eq!(resolve_vars("var(my_var)", &vars), "test");
1969    }
1970}
1971
1972#[cfg(all(target_os = "android", feature = "android"))]
1973#[cfg(feature = "android")]
1974mod android_jni;
1975
1976mod bridge_common;
1977mod ffi;
1978
1979pub use ffi::*;
1980
1981#[cfg(target_vendor = "apple")]
1982mod ios_ffi;